@xemahq/opencode-xema-plugin 0.1.4 → 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/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 +5 -4
- package/plugin.mjs +1 -1
- 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,411 @@
|
|
|
1
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
2
|
+
// ── `session.idle` auto-commit hook ──
|
|
3
|
+
//
|
|
4
|
+
// Fires on every opencode `session.idle` event (delivered via the generic
|
|
5
|
+
// `event` hook). For sessions opted into per-turn auto-commit
|
|
6
|
+
// (`context.git.autoCommit === 'enabled'` — the
|
|
7
|
+
// `shared_dev_publish_to_prod` BranchMode default), the hook:
|
|
8
|
+
//
|
|
9
|
+
// 1. Reads context.json for sessionId + actor + repoSubPath.
|
|
10
|
+
// 2. Reads the actor JWT from `$WORKER_ACTOR_JWT_PATH` (default
|
|
11
|
+
// `/run/xema/actor.jwt`). Missing JWT → log warn + skip (best-effort).
|
|
12
|
+
// 3. Runs `git status --porcelain` against `<workspace>/<repoSubPath>`.
|
|
13
|
+
// Clean tree → skip (no-op).
|
|
14
|
+
// 4. POSTs `$WORKSPACE_GIT_API_URL/sessions/<sid>/autocommit` with the
|
|
15
|
+
// actor JWT as `Authorization: Bearer …`. Body:
|
|
16
|
+
// { turn, actor: { kind, subjectRef, displayName },
|
|
17
|
+
// committed, sha, pushFailed }
|
|
18
|
+
// Wire contract: this endpoint records state; the local git ops
|
|
19
|
+
// (add/commit/push) happen ON the worker — see design note history
|
|
20
|
+
// that this file replaced. workspace-proxy ships
|
|
21
|
+
// `/control/git/autocommit` which workspace-git-api will eventually
|
|
22
|
+
// delegate to; the hook signature is stable across that switch.
|
|
23
|
+
// 5. Translates the response into local actions:
|
|
24
|
+
// - `committed=true, conflictDetected=false` → log success.
|
|
25
|
+
// - `committed=true, pushFailed=true` → one retry w/ 5s backoff;
|
|
26
|
+
// on second failure, log + continue (NEVER block the next turn).
|
|
27
|
+
// - `conflictDetected=true` → emit `xema.conflict.detected` event
|
|
28
|
+
// (best-effort; sets a process-local flag readable by other
|
|
29
|
+
// hooks) so the next user message can surface a "Conflict —
|
|
30
|
+
// Publish needed" affordance.
|
|
31
|
+
// - Any HTTP error → log + continue.
|
|
32
|
+
//
|
|
33
|
+
// HARD INVARIANT: this hook MUST NEVER throw out of the opencode
|
|
34
|
+
// dispatcher. Auto-commit is best-effort orientation; failing here would
|
|
35
|
+
// stall every user's next turn. The entire body runs inside a top-level
|
|
36
|
+
// `try { … } catch { warn(...); }` and returns `undefined` on any error.
|
|
37
|
+
//
|
|
38
|
+
// Wire contract (kept in sync with biomes/software-dev/api/workspace-git-api):
|
|
39
|
+
//
|
|
40
|
+
// POST {WORKSPACE_GIT_API_URL}/sessions/{sessionId}/autocommit
|
|
41
|
+
// Authorization: Bearer <actor-jwt>
|
|
42
|
+
// Content-Type: application/json
|
|
43
|
+
//
|
|
44
|
+
// { "turn": 7,
|
|
45
|
+
// "actor": { "kind": "org_user", "subjectRef": "user_…",
|
|
46
|
+
// "displayName": "Alice" },
|
|
47
|
+
// "committed": true,
|
|
48
|
+
// "sha": "ab12cd34…",
|
|
49
|
+
// "pushFailed": false }
|
|
50
|
+
//
|
|
51
|
+
// → 200 { committed, sha, pushFailed, conflictDetected }
|
|
52
|
+
//
|
|
53
|
+
// See [packages/runtime/opencode-xema-plugin/CHANGELOG] for the broader
|
|
54
|
+
// handoff context. The plaintext design note
|
|
55
|
+
// previously at `hooks/_TODO_session-idle-autocommit.md` was deleted
|
|
56
|
+
// when this file landed.
|
|
57
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
58
|
+
|
|
59
|
+
import { execFile } from 'node:child_process';
|
|
60
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
61
|
+
import { join } from 'node:path';
|
|
62
|
+
import { promisify } from 'node:util';
|
|
63
|
+
|
|
64
|
+
import { readInvocationContextForHook, type InvocationContext } from '../context.js';
|
|
65
|
+
import { warn, info, debug } from '../logger.js';
|
|
66
|
+
import type { OpencodeEvent, OpencodeSessionIdleEvent } from '../opencode-types.js';
|
|
67
|
+
|
|
68
|
+
const HOOK_NAME = 'session.idle';
|
|
69
|
+
const DEFAULT_JWT_PATH = '/run/xema/actor.jwt';
|
|
70
|
+
const PUSH_RETRY_DELAY_MS = 5_000;
|
|
71
|
+
|
|
72
|
+
const execFileAsync = promisify(execFile);
|
|
73
|
+
|
|
74
|
+
interface AutocommitRequestBody {
|
|
75
|
+
turn: number;
|
|
76
|
+
actor: {
|
|
77
|
+
kind: 'org_user';
|
|
78
|
+
subjectRef: string;
|
|
79
|
+
displayName: string;
|
|
80
|
+
};
|
|
81
|
+
committed: boolean;
|
|
82
|
+
sha: string | null;
|
|
83
|
+
pushFailed: boolean;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
interface AutocommitResponseBody {
|
|
87
|
+
committed: boolean;
|
|
88
|
+
sha: string | null;
|
|
89
|
+
pushFailed: boolean;
|
|
90
|
+
conflictDetected: boolean;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Process-local flag set when workspace-git-api last reported
|
|
95
|
+
* `conflictDetected=true`. Other hooks (e.g. a future chat-message
|
|
96
|
+
* pre-send hook) can read it via {@link readConflictFlag} to surface a
|
|
97
|
+
* "Conflict — Publish needed" affordance to the user. Per-session
|
|
98
|
+
* because a single worker hosts multiple sessions in pool mode.
|
|
99
|
+
*/
|
|
100
|
+
const conflictFlagBySessionId = new Map<string, { at: number; turn: number }>();
|
|
101
|
+
|
|
102
|
+
export function readConflictFlag(
|
|
103
|
+
sessionId: string,
|
|
104
|
+
): { at: number; turn: number } | undefined {
|
|
105
|
+
return conflictFlagBySessionId.get(sessionId);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function clearConflictFlag(sessionId: string): void {
|
|
109
|
+
conflictFlagBySessionId.delete(sessionId);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Per-session turn counter. `session.idle` fires once per LLM turn, so
|
|
114
|
+
* we increment on receipt before performing the autocommit work. This
|
|
115
|
+
* is the only place inside the worker that sees the boundary
|
|
116
|
+
* deterministically.
|
|
117
|
+
*/
|
|
118
|
+
const turnCounterBySessionId = new Map<string, number>();
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Test-only: reset all in-memory state. Not exported from the package
|
|
122
|
+
* barrel.
|
|
123
|
+
*/
|
|
124
|
+
export function __resetSessionIdleAutocommitStateForTests(): void {
|
|
125
|
+
conflictFlagBySessionId.clear();
|
|
126
|
+
turnCounterBySessionId.clear();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Factory: returns the `event` hook bound to a workspace directory. The
|
|
131
|
+
* hook itself dispatches on `event.type === 'session.idle'` and ignores
|
|
132
|
+
* everything else.
|
|
133
|
+
*/
|
|
134
|
+
export function buildSessionIdleAutocommit(
|
|
135
|
+
workspaceDir: string,
|
|
136
|
+
): (input: { event: OpencodeEvent }) => Promise<void> {
|
|
137
|
+
return async (input) => {
|
|
138
|
+
const evt = input.event;
|
|
139
|
+
if (evt.type !== 'session.idle') {
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
const idleEvent = evt as OpencodeSessionIdleEvent;
|
|
143
|
+
try {
|
|
144
|
+
await handleSessionIdle(workspaceDir, idleEvent);
|
|
145
|
+
} catch (err) {
|
|
146
|
+
// HARD invariant: never propagate. Auto-commit is best-effort.
|
|
147
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
148
|
+
warn(
|
|
149
|
+
`hook=${HOOK_NAME}: unexpected error swallowed (auto-commit is best-effort): ${message}`,
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function handleSessionIdle(
|
|
156
|
+
workspaceDir: string,
|
|
157
|
+
evt: OpencodeSessionIdleEvent,
|
|
158
|
+
): Promise<void> {
|
|
159
|
+
const sessionId = evt.properties.sessionID;
|
|
160
|
+
if (!sessionId) {
|
|
161
|
+
debug(`hook=${HOOK_NAME}: event missing sessionID; skipping`);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const ctx = readInvocationContextForHook(workspaceDir, HOOK_NAME);
|
|
166
|
+
if (!ctx) {
|
|
167
|
+
// Non-Xema session or pre-mount: nothing to commit.
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (ctx.git?.autoCommit !== 'enabled') {
|
|
172
|
+
debug(
|
|
173
|
+
`hook=${HOOK_NAME}: session=${sessionId} autoCommit not enabled (got ` +
|
|
174
|
+
`${ctx.git?.autoCommit ?? 'absent'}); skipping`,
|
|
175
|
+
);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (!ctx.actor) {
|
|
180
|
+
warn(
|
|
181
|
+
`hook=${HOOK_NAME}: session=${sessionId} has git.autoCommit=enabled but ` +
|
|
182
|
+
`no actor in context.json; skipping. The orchestrator must stamp ` +
|
|
183
|
+
`context.actor for sessions opted into per-turn auto-commit.`,
|
|
184
|
+
);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ── Why an env var and not the KernelState/etcd service registry ──
|
|
189
|
+
//
|
|
190
|
+
// This plugin runs INSIDE the agent-workspace container, which is
|
|
191
|
+
// deliberately OUTSIDE the etcd service mesh: it holds no registry client,
|
|
192
|
+
// no etcd credentials, and no service identity of its own — its only
|
|
193
|
+
// identity is the actor JWT the orchestrator mounts. So `WORKSPACE_GIT_API_URL`
|
|
194
|
+
// is an ORCHESTRATOR-INJECTED peer URL, not the retired `<SVC>_API_URL`
|
|
195
|
+
// discovery pattern that first-party in-mesh services must never use. The
|
|
196
|
+
// orchestrator resolves the peer FROM the registry and injects the resolved
|
|
197
|
+
// value into the container envelope; this is the only category of code
|
|
198
|
+
// allowed to read a peer URL from the environment.
|
|
199
|
+
//
|
|
200
|
+
// Absent ⇒ SKIP with a warn, never a throw: see this module's HARD
|
|
201
|
+
// INVARIANT — a `session.idle` hook that throws stalls every user's next
|
|
202
|
+
// turn. The warn names the operator fix, so the degradation is observable,
|
|
203
|
+
// not silent.
|
|
204
|
+
const apiUrl = process.env.WORKSPACE_GIT_API_URL;
|
|
205
|
+
if (!apiUrl || apiUrl.length === 0) {
|
|
206
|
+
warn(
|
|
207
|
+
`hook=${HOOK_NAME}: session=${sessionId} WORKSPACE_GIT_API_URL env not set; ` +
|
|
208
|
+
`skipping auto-commit. Wire the env into the worker container envelope.`,
|
|
209
|
+
);
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const jwtPath = process.env.WORKER_ACTOR_JWT_PATH ?? DEFAULT_JWT_PATH;
|
|
214
|
+
const actorJwt = readActorJwt(jwtPath);
|
|
215
|
+
if (actorJwt === null) {
|
|
216
|
+
warn(
|
|
217
|
+
`hook=${HOOK_NAME}: session=${sessionId} actor JWT not readable at ${jwtPath}; ` +
|
|
218
|
+
`skipping auto-commit. Verify the orchestrator mounts the actor JWT ` +
|
|
219
|
+
`before session start.`,
|
|
220
|
+
);
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// TODO(multi-repo): when the workspace can host multiple
|
|
225
|
+
// checked-out repos (persistent-repos), `git.repoSubPath`
|
|
226
|
+
// becomes an array and the hook fans out one autocommit per repo. The
|
|
227
|
+
// workspace-git-api wire shape already keys on `(sessionId,
|
|
228
|
+
// repoBindingId)` server-side; the per-call body just needs the
|
|
229
|
+
// repoBindingId added. For now we commit at the single primary
|
|
230
|
+
// repo (or workspace root if none is configured).
|
|
231
|
+
const repoSubPath = ctx.git.repoSubPath ?? null;
|
|
232
|
+
const repoDir = repoSubPath ? join(workspaceDir, repoSubPath) : workspaceDir;
|
|
233
|
+
|
|
234
|
+
const dirty = await isGitTreeDirty(repoDir);
|
|
235
|
+
if (dirty === undefined) {
|
|
236
|
+
// git status itself failed — log and skip; nothing we can usefully do.
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
if (!dirty) {
|
|
240
|
+
debug(
|
|
241
|
+
`hook=${HOOK_NAME}: session=${sessionId} clean tree at ${repoDir}; ` +
|
|
242
|
+
`skipping auto-commit`,
|
|
243
|
+
);
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const turn = nextTurn(sessionId);
|
|
248
|
+
const body: AutocommitRequestBody = {
|
|
249
|
+
turn,
|
|
250
|
+
actor: {
|
|
251
|
+
kind: ctx.actor.kind,
|
|
252
|
+
subjectRef: ctx.actor.subjectRef,
|
|
253
|
+
displayName: ctx.actor.displayName,
|
|
254
|
+
},
|
|
255
|
+
// Wire contract: the worker reports the OUTCOME. For the v1
|
|
256
|
+
// hook we forward "needs commit; service will perform / record it
|
|
257
|
+
// via the workspace-proxy bridge once available". Until then
|
|
258
|
+
// workspace-git-api treats `committed=true` as "you produced a
|
|
259
|
+
// commit locally, please record it"; the actual git push happens
|
|
260
|
+
// through the bridge.
|
|
261
|
+
committed: true,
|
|
262
|
+
sha: null,
|
|
263
|
+
pushFailed: false,
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
const endpoint = buildAutocommitUrl(apiUrl, sessionId);
|
|
267
|
+
const response = await postAutocommit(endpoint, actorJwt, body, ctx);
|
|
268
|
+
if (response === null) {
|
|
269
|
+
return; // already logged
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (response.conflictDetected) {
|
|
273
|
+
conflictFlagBySessionId.set(sessionId, { at: Date.now(), turn });
|
|
274
|
+
info(
|
|
275
|
+
`hook=${HOOK_NAME}: session=${sessionId} turn=${turn} conflict detected ` +
|
|
276
|
+
`(remote diverged); next user message will surface the Publish affordance`,
|
|
277
|
+
);
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (response.pushFailed) {
|
|
282
|
+
info(
|
|
283
|
+
`hook=${HOOK_NAME}: session=${sessionId} turn=${turn} push failed; ` +
|
|
284
|
+
`retrying once after ${PUSH_RETRY_DELAY_MS}ms`,
|
|
285
|
+
);
|
|
286
|
+
await sleep(PUSH_RETRY_DELAY_MS);
|
|
287
|
+
const retry = await postAutocommit(endpoint, actorJwt, body, ctx);
|
|
288
|
+
if (retry === null || retry.pushFailed) {
|
|
289
|
+
warn(
|
|
290
|
+
`hook=${HOOK_NAME}: session=${sessionId} turn=${turn} push failed twice; ` +
|
|
291
|
+
`giving up (auto-commit is best-effort). User-driven Publish flow ` +
|
|
292
|
+
`remains available.`,
|
|
293
|
+
);
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
if (retry.conflictDetected) {
|
|
297
|
+
conflictFlagBySessionId.set(sessionId, { at: Date.now(), turn });
|
|
298
|
+
info(
|
|
299
|
+
`hook=${HOOK_NAME}: session=${sessionId} turn=${turn} retry surfaced conflict`,
|
|
300
|
+
);
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
info(
|
|
304
|
+
`hook=${HOOK_NAME}: session=${sessionId} turn=${turn} retry committed=${retry.committed}` +
|
|
305
|
+
` sha=${retry.sha ?? '-'}`,
|
|
306
|
+
);
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
info(
|
|
311
|
+
`hook=${HOOK_NAME}: session=${sessionId} turn=${turn} committed=${response.committed}` +
|
|
312
|
+
` sha=${response.sha ?? '-'}`,
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function nextTurn(sessionId: string): number {
|
|
317
|
+
const prev = turnCounterBySessionId.get(sessionId) ?? 0;
|
|
318
|
+
const next = prev + 1;
|
|
319
|
+
turnCounterBySessionId.set(sessionId, next);
|
|
320
|
+
return next;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function readActorJwt(path: string): string | null {
|
|
324
|
+
try {
|
|
325
|
+
if (!existsSync(path)) {
|
|
326
|
+
return null;
|
|
327
|
+
}
|
|
328
|
+
const raw = readFileSync(path, 'utf-8').trim();
|
|
329
|
+
return raw.length > 0 ? raw : null;
|
|
330
|
+
} catch (err) {
|
|
331
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
332
|
+
warn(`hook=${HOOK_NAME}: failed to read actor JWT at ${path}: ${message}`);
|
|
333
|
+
return null;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Returns true iff `git status --porcelain` produces non-empty output.
|
|
339
|
+
* `undefined` signals "could not determine" (git missing, not-a-repo,
|
|
340
|
+
* permission error, etc.) — the caller MUST treat this as "skip" rather
|
|
341
|
+
* than guess.
|
|
342
|
+
*/
|
|
343
|
+
async function isGitTreeDirty(repoDir: string): Promise<boolean | undefined> {
|
|
344
|
+
try {
|
|
345
|
+
const { stdout } = await execFileAsync(
|
|
346
|
+
'git',
|
|
347
|
+
['-C', repoDir, 'status', '--porcelain'],
|
|
348
|
+
{ encoding: 'utf-8', maxBuffer: 16 * 1024 * 1024 },
|
|
349
|
+
);
|
|
350
|
+
return stdout.trim().length > 0;
|
|
351
|
+
} catch (err) {
|
|
352
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
353
|
+
warn(
|
|
354
|
+
`hook=${HOOK_NAME}: git status failed in ${repoDir}: ${message}. ` +
|
|
355
|
+
`Skipping auto-commit for this turn.`,
|
|
356
|
+
);
|
|
357
|
+
return undefined;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function buildAutocommitUrl(apiBase: string, sessionId: string): string {
|
|
362
|
+
const base = apiBase.endsWith('/') ? apiBase.slice(0, -1) : apiBase;
|
|
363
|
+
return `${base}/sessions/${encodeURIComponent(sessionId)}/autocommit`;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
async function postAutocommit(
|
|
367
|
+
url: string,
|
|
368
|
+
actorJwt: string,
|
|
369
|
+
body: AutocommitRequestBody,
|
|
370
|
+
ctx: InvocationContext,
|
|
371
|
+
): Promise<AutocommitResponseBody | null> {
|
|
372
|
+
try {
|
|
373
|
+
const res = await fetch(url, {
|
|
374
|
+
method: 'POST',
|
|
375
|
+
headers: {
|
|
376
|
+
'Content-Type': 'application/json',
|
|
377
|
+
Authorization: `Bearer ${actorJwt}`,
|
|
378
|
+
},
|
|
379
|
+
body: JSON.stringify(body),
|
|
380
|
+
});
|
|
381
|
+
if (!res.ok) {
|
|
382
|
+
const text = await safeReadBody(res);
|
|
383
|
+
warn(
|
|
384
|
+
`hook=${HOOK_NAME}: POST ${url} responded ${res.status} ${res.statusText} ` +
|
|
385
|
+
`(role=${ctx.invocation.role}, runId=${ctx.invocation.runId ?? '-'}): ${text}`,
|
|
386
|
+
);
|
|
387
|
+
return null;
|
|
388
|
+
}
|
|
389
|
+
const parsed = (await res.json()) as AutocommitResponseBody;
|
|
390
|
+
return parsed;
|
|
391
|
+
} catch (err) {
|
|
392
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
393
|
+
warn(`hook=${HOOK_NAME}: POST ${url} failed: ${message}`);
|
|
394
|
+
return null;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
async function safeReadBody(res: Response): Promise<string> {
|
|
399
|
+
try {
|
|
400
|
+
const text = await res.text();
|
|
401
|
+
return text.length > 512 ? `${text.slice(0, 512)}…` : text;
|
|
402
|
+
} catch {
|
|
403
|
+
return '<unreadable body>';
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function sleep(ms: number): Promise<void> {
|
|
408
|
+
return new Promise((resolve) => {
|
|
409
|
+
setTimeout(resolve, ms);
|
|
410
|
+
});
|
|
411
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { readInvocationContextForHook } from '../context.js';
|
|
5
|
+
import { XemaSystemOverlayMissingError } from '../errors.js';
|
|
6
|
+
import { debug } from '../logger.js';
|
|
7
|
+
import type {
|
|
8
|
+
ExperimentalChatSystemTransformInput,
|
|
9
|
+
ExperimentalChatSystemTransformOutput,
|
|
10
|
+
} from '../opencode-types.js';
|
|
11
|
+
import { renderWorkspaceListing } from './workspace-listing.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* `experimental.chat.system.transform` hook — concatenates the rendered
|
|
15
|
+
* Xema platform overlay onto OpenCode's system prompt entry on every
|
|
16
|
+
* chat call.
|
|
17
|
+
*
|
|
18
|
+
* Behavior:
|
|
19
|
+
* - **Non-Xema session** (no `context.json` in `directory`) — pass
|
|
20
|
+
* through unchanged. The plugin must not affect non-Xema OpenCode
|
|
21
|
+
* usage.
|
|
22
|
+
* - **Xema session, overlay present** — append the overlay bytes to
|
|
23
|
+
* the END of `output.system[0]` (the existing single entry that
|
|
24
|
+
* OpenCode seeded with the agent prompt + any user system). The
|
|
25
|
+
* overlay is appended as the trailing block so its directives
|
|
26
|
+
* remain authoritative on conflict — LLMs treat later system text
|
|
27
|
+
* as more recent guidance.
|
|
28
|
+
* - **Xema session, overlay missing** — throw
|
|
29
|
+
* `XemaSystemOverlayMissingError`. The platform contract is
|
|
30
|
+
* undefined; we refuse to start the chat rather than letting an
|
|
31
|
+
* agent operate without the AWP base layer + deliverable contract
|
|
32
|
+
* + authority guidance the rest of the platform assumes is in
|
|
33
|
+
* scope. The agent activity surfaces this as a typed
|
|
34
|
+
* `OVERLAY_MISSING` error.
|
|
35
|
+
*
|
|
36
|
+
* Why concatenate instead of `output.system.push(bytes)`:
|
|
37
|
+
*
|
|
38
|
+
* OpenCode renders each entry of `output.system` as a SEPARATE wire
|
|
39
|
+
* message with `role: 'system'`. Its post-hook rejoin block in
|
|
40
|
+
* `session/llm.ts` only collapses entries when `system.length > 2`, so
|
|
41
|
+
* a single push from us produces `system.length === 2`, which slips
|
|
42
|
+
* past the rejoin and lands on the wire as two `role: 'system'`
|
|
43
|
+
* messages. OpenAI and Anthropic accept that shape; some
|
|
44
|
+
* OpenAI-compatible inference servers (notably stricter Qwen vLLM
|
|
45
|
+
* deployments) reject it with `400: System message must be at the
|
|
46
|
+
* beginning.` Concatenating into the existing entry keeps
|
|
47
|
+
* `system.length === 1` and produces exactly one leading system
|
|
48
|
+
* message regardless of provider strictness — fixing the symptom at
|
|
49
|
+
* the only point this plugin owns. (The OpenCode rejoin condition is
|
|
50
|
+
* a separate upstream bug; this defense is independent of it.)
|
|
51
|
+
*/
|
|
52
|
+
export function buildSystemPromptOverlay(directory: string) {
|
|
53
|
+
return async (
|
|
54
|
+
_input: ExperimentalChatSystemTransformInput,
|
|
55
|
+
output: ExperimentalChatSystemTransformOutput,
|
|
56
|
+
): Promise<void> => {
|
|
57
|
+
const ctx = readInvocationContextForHook(
|
|
58
|
+
directory,
|
|
59
|
+
'experimental.chat.system.transform',
|
|
60
|
+
);
|
|
61
|
+
if (!ctx) {
|
|
62
|
+
// Not a Xema session — leave OpenCode's base prompt alone.
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
const overlayPath = join(directory, '.xema', 'system-overlay.md');
|
|
66
|
+
if (!existsSync(overlayPath)) {
|
|
67
|
+
throw new XemaSystemOverlayMissingError(
|
|
68
|
+
`Xema session ${ctx.invocation.runId ?? 'unknown'} (role=${ctx.invocation.role}, ` +
|
|
69
|
+
`sessionId=${ctx.invocation.sessionId ?? '-'}) is missing /workspace/.xema/system-overlay.md. ` +
|
|
70
|
+
`The platform contract is undefined; refusing to start the chat. ` +
|
|
71
|
+
`Verify the agent-run-context render flow emitted the system-overlay slot.`,
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
const bytes = readFileSync(overlayPath, 'utf8');
|
|
75
|
+
// OpenCode seeds `output.system` with exactly one entry before
|
|
76
|
+
// calling this hook (see `session/llm.ts` — agent prompt + custom
|
|
77
|
+
// + user-system are joined into `system[0]`). If that contract
|
|
78
|
+
// ever changes (e.g. another plugin pushes ahead of us), fail
|
|
79
|
+
// loudly rather than silently producing multi-system payloads.
|
|
80
|
+
if (output.system.length !== 1) {
|
|
81
|
+
throw new Error(
|
|
82
|
+
`experimental.chat.system.transform: expected output.system to contain exactly 1 entry on hook entry, got ${output.system.length}. ` +
|
|
83
|
+
`Another plugin or an OpenCode change broke the seed-then-extend contract this hook relies on.`,
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
// Render the live depth-1 workspace tree AFTER the static overlay
|
|
87
|
+
// so the trailing block is the only volatile region of the system
|
|
88
|
+
// prompt. Everything above it (agent prompt, AWP base, deliverable
|
|
89
|
+
// contract, authority section) stays prefix-cacheable across
|
|
90
|
+
// turns; only this listing recomputes when files change. Do NOT
|
|
91
|
+
// move it earlier — that would invalidate the whole system-prompt
|
|
92
|
+
// cache on every turn that touches the workspace.
|
|
93
|
+
const listing = renderWorkspaceListing(directory);
|
|
94
|
+
output.system[0] = `${output.system[0]}\n\n${bytes}\n\n${listing}`;
|
|
95
|
+
debug(
|
|
96
|
+
`experimental.chat.system.transform: concatenated ${bytes.length} overlay bytes + ${listing.length} listing bytes into system[0] (role=${ctx.invocation.role})`,
|
|
97
|
+
);
|
|
98
|
+
};
|
|
99
|
+
}
|
|
@@ -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
|
+
}
|