@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.
@@ -0,0 +1,232 @@
1
+ // ═══════════════════════════════════════════════════════════════════════════
2
+ // ── xema_memory_set / xema_memory_get ──
3
+ //
4
+ // Thin session-memory tools wired into the shared memory-api. Inspired by
5
+ // Claude Code's memory model: agents can set *named* facts that survive
6
+ // compaction and be read back with a tag/kind filter.
7
+ //
8
+ // Scope: every write is project-scoped (orgId + projectId from context.json)
9
+ // and tagged with the opencode session id so memories from different runs
10
+ // do not bleed together on recall. Role-gated — only interactive / primary
11
+ // pipeline roles may write; anyone may read.
12
+ //
13
+ // Transport: direct HTTP to `MEMORY_API_URL`. When the env var is absent,
14
+ // the tool fails fast instead of silently dropping data — memory is either
15
+ // on or off for a deployment, not best-effort. Authentication uses the
16
+ // per-allocation Keycloak service JWT read fresh from the service-token
17
+ // file workspace-proxy writes (see `service-token.ts`).
18
+ // ═══════════════════════════════════════════════════════════════════════════
19
+
20
+ import { z } from 'zod';
21
+
22
+ import { tool } from '../opencode-types.js';
23
+ import { loadSessionStateForTool } from './runtime.js';
24
+ import { serviceTokenAuthorizationHeader } from './service-token.js';
25
+ import { toJsonString } from './shared.js';
26
+
27
+ const MEMORY_KINDS = [
28
+ 'GENERAL',
29
+ 'DECISION',
30
+ 'CONSTRAINT',
31
+ 'PATTERN',
32
+ 'LESSON',
33
+ 'EPISODIC',
34
+ 'PROCEDURAL',
35
+ 'CORRECTION',
36
+ ] as const;
37
+
38
+ type MemoryKind = (typeof MEMORY_KINDS)[number];
39
+
40
+ const SET_ARGS = {
41
+ content: z
42
+ .string()
43
+ .min(5)
44
+ .describe(
45
+ 'The memory statement. A short fact / preference / lesson phrased as ' +
46
+ 'a complete sentence. Avoid ephemeral context ("just now the user said"); ' +
47
+ 'memory persists across turns.',
48
+ ),
49
+ kind: z
50
+ .enum(MEMORY_KINDS)
51
+ .default('GENERAL')
52
+ .describe(
53
+ 'Kind of memory. Use DECISION for choices made, CONSTRAINT for ' +
54
+ 'hard limits, LESSON for insights from failures, PATTERN for ' +
55
+ 'recurring structures, CORRECTION when overriding a prior memory, ' +
56
+ 'GENERAL otherwise.',
57
+ ),
58
+ tag: z
59
+ .string()
60
+ .optional()
61
+ .describe(
62
+ 'Optional short tag that appears in the originRef so the memory can ' +
63
+ 'later be recalled by the same tag (e.g. "preferences", "style").',
64
+ ),
65
+ importance: z
66
+ .number()
67
+ .int()
68
+ .min(1)
69
+ .max(4)
70
+ .optional()
71
+ .describe('1=LOW, 2=NORMAL (default), 3=HIGH, 4=CRITICAL.'),
72
+ };
73
+
74
+ const GET_ARGS = {
75
+ kind: z
76
+ .enum(MEMORY_KINDS)
77
+ .optional()
78
+ .describe('Filter to a specific kind. Omit for all.'),
79
+ tag: z
80
+ .string()
81
+ .optional()
82
+ .describe(
83
+ 'Filter to memories written with this tag. Matches on originRef suffix.',
84
+ ),
85
+ limit: z
86
+ .number()
87
+ .int()
88
+ .min(1)
89
+ .max(50)
90
+ .default(20)
91
+ .describe('Maximum number of memories to return.'),
92
+ };
93
+
94
+ interface MemoryApiCtx {
95
+ baseUrl: string;
96
+ orgId: string;
97
+ projectId: string;
98
+ actorId: string;
99
+ sessionId: string;
100
+ role: string;
101
+ }
102
+
103
+ function resolveMemoryApiCtx(workspaceDir: string, toolName: string): MemoryApiCtx {
104
+ const state = loadSessionStateForTool(workspaceDir, toolName);
105
+ // ── Why an env var and not the KernelState/etcd service registry ──
106
+ //
107
+ // This plugin runs INSIDE the agent-workspace container, which is
108
+ // deliberately OUTSIDE the etcd service mesh: it holds no registry client,
109
+ // no etcd credentials, and no service identity of its own. So
110
+ // `MEMORY_API_URL` is an ORCHESTRATOR-INJECTED peer URL, not the retired
111
+ // `<SVC>_API_URL` discovery pattern that first-party in-mesh services must
112
+ // never use. The orchestrator resolves the peer FROM the registry and
113
+ // injects the resolved value into the container envelope; this is the only
114
+ // category of code allowed to read a peer URL from the environment.
115
+ //
116
+ // Absent ⇒ THROW (fail fast). Unlike the best-effort `session.idle`
117
+ // auto-commit hook, a memory tool that silently no-ops would drop data the
118
+ // agent believes it persisted. Memory is on or off for a deployment.
119
+ const baseUrl = process.env.MEMORY_API_URL?.trim();
120
+ if (!baseUrl) {
121
+ throw new Error(
122
+ 'MEMORY_API_URL is not configured on this worker. The operator must ' +
123
+ 'set it for xema_memory_* tools to work.',
124
+ );
125
+ }
126
+ const project = state.ctx.project;
127
+ if (!project || !project.projectId) {
128
+ throw new Error(
129
+ 'Session memory requires a projectId in context.json; this session is ' +
130
+ 'not bound to a project (orgId-only). Skip xema_memory_* tools here.',
131
+ );
132
+ }
133
+ const sessionId =
134
+ state.ctx.invocation.sessionId ?? state.ctx.invocation.runId ?? 'no-session';
135
+ return {
136
+ baseUrl: baseUrl.replace(/\/+$/, ''),
137
+ orgId: project.orgId,
138
+ projectId: project.projectId,
139
+ actorId: `xema:${state.ctx.invocation.role}`,
140
+ sessionId,
141
+ role: state.ctx.invocation.role,
142
+ };
143
+ }
144
+
145
+ function memoryApiHeaders(ctx: MemoryApiCtx): Record<string, string> {
146
+ // The worker authenticates with its per-allocation Keycloak service
147
+ // JWT, read fresh from the service-token file workspace-proxy writes.
148
+ // `readServiceToken` fails fast when the file is missing/empty.
149
+ return {
150
+ 'Content-Type': 'application/json',
151
+ Authorization: serviceTokenAuthorizationHeader(),
152
+ 'x-xema-org-id': ctx.orgId,
153
+ 'x-project-id': ctx.projectId,
154
+ 'x-actor-type': 'agent',
155
+ 'x-actor-id': ctx.actorId,
156
+ };
157
+ }
158
+
159
+ export function buildXemaMemorySetTool(workspaceDir: string) {
160
+ return tool({
161
+ description:
162
+ 'Persist a project-scoped memory that survives context compaction and ' +
163
+ 'future sessions. Use sparingly — for durable preferences, decisions, ' +
164
+ 'lessons, or patterns worth recalling next time. Do NOT use for ' +
165
+ 'conversational context or task-local notes.',
166
+ args: SET_ARGS,
167
+ async execute(args) {
168
+ const ctx = resolveMemoryApiCtx(workspaceDir, 'xema_memory_set');
169
+ const originRef = args.tag
170
+ ? `session:${ctx.sessionId}:${args.tag}`
171
+ : `session:${ctx.sessionId}`;
172
+ const body: Record<string, unknown> = {
173
+ content: args.content,
174
+ type: args.kind ?? 'GENERAL',
175
+ source: ctx.actorId,
176
+ originType: 'SESSION',
177
+ originRef,
178
+ };
179
+ if (args.importance !== undefined) body.importance = args.importance;
180
+
181
+ const res = await fetch(`${ctx.baseUrl}/memories`, {
182
+ method: 'POST',
183
+ headers: memoryApiHeaders(ctx),
184
+ body: JSON.stringify(body),
185
+ signal: AbortSignal.timeout(10_000),
186
+ });
187
+ if (!res.ok) {
188
+ const errText = await res.text().catch(() => '');
189
+ throw new Error(
190
+ `memory-api rejected write (${res.status}): ${errText.slice(0, 400)}`,
191
+ );
192
+ }
193
+ const stored = (await res.json().catch(() => null)) as unknown;
194
+ return toJsonString({ stored, originRef });
195
+ },
196
+ });
197
+ }
198
+
199
+ export function buildXemaMemoryGetTool(workspaceDir: string) {
200
+ return tool({
201
+ description:
202
+ 'Recall project-scoped memories written by earlier sessions or by ' +
203
+ 'xema_memory_set. Returns the most relevant items subject to an ' +
204
+ 'optional kind or tag filter. Read this BEFORE large decisions so ' +
205
+ 'prior lessons and preferences inform the work.',
206
+ args: GET_ARGS,
207
+ async execute(args) {
208
+ const ctx = resolveMemoryApiCtx(workspaceDir, 'xema_memory_get');
209
+ const params = new URLSearchParams();
210
+ params.set('limit', String(args.limit ?? 20));
211
+ if (args.kind) params.set('type', args.kind as MemoryKind);
212
+ if (args.tag) params.set('originRefContains', `:${args.tag}`);
213
+
214
+ const res = await fetch(
215
+ `${ctx.baseUrl}/memories?${params.toString()}`,
216
+ {
217
+ method: 'GET',
218
+ headers: memoryApiHeaders(ctx),
219
+ signal: AbortSignal.timeout(10_000),
220
+ },
221
+ );
222
+ if (!res.ok) {
223
+ const errText = await res.text().catch(() => '');
224
+ throw new Error(
225
+ `memory-api rejected read (${res.status}): ${errText.slice(0, 400)}`,
226
+ );
227
+ }
228
+ const raw = (await res.json().catch(() => null)) as unknown;
229
+ return toJsonString(raw ?? []);
230
+ },
231
+ });
232
+ }