@xemahq/opencode-xema-plugin 0.1.3 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +176 -175
- package/dist/context.d.ts +0 -1
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +1 -1
- package/dist/context.js.map +1 -1
- package/dist/hooks/session-idle-autocommit.js.map +1 -1
- package/dist/tools/xema-memory.d.ts.map +1 -1
- package/dist/tools/xema-memory.js.map +1 -1
- package/package.json +14 -4
- package/plugin.mjs +93 -89
- package/src/context.ts +154 -0
- package/src/errors.ts +24 -0
- package/src/hooks/session-idle-autocommit.ts +411 -0
- package/src/hooks/system-prompt-overlay.ts +99 -0
- package/src/hooks/workspace-listing.ts +175 -0
- package/src/index.ts +163 -0
- package/src/logger.ts +40 -0
- package/src/opencode-types.ts +107 -0
- package/src/plugin-entry.ts +22 -0
- package/src/session-state.ts +92 -0
- package/src/tools/runtime.ts +7 -0
- package/src/tools/service-token.ts +68 -0
- package/src/tools/shared.ts +16 -0
- package/src/tools/xema-memory.ts +232 -0
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { readdirSync, statSync, type Dirent } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Renders the actual depth-1 contents of `/workspace` as a Markdown
|
|
6
|
+
* tree, grouped by AWP top-level slot. Appended to the system prompt
|
|
7
|
+
* by the chat-system-transform hook so the LLM sees the real layout —
|
|
8
|
+
* actual filenames, repo slugs, prior-turn deliverables — without
|
|
9
|
+
* having to spend a tool call on `glob` or `list`.
|
|
10
|
+
*
|
|
11
|
+
* Design constraints:
|
|
12
|
+
*
|
|
13
|
+
* - **Read-only, side-effect free.** Pure fs reads. No caching state,
|
|
14
|
+
* no writes, no network.
|
|
15
|
+
* - **Hides platform-private slots.** `.xema/` is platform-private —
|
|
16
|
+
* the LLM has no business reading or grep'ing it (its contents are
|
|
17
|
+
* already in the system prompt overlay). `.opencode/` is OpenCode
|
|
18
|
+
* runtime state (agent + skill bundles, plugin tree, snapshot
|
|
19
|
+
* metadata) — also hidden because operating on it is never the
|
|
20
|
+
* user's intent.
|
|
21
|
+
* - **Bounded output.** Each directory's listing is capped at
|
|
22
|
+
* {@link MAX_ENTRIES_PER_DIR}; overflow is summarised as
|
|
23
|
+
* `(N more entries — use \`glob\`/\`list\` to enumerate)` so the
|
|
24
|
+
* system prompt never balloons under sessions that accumulate
|
|
25
|
+
* hundreds of files in `deliverables/`.
|
|
26
|
+
* - **Stable ordering.** Entries are sorted alphabetically with
|
|
27
|
+
* directories first, so identical filesystem state produces
|
|
28
|
+
* identical bytes and the trailing prompt-cache window stays warm
|
|
29
|
+
* turn-to-turn when nothing changed.
|
|
30
|
+
* - **Cache positioning.** This block goes at the very END of the
|
|
31
|
+
* system prompt because it is the most volatile part. Anthropic +
|
|
32
|
+
* OpenAI prefix-cache the system prompt; everything ABOVE this
|
|
33
|
+
* block stays cached, only this trailing block recomputes.
|
|
34
|
+
*/
|
|
35
|
+
export const MAX_ENTRIES_PER_DIR = 50;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Top-level entries hidden from the listing. Keep tight: anything not
|
|
39
|
+
* in this set is rendered. The check matches on the immediate child
|
|
40
|
+
* name under `/workspace/`.
|
|
41
|
+
*/
|
|
42
|
+
const HIDDEN_TOP_LEVEL: ReadonlySet<string> = new Set(['.xema', '.opencode']);
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Top-level directories whose child entries are intentionally not expanded.
|
|
46
|
+
*
|
|
47
|
+
* These locations are usually implicit/noisy (dependency caches, VCS internals,
|
|
48
|
+
* virtual environments) and rarely useful in the prompt body. We still show the
|
|
49
|
+
* top-level path so the model can request targeted tooling if needed.
|
|
50
|
+
*/
|
|
51
|
+
const SKIP_CHILD_EXPANSION_TOP_LEVEL: ReadonlySet<string> = new Set([
|
|
52
|
+
'.git',
|
|
53
|
+
'node_modules',
|
|
54
|
+
'.venv',
|
|
55
|
+
'venv',
|
|
56
|
+
'__pycache__',
|
|
57
|
+
'.mypy_cache',
|
|
58
|
+
'.pytest_cache',
|
|
59
|
+
'.ruff_cache',
|
|
60
|
+
'.tox',
|
|
61
|
+
'.nox',
|
|
62
|
+
'.eggs',
|
|
63
|
+
]);
|
|
64
|
+
|
|
65
|
+
interface ListedEntry {
|
|
66
|
+
readonly name: string;
|
|
67
|
+
readonly isDirectory: boolean;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function renderWorkspaceListing(workspaceRoot: string): string {
|
|
71
|
+
const lines: string[] = [
|
|
72
|
+
'## Current workspace contents (depth 1)',
|
|
73
|
+
'',
|
|
74
|
+
'This is a snapshot of `/workspace/` taken at the start of this turn. Use it to orient before reaching for `glob`, `list`, or `read`. Hidden: platform-private slots (`.xema/`, `.opencode/`).',
|
|
75
|
+
'',
|
|
76
|
+
];
|
|
77
|
+
|
|
78
|
+
let topLevel: ListedEntry[];
|
|
79
|
+
try {
|
|
80
|
+
topLevel = readDirSorted(workspaceRoot);
|
|
81
|
+
} catch (err) {
|
|
82
|
+
// The hook is best-effort orientation. If the workspace can't be
|
|
83
|
+
// listed (extremely unusual — the workspace must exist for the
|
|
84
|
+
// session to have started), surface it inline rather than failing
|
|
85
|
+
// the whole chat call. The static overlay above this block already
|
|
86
|
+
// tells the LLM the canonical layout.
|
|
87
|
+
lines.push(`_Listing unavailable: ${(err as Error).message}_`);
|
|
88
|
+
return lines.join('\n');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const visible = topLevel.filter((entry) => !HIDDEN_TOP_LEVEL.has(entry.name));
|
|
92
|
+
if (visible.length === 0) {
|
|
93
|
+
lines.push('_(workspace is empty)_');
|
|
94
|
+
return lines.join('\n');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
for (const entry of visible) {
|
|
98
|
+
lines.push(...renderTopLevelEntry(workspaceRoot, entry));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return lines.join('\n');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function renderTopLevelEntry(workspaceRoot: string, entry: ListedEntry): string[] {
|
|
105
|
+
const relPath = entry.name;
|
|
106
|
+
const absPath = join(workspaceRoot, entry.name);
|
|
107
|
+
if (!entry.isDirectory) {
|
|
108
|
+
return [`- \`/workspace/${relPath}\``];
|
|
109
|
+
}
|
|
110
|
+
if (SKIP_CHILD_EXPANSION_TOP_LEVEL.has(entry.name)) {
|
|
111
|
+
return [
|
|
112
|
+
`- \`/workspace/${relPath}/\` _(children omitted: known implicit/noisy directory — use \`glob\`/\`list\` if needed)_`,
|
|
113
|
+
];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
let children: ListedEntry[];
|
|
117
|
+
try {
|
|
118
|
+
children = readDirSorted(absPath);
|
|
119
|
+
} catch (err) {
|
|
120
|
+
return [`- \`/workspace/${relPath}/\` _(unreadable: ${(err as Error).message})_`];
|
|
121
|
+
}
|
|
122
|
+
if (children.length === 0) {
|
|
123
|
+
return [`- \`/workspace/${relPath}/\` _(empty)_`];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const shown = children.slice(0, MAX_ENTRIES_PER_DIR);
|
|
127
|
+
const childLines = shown.map((child) => {
|
|
128
|
+
const suffix = child.isDirectory ? '/' : '';
|
|
129
|
+
return ` - \`${child.name}${suffix}\``;
|
|
130
|
+
});
|
|
131
|
+
const overflow = children.length - shown.length;
|
|
132
|
+
const overflowLine =
|
|
133
|
+
overflow > 0
|
|
134
|
+
? [` - _(${overflow} more entr${overflow === 1 ? 'y' : 'ies'} — use \`glob\`/\`list\` to enumerate)_`]
|
|
135
|
+
: [];
|
|
136
|
+
|
|
137
|
+
return [`- \`/workspace/${relPath}/\``, ...childLines, ...overflowLine];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* `readdirSync` with deterministic ordering: directories first, then
|
|
142
|
+
* files, each group sorted by name (case-sensitive — workspace paths
|
|
143
|
+
* are case-sensitive on the worker filesystem). Symlinks resolve to
|
|
144
|
+
* their target's type via `statSync` so a symlinked dir lists like a
|
|
145
|
+
* dir, not a file.
|
|
146
|
+
*/
|
|
147
|
+
function readDirSorted(dir: string): ListedEntry[] {
|
|
148
|
+
const dirents: Dirent[] = readdirSync(dir, { withFileTypes: true });
|
|
149
|
+
const entries: ListedEntry[] = dirents.map((d) => ({
|
|
150
|
+
name: d.name,
|
|
151
|
+
isDirectory: resolveIsDirectory(dir, d),
|
|
152
|
+
}));
|
|
153
|
+
entries.sort((a, b) => {
|
|
154
|
+
if (a.isDirectory !== b.isDirectory) {
|
|
155
|
+
return a.isDirectory ? -1 : 1;
|
|
156
|
+
}
|
|
157
|
+
if (a.name < b.name) {return -1;}
|
|
158
|
+
if (a.name > b.name) {return 1;}
|
|
159
|
+
return 0;
|
|
160
|
+
});
|
|
161
|
+
return entries;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function resolveIsDirectory(parent: string, dirent: Dirent): boolean {
|
|
165
|
+
if (dirent.isSymbolicLink()) {
|
|
166
|
+
try {
|
|
167
|
+
return statSync(join(parent, dirent.name)).isDirectory();
|
|
168
|
+
} catch {
|
|
169
|
+
// Broken symlink: classify as file so we surface its presence
|
|
170
|
+
// without crashing the listing.
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return dirent.isDirectory();
|
|
175
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
2
|
+
// ── @xemahq/opencode-xema-plugin ──
|
|
3
|
+
//
|
|
4
|
+
// Opencode plugin providing Xema-namespaced runtime tools (xema_memory_*)
|
|
5
|
+
// and the dynamic system-prompt overlay hook.
|
|
6
|
+
//
|
|
7
|
+
// Lifecycle:
|
|
8
|
+
// OpenCode evaluates this plugin once at `opencode serve` startup. At that
|
|
9
|
+
// point `/workspace/context.json` does NOT yet exist — it is written per
|
|
10
|
+
// invocation by the orchestrator's XemaRuntimeMounter before the agent
|
|
11
|
+
// starts. The plugin therefore registers all tools unconditionally and
|
|
12
|
+
// defers role validation to tool-execution time, where each tool reads
|
|
13
|
+
// `context.json` from the worker filesystem.
|
|
14
|
+
//
|
|
15
|
+
// Each tool's `execute()` loads session state at call time via
|
|
16
|
+
// `loadSessionStateForTool`, which reads the role's `allowedXemaTools`
|
|
17
|
+
// list straight from `context.json`'s `authority.allowedXemaTools`
|
|
18
|
+
// field (populated by llm-registry-api from the
|
|
19
|
+
// `RoleCapabilityProfile` YAMLs at
|
|
20
|
+
// `biomes/kernel/runtime/role-capabilities/`). Throws a typed error
|
|
21
|
+
// (surfaced to the LLM as a tool error) when the invoking role is not
|
|
22
|
+
// allowed.
|
|
23
|
+
//
|
|
24
|
+
// Path-policy enforcement (where the agent may write, what the agent
|
|
25
|
+
// may read) is intentionally NOT done here. It lives in the rails
|
|
26
|
+
// that genuinely enforce it: workspace-proxy's symlink-aware boundary
|
|
27
|
+
// check, the harvester's manifest-driven read (off-manifest writes
|
|
28
|
+
// are orphan bytes), deliverable-specs content validation, the
|
|
29
|
+
// per-allocation gateway JWT, and pod isolation. A process-local
|
|
30
|
+
// tool.execute.before hook is trivially bypassable via `bash` and
|
|
31
|
+
// would be theatre over those rails.
|
|
32
|
+
//
|
|
33
|
+
// See also:
|
|
34
|
+
// - workspace protocol: runtime AGENTS.md written by the orchestrator
|
|
35
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
36
|
+
|
|
37
|
+
import { existsSync } from 'node:fs';
|
|
38
|
+
import { join } from 'node:path';
|
|
39
|
+
|
|
40
|
+
import { buildSessionIdleAutocommit } from './hooks/session-idle-autocommit.js';
|
|
41
|
+
import { buildSystemPromptOverlay } from './hooks/system-prompt-overlay.js';
|
|
42
|
+
import { info, isDebug } from './logger.js';
|
|
43
|
+
import type { Hooks, Plugin, ToolDefinition } from './opencode-types.js';
|
|
44
|
+
|
|
45
|
+
import {
|
|
46
|
+
buildXemaMemoryGetTool,
|
|
47
|
+
buildXemaMemorySetTool,
|
|
48
|
+
} from './tools/xema-memory.js';
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Defensive idempotency cache — keyed by workspace directory.
|
|
52
|
+
*
|
|
53
|
+
* The plugin's header contract says OpenCode calls this factory once at
|
|
54
|
+
* `opencode serve` startup. Out of an abundance of caution (and because
|
|
55
|
+
* `buildSystemPromptOverlay` builds closures that read disk on each
|
|
56
|
+
* invocation), we cache the resolved `Hooks` per directory. Concurrent
|
|
57
|
+
* factory calls return the SAME in-flight promise instead of racing.
|
|
58
|
+
*
|
|
59
|
+
* This guard is precautionary — there is no confirmed observed
|
|
60
|
+
* double-boot in source today. Cost is two map operations; value is
|
|
61
|
+
* that any future OpenCode change that calls the factory twice won't
|
|
62
|
+
* double-register tools or run boot-time side effects twice.
|
|
63
|
+
*/
|
|
64
|
+
const bootCache = new Map<string, Promise<Hooks>>();
|
|
65
|
+
|
|
66
|
+
export const XemaPlugin: Plugin = async (ctx) => {
|
|
67
|
+
const { directory } = ctx;
|
|
68
|
+
const cached = bootCache.get(directory);
|
|
69
|
+
if (cached !== undefined) {
|
|
70
|
+
return cached;
|
|
71
|
+
}
|
|
72
|
+
const booting = doBoot(directory);
|
|
73
|
+
bootCache.set(directory, booting);
|
|
74
|
+
try {
|
|
75
|
+
return await booting;
|
|
76
|
+
} catch (err) {
|
|
77
|
+
// Clear on failure so the next attempt can retry instead of
|
|
78
|
+
// re-returning the rejected promise forever.
|
|
79
|
+
bootCache.delete(directory);
|
|
80
|
+
throw err;
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
async function doBoot(directory: string): Promise<Hooks> {
|
|
85
|
+
const tools = buildAllTools(directory);
|
|
86
|
+
const toolNames = Object.keys(tools).sort((a, b) => a.localeCompare(b));
|
|
87
|
+
const contextPath = join(directory, 'context.json');
|
|
88
|
+
const contextPresentAtBoot = existsSync(contextPath);
|
|
89
|
+
|
|
90
|
+
info(
|
|
91
|
+
`boot: directory=${directory} tools=${toolNames.length} [${toolNames.join(', ')}] ` +
|
|
92
|
+
`context.json=${contextPresentAtBoot ? 'present' : 'absent'} ` +
|
|
93
|
+
`debug=${isDebug() ? 'on' : 'off'}`,
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
const hooks: Hooks = {
|
|
97
|
+
tool: tools,
|
|
98
|
+
'experimental.chat.system.transform': buildSystemPromptOverlay(directory),
|
|
99
|
+
// Per-turn auto-commit. Listens for `session.idle` via the
|
|
100
|
+
// generic `event` channel — opencode 1.15.x does not expose a
|
|
101
|
+
// dedicated `session.idle` hook, only the event union. The handler
|
|
102
|
+
// is a no-op for sessions whose context.json does not opt in
|
|
103
|
+
// (`git.autoCommit !== 'enabled'`).
|
|
104
|
+
event: buildSessionIdleAutocommit(directory),
|
|
105
|
+
};
|
|
106
|
+
return hooks;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Test-only: clear the boot cache so unit tests can observe boot()
|
|
111
|
+
* being called fresh. Not exported from the package barrel.
|
|
112
|
+
*/
|
|
113
|
+
export function __resetBootCacheForTests(): void {
|
|
114
|
+
bootCache.clear();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function buildAllTools(workspaceDir: string): Record<string, ToolDefinition> {
|
|
118
|
+
return {
|
|
119
|
+
xema_memory_set: buildXemaMemorySetTool(workspaceDir),
|
|
120
|
+
xema_memory_get: buildXemaMemoryGetTool(workspaceDir),
|
|
121
|
+
// Document Buddy has no propose tool: both kinds (MARKDOWN_DOCUMENT
|
|
122
|
+
// and RICH_DOCUMENT) edit a real working file at
|
|
123
|
+
// `/workspace/document/<slug>.<ext>` and the platform derives the
|
|
124
|
+
// reviewable diff (see the design notes).
|
|
125
|
+
// emit_review / emit_review_brief: deleted. Reviewers now write
|
|
126
|
+
// `deliverables/review.json` via the standard `write` tool — the
|
|
127
|
+
// deliverable-specs framework Zod-validates at harvest using the
|
|
128
|
+
// `reviewer-output` kernel-tier deliverable spec at
|
|
129
|
+
// `biomes/kernel/runtime/deliverable-specs/specs/schema/reviewer-output/`
|
|
130
|
+
// — the single source of truth for the reviewer-agent output shape
|
|
131
|
+
// consumed by `xema/review@v3`.
|
|
132
|
+
//
|
|
133
|
+
// output_surface_rescan + output_surface_status: owned by the kernel
|
|
134
|
+
// output-surface-control plugin (biomes/kernel/output-surface-control/
|
|
135
|
+
// opencode-tools/output-surface-control.ts) and registered as standalone
|
|
136
|
+
// custom tools auto-discovered by OpenCode from .opencode/tools/. The xema plugin no longer owns them.
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export default XemaPlugin;
|
|
141
|
+
|
|
142
|
+
// Named exports for consumers that want programmatic access (tests, etc.)
|
|
143
|
+
export { readInvocationContext } from './context.js';
|
|
144
|
+
export type { InvocationContext, InvocationRole } from './context.js';
|
|
145
|
+
export * from './errors.js';
|
|
146
|
+
export {
|
|
147
|
+
loadSessionState,
|
|
148
|
+
loadSessionStateForTool,
|
|
149
|
+
ensureRoleAllowed,
|
|
150
|
+
ContextMissingError,
|
|
151
|
+
RoleNotAllowedError,
|
|
152
|
+
type SessionState,
|
|
153
|
+
} from './session-state.js';
|
|
154
|
+
// Role-tool allowlist now lives in the per-invocation context.json
|
|
155
|
+
// (`authority.allowedXemaTools`), populated by llm-registry-api from the
|
|
156
|
+
// `RoleCapabilityProfile` YAMLs at biomes/kernel/runtime/role-capabilities/.
|
|
157
|
+
// No re-export from workflow-contracts: the registry constant is gone.
|
|
158
|
+
|
|
159
|
+
// Gate-review contract lives in `@xemahq/kernel-contracts/workflow`
|
|
160
|
+
// (`ReviewSchema`, `ReviewObjectSchema`, `Review`, enum value types).
|
|
161
|
+
// Consumers should import from there directly — the plugin bundles it
|
|
162
|
+
// into its own `plugin.mjs` at build time but does not re-publish the
|
|
163
|
+
// shape on its own surface.
|
package/src/logger.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
2
|
+
// ── opencode-xema-plugin logger ──
|
|
3
|
+
//
|
|
4
|
+
// Prefixed stderr/stdout logger. `info` / `warn` / `error` are always
|
|
5
|
+
// emitted so operators have baseline visibility of plugin boot and
|
|
6
|
+
// context-read failures without needing a flag.
|
|
7
|
+
// `debug` is env-gated to avoid flooding long sessions; set
|
|
8
|
+
// `XEMA_PLUGIN_DEBUG=1` on the worker (already a first-class env var on
|
|
9
|
+
// the opencode-pool container envelope) to see per-hook / per-tool traces.
|
|
10
|
+
//
|
|
11
|
+
// All messages carry the `[opencode-xema-plugin]` prefix so they survive
|
|
12
|
+
// `docker logs` grep-filtering alongside opencode's own stderr and the
|
|
13
|
+
// workspace-proxy's access log.
|
|
14
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
15
|
+
|
|
16
|
+
const PREFIX = '[opencode-xema-plugin]';
|
|
17
|
+
|
|
18
|
+
const DEBUG_ENABLED =
|
|
19
|
+
process.env.XEMA_PLUGIN_DEBUG === '1' ||
|
|
20
|
+
process.env.XEMA_PLUGIN_DEBUG === 'true';
|
|
21
|
+
|
|
22
|
+
export function info(msg: string): void {
|
|
23
|
+
// eslint-disable-next-line no-console
|
|
24
|
+
console.log(`${PREFIX} ${msg}`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function warn(msg: string): void {
|
|
28
|
+
// eslint-disable-next-line no-console
|
|
29
|
+
console.warn(`${PREFIX} ${msg}`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function debug(msg: string): void {
|
|
33
|
+
if (!DEBUG_ENABLED) return;
|
|
34
|
+
// eslint-disable-next-line no-console
|
|
35
|
+
console.log(`${PREFIX} [debug] ${msg}`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function isDebug(): boolean {
|
|
39
|
+
return DEBUG_ENABLED;
|
|
40
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
2
|
+
// ── Minimal opencode plugin type shim ──
|
|
3
|
+
//
|
|
4
|
+
// We re-declare only the types we consume at the plugin boundary so this
|
|
5
|
+
// package does not require the full @opencode-ai/plugin package at install
|
|
6
|
+
// time. The runtime contract (exported default function returning Hooks)
|
|
7
|
+
// matches opencode's loader — see /tmp/opencode/packages/plugin/src/index.ts.
|
|
8
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
9
|
+
|
|
10
|
+
import type { z } from 'zod';
|
|
11
|
+
|
|
12
|
+
interface ToolContext {
|
|
13
|
+
sessionID: string;
|
|
14
|
+
messageID: string;
|
|
15
|
+
agent: string;
|
|
16
|
+
directory: string;
|
|
17
|
+
worktree: string;
|
|
18
|
+
abort: AbortSignal;
|
|
19
|
+
metadata(input: { title?: string; metadata?: Record<string, unknown> }): void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ToolDefinition<Args extends z.ZodRawShape = z.ZodRawShape> {
|
|
23
|
+
description: string;
|
|
24
|
+
args: Args;
|
|
25
|
+
execute(
|
|
26
|
+
args: z.infer<z.ZodObject<Args>>,
|
|
27
|
+
context: ToolContext,
|
|
28
|
+
): Promise<string>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Subset of opencode's `Model` shape passed to `experimental.chat.system.transform`.
|
|
33
|
+
* The hook fires before each chat call so we can append a Xema platform
|
|
34
|
+
* overlay to the system array; the model is provided to support per-
|
|
35
|
+
* provider tuning later.
|
|
36
|
+
*/
|
|
37
|
+
export interface ExperimentalChatModel {
|
|
38
|
+
providerID?: string;
|
|
39
|
+
modelID?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface ExperimentalChatSystemTransformInput {
|
|
43
|
+
sessionID?: string;
|
|
44
|
+
model: ExperimentalChatModel;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface ExperimentalChatSystemTransformOutput {
|
|
48
|
+
system: string[];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Generic OpenCode event delivered to the `event` hook. The upstream
|
|
53
|
+
* `Event` type is a discriminated union; we type only the variants we
|
|
54
|
+
* consume (currently just `session.idle`) and accept the rest as
|
|
55
|
+
* `{ type: string; properties?: Record<string, unknown> }` so the
|
|
56
|
+
* plugin survives new event types added by opencode without a typings
|
|
57
|
+
* bump.
|
|
58
|
+
*/
|
|
59
|
+
export type OpencodeSessionIdleEvent = {
|
|
60
|
+
type: 'session.idle';
|
|
61
|
+
properties: { sessionID: string };
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export type OpencodeEvent =
|
|
65
|
+
| OpencodeSessionIdleEvent
|
|
66
|
+
| { type: string; properties?: Record<string, unknown> };
|
|
67
|
+
|
|
68
|
+
export interface Hooks {
|
|
69
|
+
tool?: Record<string, ToolDefinition>;
|
|
70
|
+
/**
|
|
71
|
+
* Fires once per chat call. We append the rendered Xema system overlay
|
|
72
|
+
* to `output.system` so the LLM sees the AWP base layer + deliverable
|
|
73
|
+
* contract + authority + retry context as authoritative platform
|
|
74
|
+
* context — separate from the project-level `/workspace/AGENTS.md`.
|
|
75
|
+
*/
|
|
76
|
+
'experimental.chat.system.transform'?: (
|
|
77
|
+
input: ExperimentalChatSystemTransformInput,
|
|
78
|
+
output: ExperimentalChatSystemTransformOutput,
|
|
79
|
+
) => Promise<void> | void;
|
|
80
|
+
/**
|
|
81
|
+
* Generic opencode event hook. OpenCode 1.15.x exposes session
|
|
82
|
+
* lifecycle as discriminated events on this channel rather than as
|
|
83
|
+
* dedicated hooks. The xema plugin uses it to react to
|
|
84
|
+
* `session.idle` (per-turn auto-commit). All other event types are
|
|
85
|
+
* ignored.
|
|
86
|
+
*/
|
|
87
|
+
event?: (input: { event: OpencodeEvent }) => Promise<void> | void;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
interface PluginInput {
|
|
91
|
+
directory: string;
|
|
92
|
+
worktree: string;
|
|
93
|
+
serverUrl: URL;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export type Plugin = (input: PluginInput) => Promise<Hooks>;
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The opencode `tool()` helper is a pass-through identity function. We
|
|
100
|
+
* re-declare it here so plugin authors can use `tool({...})` exactly as
|
|
101
|
+
* they would via @opencode-ai/plugin.
|
|
102
|
+
*/
|
|
103
|
+
export function tool<Args extends z.ZodRawShape>(
|
|
104
|
+
input: ToolDefinition<Args>,
|
|
105
|
+
): ToolDefinition<Args> {
|
|
106
|
+
return input;
|
|
107
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
2
|
+
// ── OpenCode plugin entry point ──
|
|
3
|
+
//
|
|
4
|
+
// OpenCode v1.4.x's plugin loader inspects the ESM module's exports and
|
|
5
|
+
// expects to find a single plugin factory. When the module re-exports
|
|
6
|
+
// additional identifiers (error classes, constants, helper utilities), the
|
|
7
|
+
// loader's heuristic picks the wrong export and fails with
|
|
8
|
+
// "Plugin export is not a function". The tools then silently fail to
|
|
9
|
+
// register — the agent boots, the model is still resolvable, but every
|
|
10
|
+
// prompt that references a `xema_*` tool dies at tool-dispatch time
|
|
11
|
+
// with "Model tried to call unavailable tool".
|
|
12
|
+
//
|
|
13
|
+
// This file is the bundle entry for the worker-image build. It re-exports
|
|
14
|
+
// ONLY the plugin factory (as the default export AND as a named export for
|
|
15
|
+
// loaders that look for either). Everything else — errors, runtime helpers,
|
|
16
|
+
// the role-capability registry — stays available to TypeScript consumers via
|
|
17
|
+
// the package's regular `index.ts` barrel, which is the consumer-facing
|
|
18
|
+
// entry (not the runtime plugin entry). The two entry points let the
|
|
19
|
+
// programmatic API stay rich without breaking OpenCode's plugin contract.
|
|
20
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
21
|
+
|
|
22
|
+
export { XemaPlugin as default, XemaPlugin } from './index.js';
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
2
|
+
// ── Session state + role allowlist ──
|
|
3
|
+
//
|
|
4
|
+
// The xema plugin is evaluated once at `opencode serve` startup. That is
|
|
5
|
+
// BEFORE the orchestrator writes /workspace/context.json (which is done per
|
|
6
|
+
// invocation by XemaRuntimeMounter). Every tool and hook therefore reads
|
|
7
|
+
// context.json lazily at call time through this module.
|
|
8
|
+
//
|
|
9
|
+
// Role-tool allowlist: source of truth is the `RoleCapabilityProfile`
|
|
10
|
+
// YAMLs at `biomes/kernel/runtime/role-capabilities/`, resolved by
|
|
11
|
+
// llm-registry-api's `agent-run-context.service.ts` and written into the
|
|
12
|
+
// `authority.allowedXemaTools` field of context.json. The plugin reads
|
|
13
|
+
// the list straight off context — adding/changing a role's tool list
|
|
14
|
+
// happens in the YAML, never here.
|
|
15
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
readInvocationContext,
|
|
19
|
+
type InvocationContext,
|
|
20
|
+
type InvocationRole,
|
|
21
|
+
} from './context.js';
|
|
22
|
+
|
|
23
|
+
export interface SessionState {
|
|
24
|
+
ctx: InvocationContext;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export class ContextMissingError extends Error {
|
|
28
|
+
readonly code = 'XEMA_CONTEXT_MISSING';
|
|
29
|
+
constructor(public readonly workspaceDir: string) {
|
|
30
|
+
super(
|
|
31
|
+
`/workspace/context.json not found in ${workspaceDir}. The xema plugin ` +
|
|
32
|
+
`cannot operate without invocation context — the orchestrator must ` +
|
|
33
|
+
`write context.json before the agent starts.`,
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export class RoleNotAllowedError extends Error {
|
|
39
|
+
readonly code = 'XEMA_ROLE_NOT_ALLOWED';
|
|
40
|
+
constructor(
|
|
41
|
+
public readonly role: InvocationRole,
|
|
42
|
+
public readonly toolName: string,
|
|
43
|
+
public readonly allowedTools: string[],
|
|
44
|
+
) {
|
|
45
|
+
super(
|
|
46
|
+
`Role "${role}" is not allowed to call "${toolName}". Allowed tools ` +
|
|
47
|
+
`for this role: [${allowedTools.join(', ')}].`,
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Read /workspace/context.json from the worker filesystem. Throws
|
|
54
|
+
* ContextMissingError if context.json is not present — callers inside a
|
|
55
|
+
* tool.execute should let this propagate (opencode turns it into a tool
|
|
56
|
+
* error). Hook callers should treat a missing context as a non-xema
|
|
57
|
+
* session instead.
|
|
58
|
+
*/
|
|
59
|
+
export function loadSessionState(workspaceDir: string): SessionState {
|
|
60
|
+
const ctx = readInvocationContext(workspaceDir);
|
|
61
|
+
if (!ctx) {
|
|
62
|
+
throw new ContextMissingError(workspaceDir);
|
|
63
|
+
}
|
|
64
|
+
return { ctx };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function ensureRoleAllowed(
|
|
68
|
+
ctx: InvocationContext,
|
|
69
|
+
toolName: string,
|
|
70
|
+
): void {
|
|
71
|
+
const allowed = ctx.authority.allowedXemaTools;
|
|
72
|
+
if (!allowed.includes(toolName)) {
|
|
73
|
+
throw new RoleNotAllowedError(
|
|
74
|
+
ctx.invocation.role,
|
|
75
|
+
toolName,
|
|
76
|
+
[...allowed].sort((a, b) => a.localeCompare(b)),
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Load session state and assert the invoking role may call the given tool.
|
|
83
|
+
* Used by every Xema-namespaced tool at the top of its execute().
|
|
84
|
+
*/
|
|
85
|
+
export function loadSessionStateForTool(
|
|
86
|
+
workspaceDir: string,
|
|
87
|
+
toolName: string,
|
|
88
|
+
): SessionState {
|
|
89
|
+
const state = loadSessionState(workspaceDir);
|
|
90
|
+
ensureRoleAllowed(state.ctx, toolName);
|
|
91
|
+
return state;
|
|
92
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
2
|
+
// ── Tool runtime helpers ──
|
|
3
|
+
// Re-export from the root session-state module. Kept as a thin barrel so
|
|
4
|
+
// each Xema-namespaced tool has a stable import path.
|
|
5
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
6
|
+
|
|
7
|
+
export { loadSessionStateForTool } from '../session-state.js';
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
2
|
+
// ── Worker service-token reader ──
|
|
3
|
+
//
|
|
4
|
+
// The opencode worker authenticates against platform services with a
|
|
5
|
+
// per-allocation Keycloak service JWT. The JWT carries the `xema_org_id`
|
|
6
|
+
// org-scope claim that `ServiceTokenGuard` cross-checks against
|
|
7
|
+
// the tenant header (`X-Xema-Org-Id`, legacy `X-Org-Id`) on every
|
|
8
|
+
// downstream call — replay against a different tenant is rejected
|
|
9
|
+
// fail-fast at the receiving service.
|
|
10
|
+
// workspace-proxy — running in the same pod — writes that JWT to a file
|
|
11
|
+
// and refreshes it mid-session via `PUT /control/service-token`.
|
|
12
|
+
//
|
|
13
|
+
// The `xema_*` tools read this file FRESH on every call (never cache it):
|
|
14
|
+
// a cached value would go stale the moment workspace-proxy swaps in a
|
|
15
|
+
// refreshed token, silently producing 401s for the rest of the session.
|
|
16
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
17
|
+
|
|
18
|
+
import { readFileSync } from 'node:fs';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Path the worker service token is written to. Configurable via
|
|
22
|
+
* `XEMA_SERVICE_TOKEN_PATH`; defaults to `/run/xema/service-token`. Must
|
|
23
|
+
* stay aligned with `serviceTokenPath()` in `biomes/workspace-proxy/api/workspace-proxy`.
|
|
24
|
+
*/
|
|
25
|
+
function serviceTokenPath(): string {
|
|
26
|
+
return process.env['XEMA_SERVICE_TOKEN_PATH']?.trim() || '/run/xema/service-token';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Read the worker service JWT from disk, fresh. Throws a clear fail-fast
|
|
31
|
+
* error when the file is missing or empty — the worker cannot reach
|
|
32
|
+
* platform services without it, and silently sending no `Authorization`
|
|
33
|
+
* header would just surface as opaque 401s deeper in the stack.
|
|
34
|
+
*/
|
|
35
|
+
export function readServiceToken(): string {
|
|
36
|
+
const path = serviceTokenPath();
|
|
37
|
+
let raw: string;
|
|
38
|
+
try {
|
|
39
|
+
raw = readFileSync(path, 'utf8');
|
|
40
|
+
} catch (err) {
|
|
41
|
+
const code = (err as NodeJS.ErrnoException).code;
|
|
42
|
+
if (code === 'ENOENT') {
|
|
43
|
+
throw new Error(
|
|
44
|
+
`Worker service token file is missing at ${path}. workspace-proxy ` +
|
|
45
|
+
'must write the per-allocation Keycloak JWT (via the session ' +
|
|
46
|
+
'bundle or PUT /control/service-token) before xema_* tools run.',
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
50
|
+
throw new Error(`Failed to read worker service token at ${path}: ${message}`);
|
|
51
|
+
}
|
|
52
|
+
const token = raw.trim();
|
|
53
|
+
if (!token) {
|
|
54
|
+
throw new Error(
|
|
55
|
+
`Worker service token file at ${path} is empty. workspace-proxy ` +
|
|
56
|
+
'must write a non-empty per-allocation Keycloak JWT.',
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
return token;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Convenience wrapper returning the `Authorization: Bearer <jwt>` header
|
|
64
|
+
* value for the worker service token.
|
|
65
|
+
*/
|
|
66
|
+
export function serviceTokenAuthorizationHeader(): string {
|
|
67
|
+
return `Bearer ${readServiceToken()}`;
|
|
68
|
+
}
|