opencode-codex-memory 0.2.1 → 0.3.0

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/README.md CHANGED
@@ -117,10 +117,10 @@ those too.)
117
117
  session transcripts and extracted memories before anything is written or sent
118
118
  to a model. Notes you explicitly dictate ("remember that ...") are stored as
119
119
  you said them.
120
- - **The learning agents are sandboxed** — the extraction agent has no tools at
121
- all (the transcript is handed to it inline), and the consolidation agent gets
122
- only file tools plus access to the memory folder. Shell, network, IDE, and
123
- MCP tools are denied for both.
120
+ - **The learning agents are sandboxed** — the extraction agent can't touch your
121
+ filesystem (the transcript is handed to it inline; it only emits its structured
122
+ result), and the consolidation agent gets only file tools plus access to the
123
+ memory folder. Shell, network, IDE, and MCP tools are denied for both.
124
124
  - **Reset is safe.** `memory_reset` refuses to run if the memory folder is a
125
125
  symlink, so it can't be tricked into deleting something else.
126
126
  - **Web/MCP sessions:** by default, sessions that used web search, fetch, or MCP
@@ -32,7 +32,8 @@
32
32
  "write": "deny",
33
33
  "edit": "deny",
34
34
  "glob": "deny",
35
- "grep": "deny"
35
+ "grep": "deny",
36
+ "StructuredOutput": "allow"
36
37
  }
37
38
  }
38
39
  }
package/dist/src/llm.d.ts CHANGED
@@ -17,5 +17,14 @@ export declare function consolidateViaSubagent(memoryRoot: string, diffFileName:
17
17
  export declare function cleanupOldSubSessions(maxAgeMinutes?: number): Promise<void>;
18
18
  export declare function fillTemplate(tmpl: string, vars: Record<string, string>): string;
19
19
  export declare function buildConsolidationPrompt(memoryRoot: string, diffFileName: string): string;
20
- /** Parses the stage-1 JSON output. Returns null for the all-empty no-op response. */
20
+ /**
21
+ * Validates a parsed stage-1 object into an ExtractionResult, or null for the
22
+ * all-empty no-op. Shared by the structured-output path (AssistantMessage.
23
+ * structured) and the text parser below.
24
+ */
25
+ export declare function validateExtraction(obj: Partial<ExtractionResult>): ExtractionResult | null;
26
+ /**
27
+ * Parses stage-1 JSON from assistant text. Fallback for when structured output
28
+ * is unavailable; the primary path reads AssistantMessage.structured directly.
29
+ */
21
30
  export declare function parseExtraction(raw: string): ExtractionResult | null;
package/dist/src/llm.js CHANGED
@@ -63,7 +63,8 @@ function parseModelRef(ref) {
63
63
  return null;
64
64
  return { providerID: ref.slice(0, slash), modelID: ref.slice(slash + 1) };
65
65
  }
66
- async function promptSession(sessionId, prompt, agent, opts = {}) {
66
+ /** Runs a sub-agent prompt and returns the raw response data (`{ info, parts }`). */
67
+ async function runPrompt(sessionId, prompt, agent, opts = {}) {
67
68
  const timeoutMs = opts.timeoutMs ?? 300_000;
68
69
  const input = getPluginInput();
69
70
  if (!input)
@@ -71,10 +72,13 @@ async function promptSession(sessionId, prompt, agent, opts = {}) {
71
72
  const model = opts.model ? parseModelRef(opts.model) : null;
72
73
  const promptPromise = input.client.session.prompt({
73
74
  path: { id: sessionId },
75
+ // `format` lives in the server's PromptInput but not the generated SDK body
76
+ // type yet (same OpenAPI lag as session.list scope/roots), hence the cast.
74
77
  body: {
75
78
  agent,
76
79
  ...(opts.system ? { system: opts.system } : {}),
77
80
  ...(model ? { model } : {}),
81
+ ...(opts.format ? { format: opts.format } : {}),
78
82
  parts: [{ type: "text", text: prompt }],
79
83
  },
80
84
  });
@@ -88,12 +92,15 @@ async function promptSession(sessionId, prompt, agent, opts = {}) {
88
92
  ]);
89
93
  if (!res.data)
90
94
  throw new Error(`prompt failed: ${JSON.stringify(res.error ?? {})}`);
91
- return extractAssistantText(res.data);
95
+ return res.data;
92
96
  }
93
97
  finally {
94
98
  clearTimeout(timer);
95
99
  }
96
100
  }
101
+ async function promptSession(sessionId, prompt, agent, opts = {}) {
102
+ return extractAssistantText(await runPrompt(sessionId, prompt, agent, opts));
103
+ }
97
104
  function extractAssistantText(body) {
98
105
  if (!body)
99
106
  return "";
@@ -116,6 +123,19 @@ function extractAssistantText(body) {
116
123
  return body.output;
117
124
  return JSON.stringify(body);
118
125
  }
126
+ // JSON Schema for structured stage-1 output. Mirrors the deliverables in
127
+ // stage_one_system.md (three required string fields; all-empty = no-op) and is
128
+ // opencode's equivalent of codex's output_schema + output_schema_strict.
129
+ const EXTRACTION_SCHEMA = {
130
+ type: "object",
131
+ additionalProperties: false,
132
+ properties: {
133
+ raw_memory: { type: "string" },
134
+ rollout_summary: { type: "string" },
135
+ rollout_slug: { type: "string" },
136
+ },
137
+ required: ["raw_memory", "rollout_summary", "rollout_slug"],
138
+ };
119
139
  /** Returns null when the extractor reported a no-op (nothing worth remembering). */
120
140
  export async function extractViaSubagent(sessionId, transcript, opts = {}) {
121
141
  const agent = "memorize-extract";
@@ -124,15 +144,27 @@ export async function extractViaSubagent(sessionId, transcript, opts = {}) {
124
144
  const prompt = buildExtractionInput(sessionId, opts.cwd ?? "unknown", transcript);
125
145
  // extract_model option > opencode small_model > session default.
126
146
  const model = opts.model ?? (await getConfigModels()).smallModel;
127
- const raw = await promptSession(subId, prompt, agent, {
147
+ const data = await runPrompt(subId, prompt, agent, {
128
148
  // Mirrors the stage-1 job lease (1h): codex has no per-request timeout,
129
149
  // and a near-600k-char transcript on a slow model can easily exceed a
130
150
  // short one — repeated timeouts would exhaust the job's retries.
131
151
  timeoutMs: 3600_000,
132
152
  system: readTemplate("stage_one_system.md"),
133
153
  model,
154
+ // opencode enforces json_schema output via a forced StructuredOutput tool
155
+ // call (toolChoice: required) — which is why memorize-extract must allow
156
+ // that one otherwise-denied tool.
157
+ format: { type: "json_schema", schema: EXTRACTION_SCHEMA },
134
158
  });
135
- return parseExtraction(raw);
159
+ // The captured JSON lands on AssistantMessage.structured (schema
160
+ // v1/session.ts; absent from the generated SDK type, so read it untyped).
161
+ // Fall back to text parsing when structured output is unavailable (a host
162
+ // without the feature, or a model that emitted JSON as plain text).
163
+ const structured = data?.info?.structured;
164
+ if (structured && typeof structured === "object") {
165
+ return validateExtraction(structured);
166
+ }
167
+ return parseExtraction(extractAssistantText(data));
136
168
  }
137
169
  finally {
138
170
  void deleteSession(subId).catch(() => { });
@@ -257,16 +289,12 @@ export function buildConsolidationPrompt(memoryRoot, diffFileName) {
257
289
  function readTemplate(name) {
258
290
  return fs.readFileSync(path.join(import.meta.dirname, "templates", name), "utf8");
259
291
  }
260
- /** Parses the stage-1 JSON output. Returns null for the all-empty no-op response. */
261
- export function parseExtraction(raw) {
262
- const cleaned = raw.replace(/^```(?:json)?/gim, "").replace(/```$/gim, "").trim();
263
- const start = cleaned.indexOf("{");
264
- const end = cleaned.lastIndexOf("}");
265
- if (start === -1 || end === -1 || end <= start) {
266
- throw new Error("extraction response contained no JSON object");
267
- }
268
- const json = cleaned.slice(start, end + 1);
269
- const obj = JSON.parse(json);
292
+ /**
293
+ * Validates a parsed stage-1 object into an ExtractionResult, or null for the
294
+ * all-empty no-op. Shared by the structured-output path (AssistantMessage.
295
+ * structured) and the text parser below.
296
+ */
297
+ export function validateExtraction(obj) {
270
298
  if (typeof obj.raw_memory !== "string" || typeof obj.rollout_summary !== "string") {
271
299
  throw new Error("extraction response missing required fields");
272
300
  }
@@ -289,3 +317,16 @@ export function parseExtraction(raw) {
289
317
  rollout_slug: typeof obj.rollout_slug === "string" && obj.rollout_slug.trim() ? obj.rollout_slug : null,
290
318
  };
291
319
  }
320
+ /**
321
+ * Parses stage-1 JSON from assistant text. Fallback for when structured output
322
+ * is unavailable; the primary path reads AssistantMessage.structured directly.
323
+ */
324
+ export function parseExtraction(raw) {
325
+ const cleaned = raw.replace(/^```(?:json)?/gim, "").replace(/```$/gim, "").trim();
326
+ const start = cleaned.indexOf("{");
327
+ const end = cleaned.lastIndexOf("}");
328
+ if (start === -1 || end === -1 || end <= start) {
329
+ throw new Error("extraction response contained no JSON object");
330
+ }
331
+ return validateExtraction(JSON.parse(cleaned.slice(start, end + 1)));
332
+ }
package/opencode.json CHANGED
@@ -32,7 +32,8 @@
32
32
  "write": "deny",
33
33
  "edit": "deny",
34
34
  "glob": "deny",
35
- "grep": "deny"
35
+ "grep": "deny",
36
+ "StructuredOutput": "allow"
36
37
  }
37
38
  }
38
39
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-codex-memory",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "Persistent memory plugin for opencode — ports codex's two-phase memory system (extraction → consolidation → injection → citation feedback)",
5
5
  "type": "module",
6
6
  "main": "./dist/src/index.js",