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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 110 additions & 10 deletions packages/opencode/src/altimate/workspace/awareness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,25 @@
// still be in the catalog while routing refuses them, and silence would leave the
// model free to call what it can see. `DISABLED_COPY` below is the decision table.
//
// Extension-type tools (served through a live VS Code bridge) are the section's other
// list. They shadow nothing, so they are awareness only; they are named only when
// precedence has them as really served (in the catalog AND behind a live bridge —
// see `extensionsServed` in precedence.ts), and a dormant bridge is silence, not a
// warning. One consequence for the table: `nothing-materialised` speaks when it
// carries extension tools — a workspace can serve those and no warehouse capability
// at all — and stays silent otherwise, which keeps the byte-identical claim intact.
//
// SERVER-SIDE ONLY, for the same reason `precedence.ts` is: the TUI plugin runtime
// loads plugins in a separate module realm, so an import from there would read a
// different, always-empty `Precedence` map. Import this only from the session layer.
import { type Capability, type Precedence, inertWorkspaceName, servedInventory } from "./precedence"
import {
type Capability,
type Precedence,
type ServedExtension,
inertWorkspaceName,
servedExtensions,
servedInventory,
} from "./precedence"

/** Hard ceiling on the rendered section. Deliberately independent of
* `UNIFIED_INJECTION_BUDGET`: this is a routing directive, not knowledge, and must
Expand Down Expand Up @@ -190,12 +205,31 @@ export function systemSection(precedence: Precedence | undefined): string {
const SEPARATOR = "\n\n"

/** The routing directive. Unchanged contract: silent unless the workspace is really
* routing, so the model is never steered toward tools it should not use. */
* routing, so the model is never steered toward tools it should not use. The one
* addition is the extension tools a live IDE bridge serves, which ride along in
* both shapes and are the only thing said in the extension-only shape. */
function routingSection(precedence: Precedence, reserved = 0): string {
if (!precedence.enabled) return precedence.disabledReason ? DISABLED_COPY[precedence.disabledReason] : ""
const extLines = servedExtensions(precedence).map(extensionLine)
if (!precedence.enabled) {
// The one disabled state that can carry served extension tools (see `derive`):
// no warehouse capability is routed, but the bridge is serving, and silence
// would leave the model unaware of tools it can see. Without them the table's
// entry renders exactly as before.
if (precedence.disabledReason === "nothing-materialised" && extLines.length > 0) {
return assembleExtensionsOnly(precedence.workspaceName, precedence.workspaceId, extLines, reserved)
}
return precedence.disabledReason ? DISABLED_COPY[precedence.disabledReason] : ""
}

const served = servedInventory(precedence)
if (served.length === 0) return ""
// Enabled but no warehouse capability reachable (the analyst shape). The same
// ruleset filters the extension tools, so normally none survive either; any that
// do are still real and still callable, so they are said.
if (served.length === 0) {
return extLines.length > 0
? assembleExtensionsOnly(precedence.workspaceName, precedence.workspaceId, extLines, reserved)
: ""
}

// `type` is the canonical local driver type (`postgres`), not the user-facing
// connection name nor the engine's integration id (`postgresql`) — it is what the
Expand All @@ -209,7 +243,60 @@ function routingSection(precedence: Precedence, reserved = 0): string {
return `- ${type} — ${servedPart}${localPart}`
})

return assemble(precedence.workspaceName, precedence.workspaceId, typeLines, reserved)
return assemble(precedence.workspaceName, precedence.workspaceId, typeLines, extLines, reserved)
}

/** One extension-type integration and every tool of it the caller can call. The
* integration name is catalog-authored and precedence already made it inert; the
* keys are engine tool names, quoted the way the warehouse lines quote theirs. */
function extensionLine(group: ServedExtension): string {
return `- ${group.integration} — ${group.tools.map((t) => `\`${t.modelKey}\``).join(", ")}`
Comment thread
ralphstodomingo marked this conversation as resolved.
}

/** Above the extension lines in both shapes of the section. It names the condition
* the tools depend on, so a failure after the window closes can be explained
* rather than retried blindly. */
const EXTENSION_INTRO =
"The VS Code window open on this project serves these extension tools through the workspace. Call them " +
"like any other tool; they are unavailable while that window is closed:"

const extensionOmission = (n: number) =>
`- …and ${n} further extension integration${n === 1 ? "" : "s"} served through the connected VS Code window.`

/** The section when extension tools are served and no warehouse capability is
* routed. The local-tools sentence is kept: with nothing shadowed, every
* connection really does stay local, and the model should not infer otherwise
* from seeing `datamate_*` keys listed. Same cap, same drop rule as `assemble`;
* and once the cap has taken every extension line there is nothing left to say,
* so the shape is silent rather than an intro over an empty list. */
function assembleExtensionsOnly(
workspaceName: string,
workspaceId: string | undefined,
extLines: string[],
reserved = 0,
): string {
const label = workspaceLabel(workspaceName, workspaceId)
const render = (ext: string[]) => {
const omitted = extLines.length - ext.length
return [
HEADING,
"",
`This project is bound to Altimate workspace ${label}. No warehouse capability is routed through it in ` +
`this session: every connection uses the local tools (${ALL_LOCAL_TOOLS}).`,
"",
EXTENSION_INTRO,
"",
...ext,
...(omitted > 0 ? [extensionOmission(omitted)] : []),
].join("\n")
}
let ext = extLines
let out = render(ext)
while (out.length + reserved > MAX_SECTION_CHARS && ext.length > 0) {
ext = ext.slice(0, -1)
out = render(ext)
}
return ext.length > 0 ? out : ""
}

/** The workspace name is customer-authored and lands in the system prompt — the
Expand Down Expand Up @@ -237,11 +324,13 @@ function assemble(
workspaceName: string,
workspaceId: string | undefined,
typeLines: string[],
extLines: string[] = [],
reserved = 0,
): string {
const label = workspaceLabel(workspaceName, workspaceId)
const render = (lines: string[]) => {
const render = (lines: string[], ext: string[]) => {
const omitted = typeLines.length - lines.length
const extOmitted = extLines.length - ext.length
const converse =
omitted > 0
? "For the served types omitted above, prefer the `datamate_*` tool for that type when one is in the " +
Expand All @@ -260,16 +349,27 @@ function assemble(
...(omitted > 0
? [`- …and ${omitted} further connection type${omitted === 1 ? "" : "s"} served by this workspace.`]
: []),
// On the lines that survived the cap, not the ones asked for: with every
// extension line dropped, an intro over an omission count told the model
// to call tools it was never shown. (bot review)
...(ext.length > 0
? ["", EXTENSION_INTRO, "", ...ext, ...(extOmitted > 0 ? [extensionOmission(extOmitted)] : [])]
: []),
"",
converse,
].join("\n")
}

let lines = typeLines
let out = render(lines)
while (out.length + reserved > MAX_SECTION_CHARS && lines.length > 0) {
lines = lines.slice(0, -1)
out = render(lines)
let ext = extLines
let out = render(lines, ext)
// Extension lines are dropped first: they are awareness, while the type lines
// are directives the guard will enforce, and a redirect the model was never
// warned of is the worse failure. Type lines go only once none are left.
while (out.length + reserved > MAX_SECTION_CHARS && (ext.length > 0 || lines.length > 0)) {
if (ext.length > 0) ext = ext.slice(0, -1)
else lines = lines.slice(0, -1)
out = render(lines, ext)
}
return out
}
Expand Down
1 change: 1 addition & 0 deletions packages/opencode/src/altimate/workspace/engine-overlay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -659,6 +659,7 @@ async function reconcile(sessionID: string, directory: string, state: DirectoryS
kind: "attached",
available: present.size,
...(declared ? { declared: declared.keys.length, missing } : {}),
...(declared?.extensions?.length ? { extensions: declared.extensions } : {}),
}
const rec = record(sessionID, outcome)
// Keyed on the workspace too: a re-link with an identical inventory is still
Expand Down
47 changes: 38 additions & 9 deletions packages/opencode/src/altimate/workspace/engine-probes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { EventV2Bridge } from "@/event-v2-bridge"
import { TuiEvent } from "@/server/tui-event"
import { readLocalBindingScopedStrict } from "./state"
import { log, syncInternals, type BindingRead, type ScopedBinding } from "./engine-seams"
import type { Declared, Toast } from "./engine-types"
import type { Declared, DeclaredExtension, Toast } from "./engine-types"

/** How long the allowlist lookup may hold a turn. Once per workspace per process. */
export const DECLARED_TIMEOUT_MS = 4_000
Expand Down Expand Up @@ -129,14 +129,23 @@ export async function declared(workspaceId: string): Promise<Declared | null> {
AltimateApi.getDatamate(workspaceId),
AltimateApi.listIntegrations(),
])
const extensionIds = new Set(catalog.filter((i) => i.type === "extension").map((i) => i.id))
const extensionNames = new Map(
catalog.filter((i) => i.type === "extension").map((i): [string, string] => [i.id, i.name ?? i.id]),
)
const keys: string[] = []
const extensionKeys: string[] = []
const extensions: DeclaredExtension[] = []
for (const integration of workspace.integrations ?? []) {
const target = extensionIds.has(integration.id) ? extensionKeys : keys
for (const tool of integration.tools ?? []) target.push(tool.key)
const toolKeys = (integration.tools ?? []).map((tool) => tool.key)
const name = extensionNames.get(integration.id)
if (name === undefined) {
keys.push(...toolKeys)
continue
}
extensionKeys.push(...toolKeys)
if (toolKeys.length > 0) extensions.push({ id: integration.id, name, keys: toolKeys })
}
return { keys, extensionKeys }
return { keys, extensionKeys, ...(extensions.length > 0 ? { extensions } : {}) }
} catch (err) {
log.warn("could not read the declared workspace integrations", { workspaceId, err: String(err) })
return null
Expand Down Expand Up @@ -171,9 +180,21 @@ export async function declaredBounded(workspaceId: string): Promise<Declared | n
* sidecar whose recorded workspaceFolders contain `cwd`, else the sole live
* bridge. Read-only — a dead pid is skipped, never unlinked; GC of stale
* sidecars belongs to the engine and the extension. Presentation only: the
* engine remains the authority on what actually connects. */
export function liveBridge(cwd: string, dir: string = join(homedir(), ".altimate", "extension-rpc")): boolean {
if (syncInternals.liveBridge) return syncInternals.liveBridge(cwd)
* engine remains the authority on what actually connects.
*
* `claim` is for a caller that will STATE the bridge is this project's (the
* system prompt does, and the model may act on it). It drops the two rules
* that mirror the engine's tolerant discovery: the sole-bridge fallback (one
* live bridge counts whatever it has open) and the pidless sidecar (an older
* bridge that recorded no pid counts as live because nothing can say
* otherwise). Under `claim` it is a recorded folder match on a sidecar whose
* pid is verified alive, or nothing. (multi-model review; codex) */
export function liveBridge(
cwd: string,
dir: string = join(homedir(), ".altimate", "extension-rpc"),
opts: { claim?: boolean } = {},
Comment thread
ralphstodomingo marked this conversation as resolved.
): boolean {
if (syncInternals.liveBridge) return syncInternals.liveBridge(cwd, opts)
const bridges: string[][] = []
try {
for (const entry of readdirSync(dir)) {
Expand All @@ -194,6 +215,10 @@ export function liveBridge(cwd: string, dir: string = join(homedir(), ".altimate
// would read garbage pids as alive. (codex r3, cubic)
if ("pid" in data && !(typeof data.pid === "number" && Number.isInteger(data.pid) && data.pid > 0 && pidAlive(data.pid)))
continue
// A pidless sidecar cannot be told apart from one its bridge left
// behind on exit; the engine gives it the benefit of the doubt, a
// claim about this project does not.
if (!("pid" in data) && opts.claim) continue
// Validate the folders shape: this is an unvalidated JSON file, and a
// non-array must degrade to "live bridge, no recorded folders", not
// throw out of the probe. Only fully qualified strings survive —
Expand All @@ -219,7 +244,11 @@ export function liveBridge(cwd: string, dir: string = join(homedir(), ".altimate
return rel === "" || (rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel))
}
if (bridges.some((folders) => folders.some(within))) return true
return bridges.length === 1
// The sole-bridge fallback mirrors the engine's own discovery, which connects
// to the one live bridge whatever it has open; presentation of what the
// engine did is right to follow it. A claim about this project gets a
// folder match or nothing.
return !opts.claim && bridges.length === 1
}

/** A recorded folder must be fully qualified. On Windows, drive-relative
Expand Down
2 changes: 1 addition & 1 deletion packages/opencode/src/altimate/workspace/engine-seams.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export const syncInternals: {
versionOf?: (bin: string) => Promise<string | null>
fingerprint?: (bin: string) => string | null
declared?: (workspaceId: string) => Promise<Declared | null>
liveBridge?: (cwd: string) => boolean
liveBridge?: (cwd: string, opts?: { claim?: boolean }) => boolean
Comment thread
ralphstodomingo marked this conversation as resolved.
notify?: (toast: Toast) => Promise<void>
printLine?: (line: string) => void
/** Install-offer seams (see engine-offer.ts). */
Expand Down
22 changes: 20 additions & 2 deletions packages/opencode/src/altimate/workspace/engine-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,16 @@ export const TOOL_PREFIX = `${DATAMATE_KEY}_`
export type Outcome =
| { kind: "disabled" }
| { kind: "unbound" }
| { kind: "attached"; available: number; declared?: number; missing?: string[] }
| {
kind: "attached"
available: number
declared?: number
missing?: string[]
/** The allowlist's extension-type integrations, when it names any: what a
* live IDE bridge could serve. Whether they are present is decided per turn
* against the catalog, never recorded here. */
extensions?: DeclaredExtension[]
}
| { kind: "engine-missing"; declared?: number }
/** `found` is null when the binary ran but printed nothing usable — broken
* rather than old; the message says so. */
Expand Down Expand Up @@ -57,7 +66,16 @@ export type McpStatus = Record<string, { status: string; error?: string } | unde
/** Declared allowlist for a workspace, split by whether the CLI can serve it.
* Extension-type integrations are RPC into a live VS Code host and have no
* meaning on the CLI surface, so they are excluded from the reported gap. */
export type Declared = { keys: string[]; extensionKeys: string[] }
export type Declared = {
keys: string[]
extensionKeys: string[]
/** The extension keys again, grouped under their catalog integration, for the
* surfaces that name them rather than count them. Optional: the flat lists are
* the contract every existing reader was written against. */
extensions?: DeclaredExtension[]
}

export type DeclaredExtension = { id: string; name: string; keys: string[] }

/** A configured MCP entry in either shape it can reach us: opencode's own
* `command: string[]` argv, or the `{ command, args }` split an IDE writes. */
Expand Down
Loading
Loading