Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions docs/docs/configure/skills.md
Original file line number Diff line number Diff line change
Expand Up @@ -325,3 +325,19 @@ This disables skill discovery from `~/.claude/skills/` and `~/.agents/skills/` b
## Duplicate Handling

If multiple skills share the same name, project-level skills override global skills. A warning is logged when duplicates are found.

## Which Skill the Agent Picks

The skill list the agent sees marks each skill with where it was found:

| Source | Where |
|---|---|
| `workspace` | Synced from the Altimate workspace this project is linked to |
| `project` | Inside the project, such as `.claude/skills/`, `.agents/skills/` or `.altimate-code/skills/` |
| `built-in` | Ships with altimate-code |
| `personal` | Your own folders under your home directory, such as `~/.claude/skills/` |
| `other` | Anywhere else, such as a `skills.paths` entry outside the project and your home directory |

When more than one skill fits a task, the agent is told to load the one from the highest source in that order, and to fall back to a lower source only when nothing higher covers the task. So a skill your team added to the workspace is preferred over a similar skill another team left in the repository. The label comes from the skill's location, not from anything written in the skill.

One exception: a project skill with exactly the same name as a workspace skill replaces it, and is listed as `project`. That keeps a skill you are editing in the repository, and may later publish, from being shadowed by the workspace's older copy.
5 changes: 3 additions & 2 deletions docs/docs/usage/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,14 +54,15 @@ Workspace features are on by default. When you are signed in to Altimate, altima

With workspaces on:

- `altimate-code link` links the current project to a workspace (or creates one). The sidebar then names the workspace and shows how many memories are not yet synced and when skills last synced.
- `altimate-code link` links the current project to a workspace (or creates one), then shows what the workspace provides (its skills, knowledge, integrations and whether memory is on) and a few prompts to start with. The sidebar then names the workspace and shows how many memories are not yet synced and when skills last synced.
- From a shell, script or CI job, without the TUI:
- `altimate-code link --workspace <name|id>` links to an existing workspace (an id is matched before a name), and `altimate-code link --create [name]` creates one (named after the repo by default) and links to it. Neither prompts. If the project is already linked to a different workspace they refuse unless `--yes` is passed. Running `--create` again when the project is already linked to a workspace of that name creates nothing, so it is safe in a devcontainer's setup command; if another workspace already has that name it refuses and names its id, unless `--allow-duplicate` is passed. Linking to the workspace the project is already linked to confirms that link on this machine, which a fresh clone needs before `workspace sync` sends its memory.
- `altimate-code workspace status [--json]` shows the linked workspace and, when known, how many saved memories have not reached it and when skills last synced.
- `altimate-code workspace refresh` pulls the workspace's skills (memory is loaded by the next session started in the project); `altimate-code workspace sync` sends memory the workspace has not received; `altimate-code workspace unlink [--yes]` detaches the project (`--yes` is required without a terminal).
- These commands accept `--directory`. Exit codes: `0` done, `1` failed (including when the workspace service could not be reached), `2` a request to change (not signed in, a re-link or unlink without `--yes` and no terminal to confirm on, or a `sync` to a link not yet confirmed on this machine), `3` the project is not linked. With `--json`, `ok` is `true` exactly when the exit code is `0`.
- `/workspace` in the TUI opens a menu: **Refresh** pulls the workspace's skills and memory into this project, **Sync** re-sends local memory the workspace never received, **Open in browser** (when a web URL is available) shows the workspace on the web, **Switch workspace** relinks the project, **Unlink** detaches it. In a project that is not linked yet, it offers **Link to a workspace** instead.
- `/workspace` in the TUI opens a menu: **Get started** shows what the workspace provides and prompts to try (choosing one puts it in the prompt without sending it; after you link from the TUI, the confirmation shows the same summary and prompts), **Refresh** pulls the workspace's skills and memory into this project, **Sync** re-sends local memory the workspace never received, **Open in browser** (when a web URL is available) shows the workspace on the web, **Switch workspace** relinks the project, **Unlink** detaches it. In a project that is not linked yet, it offers **Link to a workspace** instead.
- `altimate-code skill publish <name>` uploads a project skill to the linked workspace; see [Skills](../configure/skills.md#cli-commands).
- When the linked workspace has knowledge, the agent is told to check it for best practices and team conventions before answering, without you having to mention the knowledge base.
- In an ordinary session the agent is told every turn which workspace the project is linked to — or that none is, or that the link could not be verified just now. In an extension-pinned session it is told the pinned workspace instead, and that it differs from the project's own link. Either way "which workspace am I in?" has an answer, and the agent is told not to confuse it with a Databricks workspace or an IDE workspace folder.
- When `altimate-code serve` is launched by the VS Code / Cursor extension, the workspace selected in the extension's panel governs that session's skills, memory and (unless `--integrations local` is set) warehouse tool routing, taking priority over whatever the project is linked to on the backend, and is scoped to the folder it was launched for. A pin is fixed for the life of the `serve` process, so the extension relaunches `serve` when the selection changes; nothing updates a running one.

Expand Down
83 changes: 83 additions & 0 deletions packages/opencode/src/altimate/skill-source.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
// altimate_change - new file
//
// Where a skill came from, shown to the model next to each listed skill. Without it a skill the
// linked workspace's members added, one the repository carries, a built-in and one in some other
// team's folder all looked the same, and with near-duplicates in the catalog the agent asked "which
// team's policy?" or explored the repo for clues instead of loading the workspace's own skill.
// The skill-routing measurement went from 4/16 to 16/16 with a source label plus one precedence rule.
//
// Decided by the skill's location, which Altimate Code chose when it found the file; nothing in the
// skill's own text can change it. Dependency-free like ./workspace/snapshot-path, which skill
// discovery also imports.
import path from "path"
import { isWithin, snapshotProjectOf } from "./workspace/snapshot-path"

/** Highest precedence first, the order `SKILL_PRECEDENCE_RULE` states. */
export const SKILL_SOURCES = ["workspace", "project", "built-in", "personal", "other"] as const
export type SkillSource = (typeof SKILL_SOURCES)[number]

/** Altimate-shipped: an embedded skill, or one installed with Altimate Code. Given `home`, an installed copy
* counts only inside `<home>/.altimate/builtin`, where it is installed; without it (skill-use telemetry, which
* has no home to hand) the `.altimate/builtin` segment anywhere is enough. */
export function isBuiltinSkillLocation(location: string, home?: string): boolean {
const normalized = location.replace(/\\/g, "/")
if (normalized.startsWith("builtin:") || normalized === "<built-in>") return true
if (/\/node_modules\/(@altimateai\/|altimate-code\/)/.test(normalized)) return true
if (home !== undefined) return path.isAbsolute(location) && isWithin(path.join(home, ".altimate", "builtin"), location)
return normalized.includes("/.altimate/builtin/")
}

export interface SkillSourceContext {
projectRoot?: string
home: string
/** The skill file's resolved path, when it differs from `location` (a symlink). */
real?: string
/** The project root's resolved path. */
realProjectRoot?: string
/** The session directory and its resolved path. A project linked from a subdirectory of its repository
* keeps its snapshot there, not at the repository root. */
directory?: string
realDirectory?: string
}

/**
* - workspace: this project's synced workspace snapshot, at the project root or the session directory (a project
* linked from a subdirectory of its repository). Judged on both the matched and the resolved path, and only for
* this project's own snapshot: a configured path or a symlink into another project's snapshot, or out
* of this one, is "other", not the workspace this project is linked to.
* - project: inside the project — `.claude/skills`, `.agents/skills`, `.altimate-code/skills`.
* - built-in: ships with Altimate Code.
* - personal: the user's own folders under their home directory.
* - other: anything else, such as a configured path outside both.
*
* A project opened at the home directory itself would claim every personal skill, so there the
* home directory is not treated as a project.
*/
export function skillSource(location: string, ctx: SkillSourceContext): SkillSource {
if (isBuiltinSkillLocation(location, ctx.home)) return "built-in"
if (!path.isAbsolute(location)) return "other"
const real = ctx.real ?? location
const matchedProject = snapshotProjectOf(location)
const realProject = snapshotProjectOf(real)
if (matchedProject !== null || realProject !== null) {
const ours = (project: string | null, roots: (string | undefined)[]) =>
project !== null && roots.some((root) => root !== undefined && path.resolve(project) === path.resolve(root))
return ours(matchedProject, [ctx.projectRoot, ctx.directory]) &&
ours(realProject, [ctx.realProjectRoot ?? ctx.projectRoot, ctx.realDirectory ?? ctx.directory])
? "workspace"
: "other"
}
const home = path.resolve(ctx.home)
if (ctx.projectRoot && path.resolve(ctx.projectRoot) !== home && isWithin(ctx.projectRoot, location)) return "project"
if (isWithin(home, location)) return "personal"
return "other"
}

/** The rule that goes with the labels, stated once in the skills preamble. */
export const SKILL_PRECEDENCE_RULE = [
'Each skill below carries a `source` that Altimate Code set from where it found the skill: "workspace" (added by',
'the team in the Altimate workspace this project is linked to), "project" (in this repository), "built-in" (ships',
'with Altimate Code), "personal" (the user\'s own folders), or "other". When more than one skill fits a task,',
"load the one from the highest source in that order, rather than a lower one with a similar purpose, and do not",
"ask the user which team's skill to use. Use a lower source only when nothing higher covers the task.",
].join("\n")
43 changes: 30 additions & 13 deletions packages/opencode/src/altimate/workspace/contents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,7 @@ async function fetchSummary(datamateId: number, creds: Credentials): Promise<Fet
const knowledge = await knowledgeOf(datamateId, s.knowledge_engine_enabled, s.knowledge_bases, creds)
return {
value: {
integrations: Array.isArray(s.integrations) ? s.integrations.map((i) => i.id).sort() : null,
integrations: Array.isArray(s.integrations) ? s.integrations.map((i) => clean(i.id, 80)).sort() : null,
memoryEnabled: typeof s.memory_enabled === "boolean" ? s.memory_enabled : null,
knowledge,
},
Expand All @@ -245,7 +245,7 @@ async function fetchSummary(datamateId: number, creds: Credentials): Promise<Fet

/** Integrations, memory setting and knowledge, cached per account and workspace; a slow service
* yields "not known" this step. */
export async function workspaceSummary(datamateId: number, now = Date.now()): Promise<Summary> {
export async function workspaceSummary(datamateId: number, now = Date.now(), waitMs = SUMMARY_WAIT_MS): Promise<Summary> {
const account = await currentAccount()
if (account === null) return UNKNOWN
const key = `${account.key}:${datamateId}`
Expand All @@ -266,7 +266,7 @@ export async function workspaceSummary(datamateId: number, now = Date.now()): Pr
return await Promise.race([
pending.then((f) => f.value),
new Promise<Summary>((done) => {
timer = setTimeout(() => done(stale), SUMMARY_WAIT_MS)
timer = setTimeout(() => done(stale), waitMs)
timer.unref?.()
}),
])
Expand Down Expand Up @@ -345,6 +345,13 @@ function renderWith(contents: WorkspaceContents, detail: SkillDetail): string {
"CLI and are available in every project; they are not part of the workspace — mention them only if " +
"asked, under a separate \"built-in\" heading. Do not present every installed skill as the workspace's.",
)
// Without this the agent searched the knowledge only when the user said "check the knowledge base", so the
// team's own practices lost to general advice on exactly the questions they were written for.
if (k && k.kind !== "off" && !(k.kind === "selected" && k.names?.length === 0))
lines.push(
"For best practices, conventions or how this team does things, check this workspace's knowledge " +
"(`ask_knowledge_base`, when available) first, unprompted, and prefer it over general advice.",
)
return lines.join("\n")
}

Expand All @@ -361,19 +368,29 @@ export function render(contents: WorkspaceContents, cap = MAX_CONTENTS_CHARS): s
return minimal.length <= cap ? minimal : ""
}

/** Everything the section and the starter show, each part waited on for at most `waitMs`. A prompt step
* keeps the short default; a one-off view (the starter after a link) can afford to wait for the service. */
export async function workspaceContents(
directory: string,
datamateId: number,
waitMs: { skills: number; summary: number } = { skills: SKILLS_WAIT_MS, summary: SUMMARY_WAIT_MS },
): Promise<WorkspaceContents> {
let timer: ReturnType<typeof setTimeout> | undefined
const skills = Promise.race([
workspaceSkills(directory, datamateId),
new Promise<"unknown">((done) => {
timer = setTimeout(() => done("unknown"), waitMs.skills)
timer.unref?.()
}),
]).finally(() => timer && clearTimeout(timer))
const [s, summary] = await Promise.all([skills, workspaceSummary(datamateId, Date.now(), waitMs.summary)])
return { skills: s, ...summary }
}

/** The section for a bound workspace; "" on any failure, so prompt assembly never breaks. */
export async function section(directory: string, datamateId: number): Promise<string> {
try {
let timer: ReturnType<typeof setTimeout> | undefined
const skills = Promise.race([
workspaceSkills(directory, datamateId),
new Promise<"unknown">((done) => {
timer = setTimeout(() => done("unknown"), SKILLS_WAIT_MS)
timer.unref?.()
}),
]).finally(() => timer && clearTimeout(timer))
const [s, summary] = await Promise.all([skills, workspaceSummary(datamateId)])
return render({ skills: s, ...summary })
return render(await workspaceContents(directory, datamateId))
} catch {
return ""
}
Expand Down
5 changes: 5 additions & 0 deletions packages/opencode/src/altimate/workspace/skill-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,11 @@ const store: SyncStore = (globals[STORE_KEY] ??= {
* start racing on the same project do not both stage and swap. */
const inFlight = store.inFlight

/** The sync running for `directory` now, if any, without starting one. */
export function pendingSync(directory: string): Promise<SyncResult> | undefined {
return inFlight.get(path.resolve(directory))
}

/** How long a snapshot is trusted before the next turn re-checks the workspace.
*
* `prompt` runs per message, so syncing on every turn would put an HTTP round
Expand Down
Loading
Loading