opencode-codex-memory 0.6.5 → 0.7.1

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.
Files changed (41) hide show
  1. package/README.md +26 -28
  2. package/dist/opencode.json +1 -1
  3. package/dist/src/citation.d.ts +9 -0
  4. package/dist/src/citation.js +68 -11
  5. package/dist/src/db.js +10 -0
  6. package/dist/src/host-client.d.ts +1 -0
  7. package/dist/src/host-client.js +1 -0
  8. package/dist/src/index.d.ts +12 -2
  9. package/dist/src/index.js +32 -5
  10. package/dist/src/llm.d.ts +6 -0
  11. package/dist/src/llm.js +21 -10
  12. package/dist/src/phase2.d.ts +2 -0
  13. package/dist/src/phase2.js +1 -1
  14. package/dist/src/rollout-input.d.ts +6 -0
  15. package/dist/src/rollout-input.js +111 -0
  16. package/dist/src/store.d.ts +17 -1
  17. package/dist/src/store.js +90 -4
  18. package/dist/src/v2/agents.d.ts +53 -0
  19. package/dist/src/v2/agents.js +204 -0
  20. package/dist/src/v2/citation-overlay.d.ts +7 -0
  21. package/dist/src/v2/citation-overlay.js +52 -0
  22. package/dist/src/v2/index.d.ts +7 -0
  23. package/dist/src/v2/index.js +10 -0
  24. package/dist/src/v2/injection.d.ts +14 -0
  25. package/dist/src/v2/injection.js +19 -0
  26. package/dist/src/v2/plugin.d.ts +7 -0
  27. package/dist/src/v2/plugin.js +482 -0
  28. package/dist/src/v2/service.d.ts +78 -0
  29. package/dist/src/v2/service.js +195 -0
  30. package/dist/src/v2/shim.d.ts +47 -0
  31. package/dist/src/v2/shim.js +591 -0
  32. package/dist/src/v2/status-rpc.d.ts +197 -0
  33. package/dist/src/v2/status-rpc.js +159 -0
  34. package/dist/src/v2/status.d.ts +3 -0
  35. package/dist/src/v2/status.js +83 -0
  36. package/dist/src/v2/tools.d.ts +33 -0
  37. package/dist/src/v2/tools.js +57 -0
  38. package/dist/src/v2/tui.d.ts +3 -0
  39. package/dist/src/v2/tui.js +750 -0
  40. package/opencode.json +1 -1
  41. package/package.json +38 -2
@@ -52,6 +52,12 @@ export declare class MemoryStore {
52
52
  pruneStage1Outputs(maxUnusedDays: number): number;
53
53
  upsertStage1Output(out: Omit<Stage1Output, "usage_count" | "last_usage">): boolean;
54
54
  recordUsage(sessionIds: string[]): void;
55
+ /**
56
+ * Record citations from a durable assistant message exactly once. V2 can
57
+ * observe the same persisted text through session.text.ended and later
58
+ * context calls, and either observation may happen after a process restart.
59
+ */
60
+ recordUsageOnce(sessionId: string, assistantMessageId: string, citedSessionIds: string[]): string[];
55
61
  claimStage1Jobs(sessions: ClaimableSession[], excludeSession?: string, maxClaimed?: number): Stage1Claim[];
56
62
  markStage1Succeeded(sessionId: string, ownershipToken: string, out: Omit<Stage1Output, "usage_count" | "last_usage">): void;
57
63
  /** Extraction succeeded but produced nothing worth keeping: finish the job and drop any stale output. */
@@ -75,7 +81,9 @@ export declare class MemoryStore {
75
81
  * already running, preserve its lease and advance only the input watermark.
76
82
  */
77
83
  private enqueueGlobalConsolidation;
78
- claimGlobalPhase2Job(): Phase2ClaimResult;
84
+ claimGlobalPhase2Job(opts?: {
85
+ bypassCooldown?: boolean;
86
+ }): Phase2ClaimResult;
79
87
  heartbeatPhase2Job(ownershipToken: string): boolean;
80
88
  /**
81
89
  * Marks the phase-2 job done and records exactly which stage-1 snapshots the
@@ -95,6 +103,7 @@ export declare class MemoryStore {
95
103
  last_error: string | null;
96
104
  finished_at: number | null;
97
105
  retry_at: number | null;
106
+ lease_until: number | null;
98
107
  success_finished_at: number | null;
99
108
  last_success_watermark: number | null;
100
109
  } | null;
@@ -108,6 +117,13 @@ export declare class MemoryStore {
108
117
  * Plugin dispose/reload: release the global phase-2 job without retry backoff
109
118
  * so the next process can reclaim immediately. Ownership-token guarded.
110
119
  */
120
+ /**
121
+ * Boot-time sweep: a `running` global row whose owning pid is dead can never
122
+ * be finished by anyone; release it so the next pass can reclaim instead of
123
+ * waiting the full lease out. Rows from live pids (a peer instance) and rows
124
+ * without a pid tag (older schema) are left alone.
125
+ */
126
+ releaseOrphanedPhase2Job(): boolean;
111
127
  releasePhase2OnShutdown(ownershipToken: string): void;
112
128
  /**
113
129
  * Phase 2 input set, mirroring codex get_phase2_input_selection:
package/dist/src/store.js CHANGED
@@ -12,6 +12,26 @@ export const PRUNE_BATCH_SIZE = 200;
12
12
  function newId() {
13
13
  return crypto.randomUUID();
14
14
  }
15
+ /**
16
+ * Phase-2 worker ids embed the owning pid so a later boot can tell a live
17
+ * peer's lease from one orphaned by a hard kill (host auto-update/restart).
18
+ */
19
+ const PHASE2_WORKER_PREFIX = `pid:${process.pid}:`;
20
+ function phase2WorkerId() {
21
+ return `${PHASE2_WORKER_PREFIX}${crypto.randomUUID()}`;
22
+ }
23
+ function pidAlive(pid) {
24
+ if (pid === process.pid)
25
+ return true;
26
+ try {
27
+ process.kill(pid, 0);
28
+ return true;
29
+ }
30
+ catch (err) {
31
+ // EPERM: exists but not ours. Only ESRCH proves it is gone.
32
+ return err.code !== "ESRCH";
33
+ }
34
+ }
15
35
  function now() {
16
36
  return Date.now();
17
37
  }
@@ -98,6 +118,39 @@ export class MemoryStore {
98
118
  stmt.run(ts, id);
99
119
  }).immediate();
100
120
  }
121
+ /**
122
+ * Record citations from a durable assistant message exactly once. V2 can
123
+ * observe the same persisted text through session.text.ended and later
124
+ * context calls, and either observation may happen after a process restart.
125
+ */
126
+ recordUsageOnce(sessionId, assistantMessageId, citedSessionIds) {
127
+ const ids = [...new Set(citedSessionIds)];
128
+ if (!sessionId || !assistantMessageId || ids.length === 0)
129
+ return [];
130
+ const fresh = [];
131
+ const insert = this.db.prepare(`INSERT OR IGNORE INTO memory_citation_usage
132
+ (session_id, assistant_message_id, cited_session_id, recorded_at)
133
+ VALUES (?, ?, ?, ?)`);
134
+ const update = this.db.prepare("UPDATE memory_stage1_outputs SET usage_count = usage_count + 1, last_usage = ? WHERE session_id = ?");
135
+ const ts = now();
136
+ this.db.transaction(() => {
137
+ for (const citedSessionId of ids) {
138
+ const result = insert.run(sessionId, assistantMessageId, citedSessionId, ts);
139
+ if (result.changes === 0)
140
+ continue;
141
+ const updated = update.run(ts, citedSessionId);
142
+ if (updated.changes > 0) {
143
+ fresh.push(citedSessionId);
144
+ }
145
+ else {
146
+ this.db
147
+ .prepare("DELETE FROM memory_citation_usage WHERE session_id = ? AND assistant_message_id = ? AND cited_session_id = ?")
148
+ .run(sessionId, assistantMessageId, citedSessionId);
149
+ }
150
+ }
151
+ }).immediate();
152
+ return fresh;
153
+ }
101
154
  claimStage1Jobs(sessions, excludeSession, maxClaimed) {
102
155
  const workerId = newId();
103
156
  // Cap per-pass claims at codex's max_rollouts_per_startup (max_claimed,
@@ -286,8 +339,8 @@ export class MemoryStore {
286
339
  END`)
287
340
  .run(DEFAULT_RETRY_REMAINING, inputWatermark);
288
341
  }
289
- claimGlobalPhase2Job() {
290
- const workerId = newId();
342
+ claimGlobalPhase2Job(opts = {}) {
343
+ const workerId = phase2WorkerId();
291
344
  const ownershipToken = newId();
292
345
  const tNow = nowSec();
293
346
  const lease = tNow + PHASE2_LEASE_SECONDS;
@@ -309,7 +362,10 @@ export class MemoryStore {
309
362
  }
310
363
  // codex: cooldown after a clean success (last_error IS NULL AND
311
364
  // finished_at within the window); failures fall through to retry_at.
312
- if (row.last_error == null && row.finished_at != null && tNow - row.finished_at < PHASE2_COOLDOWN_MS / 1000) {
365
+ if (!opts.bypassCooldown &&
366
+ row.last_error == null &&
367
+ row.finished_at != null &&
368
+ tNow - row.finished_at < PHASE2_COOLDOWN_MS / 1000) {
313
369
  return { type: "skipped_cooldown" };
314
370
  }
315
371
  // codex gates on retry_at regardless of status and never exhausts
@@ -378,7 +434,7 @@ export class MemoryStore {
378
434
  */
379
435
  phase2JobSnapshot() {
380
436
  const row = this.db
381
- .prepare(`SELECT status, finished_at, last_error, retry_at, last_success_watermark FROM memory_jobs
437
+ .prepare(`SELECT status, finished_at, last_error, retry_at, lease_until, last_success_watermark FROM memory_jobs
382
438
  WHERE kind='memory_consolidate_global' AND job_key='global'`)
383
439
  .get();
384
440
  if (!row)
@@ -398,6 +454,7 @@ export class MemoryStore {
398
454
  last_error: row.last_error,
399
455
  finished_at: row.finished_at,
400
456
  retry_at: row.retry_at,
457
+ lease_until: row.lease_until,
401
458
  // Codex preserves last_success_watermark across later attempts, while the
402
459
  // job finished_at describes only the latest attempt. Never label a failure
403
460
  // timestamp as a success finish time.
@@ -447,6 +504,34 @@ export class MemoryStore {
447
504
  * Plugin dispose/reload: release the global phase-2 job without retry backoff
448
505
  * so the next process can reclaim immediately. Ownership-token guarded.
449
506
  */
507
+ /**
508
+ * Boot-time sweep: a `running` global row whose owning pid is dead can never
509
+ * be finished by anyone; release it so the next pass can reclaim instead of
510
+ * waiting the full lease out. Rows from live pids (a peer instance) and rows
511
+ * without a pid tag (older schema) are left alone.
512
+ */
513
+ releaseOrphanedPhase2Job() {
514
+ const row = this.db
515
+ .prepare(`SELECT worker_id FROM memory_jobs
516
+ WHERE kind='memory_consolidate_global' AND job_key='global' AND status='running'`)
517
+ .get();
518
+ const m = row?.worker_id?.match(/^pid:(\d+):/);
519
+ if (!m)
520
+ return false;
521
+ if (pidAlive(Number(m[1])))
522
+ return false;
523
+ const res = this.db
524
+ .prepare(`UPDATE memory_jobs SET
525
+ status = 'pending',
526
+ last_error = ?,
527
+ retry_at = NULL,
528
+ finished_at = ?,
529
+ lease_until = NULL,
530
+ ownership_token = NULL
531
+ WHERE kind='memory_consolidate_global' AND job_key='global' AND status='running' AND worker_id=?`)
532
+ .run(`owner pid ${m[1]} exited before finishing`, nowSec(), row.worker_id);
533
+ return res.changes > 0;
534
+ }
450
535
  releasePhase2OnShutdown(ownershipToken) {
451
536
  this.db
452
537
  .prepare(`UPDATE memory_jobs SET
@@ -515,6 +600,7 @@ export class MemoryStore {
515
600
  this.db.transaction(() => {
516
601
  this.db.run("DELETE FROM memory_stage1_outputs");
517
602
  this.db.run("DELETE FROM memory_jobs");
603
+ this.db.run("DELETE FROM memory_citation_usage");
518
604
  this.db
519
605
  .prepare(`INSERT INTO memory_jobs
520
606
  (kind, job_key, status, finished_at, last_error, retry_remaining, last_success_watermark)
@@ -0,0 +1,53 @@
1
+ export declare const MEMORIZE_AGENT_ID = "memorize";
2
+ export declare const MEMORIZE_EXTRACT_AGENT_ID = "memorize-extract";
3
+ export declare const MEMORIZE_SYSTEM = "You are a memory consolidation agent. Read the workspace diff file and update MEMORY.md, memory_summary.md, and skills/ under the memory workspace only. Do not read or edit project source files outside that memory root. Keep memory_summary.md under 10000 chars (2500 tokens). Prune stale entries. Do not access the network.";
4
+ export declare const MEMORIZE_EXTRACT_SYSTEM = "You are a memory extraction agent. The session transcript is provided inline in the prompt. Extract raw_memory, rollout_summary, and rollout_slug as JSON. Exclude AGENTS.md/instruction content. Redact secrets.";
5
+ export declare const MEMORIZE_DESCRIPTION = "Memory consolidation agent (opencode-codex-memory)";
6
+ export declare const MEMORIZE_EXTRACT_DESCRIPTION = "Memory extraction agent (opencode-codex-memory)";
7
+ export interface V2AgentDefinition {
8
+ description: string;
9
+ mode: "subagent";
10
+ hidden?: boolean;
11
+ system: string;
12
+ permissions: {
13
+ action: string;
14
+ resource: string;
15
+ effect: "allow" | "deny" | "ask";
16
+ }[];
17
+ }
18
+ /** V2-native memorize definition (V2 action names: edit covers write/patch). */
19
+ export declare function buildMemorizeAgent(): V2AgentDefinition;
20
+ /**
21
+ * Hidden extraction definition. Unused at runtime (extraction goes through
22
+ * generate.text), kept for parity with the V1 bundle and the health
23
+ * snapshot. deny-all: V2 has no StructuredOutput-capture concept, and the
24
+ * sessionless generate path needs no tools at all.
25
+ */
26
+ export declare function buildMemorizeExtractAgent(): V2AgentDefinition;
27
+ interface AgentEditorLike {
28
+ get(id: string): {
29
+ description?: string;
30
+ mode?: string;
31
+ system?: string;
32
+ permissions?: unknown;
33
+ } | undefined;
34
+ update(id: string, update: (agent: Record<string, unknown>) => void): void;
35
+ }
36
+ /**
37
+ * V2 agent definition converted back to the V1 permission-map shape so the
38
+ * shared agent-health snapshot (surfaced by memory_inspect) keeps working.
39
+ */
40
+ export declare function toV1AgentDefinition(def: V2AgentDefinition, agentId?: string): Record<string, unknown>;
41
+ export declare function shippedV1AgentForHealth(): Record<string, unknown>;
42
+ /**
43
+ * Ensure both sub-agents exist. A user-defined agent of the same name
44
+ * always wins (mirrors injectAgentDefinitions). Records health for
45
+ * memory_inspect in both cases.
46
+ */
47
+ export declare function ensureV2Agents(ctx: {
48
+ agent: {
49
+ transform(cb: (editor: AgentEditorLike) => void): Promise<unknown>;
50
+ get(input: unknown): Promise<unknown>;
51
+ };
52
+ }): Promise<void>;
53
+ export {};
@@ -0,0 +1,204 @@
1
+ /**
2
+ * opencode2 agent provisioning.
3
+ *
4
+ * V1 ships memorize/memorize-extract via the config hook (opencode.json
5
+ * `agent` map). V2 has no config hook, but agent.transform's update() creates
6
+ * a missing agent in the active plugin location, so setup() ensures them here
7
+ * instead. Helper sessions must run in that same location because V2 agent
8
+ * registration is location-scoped.
9
+ *
10
+ * Both agents ship for parity, with the V1 prompts verbatim. `memorize` does
11
+ * consolidation work; `memorize-extract` is hidden and unused — extraction
12
+ * runs through generate.text (inherently tool-less, no session, no agent),
13
+ * and V1 skips injecting unused agents for the same reason. Keeping the
14
+ * hidden definition preserves the health snapshot both agents report into.
15
+ */
16
+ import path from "path";
17
+ import { memoryRoot } from "../paths.js";
18
+ import { pluginOptions } from "../options.js";
19
+ import { recordAgentConfig } from "../agent-health.js";
20
+ export const MEMORIZE_AGENT_ID = "memorize";
21
+ export const MEMORIZE_EXTRACT_AGENT_ID = "memorize-extract";
22
+ export const MEMORIZE_SYSTEM = "You are a memory consolidation agent. Read the workspace diff file and update MEMORY.md, memory_summary.md, and skills/ under the memory workspace only. Do not read or edit project source files outside that memory root. Keep memory_summary.md under 10000 chars (2500 tokens). Prune stale entries. Do not access the network.";
23
+ export const MEMORIZE_EXTRACT_SYSTEM = "You are a memory extraction agent. The session transcript is provided inline in the prompt. Extract raw_memory, rollout_summary, and rollout_slug as JSON. Exclude AGENTS.md/instruction content. Redact secrets.";
24
+ export const MEMORIZE_DESCRIPTION = "Memory consolidation agent (opencode-codex-memory)";
25
+ export const MEMORIZE_EXTRACT_DESCRIPTION = "Memory extraction agent (opencode-codex-memory)";
26
+ /** V2-native memorize definition (V2 action names: edit covers write/patch). */
27
+ export function buildMemorizeAgent() {
28
+ return {
29
+ description: MEMORIZE_DESCRIPTION,
30
+ mode: "subagent",
31
+ system: MEMORIZE_SYSTEM,
32
+ permissions: [
33
+ { action: "*", resource: "*", effect: "deny" },
34
+ { action: "read", resource: path.join(memoryRoot(), "*"), effect: "allow" },
35
+ { action: "edit", resource: path.join(memoryRoot(), "*"), effect: "allow" },
36
+ { action: "glob", resource: path.join(memoryRoot(), "*"), effect: "allow" },
37
+ { action: "grep", resource: path.join(memoryRoot(), "*"), effect: "allow" },
38
+ // Memories live outside every project: without this grant the wildcard
39
+ // deny blocks consolidation from touching the memory workspace (same
40
+ // role as external_directory in the V1 definition).
41
+ { action: "external_directory", resource: path.join(memoryRoot(), "*"), effect: "allow" },
42
+ ],
43
+ };
44
+ }
45
+ /**
46
+ * Hidden extraction definition. Unused at runtime (extraction goes through
47
+ * generate.text), kept for parity with the V1 bundle and the health
48
+ * snapshot. deny-all: V2 has no StructuredOutput-capture concept, and the
49
+ * sessionless generate path needs no tools at all.
50
+ */
51
+ export function buildMemorizeExtractAgent() {
52
+ return {
53
+ description: MEMORIZE_EXTRACT_DESCRIPTION,
54
+ mode: "subagent",
55
+ hidden: true,
56
+ system: MEMORIZE_EXTRACT_SYSTEM,
57
+ permissions: [{ action: "*", resource: "*", effect: "deny" }],
58
+ };
59
+ }
60
+ /**
61
+ * V2 agent definition converted back to the V1 permission-map shape so the
62
+ * shared agent-health snapshot (surfaced by memory_inspect) keeps working.
63
+ */
64
+ export function toV1AgentDefinition(def, agentId) {
65
+ const permission = {};
66
+ for (const rule of def.permissions) {
67
+ if (rule.action === "*") {
68
+ permission["*"] = rule.effect;
69
+ continue;
70
+ }
71
+ if (rule.action === "external_directory") {
72
+ const cur = (permission.external_directory ?? {});
73
+ cur[rule.resource] = rule.effect;
74
+ permission.external_directory = cur;
75
+ continue;
76
+ }
77
+ if (rule.action === "edit") {
78
+ // V1 names write/patch separately from edit.
79
+ permission.edit = rule.effect;
80
+ permission.write = rule.effect;
81
+ continue;
82
+ }
83
+ permission[rule.action] = rule.effect;
84
+ }
85
+ if (agentId === MEMORIZE_EXTRACT_AGENT_ID) {
86
+ // The V1 bundle allows the synthetic StructuredOutput capture tool so
87
+ // json_schema extraction works; V2 needs no tools for the sessionless
88
+ // generate path, but health validates the V1 bundle semantics.
89
+ permission.StructuredOutput = "allow";
90
+ }
91
+ return { mode: def.mode, prompt: def.system, description: def.description, permission };
92
+ }
93
+ export function shippedV1AgentForHealth() {
94
+ return {
95
+ [MEMORIZE_AGENT_ID]: toV1AgentDefinition(buildMemorizeAgent(), MEMORIZE_AGENT_ID),
96
+ [MEMORIZE_EXTRACT_AGENT_ID]: toV1AgentDefinition(buildMemorizeExtractAgent(), MEMORIZE_EXTRACT_AGENT_ID),
97
+ };
98
+ }
99
+ /**
100
+ * Ensure both sub-agents exist. A user-defined agent of the same name
101
+ * always wins (mirrors injectAgentDefinitions). Records health for
102
+ * memory_inspect in both cases.
103
+ */
104
+ export async function ensureV2Agents(ctx) {
105
+ const shipped = [
106
+ [MEMORIZE_AGENT_ID, buildMemorizeAgent()],
107
+ [MEMORIZE_EXTRACT_AGENT_ID, buildMemorizeExtractAgent()],
108
+ ];
109
+ if (!pluginOptions.generate_memories) {
110
+ recordAgentConfig({ agent: {} }, false, shippedV1AgentForHealth());
111
+ return;
112
+ }
113
+ const effective = {};
114
+ const missing = [];
115
+ for (const [id, def] of shipped) {
116
+ let existing = null;
117
+ try {
118
+ const res = (await ctx.agent.get({ agentID: id }));
119
+ existing = res && typeof res === "object" && "data" in res ? res.data : res;
120
+ }
121
+ catch {
122
+ existing = null;
123
+ }
124
+ if (existing && typeof existing === "object") {
125
+ const cur = existing;
126
+ if (isShippedIncomplete(id, cur, def)) {
127
+ missing.push([id, def]);
128
+ }
129
+ else {
130
+ effective[id] = {
131
+ mode: cur.mode,
132
+ prompt: cur.system,
133
+ description: cur.description,
134
+ permission: v2PermissionsToV1Map(cur.permissions),
135
+ };
136
+ }
137
+ }
138
+ else {
139
+ missing.push([id, def]);
140
+ }
141
+ }
142
+ if (missing.length > 0) {
143
+ await ctx.agent.transform((editor) => {
144
+ for (const [id, def] of missing) {
145
+ editor.update(id, (agent) => {
146
+ agent.description = def.description;
147
+ agent.mode = def.mode;
148
+ if (def.hidden !== undefined)
149
+ agent.hidden = def.hidden;
150
+ agent.system = def.system;
151
+ agent.permissions = def.permissions.map((r) => ({ ...r }));
152
+ });
153
+ }
154
+ });
155
+ for (const [id, def] of missing) {
156
+ effective[id] = toV1AgentDefinition(def, id);
157
+ }
158
+ }
159
+ recordAgentConfig({ agent: effective }, true, shippedV1AgentForHealth());
160
+ }
161
+ function isShippedIncomplete(id, existing, shipped) {
162
+ const system = typeof existing.system === "string"
163
+ ? existing.system
164
+ : typeof existing.prompt === "string"
165
+ ? existing.prompt
166
+ : "";
167
+ if (system !== shipped.system)
168
+ return false;
169
+ if (typeof existing.description === "string" && existing.description !== shipped.description)
170
+ return false;
171
+ if (id !== MEMORIZE_AGENT_ID)
172
+ return false;
173
+ const perms = existing.permissions;
174
+ if (!Array.isArray(perms))
175
+ return true;
176
+ return !perms.some((rule) => rule && typeof rule === "object" && rule.action === "external_directory");
177
+ }
178
+ function v2PermissionsToV1Map(permissions) {
179
+ const out = {};
180
+ if (!Array.isArray(permissions))
181
+ return out;
182
+ for (const rule of permissions) {
183
+ if (typeof rule?.action !== "string")
184
+ continue;
185
+ if (rule.action === "*") {
186
+ out["*"] = rule.effect;
187
+ continue;
188
+ }
189
+ if (rule.action === "external_directory" && typeof rule.resource === "string") {
190
+ const cur = (out.external_directory ?? {});
191
+ cur[rule.resource] = rule.effect;
192
+ out.external_directory = cur;
193
+ continue;
194
+ }
195
+ if (rule.action === "edit") {
196
+ out.edit = rule.effect;
197
+ out.write = rule.effect;
198
+ }
199
+ else {
200
+ out[rule.action] = rule.effect;
201
+ }
202
+ }
203
+ return out;
204
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * V2-only citation instructions. V1 `read_path.md` keeps the XML contract;
3
+ * the TUI renders fenced `memory-citation` blocks, so the V2 context hook
4
+ * overlays this section onto the shared inject prompt.
5
+ */
6
+ export declare const V2_CITATION_INSTRUCTIONS = "Memory citation requirements:\n\n- If ANY relevant memory files were used: append exactly one fenced code\n block with the language tag `memory-citation` as the VERY LAST content of\n the final reply. Normal responses should include the answer first, then\n append the block at the end. The host renders this block natively.\n- Use this exact structure for programmatic parsing:\n````\n```memory-citation\nMEMORY.md:234-236|note=build command for the api service\nrollout_summaries/2026-02-17T21-23-02-ln3m-example.md:10-12|note=weekly report format\nsessions: ses_abc123 ses_def456\n```\n````\n- Do not wrap it in `<memory-citation>` XML tags; the fenced form replaces\n that older format.\n- Citation entry lines are for rendering:\n - one citation entry per line\n - format: `<file>:<line_start>-<line_end>|note=<how memory was used>`\n - use file paths relative to the memory base path (for example, `MEMORY.md`,\n `rollout_summaries/...`, `skills/...`)\n - only cite files actually used under the memory base path (do not cite\n workspace files as memory citations)\n - if you used `MEMORY.md` and then a rollout summary/skill file, cite both\n - list entries in order of importance (most important first)\n - `note` should be short, single-line, and use simple characters only (avoid\n unusual symbols, no newlines)\n- The final `sessions:` line is for us to track which past sessions you find\n useful:\n - one line, space-separated session ids after `sessions:`\n - session ids look like `ses_...` and appear in rollout summary files and\n MEMORY.md as `session_id:`\n - include unique ids only; do not repeat ids\n - omit the `sessions:` line if no session ids are available\n - do not include file paths or notes on this line\n - for every citation entry, try to find and cite the corresponding session id\n- Never include memory citations inside pull-request messages.\n- Never cite blank lines; double-check ranges.\n\n";
7
+ export declare function overlayV2CitationInstructions(prompt: string): string;
@@ -0,0 +1,52 @@
1
+ /**
2
+ * V2-only citation instructions. V1 `read_path.md` keeps the XML contract;
3
+ * the TUI renders fenced `memory-citation` blocks, so the V2 context hook
4
+ * overlays this section onto the shared inject prompt.
5
+ */
6
+ export const V2_CITATION_INSTRUCTIONS = `Memory citation requirements:
7
+
8
+ - If ANY relevant memory files were used: append exactly one fenced code
9
+ block with the language tag \`memory-citation\` as the VERY LAST content of
10
+ the final reply. Normal responses should include the answer first, then
11
+ append the block at the end. The host renders this block natively.
12
+ - Use this exact structure for programmatic parsing:
13
+ \`\`\`\`
14
+ \`\`\`memory-citation
15
+ MEMORY.md:234-236|note=build command for the api service
16
+ rollout_summaries/2026-02-17T21-23-02-ln3m-example.md:10-12|note=weekly report format
17
+ sessions: ses_abc123 ses_def456
18
+ \`\`\`
19
+ \`\`\`\`
20
+ - Do not wrap it in \`<memory-citation>\` XML tags; the fenced form replaces
21
+ that older format.
22
+ - Citation entry lines are for rendering:
23
+ - one citation entry per line
24
+ - format: \`<file>:<line_start>-<line_end>|note=<how memory was used>\`
25
+ - use file paths relative to the memory base path (for example, \`MEMORY.md\`,
26
+ \`rollout_summaries/...\`, \`skills/...\`)
27
+ - only cite files actually used under the memory base path (do not cite
28
+ workspace files as memory citations)
29
+ - if you used \`MEMORY.md\` and then a rollout summary/skill file, cite both
30
+ - list entries in order of importance (most important first)
31
+ - \`note\` should be short, single-line, and use simple characters only (avoid
32
+ unusual symbols, no newlines)
33
+ - The final \`sessions:\` line is for us to track which past sessions you find
34
+ useful:
35
+ - one line, space-separated session ids after \`sessions:\`
36
+ - session ids look like \`ses_...\` and appear in rollout summary files and
37
+ MEMORY.md as \`session_id:\`
38
+ - include unique ids only; do not repeat ids
39
+ - omit the \`sessions:\` line if no session ids are available
40
+ - do not include file paths or notes on this line
41
+ - for every citation entry, try to find and cite the corresponding session id
42
+ - Never include memory citations inside pull-request messages.
43
+ - Never cite blank lines; double-check ranges.
44
+
45
+ `;
46
+ export function overlayV2CitationInstructions(prompt) {
47
+ const start = prompt.indexOf("Memory citation requirements:");
48
+ const end = prompt.indexOf("Updating memories:");
49
+ if (start === -1 || end === -1 || end <= start)
50
+ return `${prompt.trimEnd()}\n\n${V2_CITATION_INSTRUCTIONS}`;
51
+ return prompt.slice(0, start) + V2_CITATION_INSTRUCTIONS + prompt.slice(end);
52
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * opencode2 entry point (re-exported through the package main for
3
+ * single-entry dual-host support: V1 reads server(), V2 reads id+setup()).
4
+ */
5
+ import { Plugin } from "@opencode/plugin";
6
+ declare const _default: Plugin.Plugin;
7
+ export default _default;
@@ -0,0 +1,10 @@
1
+ /**
2
+ * opencode2 entry point (re-exported through the package main for
3
+ * single-entry dual-host support: V1 reads server(), V2 reads id+setup()).
4
+ */
5
+ import { Plugin } from "@opencode/plugin";
6
+ import { setup } from "./plugin.js";
7
+ export default Plugin.define({
8
+ id: "opencode-codex-memory",
9
+ setup,
10
+ });
@@ -0,0 +1,14 @@
1
+ /**
2
+ * In-process ledger of memory text injected into model requests by the V2
3
+ * `context` hook. Token counts are the same chars/4 estimate used for the
4
+ * injection cap, so they are comparable to `memory_inspect`'s
5
+ * `summary_tokens_est`, not provider-billed tokens.
6
+ */
7
+ export interface InjectionTotals {
8
+ tokens: number;
9
+ requests: number;
10
+ }
11
+ export declare function recordInjection(sessionID: string, tokens: number): void;
12
+ export declare function injectionTotals(): InjectionTotals;
13
+ export declare function sessionInjection(sessionID: string | null | undefined): InjectionTotals;
14
+ export declare function resetInjectionStats(): void;
@@ -0,0 +1,19 @@
1
+ const perSession = new Map();
2
+ let global = { tokens: 0, requests: 0 };
3
+ export function recordInjection(sessionID, tokens) {
4
+ global = { tokens: global.tokens + tokens, requests: global.requests + 1 };
5
+ const current = perSession.get(sessionID) ?? { tokens: 0, requests: 0 };
6
+ perSession.set(sessionID, { tokens: current.tokens + tokens, requests: current.requests + 1 });
7
+ }
8
+ export function injectionTotals() {
9
+ return { ...global };
10
+ }
11
+ export function sessionInjection(sessionID) {
12
+ if (!sessionID)
13
+ return { tokens: 0, requests: 0 };
14
+ return { ...(perSession.get(sessionID) ?? { tokens: 0, requests: 0 }) };
15
+ }
16
+ export function resetInjectionStats() {
17
+ perSession.clear();
18
+ global = { tokens: 0, requests: 0 };
19
+ }
@@ -0,0 +1,7 @@
1
+ import { type V2Context } from "./shim.js";
2
+ /** Test seam: wait for hook-launched work to settle. */
3
+ export declare function waitForV2BackgroundTasks(): Promise<void>;
4
+ /** Test seam: reset module state between tests. */
5
+ export declare function resetV2ModuleStateForTest(): void;
6
+ export declare function markV2TurnSeen(sessionId: string): boolean;
7
+ export declare function setup(ctx: V2Context): Promise<(() => void | Promise<void>) | void>;