@xemahq/opencode-xema-plugin 0.1.5 → 0.1.6

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/src/index.ts DELETED
@@ -1,163 +0,0 @@
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 DELETED
@@ -1,40 +0,0 @@
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
- }
@@ -1,107 +0,0 @@
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
- }
@@ -1,22 +0,0 @@
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';
@@ -1,92 +0,0 @@
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
- }
@@ -1,7 +0,0 @@
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';
@@ -1,68 +0,0 @@
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
- }
@@ -1,16 +0,0 @@
1
- import { mkdirSync, writeFileSync } from 'node:fs';
2
- import { dirname, join } from 'node:path';
3
-
4
- export function writeOutputFile(
5
- workspaceDir: string,
6
- relativePath: string,
7
- content: string,
8
- ): void {
9
- const fullPath = join(workspaceDir, relativePath);
10
- mkdirSync(dirname(fullPath), { recursive: true });
11
- writeFileSync(fullPath, content, 'utf-8');
12
- }
13
-
14
- export function toJsonString(value: unknown): string {
15
- return JSON.stringify(value, null, 2);
16
- }