pi-session-memory 0.1.3 → 0.2.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.
@@ -0,0 +1,188 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ interface CodexMessage {
7
+ id: string;
8
+ role: "user" | "assistant";
9
+ text: string;
10
+ timestamp: number;
11
+ }
12
+
13
+ interface CodexSession {
14
+ id: string;
15
+ cwd: string;
16
+ timestamp: number;
17
+ messages: CodexMessage[];
18
+ }
19
+
20
+ export interface ProjectSessionMigrationStats {
21
+ scannedFiles: number;
22
+ migratedSessions: number;
23
+ skippedSessions: number;
24
+ migratedMessages: number;
25
+ issues: Array<{ path: string; error: string }>;
26
+ }
27
+
28
+ /** Convert every Codex session recorded for cwd into an independently resumable Pi session file. */
29
+ export function migrateCodexProjectSessions(cwd: string): ProjectSessionMigrationStats {
30
+ const stats: ProjectSessionMigrationStats = {
31
+ scannedFiles: 0,
32
+ migratedSessions: 0,
33
+ skippedSessions: 0,
34
+ migratedMessages: 0,
35
+ issues: [],
36
+ };
37
+ for (const path of _jsonlFiles(join(homedir(), ".codex", "sessions"))) {
38
+ stats.scannedFiles++;
39
+ try {
40
+ const session = _parseCodexSession(path);
41
+ if (!session || session.cwd !== cwd) continue;
42
+ const outputPath = _targetPath(session);
43
+ if (existsSync(outputPath)) {
44
+ stats.skippedSessions++;
45
+ continue;
46
+ }
47
+ _writePiSession(session, outputPath);
48
+ stats.migratedSessions++;
49
+ stats.migratedMessages += session.messages.length;
50
+ } catch (error) {
51
+ stats.issues.push({ path, error: error instanceof Error ? error.message : String(error) });
52
+ }
53
+ }
54
+ return stats;
55
+ }
56
+
57
+ /** Convert one supported Codex JSONL file into its session metadata and textual messages. */
58
+ function _parseCodexSession(path: string): CodexSession | undefined {
59
+ const entries = readFileSync(path, "utf8").split("\n").filter(Boolean).map((line) => JSON.parse(line) as Record<string, unknown>);
60
+ const metadata = entries.find((entry) => entry.type === "session_meta")?.payload as Record<string, unknown> | undefined;
61
+ if (!metadata) return undefined;
62
+ const id = _string(metadata.session_id) ?? _string(metadata.id);
63
+ const cwd = _string(metadata.cwd);
64
+ const timestamp = _timestamp(_string(metadata.timestamp));
65
+ if (!id || !cwd || timestamp === undefined) throw new Error("Codex session_meta requires session_id/id, cwd, and timestamp");
66
+
67
+ const messages: CodexMessage[] = [];
68
+ for (const [index, entry] of entries.entries()) {
69
+ if (entry.type !== "response_item") continue;
70
+ const payload = entry.payload as Record<string, unknown> | undefined;
71
+ if (payload?.type !== "message") continue;
72
+ const role = _string(payload.role);
73
+ if (role !== "user" && role !== "assistant") continue;
74
+ const text = _text(payload.content);
75
+ if (!text) continue;
76
+ const messageId = _string(payload.id)
77
+ ?? _string(entry.id)
78
+ ?? _string((payload.internal_chat_message_metadata_passthrough as Record<string, unknown> | undefined)?.turn_id);
79
+ if (!messageId) throw new Error(`Codex textual message at entry ${index} has no stable native ID`);
80
+ messages.push({
81
+ id: messageId,
82
+ role,
83
+ text,
84
+ timestamp: _timestamp(_string(entry.timestamp)) ?? timestamp,
85
+ });
86
+ }
87
+ return { id, cwd, timestamp, messages };
88
+ }
89
+
90
+ /** Write a valid Pi v3 session with a linear message branch and an explicit migration name. */
91
+ function _writePiSession(session: CodexSession, path: string): void {
92
+ mkdirSync(join(homedir(), ".pi", "agent", "sessions", _encodedCwd(session.cwd)), { recursive: true });
93
+ const lines: string[] = [JSON.stringify({
94
+ type: "session",
95
+ version: 3,
96
+ id: randomUUID(),
97
+ timestamp: new Date(session.timestamp).toISOString(),
98
+ cwd: session.cwd,
99
+ })];
100
+ let parentId: string | null = null;
101
+ const nameId = _entryId(session.id, "name");
102
+ lines.push(JSON.stringify({
103
+ type: "session_info",
104
+ id: nameId,
105
+ parentId,
106
+ timestamp: new Date(session.timestamp).toISOString(),
107
+ name: `Migrated from Codex: ${session.id}`,
108
+ }));
109
+ parentId = nameId;
110
+ for (const message of session.messages) {
111
+ const id = _entryId(session.id, message.id);
112
+ const timestamp = new Date(message.timestamp).toISOString();
113
+ lines.push(JSON.stringify({
114
+ type: "message",
115
+ id,
116
+ parentId,
117
+ timestamp,
118
+ message: message.role === "user"
119
+ ? { role: "user", content: [{ type: "text", text: message.text }], timestamp: message.timestamp }
120
+ : {
121
+ role: "assistant",
122
+ content: [{ type: "text", text: message.text }],
123
+ api: "codex-migration",
124
+ provider: "codex",
125
+ model: "unknown",
126
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
127
+ stopReason: "stop",
128
+ timestamp: message.timestamp,
129
+ },
130
+ }));
131
+ parentId = id;
132
+ }
133
+ const temporaryPath = `${path}.tmp-${randomUUID()}`;
134
+ try {
135
+ writeFileSync(temporaryPath, `${lines.join("\n")}\n`, { flag: "wx" });
136
+ renameSync(temporaryPath, path);
137
+ } finally {
138
+ if (existsSync(temporaryPath)) unlinkSync(temporaryPath);
139
+ }
140
+ }
141
+
142
+ /** Return the deterministic Pi session-file location for one Codex session. */
143
+ function _targetPath(session: CodexSession): string {
144
+ return join(homedir(), ".pi", "agent", "sessions", _encodedCwd(session.cwd), `${new Date(session.timestamp).toISOString().replace(/[.:]/g, "-")}_codex-${session.id}.jsonl`);
145
+ }
146
+
147
+ /** Encode cwd exactly as Pi's default session directory convention. */
148
+ function _encodedCwd(cwd: string): string {
149
+ return `--${cwd.split("/").filter(Boolean).join("-")}--`;
150
+ }
151
+
152
+ /** Create stable, Pi-safe entry IDs without fabricating source message identity. */
153
+ function _entryId(sessionId: string, sourceId: string): string {
154
+ return createHash("sha256").update(`${sessionId}:${sourceId}`).digest("hex").slice(0, 16);
155
+ }
156
+
157
+ /** Extract non-empty string values only. */
158
+ function _string(value: unknown): string | undefined {
159
+ return typeof value === "string" && value.trim() ? value : undefined;
160
+ }
161
+
162
+ /** Parse an ISO timestamp only when it is valid. */
163
+ function _timestamp(value: string | undefined): number | undefined {
164
+ if (!value) return undefined;
165
+ const timestamp = Date.parse(value);
166
+ return Number.isNaN(timestamp) ? undefined : timestamp;
167
+ }
168
+
169
+ /** Join supported Codex text content blocks. */
170
+ function _text(content: unknown): string {
171
+ if (!Array.isArray(content)) return "";
172
+ return content
173
+ .filter((block): block is { type: string; text: string } => Boolean(block) && typeof block === "object" && typeof (block as { type?: unknown }).type === "string" && typeof (block as { text?: unknown }).text === "string")
174
+ .filter((block) => block.type === "input_text" || block.type === "output_text" || block.type === "text")
175
+ .map((block) => block.text)
176
+ .join("\n")
177
+ .trim();
178
+ }
179
+
180
+ /** Recursively enumerate source JSONL files. */
181
+ function _jsonlFiles(root: string): string[] {
182
+ if (!existsSync(root)) return [];
183
+ return readdirSync(root, { withFileTypes: true }).flatMap((entry) => {
184
+ const path = join(root, entry.name);
185
+ if (entry.isDirectory()) return _jsonlFiles(path);
186
+ return entry.isFile() && path.endsWith(".jsonl") ? [path] : [];
187
+ });
188
+ }
package/src/writer.ts CHANGED
@@ -2,6 +2,7 @@ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import type { AssistantMessage } from "@earendil-works/pi-ai";
3
3
  import { upsertSession, insertTurn } from "./db.ts";
4
4
 
5
+ /** Persist the latest Pi user request and all following assistant output as one turn. */
5
6
  export function writeTurn(ctx: ExtensionContext): void {
6
7
  const sessionManager = ctx.sessionManager;
7
8
  const sessionId = `pi:${sessionManager.getSessionId()}`;
@@ -50,6 +51,7 @@ export function writeTurn(ctx: ExtensionContext): void {
50
51
  });
51
52
  }
52
53
 
54
+ /** Join text blocks from a Pi message while ignoring non-text content. */
53
55
  function _extractText(message: { content: string | Array<{ type: string; text?: string }> }): string {
54
56
  if (typeof message.content === "string") return message.content.trim();
55
57
  return message.content
@@ -59,6 +61,7 @@ function _extractText(message: { content: string | Array<{ type: string; text?:
59
61
  .trim();
60
62
  }
61
63
 
64
+ /** Calculate the zero-based user-turn position within the current session branch. */
62
65
  function _userTurnIndex(branch: ReturnType<ExtensionContext["sessionManager"]["getBranch"]>, userIndex: number): number {
63
66
  return branch.slice(0, userIndex + 1)
64
67
  .filter((entry) => entry.type === "message" && entry.message.role === "user")
@@ -1,19 +1,26 @@
1
1
  import assert from "node:assert/strict";
2
- import { existsSync, rmSync } from "node:fs";
2
+ import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
 
6
6
  const dbPath = join(tmpdir(), `pi-session-memory-${process.pid}.db`);
7
+ const historyHome = join(tmpdir(), `pi-session-memory-home-${process.pid}`);
7
8
  process.env.MEMORY_DB_PATH = dbPath;
9
+ process.env.HOME = historyHome;
8
10
 
9
- const { getDb, insertTurn, upsertSession } = await import("../src/db.ts");
10
- const { recallTurns } = await import("../src/retriever.ts");
11
+ const { confirmMemory, createMemory, deleteMemory, deleteTurn, getDb, getMemoryHistory, getMemoryStats, getSession, insertTurn, listMemories, pinTurnAsMemory, supersedeMemory, upsertSession } = await import("../src/db.ts");
12
+ const { formatRecallResults, recallMemories, recallTurns } = await import("../src/retriever.ts");
13
+ const { HISTORY_SCHEMA_REFERENCE_VERSIONS, backfillAll } = await import("../src/backfill.ts");
14
+ const { migrateCodexProjectSessions } = await import("../src/session-migration.ts");
15
+ const { SessionManager } = await import("@earendil-works/pi-coding-agent");
11
16
 
17
+ /** Remove the temporary SQLite database and its WAL sidecar files after this test. */
12
18
  function cleanup(): void {
13
19
  for (const suffix of ["", "-wal", "-shm"]) {
14
20
  const path = `${dbPath}${suffix}`;
15
21
  if (existsSync(path)) rmSync(path);
16
22
  }
23
+ if (existsSync(historyHome)) rmSync(historyHome, { recursive: true });
17
24
  }
18
25
 
19
26
  cleanup();
@@ -60,9 +67,221 @@ assert.equal(insertTurn({
60
67
  user_message_id: "user-1",
61
68
  }), false);
62
69
 
70
+ assert.throws(() => insertTurn({
71
+ turn_id: "pi:test:invalid-message-id",
72
+ session_id: "pi:test",
73
+ turn_index: 2,
74
+ ts: 3,
75
+ user_text: "invalid SQLite parameter",
76
+ reply_text: "",
77
+ tool_names: null,
78
+ user_message_id: undefined as unknown as string,
79
+ }), /SQLite parameter 8 must be string, number, bigint, Uint8Array, or null; received undefined/);
80
+
81
+ mkdirSync(join(historyHome, ".pi", "agent", "sessions"), { recursive: true });
82
+ mkdirSync(join(historyHome, ".claude", "projects"), { recursive: true });
83
+ mkdirSync(join(historyHome, ".codex", "sessions"), { recursive: true });
84
+ writeFileSync(join(historyHome, ".pi", "agent", "sessions", "invalid.jsonl"), [
85
+ JSON.stringify({ type: "session", id: "pi-invalid", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/tmp" }),
86
+ JSON.stringify({ type: "message", message: { role: "user", timestamp: 1, content: [{ type: "text", text: "invalid Pi id" }] } }),
87
+ ].join("\n"));
88
+ writeFileSync(join(historyHome, ".pi", "agent", "sessions", "valid.jsonl"), [
89
+ JSON.stringify({ type: "session", id: "pi-compatible", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/tmp" }),
90
+ JSON.stringify({ type: "message", id: "pi-user", message: { role: "user", timestamp: 1, content: [{ type: "text", text: "schema normal Pi request" }] } }),
91
+ JSON.stringify({ type: "message", id: "pi-assistant", message: { role: "assistant", timestamp: 2, content: [{ type: "text", text: "schema normal Pi reply" }] } }),
92
+ ].join("\n"));
93
+ writeFileSync(join(historyHome, ".claude", "projects", "invalid.jsonl"), [
94
+ JSON.stringify({ type: "user", sessionId: "claude-invalid", cwd: "/tmp", timestamp: "2026-01-01T00:00:00.000Z", message: { role: "user", content: "invalid Claude id" } }),
95
+ ].join("\n"));
96
+ writeFileSync(join(historyHome, ".claude", "projects", "valid-old-schema.jsonl"), [
97
+ JSON.stringify({ type: "user", id: "claude-user", sessionId: "claude-compatible", cwd: "/tmp", timestamp: "2026-01-01T00:00:00.000Z", message: { role: "user", content: "schema normal Claude request" } }),
98
+ JSON.stringify({ type: "assistant", id: "claude-assistant", sessionId: "claude-compatible", cwd: "/tmp", timestamp: "2026-01-01T00:00:01.000Z", message: { role: "assistant", content: [{ type: "text", text: "schema normal Claude reply" }] } }),
99
+ ].join("\n"));
100
+ writeFileSync(join(historyHome, ".codex", "sessions", "invalid.jsonl"), [
101
+ JSON.stringify({ type: "session_meta", payload: { session_id: "codex-invalid", cwd: "/tmp", timestamp: "2026-01-01T00:00:00.000Z" } }),
102
+ JSON.stringify({ type: "response_item", timestamp: "2026-01-01T00:00:00.000Z", payload: { type: "message", role: "user", content: [{ type: "input_text", text: "invalid Codex id" }] } }),
103
+ ].join("\n"));
104
+ writeFileSync(join(historyHome, ".codex", "sessions", "valid.jsonl"), [
105
+ JSON.stringify({ type: "session_meta", payload: { session_id: "codex-compatible", cwd: "/tmp", timestamp: "2026-01-01T00:00:00.000Z" } }),
106
+ JSON.stringify({ type: "response_item", timestamp: "2026-01-01T00:00:00.000Z", payload: { type: "message", id: "codex-user", role: "user", content: [{ type: "input_text", text: "schema normal Codex request" }] } }),
107
+ JSON.stringify({ type: "response_item", timestamp: "2026-01-01T00:00:01.000Z", payload: { type: "message", id: "codex-assistant", role: "assistant", content: [{ type: "output_text", text: "schema normal Codex reply" }] } }),
108
+ ].join("\n"));
109
+ writeFileSync(join(historyHome, ".codex", "sessions", "valid-legacy.jsonl"), [
110
+ JSON.stringify({ type: "session_meta", payload: { session_id: "codex-legacy", cwd: "/tmp", timestamp: "2026-07-17T03:03:23.000Z" } }),
111
+ JSON.stringify({ type: "response_item", timestamp: "2026-07-17T03:03:24.000Z", payload: { type: "message", role: "user", content: [{ type: "input_text", text: "schema legacy Codex request" }], internal_chat_message_metadata_passthrough: { turn_id: "legacy-codex-turn" } } }),
112
+ JSON.stringify({ type: "response_item", timestamp: "2026-07-17T03:03:25.000Z", payload: { type: "message", role: "assistant", content: [{ type: "output_text", text: "schema legacy Codex reply" }], internal_chat_message_metadata_passthrough: { turn_id: "legacy-codex-turn" } } }),
113
+ ].join("\n"));
114
+ writeFileSync(join(historyHome, ".codex", "sessions", "other-project.jsonl"), [
115
+ JSON.stringify({ type: "session_meta", payload: { session_id: "codex-other-project", cwd: "/other-project", timestamp: "2026-01-01T00:00:00.000Z" } }),
116
+ JSON.stringify({ type: "response_item", timestamp: "2026-01-01T00:00:00.000Z", payload: { type: "message", id: "codex-other-user", role: "user", content: [{ type: "input_text", text: "other project request" }] } }),
117
+ JSON.stringify({ type: "response_item", timestamp: "2026-01-01T00:00:01.000Z", payload: { type: "message", id: "codex-other-assistant", role: "assistant", content: [{ type: "output_text", text: "other project reply" }] } }),
118
+ ].join("\n"));
119
+ const migrationStats = migrateCodexProjectSessions("/tmp");
120
+ assert.deepEqual({ scannedFiles: migrationStats.scannedFiles, migratedSessions: migrationStats.migratedSessions, skippedSessions: migrationStats.skippedSessions, migratedMessages: migrationStats.migratedMessages, issues: migrationStats.issues.length }, { scannedFiles: 4, migratedSessions: 2, skippedSessions: 0, migratedMessages: 4, issues: 1 });
121
+ assert.match(migrationStats.issues[0].error, /no stable native ID/);
122
+ const migratedSessionFiles = [...(await SessionManager.list("/tmp"))].filter((session) => session.name?.startsWith("Migrated from Codex:"));
123
+ assert.deepEqual(migratedSessionFiles.map((session) => session.name).sort(), ["Migrated from Codex: codex-compatible", "Migrated from Codex: codex-legacy"]);
124
+ const migrated = SessionManager.open(migratedSessionFiles.find((session) => session.name === "Migrated from Codex: codex-compatible")!.path);
125
+ assert.deepEqual(migrated.getBranch().filter((entry) => entry.type === "message").map((entry) => entry.message.role), ["user", "assistant"]);
126
+ assert.equal(migrateCodexProjectSessions("/tmp").skippedSessions, 2);
127
+ assert.equal((await SessionManager.list("/other-project")).some((session) => session.name === "Migrated from Codex: codex-other-project"), false);
128
+ const backfillStats = backfillAll();
129
+ assert.deepEqual({ pi: backfillStats.pi, claude: backfillStats.claude, codex: backfillStats.codex, turns: backfillStats.turns }, { pi: 3, claude: 1, codex: 3, turns: 7 });
130
+ assert.deepEqual(backfillStats.issues.map((issue) => issue.source), ["pi", "claude", "codex"]);
131
+ for (const issue of backfillStats.issues) {
132
+ assert.match(issue.error, new RegExp(`${issue.source} history import failed[\\s\\S]*supported reference ${HISTORY_SCHEMA_REFERENCE_VERSIONS[issue.source]}`));
133
+ }
134
+ assert.deepEqual(HISTORY_SCHEMA_REFERENCE_VERSIONS, { pi: "0.85.1", claude: "2.1.234", codex: "0.154.0" });
135
+ assert.deepEqual(recallTurns(["schema normal"], 10).map((result) => result.turn_id).filter((turnId) => !turnId.startsWith("pi:") || turnId === "pi:pi-compatible:pi-user"), [
136
+ "claude:claude-compatible:claude-user",
137
+ "codex:codex-compatible:codex-user",
138
+ "pi:pi-compatible:pi-user",
139
+ ]);
140
+ assert.equal(recallTurns(["schema legacy Codex"], 10).some((result) => result.turn_id === "codex:codex-legacy:legacy-codex-turn"), true);
141
+
63
142
  assert.deepEqual(recallTurns(["100%"], 10).map((result) => result.turn_id), ["pi:test:user-1"]);
64
143
  assert.deepEqual(recallTurns(["a_b"], 10).map((result) => result.turn_id), ["pi:test:user-1"]);
65
- assert.equal(getDb().prepare("SELECT count(*) AS count FROM turns").get().count, 2);
144
+ const thinRecall = formatRecallResults(recallMemories({ query: "literal", topK: 1 }));
145
+ assert.match(thinRecall, /\*\*Session:\*\* pi:test · \*\*Turn:\*\* 0[\s\S]*\*\*Excerpt:\*\* literal 100% and a_b[\s\S]*Use `fetch_session`/);
146
+ assert.doesNotMatch(thinRecall, /\*\*Assistant:\*\* first reply/);
147
+ assert.match(
148
+ formatRecallResults([], { query: "SAP BTP", entities: ["BTP", "Business Technology Platform"], sources: ["pi"], cwd: "/tmp" }),
149
+ /\*\*Search query:\*\* `SAP BTP`[\s\S]*\*\*Filters:\*\* entities: `BTP`, `Business Technology Platform` · sources: pi · cwd: `\/tmp`[\s\S]*No relevant past conversations found\./,
150
+ );
151
+ upsertSession({
152
+ session_id: "claude:project-a",
153
+ source: "claude",
154
+ cwd: "/workspace/project-a",
155
+ started_at: 10,
156
+ model_id: null,
157
+ jsonl_path: "/tmp/project-a.jsonl",
158
+ });
159
+ upsertSession({
160
+ session_id: "codex:project-b",
161
+ source: "codex",
162
+ cwd: "/workspace/project-b",
163
+ started_at: 20,
164
+ model_id: null,
165
+ jsonl_path: "/tmp/project-b.jsonl",
166
+ });
167
+ for (const [turnId, sessionId, index, ts, text] of [
168
+ ["claude:project-a:user-1", "claude:project-a", 0, 1_000, "deploy memory ranking"],
169
+ ["claude:project-a:user-2", "claude:project-a", 1, 2_000, "deploy memory testing"],
170
+ ["claude:project-a:user-3", "claude:project-a", 2, 3_000, "deploy memory release"],
171
+ ["codex:project-b:user-1", "codex:project-b", 0, 4_000, "deploy memory ranking"],
172
+ ] as Array<[string, string, number, number, string]>) {
173
+ assert.equal(insertTurn({
174
+ turn_id: turnId,
175
+ session_id: sessionId,
176
+ turn_index: index,
177
+ ts,
178
+ user_text: text,
179
+ reply_text: "confirmed",
180
+ tool_names: null,
181
+ user_message_id: turnId.split(":").at(-1)!,
182
+ }), true);
183
+ }
184
+
185
+ assert.deepEqual(
186
+ recallMemories({ query: "deploy memory", cwd: "/workspace/project-a", topK: 10 }).map((result) => result.turn_id),
187
+ ["claude:project-a:user-3", "claude:project-a:user-2"],
188
+ );
189
+ assert.deepEqual(
190
+ recallMemories({ query: "deploy memory", sources: ["codex"], after: 4_000, topK: 10 }).map((result) => result.turn_id),
191
+ ["codex:project-b:user-1"],
192
+ );
193
+ assert.deepEqual(
194
+ recallMemories({ query: "deploy memory", topK: 10 }).map((result) => result.type === "turn" ? result.turn_id : result.memory_id),
195
+ ["codex:project-b:user-1", "claude:project-a:user-3", "claude:project-a:user-2"],
196
+ );
197
+
198
+ const explicitMemory = createMemory({
199
+ memory_id: "memory:explicit",
200
+ kind: "decision",
201
+ content: "Use SQLite durable memory for deploy decisions.",
202
+ project_key: "/workspace/project-a",
203
+ source_turn_id: null,
204
+ importance: 2,
205
+ created_at: 5_000,
206
+ });
207
+ const pinnedMemory = pinTurnAsMemory("claude:project-a:user-1");
208
+ assert.equal(pinnedMemory.source_turn_id, "claude:project-a:user-1");
209
+ assert.equal(pinnedMemory.source_session_id, "claude:project-a");
210
+ assert.ok(pinnedMemory.source_content_hash);
211
+ assert.deepEqual(listMemories("decision").map((memory) => memory.memory_id), [explicitMemory.memory_id]);
212
+ const deployRecall = recallMemories({ query: "deploy", cwd: "/workspace/project-a", topK: 10 });
213
+ assert.deepEqual(
214
+ deployRecall.map((result) => result.type === "memory" ? result.memory_id : result.turn_id),
215
+ [explicitMemory.memory_id, pinnedMemory.memory_id, "claude:project-a:user-3", "claude:project-a:user-2"],
216
+ );
217
+ assert.equal(deployRecall.find((result) => result.type === "memory" && result.memory_id === pinnedMemory.memory_id)?.freshness_candidate, true);
218
+ const confirmedMemory = confirmMemory(pinnedMemory.memory_id);
219
+ assert.ok(confirmedMemory.last_confirmed_at >= pinnedMemory.last_confirmed_at);
220
+ const replacementMemory = createMemory({
221
+ memory_id: "memory:replacement",
222
+ kind: "decision",
223
+ content: "Use reviewed SQLite durable memory for deploy decisions.",
224
+ project_key: "/workspace/project-a",
225
+ source_turn_id: null,
226
+ importance: 2,
227
+ });
228
+ supersedeMemory(explicitMemory.memory_id, replacementMemory.memory_id);
229
+ assert.deepEqual(getMemoryHistory(replacementMemory.memory_id).map((memory) => memory.memory_id), [explicitMemory.memory_id, replacementMemory.memory_id]);
230
+ assert.deepEqual(
231
+ recallMemories({ query: "SQLite durable memory", cwd: "/workspace/project-a", topK: 10 }).filter((result) => result.type === "memory").map((result) => result.memory_id),
232
+ [replacementMemory.memory_id],
233
+ );
234
+ assert.throws(() => supersedeMemory(explicitMemory.memory_id, replacementMemory.memory_id), /already superseded/);
235
+ upsertSession({
236
+ session_id: "pi:provenance",
237
+ source: "pi",
238
+ cwd: "/workspace/provenance",
239
+ started_at: 6_000,
240
+ model_id: null,
241
+ jsonl_path: "/tmp/provenance.jsonl",
242
+ });
243
+ assert.equal(insertTurn({
244
+ turn_id: "pi:provenance:user-1",
245
+ session_id: "pi:provenance",
246
+ turn_index: 0,
247
+ ts: 6_000,
248
+ user_text: "provenance deduplication",
249
+ reply_text: "original source evidence",
250
+ tool_names: null,
251
+ user_message_id: "user-1",
252
+ }), true);
253
+ const provenanceMemory = pinTurnAsMemory("pi:provenance:user-1");
254
+ assert.deepEqual(
255
+ recallMemories({ query: "provenance deduplication", cwd: "/workspace/provenance", topK: 10 }).map((result) => result.type === "memory" ? result.memory_id : result.turn_id),
256
+ [provenanceMemory.memory_id],
257
+ );
258
+ getDb().prepare("UPDATE turns SET reply_text = ? WHERE turn_id = ?").run("changed source evidence", "pi:provenance:user-1");
259
+ assert.deepEqual(
260
+ recallMemories({ query: "provenance deduplication", cwd: "/workspace/provenance", topK: 10 }).map((result) => result.type === "memory" ? result.memory_id : result.turn_id),
261
+ [provenanceMemory.memory_id, "pi:provenance:user-1"],
262
+ );
263
+ assert.deepEqual(
264
+ getSession("claude:project-a", 1, 2).turns.map((turn) => [turn.turn_index, turn.turn_id]),
265
+ [[1, "claude:project-a:user-2"], [2, "claude:project-a:user-3"]],
266
+ );
267
+ assert.throws(() => getSession("missing:session"), /Memory session not found/);
268
+ assert.equal(deleteTurn("claude:project-a:user-1"), true);
269
+ assert.equal(listMemories().some((memory) => memory.memory_id === pinnedMemory.memory_id), true);
270
+ assert.equal(deleteMemory(pinnedMemory.memory_id), true);
271
+ assert.equal(deleteMemory(pinnedMemory.memory_id), false);
272
+
273
+ const stats = getMemoryStats();
274
+ assert.equal(stats.sessions, 11);
275
+ assert.equal(stats.turns, 13);
276
+ assert.deepEqual([...stats.sources].map(({ source, sessions, turns }) => ({ source, sessions, turns })), [
277
+ { source: "claude", sessions: 2, turns: 3 },
278
+ { source: "codex", sessions: 4, turns: 4 },
279
+ { source: "pi", sessions: 5, turns: 6 },
280
+ ]);
281
+ assert.equal(deleteTurn("codex:project-b:user-1"), true);
282
+ assert.equal(deleteTurn("codex:project-b:user-1"), false);
283
+ assert.equal(getDb().prepare("SELECT count(*) AS count FROM sessions WHERE session_id = 'codex:project-b'").get().count, 0);
284
+ assert.equal(getDb().prepare("SELECT count(*) AS count FROM turns").get().count, 12);
66
285
 
67
286
  cleanup();
68
287
  console.log("core.test.ts: passed");