diff --git a/packages/opencode/src/altimate/plugin/onboarding-telemetry.ts b/packages/opencode/src/altimate/plugin/onboarding-telemetry.ts index 4fed174ee1..b249f7fe79 100644 --- a/packages/opencode/src/altimate/plugin/onboarding-telemetry.ts +++ b/packages/opencode/src/altimate/plugin/onboarding-telemetry.ts @@ -261,7 +261,13 @@ export async function OnboardingTelemetryPlugin(_input: PluginInput): Promise() + let nextTurn = 0 + // altimate_change end let appInsights: AppInsightsConfig | undefined let droppedEvents = 0 let initPromise: Promise | undefined @@ -2177,13 +2259,65 @@ export namespace Telemetry { } } - export function setContext(opts: { sessionId: string; projectId: string }) { + // altimate_change start — per-session use of the bound workspace. The skill, memory and tool + // emit sites note what they used; session_end reports the counts and task_outcome_signal + // whether any workspace context was in play. Keyed by session so a serve process with several + // sessions keeps them apart; cleared when the session ends. + export type WorkspaceUse = { skills: number; memory: number; integrations: number } + /** Keyed by session and turn, so a cancelled turn unwinding late and its replacement never share + * counts; a site that carries no turn counts toward the session's current one. */ + const workspaceUse = new Map() + const useKey = (sessionId: string, turn?: number) => `${sessionId}#${turn ?? sessionTurns.get(sessionId) ?? 0}` + /** `add` accumulates; `max` records the largest value seen in the turn (memory is injected on every + * step of a turn, so a sum would multiply the blocks by the step count). */ + export function noteWorkspaceUse( + sessionId: string, + kind: keyof WorkspaceUse, + n = 1, + mode: "add" | "max" = "add", + /** The turn the use belongs to (from `setContext`): a use landing after its turn was replaced + * does not count toward the replacement. */ + turn?: number, + ) { + if (!sessionId || n <= 0) return + if (turn !== undefined && sessionTurns.get(sessionId) !== turn) return + const key = useKey(sessionId, turn) + const use = workspaceUse.get(key) ?? { skills: 0, memory: 0, integrations: 0 } + use[kind] = mode === "max" ? Math.max(use[kind], n) : use[kind] + n + workspaceUse.set(key, use) + } + /** The counts of a turn (the session's current one when no turn is given). */ + export function workspaceUseOf(sessionId: string, turn?: number): WorkspaceUse { + return { skills: 0, memory: 0, integrations: 0, ...workspaceUse.get(useKey(sessionId, turn)) } + } + export function clearWorkspaceUse(sessionId: string, turn?: number) { + workspaceUse.delete(useKey(sessionId, turn)) + } + /** A session's turn ended: its use counts are read out and its `session_end` tracked by then. With + * `turn` (from `setContext`), a turn that is no longer the session's current one ends only its own + * counts: the replacement's are the replacement's. */ + export function endSession(sessionId: string, turn?: number) { + workspaceUse.delete(useKey(sessionId, turn)) + if (turn !== undefined && sessionTurns.get(sessionId) !== turn) return + sessionTurns.delete(sessionId) + } + // altimate_change end + + /** Returns the turn generation for the session; pass it to `endSession` so only this turn ends it. */ + export function setContext(opts: { sessionId: string; projectId: string; workspaceId?: number }): number { sessionId = opts.sessionId projectId = opts.projectId + workspaceId = opts.workspaceId + // altimate_change start — a turn token for the session's counters and its end + if (!opts.sessionId) return 0 + const turn = ++nextTurn + sessionTurns.set(opts.sessionId, turn) + return turn + // altimate_change end } - export function getContext() { - return { sessionId, projectId } + export function getContext(): { sessionId: string; projectId: string; workspaceId?: number } { + return { sessionId, projectId, ...(workspaceId !== undefined ? { workspaceId } : {}) } } /** Returns true only after init() has completed and telemetry is enabled. */ @@ -2333,6 +2467,10 @@ export namespace Telemetry { const operationId = typeof eventSession === "string" ? TraceContext.activeTraceId(eventSession) : undefined if (operationId) (event as any)._operation_id = operationId // altimate_change end + // altimate_change — no ambient workspace stamping: a row names its workspace itself (the prompt + // loop's rows, tool rows and memory rows carry the turn's; workspace rows carry their own) or it + // carries none. A process-wide or per-session fallback kept attributing cancelled turns' rows to + // their replacements. buffer.push(event) if (buffer.length > MAX_BUFFER_SIZE) { buffer.shift() @@ -2529,6 +2667,7 @@ export namespace Telemetry { droppedEvents = 0 sessionId = "" projectId = "" + workspaceId = undefined machineId = "" // doInit() only assigns userEmail when Account.active() returns one, so without this a // logout followed by a re-init in the same process kept hashing the previous account into diff --git a/packages/opencode/src/altimate/tool-zod-compat.ts b/packages/opencode/src/altimate/tool-zod-compat.ts index 76e1bff413..a0a44ae6b7 100644 --- a/packages/opencode/src/altimate/tool-zod-compat.ts +++ b/packages/opencode/src/altimate/tool-zod-compat.ts @@ -28,6 +28,10 @@ export type Metadata = { [key: string]: any; error?: string } export interface LegacyContext { sessionID: SessionID messageID: MessageID + // altimate_change start — the calling turn's workspace and token, as on Tool.Context + workspaceId?: number + turn?: number + // altimate_change end agent: string abort: AbortSignal callID?: string @@ -221,6 +225,10 @@ function legacyDefToDef(legacy: LegacyToolDef): DefWithoutID { const legacyCtx: LegacyContext = { sessionID: ctx.sessionID, messageID: ctx.messageID, + // altimate_change start — carried through for the rows a legacy tool emits + workspaceId: ctx.workspaceId, + turn: ctx.turn, + // altimate_change end agent: ctx.agent, abort: ctx.abort, callID: ctx.callID, diff --git a/packages/opencode/src/altimate/tools/warehouse-add.ts b/packages/opencode/src/altimate/tools/warehouse-add.ts index 54adc86657..fb4803240a 100644 --- a/packages/opencode/src/altimate/tools/warehouse-add.ts +++ b/packages/opencode/src/altimate/tools/warehouse-add.ts @@ -120,7 +120,8 @@ IMPORTANT: For private key file paths, always use "private_key_path" (not "priva Telemetry.track({ type: "core_failure", timestamp: Date.now(), - session_id: Telemetry.getContext().sessionId || "unknown-session", + session_id: ctx.sessionID, + workspace_id: ctx.workspaceId, tool_name: "warehouse_add", tool_category: "warehouse", error_class: "internal", diff --git a/packages/opencode/src/altimate/workspace/memory-sync.ts b/packages/opencode/src/altimate/workspace/memory-sync.ts index 6302d8bdc7..838e7bbf0d 100644 --- a/packages/opencode/src/altimate/workspace/memory-sync.ts +++ b/packages/opencode/src/altimate/workspace/memory-sync.ts @@ -16,6 +16,7 @@ // Nothing is written to disk: local files are the source of truth, and a cloud // record can have been edited elsewhere by a client that does not preserve this // CLI's metadata. +import { Telemetry } from "@/altimate/telemetry" import { createHash } from "node:crypto" import { Flag as CoreFlag } from "@opencode-ai/core/flag/flag" import { Flag } from "@/flag/flag" @@ -34,7 +35,8 @@ import { type CachedBinding, } from "./state" import { indexKey, readIndex, readIndexEntry, recordIndexEntry } from "./memory-index" -import { WorkspaceApi } from "./api-client" +import { WorkspaceApi, WorkspaceApiError } from "./api-client" +import { redact } from "@/altimate/debug/report" // altimate_change — failure text in the sync record import { AltimateApi } from "@/altimate/api/client" import { LIST_LIMIT, @@ -260,6 +262,19 @@ async function memoryEnabled(binding: CachedBinding): Promise { return (await memoryStatus(binding)) === "enabled" } +/** A failed read says what failed, so the sync record can classify it. */ +type MemoryStatusOutcome = "enabled" | "disabled" | { status: "error"; error: string } + +// altimate_change start — what failed, for the persisted sync record: the HTTP status when the API +// answered one (so the reason class sees it whatever the detail says), redacted of secrets, URLs +// and the user's identity before it is written. +function failureText(err: unknown): string { + const status = err instanceof WorkspaceApiError ? err.status : undefined + const base = err instanceof Error ? `${err.name}: ${err.message}` : String(err) + return redact(status !== undefined ? `HTTP ${status} ${base}` : base) +} +// altimate_change end + /** Three-way, because a read that cannot reach the service must not be reported * as "this workspace has no memory" — that reads as success while destroying * whatever the session already had. */ @@ -272,7 +287,7 @@ async function memoryStatus( * inherit a positive written under a previous account. */ fresh?: boolean } = {}, -): Promise<"enabled" | "disabled" | "error"> { +): Promise { const cached = opts.fresh ? undefined : memoryEnabledCache.get(binding.datamateId) if (cached && Date.now() - cached.checkedAt < MEMORY_ENABLED_TTL_MS) return "enabled" try { @@ -288,7 +303,7 @@ async function memoryStatus( missingFromListWarned.add(binding.datamateId) log.warn("workspace missing from the workspace list; memory setting unknown", { workspace: binding.datamateId }) } - return "error" + return { status: "error", error: "workspace missing from the workspace list" } } missingFromListWarned.delete(binding.datamateId) if (match.memoryEnabled === undefined && !missingFieldWarned.has(binding.datamateId)) { @@ -315,7 +330,7 @@ async function memoryStatus( log.warn("could not confirm workspace memory setting", { err: String(err) }) // Not cached either way: a transient failure should neither disable the // mirror for a minute nor keep it enabled. - return "error" + return { status: "error", error: failureText(err) } } } @@ -332,11 +347,7 @@ function stripTrainingMeta(content: string): string { /** Fingerprint of everything about a block that reaches the store. Tags and * expiry are included because both are mirrored. */ function contentHash(block: MemoryBlock): string { - const payload = JSON.stringify([ - stripTrainingMeta(block.content), - [...block.tags].sort(), - block.expires ?? "", - ]) + const payload = JSON.stringify([stripTrainingMeta(block.content), [...block.tags].sort(), block.expires ?? ""]) // sha-256, not a 32-bit non-cryptographic hash. This value is the single // gate deciding whether a save is sent at all: on a collision ``push`` // returns "unchanged" and a real edit is silently never mirrored, with no @@ -656,7 +667,9 @@ async function reapArchivedBlocks( try { const gone = syncInternals.removeBlock ? await syncInternals.removeBlock(scope, blockId, directory ?? undefined, block.updated) - : await (await import("@/memory/store")).MemoryStore.remove(scope, blockId, directory ?? undefined, { + : await ( + await import("@/memory/store") + ).MemoryStore.remove(scope, blockId, directory ?? undefined, { expectUpdated: block.updated, }) if (gone) { @@ -675,6 +688,8 @@ async function push( binding: CachedBinding | null, known?: KnownRecords, directory?: string, + /** The session that wrote the block, captured before the queue; the context's when absent. */ + sessionID?: string, ): Promise { const key = indexKey({ scope: block.scope, @@ -724,9 +739,7 @@ async function push( // user recreated under an old id gets a fresh record instead of un-archiving // the old one (`MemoryApi.update` replaces metadata wholesale, which would // drop the archived marker). - const indexed = existing?.memoryId - ? view.records.find((r) => r.id === existing.memoryId) - : undefined + const indexed = existing?.memoryId ? view.records.find((r) => r.id === existing.memoryId) : undefined // A record the index names but a COMPLETE record set does not hold is gone — // deleted in the web app, by another client, or by a wipe. Absence used to // read as "still ours", so `match` stayed pointing at a dead id and every @@ -740,7 +753,8 @@ async function push( // that already has one — the duplicate this function works hardest to avoid. const indexedGone = existing?.memoryId !== undefined && indexed === undefined && !view.truncated const indexUsable = existing?.memoryId && !indexedGone && (!indexed || !isArchived(indexed)) - const match = (indexUsable ? existing?.memoryId : undefined) ?? view.records.find((r) => isSameBlock(r, block, binding))?.id + const match = + (indexUsable ? existing?.memoryId : undefined) ?? view.records.find((r) => isSameBlock(r, block, binding))?.id if (match) { // Refuse to move a record backwards. Two machines editing the same block, // or a stale clone running a sweep, would otherwise overwrite a newer cloud @@ -759,6 +773,7 @@ async function push( } await MemoryApi.update(match, block.content, metadata) await recordIndexEntry(key, { memoryId: match, contentHash: hash, syncedAt: Date.now() }) + if (block.scope === "project" && binding) trackContributed(binding, sessionID) return "stored" } @@ -784,6 +799,9 @@ async function push( const [primary, ...extras] = created await MemoryApi.update(primary, block.content, metadata) await recordIndexEntry(key, { memoryId: primary, contentHash: hash, syncedAt: Date.now() }) + // The write-back is durable here: reported now, so archiving an extra record below cannot cost + // the row (the content hash would make the next attempt "unchanged"). + if (block.scope === "project" && binding) trackContributed(binding, sessionID) // An extractor may split one submission into several records, each carrying // the metadata we sent. Only one can represent the block; the rest would @@ -796,6 +814,24 @@ async function push( return "stored" } +// altimate_change start — a successful write-back of a project block is the workspace getting something +// back; a global block is the account's (the live mirror resolves a binding for it too, to consult the +// memory setting), so it is not reported. +function trackContributed(binding: CachedBinding, sessionID?: string) { + try { + Telemetry.track({ + type: "workspace_memory_contributed", + timestamp: Date.now(), + session_id: sessionID ?? Telemetry.getContext().sessionId, + workspace_id: binding.datamateId, + block_count: 1, + }) + } catch { + // telemetry never blocks a mirror + } +} +// altimate_change end + /** Cloud operations for one logical block, run one at a time. * * Both callers are fire-and-forget from the store's write and delete paths, so @@ -869,8 +905,11 @@ export async function flushPendingMirrors(timeoutMs = 30_000): Promise { /** Mirror one block. Safe to call unconditionally — returns immediately when * workspaces are disabled, the project is unbound, or the workspace has memory * disabled. */ -export async function mirrorBlock(block: MemoryBlock, directory?: string): Promise { +export async function mirrorBlock(block: MemoryBlock, directory?: string, sessionID?: string): Promise { if (!isEnabled()) return + // altimate_change — the writer's session: the one the memory tool passed, else the context's read + // before the queue (the context can be another session's by the time the mirror lands). + const writer = sessionID ?? Telemetry.getContext().sessionId // Queued BEFORE the binding lookup, not after. Both are async, so resolving // them first let two operations on one block reach `serialize` in the // opposite order to the writes that triggered them. @@ -884,7 +923,7 @@ export async function mirrorBlock(block: MemoryBlock, directory?: string): Promi const binding = await currentBinding(directory) if (!binding) return if (!(await memoryEnabled(binding))) return - await push(block, binding, undefined, directory) + await push(block, binding, undefined, directory, writer) }) return tracked(task) } @@ -892,11 +931,7 @@ export async function mirrorBlock(block: MemoryBlock, directory?: string): Promi /** Archive a block's cloud record rather than deleting it, so the workspace * keeps the history. Only this client filters the marker — other readers do * not — so an archived record stays visible elsewhere. */ -export async function archiveBlock( - scope: "global" | "project", - blockId: string, - directory?: string, -): Promise { +export async function archiveBlock(scope: "global" | "project", blockId: string, directory?: string): Promise { if (!isEnabled()) return // Queued behind any in-flight mirror for the same block, so a delete cannot // run before the create it is meant to undo. The binding lookup happens @@ -915,11 +950,7 @@ export async function archiveBlock( ) } -async function archiveNow( - scope: "global" | "project", - blockId: string, - binding: CachedBinding, -): Promise { +async function archiveNow(scope: "global" | "project", blockId: string, binding: CachedBinding): Promise { const key = indexKey({ scope, blockId, @@ -1082,9 +1113,7 @@ async function pollMemoKey(binding: CachedBinding): Promise { * * An "unknown" is never memoized: the next tick should ask again rather than * inherit a network blip. */ -export async function memoryEnabledForPoller( - binding: CachedBinding, -): Promise<"enabled" | "disabled" | "unknown"> { +export async function memoryEnabledForPoller(binding: CachedBinding): Promise<"enabled" | "disabled" | "unknown"> { // The scoped memo is the poller's only cache. `memoryEnabledCache` is keyed by // bare workspace id — right for the write path, which is tenant-bound by its // credentials — but consulting it here reopened a 60s cross-tenant window the @@ -1093,8 +1122,7 @@ export async function memoryEnabledForPoller( // minutes per tenant is the price, and `memoryStatus` still warms both. const key = await pollMemoKey(binding) const memo = pollMemo.get(key) - if (memo && Date.now() - memo.at < (memo.status === "unknown" ? POLL_UNKNOWN_TTL_MS : POLL_TTL_MS)) - return memo.status + if (memo && Date.now() - memo.at < (memo.status === "unknown" ? POLL_UNKNOWN_TTL_MS : POLL_TTL_MS)) return memo.status // The in-flight ask is memoized too, not only the settled answer. Two // refreshes overlapping on a cold memo — a remount while a slow one is // still out — both saw it empty and both put a request on the wire. @@ -1103,7 +1131,7 @@ export async function memoryEnabledForPoller( const ask = (async () => { try { const status = await memoryStatus(binding, { fresh: true }) - const answer = status === "error" ? ("unknown" as const) : status + const answer = typeof status === "string" ? status : ("unknown" as const) pollMemo.set(key, { at: Date.now(), status: answer }) return answer } finally { @@ -1229,15 +1257,18 @@ export async function backfill( // ``gated`` says the sweep never ran, as opposed to running and storing // nothing. A caller recording "this binding is seeded" must be able to tell // those apart: memory being off is not a completed seed. - if (!isEnabled()) return { ok: 0, failed: 0, skipped: 0, declined: 0, deferred: 0, gated: true, gateReason: "local-off" } + if (!isEnabled()) + return { ok: 0, failed: 0, skipped: 0, declined: 0, deferred: 0, gated: true, gateReason: "local-off" } // The bind path passes the binding it just recorded; there is no ambient // instance to resolve one from on the `link` subcommand. const binding = explicitBinding ?? (await currentBinding()) if (!binding) return { ok: 0, failed: 0, skipped: blocks.length, declined: 0, deferred: 0, gated: true, gateReason: "unbound" } const status = await memoryStatus(binding) - if (status !== "enabled") - return { ok: 0, failed: 0, skipped: blocks.length, declined: 0, deferred: 0, gated: true, gateReason: status } + if (status !== "enabled") { + const gateReason = typeof status === "string" ? status : status.status + return { ok: 0, failed: 0, skipped: blocks.length, declined: 0, deferred: 0, gated: true, gateReason } + } const index = await readIndex() const { pending, skipped } = partitionPending(blocks, binding, index) @@ -1259,10 +1290,7 @@ export async function backfill( log.info("workspace memory backfill starting", { pending: pending.length, skipped }) const result = await runQueue( pending, - (item) => - serialize(item.block.scope, item.block.id, () => - push(item.block, item.binding, known, sweepDirectory), - ), + (item) => serialize(item.block.scope, item.block.id, () => push(item.block, item.binding, known, sweepDirectory)), BACKFILL_CONCURRENCY, ) // `skipped` combines blocks filtered before the queue (already synced, or @@ -1277,7 +1305,10 @@ export async function backfill( function originLabel(meta: Record): string | undefined { const remote = typeof meta.repo_remote === "string" ? meta.repo_remote : undefined if (remote) { - const trimmed = remote.replace(/[/]+$/, "").replace(/\.git$/, "").replace(/[/]+$/, "") + const trimmed = remote + .replace(/[/]+$/, "") + .replace(/\.git$/, "") + .replace(/[/]+$/, "") const last = trimmed.split(/[/:]/).pop() if (last) return last } @@ -1291,10 +1322,7 @@ function originLabel(meta: Record): string | undefined { /** Map a cloud record back to an injectable block, or null if it is not a * well-formed mirrored block. */ -export function toBlock( - record: CloudMemoryRecord, - ownProjectKey: string | undefined, -): RemoteMemoryBlock | null { +export function toBlock(record: CloudMemoryRecord, ownProjectKey: string | undefined): RemoteMemoryBlock | null { const meta = record.metadata if (!meta || typeof meta !== "object") return null const blockId = typeof meta.block_id === "string" ? meta.block_id : undefined @@ -1368,10 +1396,7 @@ export async function hydrate(sessionID: string): Promise { /** Wait for a session's in-flight hydration, capped. Resolves immediately once * the fetch has settled, so only the first injection of a session pays. */ -export async function whenHydrated( - sessionID: string, - timeoutMs: number = HYDRATION_WAIT_MS, -): Promise { +export async function whenHydrated(sessionID: string, timeoutMs: number = HYDRATION_WAIT_MS): Promise { const state = sessions.get(sessionID) const pending = state?.hydration if (!pending || !state) return @@ -1412,7 +1437,7 @@ type LoadOutcome = ( } | { status: "unlinked"; /** The server confirmed it, rather than a lookup failing. */ confirmed: boolean } | { status: "disabled" } - | { status: "error" } + | { status: "error"; /** What failed, for the sync record and its reason class. */ error: string } ) & { epoch?: string dir?: string | null @@ -1454,12 +1479,12 @@ async function loadWorkspaceMemory(directory?: string): Promise { stable = before === epoch } vouched = epoch - if (!stable) return { status: "error", epoch, dir, ...stamp } + if (!stable) return { status: "error", error: "the workspace link changed while loading", epoch, dir, ...stamp } if (!binding) return { status: "unlinked", confirmed, epoch, dir, ...stamp } const datamateId = binding.datamateId resolved = datamateId const enabled = await memoryStatus(binding) - if (enabled === "error") return { status: "error", epoch, dir, datamateId, ...stamp } + if (typeof enabled !== "string") return { status: "error", error: enabled.error, epoch, dir, datamateId, ...stamp } if (enabled === "disabled") return { status: "disabled", epoch, dir, datamateId, ...stamp } const ownProjectKey = projectKeyFor(binding) @@ -1503,7 +1528,7 @@ async function loadWorkspaceMemory(directory?: string): Promise { log.warn("workspace memory load failed", { err: String(err) }) // Stamped like any other outcome: without an epoch the session would reload (and make // the prompt wait) on every turn for as long as the service is down. - return { status: "error", epoch: vouched, dir, datamateId: resolved, ...stamp } + return { status: "error", error: failureText(err), epoch: vouched, dir, datamateId: resolved, ...stamp } } } @@ -1549,7 +1574,9 @@ function recordSyncState(outcome: LoadOutcome): void { if (retire) return SyncState.forget(dir, "memory", { isCurrent, before: outcome.observedAt }) if (outcome.datamateId === undefined) return const result = - outcome.status === "loaded" ? { items: outcome.items } : { error: "could not load workspace memory" } + outcome.status === "loaded" + ? { items: outcome.items } + : { error: `could not load workspace memory: ${"error" in outcome ? outcome.error : outcome.status}` } return SyncState.record(dir, outcome.datamateId, "memory", result, { isCurrent, scope: outcome.scope, diff --git a/packages/opencode/src/altimate/workspace/starter.ts b/packages/opencode/src/altimate/workspace/starter.ts index 7532073926..5b7680426a 100644 --- a/packages/opencode/src/altimate/workspace/starter.ts +++ b/packages/opencode/src/altimate/workspace/starter.ts @@ -5,6 +5,7 @@ // `GET /altimate/workspace/starter`. Fixed in format and built from the same data as the prompt's // "What this Altimate Workspace provides" section, so it costs no model call (none of the user's // tokens) and reads the same every time. Anything not known is left out, never shown as "none". +import { Telemetry } from "@/altimate/telemetry" import { displayWorkspaceName } from "./workspace-name" import { workspaceContents, type WorkspaceContents } from "./contents" @@ -33,10 +34,14 @@ function list(names: string[], max: number): string { } function skillsLine(skills: WorkspaceContents["skills"]): string | null { - if (skills === null) return "Skills: not synced to this project yet; run `altimate-code workspace refresh` to fetch them." + if (skills === null) + return "Skills: not synced to this project yet; run `altimate-code workspace refresh` to fetch them." if (skills === "unknown") return null if (skills.length === 0) return "Skills: none yet. Add them in the workspace and they sync here." - return `Skills (${skills.length}): ${list(skills.map((s) => s.name), MAX_LISTED_SKILLS)}` + return `Skills (${skills.length}): ${list( + skills.map((s) => s.name), + MAX_LISTED_SKILLS, + )}` } function knowledgeLine(k: WorkspaceContents["knowledge"]): string | null { @@ -86,7 +91,9 @@ function summaryOf(contents: WorkspaceContents): string { else if (k?.kind === "selected") parts.push(plural(k.names?.length ?? k.selected, "knowledge document")) else if (k?.kind === "off") parts.push("knowledge off") if (contents.integrations !== null) - parts.push(contents.integrations.length === 0 ? "no integrations" : `integrations: ${contents.integrations.join(", ")}`) + parts.push( + contents.integrations.length === 0 ? "no integrations" : `integrations: ${contents.integrations.join(", ")}`, + ) if (contents.memoryEnabled !== null) parts.push(contents.memoryEnabled ? "memory on" : "memory off") return parts.join(" · ") } @@ -138,10 +145,34 @@ export function wrapStarter(text: string, width: number): string { } /** The starter for a linked project. `directory` is the project key the link was recorded under. */ +// altimate_change start — the starter is the first thing a user sees of the workspace in a tool +/** `starter_shown`, with what the workspace provides as counts. Never throws. */ +export function trackStarterShown(workspaceId: number, contents: WorkspaceContents): void { + try { + Telemetry.track({ + type: "starter_shown", + timestamp: Date.now(), + session_id: Telemetry.getContext().sessionId, + workspace_id: workspaceId, + // Unknown is left out, not reported as none: the starter's own rule. + ...(Array.isArray(contents.skills) ? { skills: contents.skills.length } : {}), + ...(Array.isArray(contents.integrations) ? { integrations: contents.integrations.length } : {}), + knowledge: contents.knowledge?.kind ?? "unknown", + ...(typeof contents.memoryEnabled === "boolean" ? { memory_enabled: contents.memoryEnabled } : {}), + }) + } catch { + // telemetry never blocks the starter + } +} +// altimate_change end + export async function starterFor( directory: string, binding: { datamateId: number; datamateName: string }, waitMs = STARTER_WAIT_MS, ): Promise { - return renderStarter(binding.datamateName, await workspaceContents(directory, binding.datamateId, waitMs)) + const contents = await workspaceContents(directory, binding.datamateId, waitMs) + const starter = renderStarter(binding.datamateName, contents) + trackStarterShown(binding.datamateId, contents) + return starter } diff --git a/packages/opencode/src/altimate/workspace/sync-state.ts b/packages/opencode/src/altimate/workspace/sync-state.ts index 9e72ebc77b..c8dd5819dc 100644 --- a/packages/opencode/src/altimate/workspace/sync-state.ts +++ b/packages/opencode/src/altimate/workspace/sync-state.ts @@ -25,6 +25,7 @@ // run in its server worker. A refresh run on the TUI main thread, or by a separate CLI // process, publishes to no `/event` subscriber — the file is still written, and the TUI // sidebar and the status route read it from there. +import { Telemetry } from "@/altimate/telemetry" import { createHash, randomBytes } from "node:crypto" import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs" import path from "node:path" @@ -275,6 +276,15 @@ function chain(file: string, work: () => Promise, kind: SyncKind): Promise return settled } +/** The reason class of a sync error message, for workspace_sync_failed. */ +export function syncFailureReason(error: string | undefined): "auth" | "not_found" | "network" | "other" { + const e = (error ?? "").toLowerCase() + if (/\b(401|403)\b|unauthori[sz]ed|forbidden|\bsign(ed)? in\b|credential/.test(e)) return "auth" + if (/\b404\b|not found|no longer exists|unknown workspace/.test(e)) return "not_found" + if (/timed? ?out|cannot reach|unreachable|econn|enotfound|network|socket/.test(e)) return "network" + return "other" +} + async function apply( directory: string, datamateId: number, @@ -330,6 +340,23 @@ async function apply( } const file: StoredFile = { version: 2, directory: canon, scope, datamateId, entity: next } writePrivate(filePath(directory, kind), file) + // altimate_change start — a kind flipping to error is a blocker the dashboard counts; a repeat + // while already in error is not. + if (next.status === "error" && previous?.status !== "error") { + try { + Telemetry.track({ + type: "workspace_sync_failed", + timestamp: Date.now(), + session_id: Telemetry.getContext().sessionId, + workspace_id: datamateId, + kind, + reason: syncFailureReason(outcome.error), + }) + } catch { + // telemetry never blocks the record + } + } + // altimate_change end const errorChanged = (previous?.status ?? "ok") !== next.status || previous?.error !== next.error if (!changes && !errorChanged) return diff --git a/packages/opencode/src/altimate/workspace/tool-call-telemetry.ts b/packages/opencode/src/altimate/workspace/tool-call-telemetry.ts new file mode 100644 index 0000000000..7a6ecf66bc --- /dev/null +++ b/packages/opencode/src/altimate/workspace/tool-call-telemetry.ts @@ -0,0 +1,64 @@ +// altimate_change — tool_call rows for the bound workspace's integration calls. +import { Telemetry } from "@/altimate/telemetry" +import { DATAMATE_KEY } from "@/altimate/datamate-transport" +import { redact } from "@/altimate/debug/report" + +/** True when the MCP client serving a tool is the engine the workspace overlay manages. */ +export function isWorkspaceClient(clientName: string): boolean { + return clientName === DATAMATE_KEY +} + +/** + * Track one MCP call that went through the bound workspace's engine. MCP tools never pass through + * the `Tool.define` wrapper that tracks registry tools, so this is where a workspace integration + * call becomes a `tool_call` row; a successful one also counts as an integration use for the + * session. Only the managed engine client (`datamate`) in a bound turn counts: a standalone + * `datamate-` server, or the key in an unbound turn, is not the workspace's. The turn's own + * workspace is passed in and named on the row, so a call that finishes after the turn was + * cancelled and replaced keeps the workspace it ran for. Returns whether the call was tracked. + */ +export function trackWorkspaceToolCall(input: { + key: string + clientName: string + sessionID: string + messageID: string + /** The workspace the calling turn resolved; undefined for an unbound turn. */ + workspaceId: number | undefined + /** The calling turn (from `setContext`); a success landing after it was replaced is not counted. */ + turn?: number + startedAt: number + outcome: "success" | "error" + error?: unknown +}): boolean { + if (!isWorkspaceClient(input.clientName)) return false + if (input.workspaceId === undefined) return false + try { + if (input.outcome === "success") Telemetry.noteWorkspaceUse(input.sessionID, "integrations", 1, "add", input.turn) + const error = + input.error === undefined + ? undefined + : redact(Telemetry.maskString(input.error instanceof Error ? input.error.message : String(input.error))).slice( + 0, + 500, + ) + Telemetry.track({ + type: "tool_call", + timestamp: Date.now(), + session_id: input.sessionID, + message_id: input.messageID, + tool_name: input.key, + workspace_id: input.workspaceId, + tool_type: "mcp", + tool_category: Telemetry.categorizeToolName(input.key, "mcp"), + status: input.outcome, + via_workspace: true, + duration_ms: Date.now() - input.startedAt, + sequence_index: 0, + previous_tool: null, + ...(error !== undefined && { error }), + }) + } catch { + // telemetry never surfaces into the tool call + } + return true +} diff --git a/packages/opencode/src/cli/cmd/skill.ts b/packages/opencode/src/cli/cmd/skill.ts index 53d1d86211..4b8d4ce86e 100644 --- a/packages/opencode/src/cli/cmd/skill.ts +++ b/packages/opencode/src/cli/cmd/skill.ts @@ -533,6 +533,7 @@ const SkillPublishCommand = cmd({ action: report.action, file_count: report.files, source: "cli", + workspace_id: report.datamateId, }) } catch {} } catch (err) { diff --git a/packages/opencode/src/memory/prompt.ts b/packages/opencode/src/memory/prompt.ts index 89c4cb2208..7cc30c852b 100644 --- a/packages/opencode/src/memory/prompt.ts +++ b/packages/opencode/src/memory/prompt.ts @@ -206,6 +206,8 @@ export namespace MemoryPrompt { let injectedCount = 0 const injectedTraining: MemoryBlock[] = [] const scopesSeen = new Set() + // altimate_change — blocks injected from the bound workspace's cloud memory + let workspaceBlocks = 0 // Group training blocks by kind for structured display const byKind = new Map() @@ -244,6 +246,13 @@ export namespace MemoryPrompt { injectedCount++ injectedTraining.push(item.block) scopesSeen.add(item.block.scope) + // altimate_change start — a project-scope block from the bound workspace's cloud memory counts as + // workspace scope; a global cloud block is the account's and applies everywhere + if (isRemote(item.block) && item.block.scope === "project") { + scopesSeen.add("workspace") + workspaceBlocks++ + } + // altimate_change end } } @@ -263,6 +272,12 @@ export namespace MemoryPrompt { used += needed injectedCount++ scopesSeen.add(item.block.scope) + // altimate_change start — as above, for memory blocks + if (isRemote(item.block) && item.block.scope === "project") { + scopesSeen.add("workspace") + workspaceBlocks++ + } + // altimate_change end } } } @@ -288,12 +303,21 @@ export namespace MemoryPrompt { Telemetry.track({ type: "memory_injection", timestamp: Date.now(), - session_id: Telemetry.getContext().sessionId, + // altimate_change — the injecting session, not the process context (another turn's in serve) + session_id: ctx?.sessionID ?? Telemetry.getContext().sessionId, + workspace_id: ctx?.workspaceId, // altimate_change — the turn's workspace, named explicitly block_count: injectedCount, total_chars: used, budget, scopes_used: [...scopesSeen], + // altimate_change start — workspace memory use, for memory_injection and the session's summary rows + ...(workspaceBlocks > 0 && { workspace_blocks: workspaceBlocks }), }) + // The turn injects on every step: the count is the most blocks any step injected, not a sum. + if (workspaceBlocks > 0 && ctx?.sessionID) { + Telemetry.noteWorkspaceUse(ctx.sessionID, "memory", workspaceBlocks, "max", ctx.turn) + } + // altimate_change end } return injectedCount > 0 ? result : "" diff --git a/packages/opencode/src/memory/store.ts b/packages/opencode/src/memory/store.ts index d6b2b7f46a..2665e033be 100644 --- a/packages/opencode/src/memory/store.ts +++ b/packages/opencode/src/memory/store.ts @@ -265,7 +265,11 @@ export namespace MemoryStore { }) } - export async function write(block: MemoryBlock): Promise<{ duplicates: MemoryBlock[] }> { + export async function write( + block: MemoryBlock, + // altimate_change — the writing session, for the workspace write-back's row + opts: { sessionID?: string } = {}, + ): Promise<{ duplicates: MemoryBlock[] }> { if (block.content.length > MEMORY_MAX_BLOCK_SIZE) { throw new Error( `Memory block "${block.id}" content exceeds maximum size of ${MEMORY_MAX_BLOCK_SIZE} characters (got ${block.content.length})`, @@ -308,7 +312,8 @@ export namespace MemoryStore { Telemetry.track({ type: "memory_operation", timestamp: Date.now(), - session_id: Telemetry.getContext().sessionId, + // altimate_change — the writing session when the caller passed it, else the context's + session_id: opts.sessionID ?? Telemetry.getContext().sessionId, operation: "write", scope: block.scope, block_id: block.id, @@ -327,7 +332,7 @@ export namespace MemoryStore { // local-existence check would then look for this block in the wrong tree // and skip it as deleted. const owningDirectory = safeDirectory() - void mirrorBlock(block, owningDirectory).catch((e) => { + void mirrorBlock(block, owningDirectory, opts.sessionID).catch((e) => { mirrorLog.warn("failed to mirror memory block to workspace", { id: block.id, scope: block.scope, diff --git a/packages/opencode/src/memory/tools/memory-extract.ts b/packages/opencode/src/memory/tools/memory-extract.ts index 1a316117bb..46fdb9b34b 100644 --- a/packages/opencode/src/memory/tools/memory-extract.ts +++ b/packages/opencode/src/memory/tools/memory-extract.ts @@ -50,7 +50,7 @@ export const MemoryExtractTool = Tool.define("altimate_memory_extract", { updated: now, citations: fact.citations, content: fact.content, - }) + }, { sessionID: ctx.sessionID }) // altimate_change — the writer's session, for the workspace row const action = existing ? "Updated" : "Created" results.push(` ✓ ${action} "${fact.id}" (${fact.scope})`) diff --git a/packages/opencode/src/memory/tools/memory-write.ts b/packages/opencode/src/memory/tools/memory-write.ts index 4613d9756d..439bc9ec0b 100644 --- a/packages/opencode/src/memory/tools/memory-write.ts +++ b/packages/opencode/src/memory/tools/memory-write.ts @@ -51,7 +51,7 @@ export const MemoryWriteTool = Tool.define("altimate_memory_write", { expires: args.expires, citations: args.citations, content: args.content, - }) + }, { sessionID: ctx.sessionID }) // altimate_change — the writer's session, for the workspace row const action = existing ? "Updated" : "Created" let output = `${action} memory block "${args.id}" in ${args.scope} scope.` diff --git a/packages/opencode/src/memory/types.ts b/packages/opencode/src/memory/types.ts index 8f3e34880c..61db9077ea 100644 --- a/packages/opencode/src/memory/types.ts +++ b/packages/opencode/src/memory/types.ts @@ -46,6 +46,10 @@ export const AGENT_TRAINING_RELEVANCE: Record null) + const telemetryTurn = Telemetry.setContext({ + sessionId: sessionID, + projectId: Instance.project?.id ?? "", + workspaceId: boundWorkspace?.datamateId, + }) + // The turn's own workspace, named on the rows this loop emits at its end: a cancelled turn + // unwinding after its replacement started must not take the replacement's entry. + // Always keyed, undefined for an unbound turn: a row that carries the key is never stamped from + // the session's entry, and the envelope drops an undefined value. + const turnWorkspace = { workspace_id: boundWorkspace?.datamateId } + // altimate_change end // altimate_change start — the learn kill switch disables new delivery, while retaining trusted note cleanup. // An unused project pays only an existence check; do not load stores, migrate, // create state, or even schedule async learning work on that default-off path. @@ -790,13 +819,18 @@ export namespace SessionPrompt { emergencySessionEndFired = true Telemetry.track({ type: "session_end", + ...turnWorkspace, // altimate_change — this turn's workspace, not the session's current entry timestamp: Date.now(), session_id: sessionID, total_cost: sessionTotalCost, total_tokens: sessionTotalTokens, tool_call_count: toolCallCount, duration_ms: Date.now() - sessionStartTime, + // altimate_change start — what of the bound workspace this session used + ...workspaceUseFields(sessionID, telemetryTurn), + // altimate_change end }) + Telemetry.endSession(sessionID, telemetryTurn) // altimate_change — after the row, this turn only } process.once("beforeExit", emergencySessionEnd) process.once("exit", emergencySessionEnd) @@ -1005,6 +1039,10 @@ export namespace SessionPrompt { sessionID: sessionID, abort, callID: part.callID, + // altimate_change start — the turn's workspace and token, for the task's own rows + workspaceId: boundWorkspace?.datamateId, + turn: telemetryTurn, + // altimate_change end extra: { bypassAgentCheck: true }, // altimate_change start — fork MessageV2.WithParts ≡ core SessionV1.WithParts at the Tool.Context boundary messages: msgs as unknown as Tool.Context["messages"], @@ -1051,6 +1089,7 @@ export namespace SessionPrompt { sessionID, callID: part.id, args: taskArgs, + workspaceId: boundWorkspace?.datamateId, // altimate_change — the turn's workspace, for plugin rows }, result, ) @@ -1276,6 +1315,7 @@ export namespace SessionPrompt { // Cap reached — track and inject a synthetic hint so the LLM informs the user Telemetry.track({ type: "plan_revision", + ...turnWorkspace, // altimate_change — this turn's workspace, named explicitly timestamp: Date.now(), session_id: sessionID, revision_number: planRevisionCount, @@ -1360,6 +1400,7 @@ export namespace SessionPrompt { const action = isRejection ? "reject" : isApproval ? "approve" : "refine" Telemetry.track({ type: "plan_revision", + ...turnWorkspace, // altimate_change — this turn's workspace, named explicitly timestamp: Date.now(), session_id: sessionID, revision_number: planRevisionCount, @@ -1440,6 +1481,8 @@ export namespace SessionPrompt { messages: msgs, // altimate_change start — file rules append only to this tool result lessons, + workspaceId: boundWorkspace?.datamateId, + turn: telemetryTurn, // altimate_change end }), { step, agent: agent.name }, @@ -1497,6 +1540,7 @@ export namespace SessionPrompt { // "build" sessions). See the helper at the top of this namespace. Telemetry.track({ type: "session_start", + ...turnWorkspace, // altimate_change — this turn's workspace, named explicitly timestamp: Date.now(), session_id: sessionID, model_id: model.id, @@ -1548,6 +1592,7 @@ export namespace SessionPrompt { ) ?? "unknown" Telemetry.track({ type: "task_classified", + ...turnWorkspace, // altimate_change — this turn's workspace, named explicitly timestamp: Date.now(), session_id: sessionID, intent: intent as any, @@ -1581,7 +1626,7 @@ export namespace SessionPrompt { OnboardingTelemetry.isOnboardingSession(sessionID) && OnboardingTelemetry.claimFirstPrompt(sessionID) ) { - void OnboardingTelemetry.emit({ type: "first_prompt_sent" }, sessionID) + void OnboardingTelemetry.emit({ type: "first_prompt_sent", ...turnWorkspace }, sessionID) } // altimate_change end // altimate_change end — session start telemetry @@ -1621,6 +1666,9 @@ export namespace SessionPrompt { agent: agent.name, disableTraining: Flag.ALTIMATE_DISABLE_TRAINING, sessionID, + // altimate_change — the turn's workspace and token, for the injection row and its count + turn: telemetryTurn, + workspaceId: boundWorkspace?.datamateId, }) // altimate_change end // altimate_change start — workspace identity. @@ -1970,6 +2018,7 @@ export namespace SessionPrompt { // (verdict enums, counters, model names) passes through. Telemetry.track({ type: "validator_check", + ...turnWorkspace, // altimate_change — this turn's workspace, named explicitly timestamp: Date.now(), session_id: sessionID, validator_name: validator.name, @@ -2035,6 +2084,7 @@ export namespace SessionPrompt { }) Telemetry.track({ type: "validator_retries_exhausted", + ...turnWorkspace, // altimate_change — this turn's workspace, named explicitly timestamp: Date.now(), session_id: sessionID, step, @@ -2098,6 +2148,7 @@ export namespace SessionPrompt { // altimate_change start — emit quality signal, tool chain, and error fingerprint events Telemetry.track({ type: "task_outcome_signal", + ...turnWorkspace, // altimate_change — this turn's workspace, not the session's current entry timestamp: Date.now(), session_id: sessionID, signal: Telemetry.deriveQualitySignal(outcome), @@ -2105,11 +2156,14 @@ export namespace SessionPrompt { step_count: step, duration_ms: Date.now() - sessionStartTime, last_tool_category: lastToolCategory || "none", + // altimate_change — whether workspace skills, memory or integrations were in play + workspace_context_used: Object.values(Telemetry.workspaceUseOf(sessionID, telemetryTurn)).some((n) => n > 0), }) // Tool chain effectiveness — aggregated tool sequence + outcome if (toolChain.length > 0) { Telemetry.track({ type: "tool_chain_outcome", + ...turnWorkspace, // altimate_change — this turn's workspace, not the session's current entry timestamp: Date.now(), session_id: sessionID, chain: JSON.stringify(toolChain), @@ -2129,6 +2183,7 @@ export namespace SessionPrompt { for (const err of errorRecords.slice(0, 20)) { Telemetry.track({ type: "error_fingerprint", + ...turnWorkspace, // altimate_change — this turn's workspace, not the session's current entry timestamp: Date.now(), session_id: sessionID, error_hash: err.errorHash, @@ -2161,6 +2216,7 @@ export namespace SessionPrompt { // altimate_change end Telemetry.track({ type: "agent_outcome", + ...turnWorkspace, // altimate_change — this turn's workspace, not the session's current entry timestamp: Date.now(), session_id: sessionID, // altimate_change start — route through normalizeAgentName (shared with @@ -2186,13 +2242,18 @@ export namespace SessionPrompt { process.off("exit", emergencySessionEnd) Telemetry.track({ type: "session_end", + ...turnWorkspace, // altimate_change — this turn's workspace, not the session's current entry timestamp: Date.now(), session_id: sessionID, total_cost: sessionTotalCost, total_tokens: sessionTotalTokens, tool_call_count: toolCallCount, duration_ms: Date.now() - sessionStartTime, + // altimate_change start — what of the bound workspace this session used + ...workspaceUseFields(sessionID, telemetryTurn), + // altimate_change end }) + Telemetry.endSession(sessionID, telemetryTurn) // altimate_change — after the row, this turn only } await Telemetry.shutdown() // altimate_change end @@ -2294,6 +2355,10 @@ export namespace SessionPrompt { messages: MessageV2.WithParts[] // altimate_change start — optional for callers outside the main prompt loop lessons?: LessonDelivery + /** The workspace the calling turn resolved, for the rows its MCP calls emit, and the turn itself, + * so a call landing after the turn was replaced does not count toward the replacement. */ + workspaceId?: number + turn?: number // altimate_change end }) { using _ = log.time("resolveTools") @@ -2307,6 +2372,10 @@ export namespace SessionPrompt { abort: options.abortSignal!, messageID: input.processor.message.id, callID: options.toolCallId, + // altimate_change start — the turn's workspace and token, for the rows the tool emits + workspaceId: input.workspaceId, + turn: input.turn, + // altimate_change end // altimate_change start — batch inner calls reuse the prompt's lesson delivery state extra: { model: input.model, bypassAgentCheck: input.bypassAgentCheck, lessons: input.lessons }, // altimate_change end @@ -2419,6 +2488,7 @@ export namespace SessionPrompt { sessionID: ctx.sessionID, callID: ctx.callID, args, + workspaceId: input.workspaceId, // altimate_change — the turn's workspace, for plugin rows }, // altimate_change start — plugins observe the source-stamped output stamped, @@ -2484,7 +2554,35 @@ export namespace SessionPrompt { ) // altimate_change end - const result = await execute(args, opts) + // altimate_change start — a workspace integration call is tracked here, where MCP tools run + const startedAt = Date.now() + let result + try { + result = await execute(args, opts) + } catch (err) { + trackWorkspaceToolCall({ + key, + clientName, + sessionID: ctx.sessionID, + messageID: ctx.messageID, + workspaceId: input.workspaceId, + startedAt, + outcome: "error", + error: err, + }) + throw err + } + trackWorkspaceToolCall({ + key, + clientName, + sessionID: ctx.sessionID, + messageID: ctx.messageID, + workspaceId: input.workspaceId, + turn: input.turn, + startedAt, + outcome: (result as { isError?: boolean }).isError === true ? "error" : "success", + }) + // altimate_change end await Plugin.trigger( "tool.execute.after", @@ -2493,6 +2591,7 @@ export namespace SessionPrompt { sessionID: ctx.sessionID, callID: opts.toolCallId, args, + workspaceId: input.workspaceId, // altimate_change — the turn's workspace, for plugin rows }, result, ) @@ -2810,6 +2909,8 @@ export namespace SessionPrompt { const model = await Provider.getModel(info.model.providerID, info.model.modelID) const readCtx: Tool.Context = { sessionID: input.sessionID, + // altimate_change — the project's binding, so the attachment read's tool rows name the workspace + workspaceId: (await readLocalBinding(Instance.directory).catch(() => null))?.datamateId, abort: new AbortController().signal, agent: input.agent!, messageID: info.id, diff --git a/packages/opencode/src/tool/batch.ts b/packages/opencode/src/tool/batch.ts index c1eeb0adbc..720eb92413 100644 --- a/packages/opencode/src/tool/batch.ts +++ b/packages/opencode/src/tool/batch.ts @@ -23,6 +23,9 @@ function toEffectContext(ctx: LegacyContext, callID: string): Tool.Context { agent: ctx.agent, abort: ctx.abort, callID, + // the turn's workspace and token, so the inner tools' rows name it + workspaceId: ctx.workspaceId, + turn: ctx.turn, extra: ctx.extra, messages: ctx.messages, metadata: (input) => Effect.sync(() => ctx.metadata(input)), diff --git a/packages/opencode/src/tool/skill.ts b/packages/opencode/src/tool/skill.ts index a33dd49a34..26271bb804 100644 --- a/packages/opencode/src/tool/skill.ts +++ b/packages/opencode/src/tool/skill.ts @@ -19,11 +19,16 @@ import { selectSkillsWithLLM } from "../altimate/skill-selector" import { Telemetry } from "../altimate/telemetry" import os from "os" import { isBuiltinSkillLocation } from "../altimate/skill-source" +import { Instance } from "../project/instance" const MAX_DISPLAY_SKILLS = 50 // altimate_change start — classifySkillSource helper for skill telemetry + source badge -export function classifySkillSource(location: string): "builtin" | "global" | "project" { +export function classifySkillSource( + location: string, + /** This project's root; with it, only its own managed folder is the workspace's. */ + projectRoot?: string, +): "builtin" | "global" | "project" | "workspace" { // Normalize separators so `.altimate/builtin` / homedir prefix match on Windows too. const normalized = location.replace(/\\/g, "/") // Embedded skills load with a `builtin:/SKILL.md` location and Altimate's @@ -33,6 +38,14 @@ export function classifySkillSource(location: string): "builtin" | "global" | "p // `node_modules/` isn't tagged as Altimate. // altimate_change - the same predicate the skill listing labels with, so the two cannot drift apart if (isBuiltinSkillLocation(location)) return "builtin" + // altimate_change start — skills synced from the bound workspace live under the managed + // `_workspace` folder of this project; they are the workspace's, not the project's. Another + // project's managed folder is not this session's workspace. + if (projectRoot !== undefined) { + const root = projectRoot.replace(/\\/g, "/").replace(/\/+$/, "") + if (normalized.startsWith(`${root}/.altimate-code/skill/_workspace/`)) return "workspace" + } else if (/\/\.altimate-code\/skill\/_workspace\//.test(normalized)) return "workspace" + // altimate_change end if (normalized.startsWith(os.homedir().replace(/\\/g, "/"))) return "global" return "project" } @@ -256,7 +269,13 @@ export const SkillTool = Tool.define("skill", async (ctx) => { // altimate_change end // altimate_change start — classify origin once, reused for telemetry and the source badge - const skillOrigin = classifySkillSource(skill.location) + const skillOrigin = classifySkillSource(skill.location, Instance.directory) + // altimate_change start — count workspace skill use for the session's summary rows + // Only a bound turn's use counts: a stale `_workspace` folder in an unbound project is not the workspace's. + if (skillOrigin === "workspace" && ctx.workspaceId !== undefined) { + Telemetry.noteWorkspaceUse(ctx.sessionID, "skills", 1, "add", ctx.turn) + } + // altimate_change end // altimate_change end // altimate_change start — telemetry instrumentation for skill loading with trigger classification @@ -265,6 +284,7 @@ export const SkillTool = Tool.define("skill", async (ctx) => { type: "skill_used", timestamp: Date.now(), session_id: ctx.sessionID, + workspace_id: ctx.workspaceId, // altimate_change — the turn's workspace message_id: ctx.messageID, skill_name: skill.name, skill_source: skillOrigin, diff --git a/packages/opencode/src/tool/tool.ts b/packages/opencode/src/tool/tool.ts index c4bd52eca1..8758656029 100644 --- a/packages/opencode/src/tool/tool.ts +++ b/packages/opencode/src/tool/tool.ts @@ -55,6 +55,10 @@ export class InvalidArgumentsError extends Schema.TaggedErrorClass = { sessionID: SessionID messageID: MessageID + // altimate_change start — the calling turn's workspace and token, named on the rows a tool emits + workspaceId?: number + turn?: number + // altimate_change end agent: string abort: AbortSignal callID?: string @@ -139,6 +143,7 @@ function altimateTrackError(id: string, args: unknown, ctx: Context, startTime: type: "tool_call", timestamp: Date.now(), session_id: ctx.sessionID, + workspace_id: ctx.workspaceId, // altimate_change — the turn's workspace message_id: ctx.messageID, tool_name: id, tool_type: "standard", @@ -154,6 +159,7 @@ function altimateTrackError(id: string, args: unknown, ctx: Context, startTime: type: "core_failure", timestamp: Date.now(), session_id: ctx.sessionID, + workspace_id: ctx.workspaceId, // altimate_change — the turn's workspace tool_name: id, tool_category: Telemetry.categorizeToolName(id, "standard"), error_class: Telemetry.classifyError(errorMsg), @@ -184,6 +190,7 @@ function altimateTrackSuccess( type: "tool_call", timestamp: Date.now(), session_id: ctx.sessionID, + workspace_id: ctx.workspaceId, // altimate_change — the turn's workspace message_id: ctx.messageID, tool_name: id, tool_type: "standard", @@ -221,6 +228,7 @@ function altimateTrackSuccess( type: "core_failure", timestamp: Date.now(), session_id: ctx.sessionID, + workspace_id: ctx.workspaceId, // altimate_change — the turn's workspace tool_name: id, tool_category: Telemetry.categorizeToolName(id, "standard"), error_class: Telemetry.classifyError(errorMsg), @@ -238,6 +246,7 @@ function altimateTrackSuccess( type: "sql_quality", timestamp: Date.now(), session_id: ctx.sessionID, + workspace_id: ctx.workspaceId, // altimate_change — the turn's workspace tool_name: id, tool_category: toolCategory, finding_count: findings.length, diff --git a/packages/opencode/test/altimate/telemetry/onboarding.test.ts b/packages/opencode/test/altimate/telemetry/onboarding.test.ts index fd252a6c02..266a394eac 100644 --- a/packages/opencode/test/altimate/telemetry/onboarding.test.ts +++ b/packages/opencode/test/altimate/telemetry/onboarding.test.ts @@ -215,6 +215,25 @@ describe("activation events", () => { expect((events.find((e) => e.type === "first_job_completed") as any).job).toBe("sample_duck_db") }) + test("the first job's row names the workspace the hook input carries", async () => { + const events = captureEvents() + const hooks = await plugin() + await startOnboarding(hooks, "skip") + + // The trigger site sets `workspaceId` from the turn that ran the tool; the hook never asks the + // session for its current turn (a replacement's, when a cancelled turn's tool returns late). + await runTool( + hooks, + { tool: "sample_setup", sessionID: SESSION, callID: "c3w", args: {}, workspaceId: 7 }, + { title: "", output: "", metadata: { success: true } }, + ) + await settle() + + const completed = events.find((e) => e.type === "first_job_completed") as any + expect(completed.workspace_id).toBe(7) + expect("workspace_id" in (events.find((e) => e.type === "activation_job_selected") as any)).toBe(false) + }) + test("a failed sample setup counts as selected but not completed", async () => { const events = captureEvents() const hooks = await plugin() diff --git a/packages/opencode/test/altimate/telemetry/workspace-context.test.ts b/packages/opencode/test/altimate/telemetry/workspace-context.test.ts new file mode 100644 index 0000000000..758c5b4474 --- /dev/null +++ b/packages/opencode/test/altimate/telemetry/workspace-context.test.ts @@ -0,0 +1,142 @@ +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test" +import { mkdtempSync, rmSync } from "fs" +import os from "os" +import path from "path" +import { Telemetry } from "../../../src/altimate/telemetry" + +describe("workspace id on the telemetry context", () => { + let home: string + let posts: string[] + let fetchSpy: ReturnType + let homedirSpy: ReturnType + let savedCs: string | undefined + let savedHome: string | undefined + // Either disable flag wins over the connection string; cleared for the real init below and restored. + const DISABLE_FLAGS = ["ALTIMATE_TELEMETRY_DISABLED", "OPENCODE_DISABLE_TELEMETRY"] as const + let savedFlags: Record + + beforeEach(() => { + home = mkdtempSync(path.join(os.tmpdir(), "telemetry-ws-")) + savedCs = process.env.APPLICATIONINSIGHTS_CONNECTION_STRING + savedFlags = {} + for (const flag of DISABLE_FLAGS) { + savedFlags[flag] = process.env[flag] + delete process.env[flag] + } + savedHome = process.env.OPENCODE_TEST_HOME + process.env.OPENCODE_TEST_HOME = home + process.env.APPLICATIONINSIGHTS_CONNECTION_STRING = + "InstrumentationKey=00000000-0000-0000-0000-000000000000;IngestionEndpoint=https://ingest.example.invalid/" + homedirSpy = spyOn(os, "homedir").mockImplementation(() => home) + posts = [] + fetchSpy = spyOn(globalThis, "fetch").mockImplementation((async (input: any, init?: RequestInit) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url + if (url.includes("/v2/track")) posts.push(String(init?.body ?? "")) + return new Response("", { status: 200 }) + }) as any) + }) + + afterEach(async () => { + await Telemetry.shutdown() + fetchSpy.mockRestore() + homedirSpy.mockRestore() + if (savedCs === undefined) delete process.env.APPLICATIONINSIGHTS_CONNECTION_STRING + else process.env.APPLICATIONINSIGHTS_CONNECTION_STRING = savedCs + if (savedHome === undefined) delete process.env.OPENCODE_TEST_HOME + else process.env.OPENCODE_TEST_HOME = savedHome + for (const flag of DISABLE_FLAGS) { + if (savedFlags[flag] === undefined) delete process.env[flag] + else process.env[flag] = savedFlags[flag] + } + rmSync(home, { recursive: true, force: true }) + }) + + const envelopes = () => posts.flatMap((b) => JSON.parse(b) as any[]) + const startup = (command: string) => + ({ + type: "startup_ready", + session_id: "", + command, + duration_ms: 1, + fresh_install: false, + timestamp: Date.now(), + }) as const + + test("workspace use is counted per session and cleared explicitly", () => { + Telemetry.clearWorkspaceUse("s9") + expect(Telemetry.workspaceUseOf("s9")).toEqual({ skills: 0, memory: 0, integrations: 0 }) + Telemetry.noteWorkspaceUse("s9", "skills") + Telemetry.noteWorkspaceUse("s9", "memory", 3) + Telemetry.noteWorkspaceUse("s9", "integrations") + Telemetry.noteWorkspaceUse("s9", "integrations") + Telemetry.noteWorkspaceUse("", "skills") // no session: ignored + Telemetry.noteWorkspaceUse("s9", "skills", 0) // nothing to count + expect(Telemetry.workspaceUseOf("s9")).toEqual({ skills: 1, memory: 3, integrations: 2 }) + // Memory is injected on every step of a turn: "max" keeps the largest injection, not a sum. + Telemetry.noteWorkspaceUse("s9", "memory", 2, "max") + Telemetry.noteWorkspaceUse("s9", "memory", 5, "max") + Telemetry.noteWorkspaceUse("s9", "memory", 4, "max") + expect(Telemetry.workspaceUseOf("s9").memory).toBe(5) + expect(Telemetry.workspaceUseOf("other")).toEqual({ skills: 0, memory: 0, integrations: 0 }) + Telemetry.clearWorkspaceUse("s9") + expect(Telemetry.workspaceUseOf("s9")).toEqual({ skills: 0, memory: 0, integrations: 0 }) + }) + + test("a turn ends only its own counters; a stale turn's use does not count", () => { + const first = Telemetry.setContext({ sessionId: "s7", projectId: "p1", workspaceId: 42 }) + Telemetry.noteWorkspaceUse("s7", "skills") + const second = Telemetry.setContext({ sessionId: "s7", projectId: "p1", workspaceId: 42 }) + expect(second).toBeGreaterThan(first) + Telemetry.noteWorkspaceUse("s7", "memory", 2) + // Counts are per turn: the cancelled turn's rows read its own, the replacement's its own. + expect(Telemetry.workspaceUseOf("s7", first)).toEqual({ skills: 1, memory: 0, integrations: 0 }) + expect(Telemetry.workspaceUseOf("s7", second)).toEqual({ skills: 0, memory: 2, integrations: 0 }) + Telemetry.endSession("s7", first) // the old generation, after the replacement started + expect(Telemetry.workspaceUseOf("s7", first)).toEqual({ skills: 0, memory: 0, integrations: 0 }) + expect(Telemetry.workspaceUseOf("s7").memory).toBe(2) + // A use landing with a stale turn does not count toward the replacement. + Telemetry.noteWorkspaceUse("s7", "integrations", 1, "add", first) + expect(Telemetry.workspaceUseOf("s7").integrations).toBe(0) + Telemetry.noteWorkspaceUse("s7", "integrations", 1, "add", second) + expect(Telemetry.workspaceUseOf("s7").integrations).toBe(1) + Telemetry.endSession("s7", second) + expect(Telemetry.workspaceUseOf("s7")).toEqual({ skills: 0, memory: 0, integrations: 0 }) + // Tokens never repeat, so a stale end can never hit a later turn. + const stale = Telemetry.setContext({ sessionId: "s8", projectId: "p1", workspaceId: 5 }) + Telemetry.endSession("s8", stale) + const fresh = Telemetry.setContext({ sessionId: "s8", projectId: "p1", workspaceId: 6 }) + expect(fresh).toBeGreaterThan(stale) + Telemetry.noteWorkspaceUse("s8", "skills") + Telemetry.endSession("s8", stale) + expect(Telemetry.workspaceUseOf("s8", fresh).skills).toBe(1) + Telemetry.endSession("s8", fresh) + Telemetry.setContext({ sessionId: "", projectId: "" }) + }) + + test("a row carries the workspace it names and nothing is stamped from the context", async () => { + await Telemetry.shutdown() + await Telemetry.init() + Telemetry.setContext({ sessionId: "s1", projectId: "p1", workspaceId: 42 }) + expect(Telemetry.getContext().workspaceId).toBe(42) + + Telemetry.track(startup("bound")) // names none: carries none, whatever the context holds + Telemetry.track({ + type: "workspace_linked", + timestamp: Date.now(), + session_id: "s1", + workspace_id: 7, + how: "bind_existing", + }) + Telemetry.track({ type: "workspace_unlinked", timestamp: Date.now(), session_id: "s1" }) + Telemetry.track({ type: "workspace_unlinked", timestamp: Date.now(), session_id: "s1", workspace_id: undefined }) + await Telemetry.flush() + const bound = envelopes().find((e) => e.data.baseData.properties.command === "bound") + expect("workspace_id" in bound.data.baseData.properties).toBe(false) + expect(bound.data.baseData.properties.client).toBe("altimate-code") + const linked = envelopes().find((e) => e.data.baseData.name === "workspace_linked") + expect(linked.data.baseData.properties.workspace_id).toBe("7") + const unlinked = envelopes().filter((e) => e.data.baseData.name === "workspace_unlinked") + expect(unlinked.map((e) => e.data.baseData.properties.workspace_id)).toEqual([undefined, undefined]) + Telemetry.endSession("s1") + }) +}) diff --git a/packages/opencode/test/altimate/workspace/memory-sync.test.ts b/packages/opencode/test/altimate/workspace/memory-sync.test.ts index b32598d174..1f1ad25578 100644 --- a/packages/opencode/test/altimate/workspace/memory-sync.test.ts +++ b/packages/opencode/test/altimate/workspace/memory-sync.test.ts @@ -6,7 +6,7 @@ // the mirror actually issues — method, path, body — rather than a mock's call // log. Cases claiming "nothing was sent" check a zero request count, not merely // the absence of a throw. -import { afterAll, afterEach, beforeEach, describe, expect, test } from "bun:test" +import { afterAll, afterEach, beforeEach, describe, expect, test, spyOn } from "bun:test" import { mkdirSync, mkdtempSync, rmSync, statSync, writeFileSync } from "node:fs" import path from "node:path" import os from "node:os" @@ -53,9 +53,7 @@ const { toBlock, whenHydrated, } = await import("../../../src/altimate/workspace/memory-sync") -const { MIRROR_SOURCE, extractRecordId, LIST_LIMIT } = await import( - "../../../src/altimate/workspace/memory-api" -) +const { MIRROR_SOURCE, extractRecordId, LIST_LIMIT } = await import("../../../src/altimate/workspace/memory-api") import { AltimateApi } from "../../../src/altimate/api/client" @@ -113,7 +111,8 @@ function stubFetch() { if (method === "POST") { const body = init?.body ? JSON.parse(init.body) : {} for (const rec of Array.isArray(createResult) ? createResult : []) { - if (rec?.id) listResponse.push({ id: rec.id, memory: body.messages?.[0]?.content ?? "", metadata: body.metadata }) + if (rec?.id) + listResponse.push({ id: rec.id, memory: body.messages?.[0]?.content ?? "", metadata: body.metadata }) } } return { message: "ok", result: createResult } @@ -121,8 +120,7 @@ function stubFetch() { if (url.includes("/datamates/")) return { datamates: workspaces } return { message: "ok" } })() - const status = - url.includes("/datamate-project-bindings/by-") && !serverBinding ? 404 : 200 + const status = url.includes("/datamate-project-bindings/by-") && !serverBinding ? 404 : 200 return new Response(JSON.stringify(payload), { status, headers: { "Content-Type": "application/json" }, @@ -182,10 +180,8 @@ afterEach(() => { // seam fall through to the real lookup, so a leaked value from a previous // test would decide their outcome. serverBinding = null - ;(AltimateApi as unknown as { isConfigured: typeof originalIsConfigured }).isConfigured = - originalIsConfigured - ;(AltimateApi as unknown as { getCredentials: typeof originalGetCreds }).getCredentials = - originalGetCreds + ;(AltimateApi as unknown as { isConfigured: typeof originalIsConfigured }).isConfigured = originalIsConfigured + ;(AltimateApi as unknown as { getCredentials: typeof originalGetCreds }).getCredentials = originalGetCreds delete process.env.ALTIMATE_DISABLE_WORKSPACE delete syncInternals.resolveBinding delete syncInternals.blockExists @@ -516,7 +512,11 @@ describe("mirrorBlock", () => { createResult = [{ id: "mem-archive-late" }] await mirrorBlock(b) listResponse = [ - { id: "mem-archive-late", memory: b.content, metadata: { source: MIRROR_SOURCE, block_id: "to-archive-late", block_scope: "global" } }, + { + id: "mem-archive-late", + memory: b.content, + metadata: { source: MIRROR_SOURCE, block_id: "to-archive-late", block_scope: "global" }, + }, ] const net = gatedFetch() let settled = false @@ -828,8 +828,7 @@ describe("mirrorBlock", () => { // incrementApplied rewrites a training block on EVERY session start to bump // a counter inside the content body. Mirroring that would cost a request // per training block per session. - const meta = (applied: number) => - `\nAlways alias CTEs.` + const meta = (applied: number) => `\nAlways alias CTEs.` const b = block({ id: "training/rule/aliases", tags: ["training", "rule"], content: meta(1) }) await mirrorBlock(b) captured = [] @@ -876,7 +875,7 @@ describe("memory_enabled", () => { expect(callsTo("/datamates/memory/", "POST").length).toBe(0) workspaces = [{ id: 42, name: "acme", memory_enabled: true }] - serverBinding = null + serverBinding = null captured = [] await mirrorBlock(block({ id: "after-enable" })) expect(callsTo("/datamates/memory/", "POST").length).toBe(1) @@ -955,10 +954,7 @@ describe("toBlock", () => { test("a record from this same project is not labelled", () => { const own = "ssh://git@github.com/acme/analytics.git" - const b = toBlock( - record({ source: MIRROR_SOURCE, block_id: "x", block_scope: "project", repo_remote: own }), - own, - ) + const b = toBlock(record({ source: MIRROR_SOURCE, block_id: "x", block_scope: "project", repo_remote: own }), own) expect(b?.origin).toBeUndefined() }) @@ -990,10 +986,7 @@ describe("tag encoding", () => { // The previous comma-joined form split this tag into two on read. const tags = ["warehouse", "owner: data, platform", "prod"] const meta = buildMetadata(block({ tags }), null) - const back = toBlock( - { id: "1", memory: "x", metadata: { ...meta, source: MIRROR_SOURCE } } as any, - undefined, - ) + const back = toBlock({ id: "1", memory: "x", metadata: { ...meta, source: MIRROR_SOURCE } } as any, undefined) expect(back?.tags).toEqual(tags) }) @@ -1053,9 +1046,7 @@ describe("reaping blocks archived elsewhere", () => { const seeThrough = (blocks: Record) => { const removed: { id: string; scope: string; directory?: string; expectUpdated?: string }[] = [] syncInternals.readBlock = async (scope, id) => - blocks[id] - ? ({ id, scope, content: "c", tags: [], created: "", updated: blocks[id].updated } as any) - : undefined + blocks[id] ? ({ id, scope, content: "c", tags: [], created: "", updated: blocks[id].updated } as any) : undefined syncInternals.removeBlock = async (scope, id, directory, expectUpdated) => { removed.push({ id, scope, directory, expectUpdated }) return true @@ -1152,7 +1143,11 @@ describe("reaping blocks archived elsewhere", () => { const removed = seeThrough({ split: { updated: "2026-05-01T00:00:00.000Z" } }) listResponse = [ // The live primary. - { id: "mem-primary", memory: "kept", metadata: { source: MIRROR_SOURCE, block_id: "split", block_scope: "global" } }, + { + id: "mem-primary", + memory: "kept", + metadata: { source: MIRROR_SOURCE, block_id: "split", block_scope: "global" }, + }, // The extra, archived at push time. archived("split", { archived_at: "2026-06-01T00:00:00.000Z" }), ] @@ -1351,7 +1346,11 @@ describe("hydrate", () => { test("drops archived records", async () => { listResponse = [ { id: "1", memory: "live", metadata: { source: MIRROR_SOURCE, block_id: "live", block_scope: "global" } }, - { id: "2", memory: "gone", metadata: { source: MIRROR_SOURCE, block_id: "gone", block_scope: "global", archived: "true" } }, + { + id: "2", + memory: "gone", + metadata: { source: MIRROR_SOURCE, block_id: "gone", block_scope: "global", archived: "true" }, + }, ] await hydrate(SES) expect(overlayBlocks(SES).map((b) => b.id)).toEqual(["live"]) @@ -1360,7 +1359,11 @@ describe("hydrate", () => { test("drops project records belonging to another workspace", async () => { listResponse = [ { id: "1", memory: "g", metadata: { source: MIRROR_SOURCE, block_id: "g", block_scope: "global" } }, - { id: "2", memory: "other", metadata: { source: MIRROR_SOURCE, block_id: "other", block_scope: "project", datamate_id: "999" } }, + { + id: "2", + memory: "other", + metadata: { source: MIRROR_SOURCE, block_id: "other", block_scope: "project", datamate_id: "999" }, + }, ] await hydrate(SES) expect(overlayBlocks(SES).map((b) => b.id)).toEqual(["g"]) @@ -1434,7 +1437,11 @@ describe("archiveBlock", () => { await mirrorBlock(b) captured = [] listResponse = [ - { id: "mem-archive", memory: b.content, metadata: { source: MIRROR_SOURCE, block_id: "to-archive", block_scope: "global" } }, + { + id: "mem-archive", + memory: b.content, + metadata: { source: MIRROR_SOURCE, block_id: "to-archive", block_scope: "global" }, + }, ] await archiveBlock("global", "to-archive") @@ -1572,6 +1579,26 @@ describe("backfill", () => { block({ id: `${prefix}/${i}`, content: `Block ${prefix} ${i} describes a warehouse convention.` }), ) + test("every block stored in the workspace reports workspace_memory_contributed with the workspace id", async () => { + const { Telemetry } = await import("../../../src/altimate/telemetry") + const events: any[] = [] + const spy = spyOn(Telemetry, "track").mockImplementation((e) => { + events.push(e) + }) + try { + createResult = [{ id: "mem-contrib" }] + // Project-scope blocks are the ones bound to the workspace; global memory is the account's. + const bound = blocks("contrib", 2).map((b) => ({ ...b, scope: "project" })) + const result = await backfill(bound, BINDING as any) + expect(result.ok).toBe(2) + const contributed = events.filter((e) => e.type === "workspace_memory_contributed") + expect(contributed).toHaveLength(2) + expect(contributed[0]).toEqual(expect.objectContaining({ workspace_id: 42, block_count: 1 })) + } finally { + spy.mockRestore() + } + }) + test("reads the record set once for the whole sweep, not once per block", async () => { // Every block in a first bind is an index miss, so resolving each through // its own lookup made a bind cost one full fetch per block. @@ -1748,7 +1775,11 @@ describe("binding changes", () => { // The first lookup adopts (and notifies); later ones read the cache, as in production. if (!adopted) { adopted = true - await recordApprovedBinding(dir, { ...BINDING, projectPath: dir, linkedAt: 3 }, { awaitBackfill: true, seed: false }) + await recordApprovedBinding( + dir, + { ...BINDING, projectPath: dir, linkedAt: 3 }, + { awaitBackfill: true, seed: false }, + ) } return BINDING as any } @@ -1779,7 +1810,11 @@ describe("refresh racing a relink", () => { await reachedList const { recordApprovedBinding } = await import("../../../src/altimate/workspace/state") const dir = mkdtempSync(path.join(SANDBOX, "race-")) - await recordApprovedBinding(dir, { ...BINDING, datamateId: 44, projectPath: dir, linkedAt: 4 }, { awaitBackfill: true, seed: false }) + await recordApprovedBinding( + dir, + { ...BINDING, datamateId: 44, projectPath: dir, linkedAt: 4 }, + { awaitBackfill: true, seed: false }, + ) release?.() const result = await pending // Superseded: neither its read nor the prior overlay belongs to the new binding. @@ -1803,7 +1838,11 @@ describe("overlay invalidation", () => { await hydrate(SES) expect(overlayBlocks(SES).length).toBe(1) const dir = mkdtempSync(path.join(SANDBOX, "hide-")) - await recordApprovedBinding(dir, { ...BINDING, datamateId: 45, projectPath: dir, linkedAt: 5 }, { awaitBackfill: true, seed: false }) + await recordApprovedBinding( + dir, + { ...BINDING, datamateId: 45, projectPath: dir, linkedAt: 5 }, + { awaitBackfill: true, seed: false }, + ) expect(overlayBlocks(SES)).toEqual([]) }) @@ -1846,7 +1885,11 @@ describe("epoch bracketing and scope", () => { calls++ // First lookup returns A, but the relink to B lands before it returns. if (calls === 1) { - await recordApprovedBinding(dir, { ...BINDING, datamateId: 46, projectPath: dir, linkedAt: 6 }, { awaitBackfill: true, seed: false }) + await recordApprovedBinding( + dir, + { ...BINDING, datamateId: 46, projectPath: dir, linkedAt: 6 }, + { awaitBackfill: true, seed: false }, + ) return BINDING as any } return { ...BINDING, datamateId: 46 } as any @@ -1868,10 +1911,18 @@ describe("epoch bracketing and scope", () => { const other = mkdtempSync(path.join(SANDBOX, "other-")) await refresh(SES, mine) expect(overlayBlocks(SES).map((x) => x.id)).toEqual(["mine"]) - await recordApprovedBinding(other, { ...BINDING, datamateId: 47, projectPath: other, linkedAt: 7 }, { awaitBackfill: true, seed: false }) + await recordApprovedBinding( + other, + { ...BINDING, datamateId: 47, projectPath: other, linkedAt: 7 }, + { awaitBackfill: true, seed: false }, + ) expect(overlayBlocks(SES).map((x) => x.id)).toEqual(["mine"]) // A change to this project's own binding still hides it. - await recordApprovedBinding(mine, { ...BINDING, datamateId: 48, projectPath: mine, linkedAt: 8 }, { awaitBackfill: true, seed: false }) + await recordApprovedBinding( + mine, + { ...BINDING, datamateId: 48, projectPath: mine, linkedAt: 8 }, + { awaitBackfill: true, seed: false }, + ) expect(overlayBlocks(SES)).toEqual([]) }) }) @@ -1930,9 +1981,7 @@ describe("session isolation and turn behaviour", () => { // The caller's enclosing block runs on EVERY user turn, not once per // session. Without idempotence a 20-turn conversation costs 40 requests, // and the first injection of every turn blocks on them. - listResponse = [ - { id: "1", memory: "x", metadata: { source: MIRROR_SOURCE, block_id: "x", block_scope: "global" } }, - ] + listResponse = [{ id: "1", memory: "x", metadata: { source: MIRROR_SOURCE, block_id: "x", block_scope: "global" } }] await hydrate(SES) await hydrate(SES) await hydrate(SES) @@ -1970,9 +2019,7 @@ describe("session isolation and turn behaviour", () => { }) test("overlayBlocks returns a copy, so a caller cannot corrupt the cache", async () => { - listResponse = [ - { id: "1", memory: "x", metadata: { source: MIRROR_SOURCE, block_id: "x", block_scope: "global" } }, - ] + listResponse = [{ id: "1", memory: "x", metadata: { source: MIRROR_SOURCE, block_id: "x", block_scope: "global" } }] await hydrate(SES) overlayBlocks(SES).length = 0 expect(overlayBlocks(SES).length).toBe(1) @@ -2031,9 +2078,7 @@ describe("binding resolution for the mirror", () => { await mirrorBlock(block(), dir) - const posts = captured.filter( - (c) => c.method === "POST" && c.url.includes("/datamates/memory/"), - ) + const posts = captured.filter((c) => c.method === "POST" && c.url.includes("/datamates/memory/")) expect(posts.length).toBe(1) }) @@ -2047,9 +2092,7 @@ describe("binding resolution for the mirror", () => { await mirrorBlock(block(), dir) - const posts = captured.filter( - (c) => c.method === "POST" && c.url.includes("/datamates/memory/"), - ) + const posts = captured.filter((c) => c.method === "POST" && c.url.includes("/datamates/memory/")) expect(posts.length).toBe(0) }) }) @@ -2093,7 +2136,11 @@ describe("sync state", () => { listResponse = [note("a", "Naming conventions", "2026-02-01T00:00:00.000Z"), note("c", "Cost alerts")] await refresh(`${SES}-state-3`, dir) const entity = await memoryState(dir, (e) => e.changes !== null) - expect(entity?.changes).toEqual({ added: ["Cost alerts"], removed: ["Warehouse sizing"], updated: ["Naming conventions"] }) + expect(entity?.changes).toEqual({ + added: ["Cost alerts"], + removed: ["Warehouse sizing"], + updated: ["Naming conventions"], + }) expect(entity?.count).toBe(2) }) @@ -2190,7 +2237,9 @@ describe("sync state", () => { listFails = false } const failed = await memoryState(dir, (e) => e.status === "error") - expect(failed).toMatchObject({ status: "error", count: 1, error: "could not load workspace memory" }) + expect(failed).toMatchObject({ status: "error", count: 1 }) + // The record keeps what failed, so the sync-failure reason class is real rather than "other". + expect(failed?.error).toMatch(/^could not load workspace memory: /) // "memory loaded Xm ago" reads the last success, which the failure did not move. expect(failed?.lastSucceededAt).toBe(loaded!.lastSucceededAt) }) @@ -2226,12 +2275,18 @@ describe("sync state", () => { // project is relinked. let release!: () => void const gate = new Promise((r) => (release = r)) - const holder = SyncState.record(dir, BINDING.datamateId, "memory", {}, { - isCurrent: async () => { - await gate - return false + const holder = SyncState.record( + dir, + BINDING.datamateId, + "memory", + {}, + { + isCurrent: async () => { + await gate + return false + }, }, - }) + ) listResponse = [] await refresh(`${SES}-state-late-2`, dir) // Unlink: every binding epoch moves, and the queued "nothing left" record is now stale. diff --git a/packages/opencode/test/altimate/workspace/starter.test.ts b/packages/opencode/test/altimate/workspace/starter.test.ts index 52a2104d1b..547393bea3 100644 --- a/packages/opencode/test/altimate/workspace/starter.test.ts +++ b/packages/opencode/test/altimate/workspace/starter.test.ts @@ -1,6 +1,7 @@ // altimate_change - new file -import { describe, expect, test } from "bun:test" -import { renderStarter, wrapStarter } from "../../../src/altimate/workspace/starter" +import { describe, expect, spyOn, test } from "bun:test" +import { renderStarter, trackStarterShown, wrapStarter } from "../../../src/altimate/workspace/starter" +import { Telemetry } from "../../../src/altimate/telemetry" import type { WorkspaceContents } from "../../../src/altimate/workspace/contents" const full: WorkspaceContents = { @@ -46,7 +47,12 @@ describe("renderStarter", () => { }) test("says what an empty or unsynced workspace means, and suggests only what it can back", () => { - const empty = renderStarter("Acme", { skills: [], integrations: [], memoryEnabled: false, knowledge: { kind: "off" } }) + const empty = renderStarter("Acme", { + skills: [], + integrations: [], + memoryEnabled: false, + knowledge: { kind: "off" }, + }) expect(empty.lines).toEqual([ "Skills: none yet. Add them in the workspace and they sync here.", "Knowledge: off for this workspace.", @@ -110,3 +116,31 @@ describe("wrapStarter", () => { expect(wrapStarter("a\n\nb c", 80)).toBe("a\n\nb c") }) }) + +describe("starter_shown telemetry", () => { + test("reports what the workspace provides as counts, and leaves unknowns out", () => { + const events: any[] = [] + const spy = spyOn(Telemetry, "track").mockImplementation((e) => { + events.push(e) + }) + try { + trackStarterShown(5, full) + expect(events).toEqual([ + expect.objectContaining({ + type: "starter_shown", + workspace_id: 5, + skills: Array.isArray(full.skills) ? full.skills.length : undefined, + integrations: Array.isArray(full.integrations) ? full.integrations.length : undefined, + knowledge: full.knowledge?.kind ?? "unknown", + memory_enabled: full.memoryEnabled === true, + }), + ]) + events.length = 0 + trackStarterShown(6, { skills: "unknown", integrations: null, memoryEnabled: null, knowledge: null }) + expect(events[0]).toEqual(expect.objectContaining({ workspace_id: 6, knowledge: "unknown" })) + for (const key of ["skills", "integrations", "memory_enabled"]) expect(key in events[0]).toBe(false) + } finally { + spy.mockRestore() + } + }) +}) diff --git a/packages/opencode/test/altimate/workspace/sync-state.test.ts b/packages/opencode/test/altimate/workspace/sync-state.test.ts index c4e275e6d9..9796884a4d 100644 --- a/packages/opencode/test/altimate/workspace/sync-state.test.ts +++ b/packages/opencode/test/altimate/workspace/sync-state.test.ts @@ -61,7 +61,11 @@ describe("diff", () => { }) test("identical sets have no changes", () => { - expect(SyncState.diff({ a: skill("Alpha") }, { a: skill("Alpha") })).toEqual({ added: [], removed: [], updated: [] }) + expect(SyncState.diff({ a: skill("Alpha") }, { a: skill("Alpha") })).toEqual({ + added: [], + removed: [], + updated: [], + }) }) test("an id named like an inherited property is still an item", () => { @@ -75,7 +79,13 @@ describe("record", () => { await SyncState.record(project, 7, "skills", { items: { a: skill("Alpha"), b: skill("Beta") } }) const state = await SyncState.read(project, 7) - expect(state?.entities.skills).toMatchObject({ kind: "skills", status: "ok", count: 2, lastChangedAt: null, changes: null }) + expect(state?.entities.skills).toMatchObject({ + kind: "skills", + status: "ok", + count: 2, + lastChangedAt: null, + changes: null, + }) expect(state?.entities.skills?.lastCheckedAt).toBeNumber() expect(state?.entities.skills?.lastSucceededAt).toBe(state!.entities.skills!.lastCheckedAt) expect(published).toEqual([]) @@ -196,7 +206,13 @@ describe("record", () => { test("a late result from a previous binding is dropped", async () => { await SyncState.record(project, 8, "memory", { items: { m1: skill("New workspace block") } }) - await SyncState.record(project, 7, "memory", { error: "could not load workspace memory" }, { isCurrent: () => false }) + await SyncState.record( + project, + 7, + "memory", + { error: "could not load workspace memory" }, + { isCurrent: () => false }, + ) const state = await SyncState.read(project, 8) expect(state?.entities.memory).toMatchObject({ status: "ok", count: 1 }) @@ -298,3 +314,36 @@ describe("forget", () => { expect((await SyncState.read(project, 7))?.entities.memory?.count).toBe(1) }) }) + +describe("workspace_sync_failed telemetry", () => { + test("a kind flipping to error reports once, with a reason class; a repeat does not", async () => { + const { Telemetry } = await import("../../../src/altimate/telemetry") + const events: any[] = [] + const spy = spyOn(Telemetry, "track").mockImplementation((e) => { + events.push(e) + }) + try { + await SyncState.record(project, 9, "memory", { error: "401 unauthorized" }) + expect(events).toEqual([ + expect.objectContaining({ type: "workspace_sync_failed", workspace_id: 9, kind: "memory", reason: "auth" }), + ]) + await SyncState.record(project, 9, "memory", { error: "still unauthorized" }) // already in error: no new row + expect(events).toHaveLength(1) + await SyncState.record(project, 9, "memory", { items: { a: skill("Alpha") } }) // back to ok + await SyncState.record(project, 9, "memory", { error: "Cannot reach https://api: ECONNREFUSED" }) + expect(events).toHaveLength(2) + expect(events[1]).toEqual(expect.objectContaining({ kind: "memory", reason: "network" })) + } finally { + spy.mockRestore() + } + }) + + test("syncFailureReason classifies the messages the sync produces", () => { + expect(SyncState.syncFailureReason("Request failed with status 403")).toBe("auth") + expect(SyncState.syncFailureReason("Please sign in again")).toBe("auth") + expect(SyncState.syncFailureReason("Workspace not found (404)")).toBe("not_found") + expect(SyncState.syncFailureReason("Request to x timed out after 15s")).toBe("network") + expect(SyncState.syncFailureReason("The server returned no usable id")).toBe("other") + expect(SyncState.syncFailureReason(undefined)).toBe("other") + }) +}) diff --git a/packages/opencode/test/altimate/workspace/tool-call-telemetry.test.ts b/packages/opencode/test/altimate/workspace/tool-call-telemetry.test.ts new file mode 100644 index 0000000000..fc8389f040 --- /dev/null +++ b/packages/opencode/test/altimate/workspace/tool-call-telemetry.test.ts @@ -0,0 +1,105 @@ +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test" +import { Telemetry } from "../../../src/altimate/telemetry" +import { isWorkspaceClient, trackWorkspaceToolCall } from "../../../src/altimate/workspace/tool-call-telemetry" + +describe("workspace integration tool calls", () => { + let rows: any[] + let spy: ReturnType + beforeEach(() => { + rows = [] + spy = spyOn(Telemetry, "track").mockImplementation((e) => { + rows.push(e) + }) + Telemetry.endSession("s1") + }) + afterEach(() => { + spy.mockRestore() + Telemetry.endSession("s1") + Telemetry.endSession("s0") + // The singleton context is process-wide: left at s1/42 it would stamp a later suite's + // session-less rows. + Telemetry.setContext({ sessionId: "", projectId: "" }) + }) + + const call = ( + clientName: string, + outcome: "success" | "error", + error?: unknown, + workspaceId: number | undefined = 42, + ) => + trackWorkspaceToolCall({ + key: `${clientName}_jira_get_issue`, + clientName, + sessionID: "s1", + messageID: "m1", + workspaceId, + startedAt: Date.now() - 5, + outcome, + error, + }) + + test("the engine client's calls become tool_call rows with via_workspace; a success counts as an integration use", () => { + expect(call("datamate", "success")).toBe(true) + expect(rows).toHaveLength(1) + expect(rows[0]).toEqual( + expect.objectContaining({ + type: "tool_call", + tool_name: "datamate_jira_get_issue", + tool_type: "mcp", + status: "success", + via_workspace: true, + workspace_id: 42, + session_id: "s1", + message_id: "m1", + }), + ) + expect(rows[0].duration_ms).toBeGreaterThanOrEqual(0) + expect("error" in rows[0]).toBe(false) + expect(Telemetry.workspaceUseOf("s1").integrations).toBe(1) + }) + + test("a failed call is a tool_call error row with a masked message and no integration use", () => { + expect(call("datamate", "error", new Error("boom at /home/someone/secret.txt"))).toBe(true) + expect(rows[0]).toEqual(expect.objectContaining({ status: "error", via_workspace: true, tool_type: "mcp" })) + expect(rows[0].error).not.toContain("/home/someone") + expect(Telemetry.workspaceUseOf("s1").integrations).toBe(0) + expect(call("datamate", "error", "string failure with token=abc123secret and https://u:pw@host/x?q=1")).toBe(true) + expect(rows[1].error).toContain("string failure") + expect(rows[1].error).not.toContain("abc123secret") + expect(rows[1].error).not.toContain("u:pw@") + }) + + test("the engine key in an unbound turn is not the workspace's; the turn's workspace, not the session's entry, names the row", () => { + expect( + trackWorkspaceToolCall({ + key: "datamate_x", + clientName: "datamate", + sessionID: "s1", + messageID: "m", + workspaceId: undefined, + startedAt: Date.now(), + outcome: "success", + }), + ).toBe(false) + expect(rows).toHaveLength(0) + // The session's current entry says 7 (a replacement turn), but this call ran for turn 42. + Telemetry.setContext({ sessionId: "s1", projectId: "p1", workspaceId: 7 }) + expect(call("datamate", "success")).toBe(true) + expect(rows[0].workspace_id).toBe(42) + Telemetry.setContext({ sessionId: "", projectId: "" }) + }) + + test("other MCP clients, standalone datamate servers and registry tool ids are not the workspace's engine", () => { + expect(call("github", "success")).toBe(false) + expect(call("filesystem", "error", new Error("x"))).toBe(false) + expect(call("datamate-acme", "success")).toBe(false) // a standalone datamate server the user configured + expect(call("datamates", "success")).toBe(false) + expect(rows).toHaveLength(0) + expect(Telemetry.workspaceUseOf("s1").integrations).toBe(0) + // The registry tool `datamate_manager` runs through Tool.define, never through this path, and its + // id is not the engine's client name either. + expect(isWorkspaceClient("datamate_manager")).toBe(false) + expect(isWorkspaceClient("datamate")).toBe(true) + expect(isWorkspaceClient("Datamate")).toBe(false) + }) +}) diff --git a/packages/opencode/test/memory/overlay-merge.test.ts b/packages/opencode/test/memory/overlay-merge.test.ts index dddceea31f..fad1643e1f 100644 --- a/packages/opencode/test/memory/overlay-merge.test.ts +++ b/packages/opencode/test/memory/overlay-merge.test.ts @@ -2,7 +2,7 @@ // Covers the injection-side merge of workspace memory into local memory: // which blocks survive, and the guard that keeps a cloud-sourced training // block from writing to the local store. -import { afterAll, afterEach, beforeEach, describe, expect, test } from "bun:test" +import { afterAll, afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test" import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs" import path from "node:path" import os from "node:os" @@ -141,6 +141,41 @@ describe("the memory refresh tool", () => { expect(res.output).toContain("2 block(s)") }) + test("a block from the bound workspace's memory is injected under the workspace scope and counted", async () => { + const { Telemetry } = await import("../../src/altimate/telemetry") + // A project-scope block is the workspace's; a global cloud block is the account's and is + // injected too, but not counted as workspace memory. + listResponse = [ + remote("ws/one", "WORKSPACE BLOCK", { block_scope: "project", datamate_id: "7" }), + remote("acct/one", "GLOBAL BLOCK"), + ] + await hydrate(SES) + expect( + overlayBlocks(SES) + .map((b) => b.id) + .sort(), + ).toEqual(["acct/one", "ws/one"]) + const events: any[] = [] + const spy = spyOn(Telemetry, "track").mockImplementation((e) => { + events.push(e) + }) + try { + Telemetry.clearWorkspaceUse(SES) + const injected = await MemoryPrompt.inject(8000, { sessionID: SES }) + expect(injected).toContain("WORKSPACE BLOCK") + expect(injected).toContain("GLOBAL BLOCK") + const injection = events.find((e) => e.type === "memory_injection") + expect(injection).toBeDefined() + expect(injection.scopes_used).toContain("workspace") + expect(injection.scopes_used).toContain("global") + expect(injection.workspace_blocks).toBe(1) + expect(Telemetry.workspaceUseOf(SES).memory).toBe(1) + } finally { + spy.mockRestore() + Telemetry.clearWorkspaceUse(SES) + } + }) + test("says the workspace is unreachable rather than reporting an empty reload", async () => { listResponse = [remote("keep/one", "KEEP")] await hydrate(SES) @@ -270,7 +305,11 @@ describe("on-demand reload", () => { const { count, ok } = await refresh(SES) expect(ok).toBe(true) expect(count).toBe(2) - expect(overlayBlocks(SES).map((b) => b.id).sort()).toEqual(["warehouse/one", "warehouse/two"]) + expect( + overlayBlocks(SES) + .map((b) => b.id) + .sort(), + ).toEqual(["warehouse/one", "warehouse/two"]) }) test("a failed refresh keeps the memory the session already had", async () => { @@ -303,7 +342,7 @@ describe("on-demand reload", () => { await hydrate(SES) expect(overlayBlocks(SES).map((b) => b.id)).toEqual(["keep/me"]) - memoryEnabledCache.clear() // past the 60s positive-cache TTL + memoryEnabledCache.clear() // past the 60s positive-cache TTL const originalFetch = globalThis.fetch globalThis.fetch = (async (_i?: unknown, _n?: unknown) => { throw new Error("workspace unreachable") @@ -341,21 +380,22 @@ describe("on-demand reload", () => { firstCall = false await gate return new Response(JSON.stringify([remote("stale/one", "STALE")]), { - status: 200, headers: { "Content-Type": "application/json" }, + status: 200, + headers: { "Content-Type": "application/json" }, }) } return originalFetch(input, init) }) as typeof fetch try { - void hydrate(SES) // A — stalls on the gate + void hydrate(SES) // A — stalls on the gate await new Promise((r) => setTimeout(r, 20)) listResponse = [remote("fresh/one", "FRESH")] - const res = await refresh(SES) // B — completes first + const res = await refresh(SES) // B — completes first expect(res.status).toBe("loaded") expect(overlayBlocks(SES).map((b) => b.id)).toEqual(["fresh/one"]) - release?.() // A resolves late + release?.() // A resolves late await new Promise((r) => setTimeout(r, 50)) expect(overlayBlocks(SES).map((b) => b.id)).toEqual(["fresh/one"]) } finally { diff --git a/packages/opencode/test/tool/batch.test.ts b/packages/opencode/test/tool/batch.test.ts index 556a9e9c33..a6a1288801 100644 --- a/packages/opencode/test/tool/batch.test.ts +++ b/packages/opencode/test/tool/batch.test.ts @@ -66,6 +66,36 @@ describe("BatchTool: file lessons", () => { }) }) +describe("BatchTool: inner tool context", () => { + test("carries the turn's workspace and token to the inner tools", async () => { + const tool = await getToolInfo() + let seen: { workspaceId?: number; turn?: number } | undefined + const registry = spyOn(ToolRegistry, "tools").mockResolvedValue([{ + id: "probe", + registrySource: undefined, + description: "captures its context", + parameters: Schema.Unknown, + execute: (_args: unknown, ctx: { workspaceId?: number; turn?: number }) => { + seen = { workspaceId: ctx.workspaceId, turn: ctx.turn } + return Effect.succeed({ title: "ok", output: "ok", metadata: {} }) + }, + }] as any) + const updatePart = Object.assign(async (part: Parameters[0]) => part, Session.updatePart) + const updates = spyOn(Session, "updatePart").mockImplementation(updatePart) + try { + const result = await tool.execute({ tool_calls: [{ tool: "probe", parameters: {} }] }, { + sessionID: SessionID.make("ses_batch_ctx"), messageID: MessageID.make("msg_batch_ctx"), agent: "build", + abort: AbortSignal.any([]), messages: [], extra: {}, workspaceId: 7, turn: 3, + } as any) + expect(result.metadata.successful).toBe(1) + expect(seen).toEqual({ workspaceId: 7, turn: 3 }) + } finally { + updates.mockRestore() + registry.mockRestore() + } + }) +}) + describe("BatchTool: schema validation", () => { test("rejects empty tool_calls array", async () => { const tool = await getToolInfo() diff --git a/packages/opencode/test/tool/skill-source.test.ts b/packages/opencode/test/tool/skill-source.test.ts index 3ede7dcf73..2355c98c53 100644 --- a/packages/opencode/test/tool/skill-source.test.ts +++ b/packages/opencode/test/tool/skill-source.test.ts @@ -18,6 +18,19 @@ describe("classifySkillSource", () => { expect(classifySkillSource("C:\\Users\\x\\.altimate\\builtin\\dbt-analyze\\SKILL.md")).toBe("builtin") }) + test("skills synced from the bound workspace (the managed _workspace folder) → workspace", () => { + expect(classifySkillSource("/work/repo/.altimate-code/skill/_workspace/dbt-review/SKILL.md")).toBe("workspace") + expect(classifySkillSource("C:\\work\\repo\\.altimate-code\\skill\\_workspace\\x\\SKILL.md")).toBe("workspace") + // a project skill that merely mentions the word is still the project's + expect(classifySkillSource("/work/repo/.altimate-code/skill/_workspace-notes/SKILL.md")).toBe("project") + // With the project root known, only this project's managed folder is the workspace's. + expect(classifySkillSource("/work/repo/.altimate-code/skill/_workspace/x/SKILL.md", "/work/repo")).toBe("workspace") + expect(classifySkillSource("/work/repo/.altimate-code/skill/_workspace/x/SKILL.md", "/work/repo/")).toBe( + "workspace", + ) + expect(classifySkillSource("/work/other/.altimate-code/skill/_workspace/x/SKILL.md", "/work/repo")).toBe("project") + }) + test("a third-party skill under some other node_modules is NOT tagged Altimate", () => { expect(classifySkillSource("/work/repo/node_modules/some-other-pkg/skills/x/SKILL.md")).toBe("project") }) diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts index edfa0139df..42d3e1fdb8 100644 --- a/packages/plugin/src/index.ts +++ b/packages/plugin/src/index.ts @@ -271,14 +271,18 @@ export interface Hooks { input: { cwd: string; sessionID?: string; callID?: string }, output: { env: Record }, ) => Promise + // altimate_change start — `workspaceId`: the workspace of the turn the tool ran in, set by the + // trigger site, so a plugin's own rows name it without consulting the session's current turn + // (which may be a replacement by the time a cancelled turn's tool returns) "tool.execute.after"?: ( - input: { tool: string; sessionID: string; callID: string; args: any }, + input: { tool: string; sessionID: string; callID: string; args: any; workspaceId?: number }, output: { title: string output: string metadata: any }, ) => Promise + // altimate_change end "experimental.chat.messages.transform"?: ( input: {}, output: {