pi-session-memory 0.2.0 → 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 +37 -7
- package/extensions/index.ts +19 -13
- package/package.json +6 -2
- package/src/retriever.ts +98 -34
- package/AGENTS.md +0 -5
- package/spec.md +0 -28
- package/tests/core.test.ts +0 -287
- package/tsconfig.json +0 -10
package/README.md
CHANGED
|
@@ -17,20 +17,36 @@ A local-first Pi extension that saves completed conversations to SQLite and give
|
|
|
17
17
|
- Ranks results by literal relevance and recency, boosts an explicitly scoped project, and limits results to two turns per session for diversity.
|
|
18
18
|
- Supports explicit durable memories, which remain after their source transcript turns are deleted.
|
|
19
19
|
- Suppresses a raw turn from recall when an active durable memory contains the same unchanged pinned source evidence; other turns in that session remain eligible.
|
|
20
|
-
-
|
|
20
|
+
- Associates recalled durable memories with later source-session activity and newer query-relevant evidence from the same or another session, so users can explicitly compare, confirm, or supersede them without silent updates.
|
|
21
21
|
- Includes status, direct search, and permanent deletion commands so users can inspect and control local memory.
|
|
22
22
|
- Uses only Node.js built-ins and SQLite (`node:sqlite`); no external runtime dependencies.
|
|
23
23
|
|
|
24
24
|
## Installation
|
|
25
25
|
|
|
26
26
|
```bash
|
|
27
|
-
pi install npm:pi-session-memory
|
|
27
|
+
pi install npm:pi-session-memory
|
|
28
28
|
```
|
|
29
29
|
|
|
30
|
-
|
|
30
|
+
This unpinned source can receive package-update checks at Pi startup. After a new release, update it explicitly with:
|
|
31
31
|
|
|
32
32
|
```bash
|
|
33
|
-
pi
|
|
33
|
+
pi update npm:pi-session-memory
|
|
34
|
+
# or update every unpinned Pi extension
|
|
35
|
+
pi update --extensions
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Restart Pi after the update to load the new extension code.
|
|
39
|
+
|
|
40
|
+
To try the latest package without installing it permanently:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pi -e npm:pi-session-memory
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
To intentionally pin a known version (which `pi update --extensions` skips), add its version explicitly:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
pi install npm:pi-session-memory@0.3.0
|
|
34
50
|
```
|
|
35
51
|
|
|
36
52
|
## Usage
|
|
@@ -90,9 +106,9 @@ The extension instructs Pi to call `recall_memory` when a user explicitly asks a
|
|
|
90
106
|
What did we decide about LangGraph last time?
|
|
91
107
|
```
|
|
92
108
|
|
|
93
|
-
`recall_memory` is the discovery step: it searches
|
|
109
|
+
`recall_memory` is the discovery step: it searches and ranks the complete active durable-memory and raw-turn match set using the original request plus important entities. It supports optional exact project-directory, source, and time-window filters. Each tool response deliberately renders five results and reports `totalResults` and `nextOffset`; when more candidates are needed, Pi repeats the exact same query and filters with that explicit offset. This pages model context without silently limiting the local search. Raw transcript candidates contain a short excerpt plus a session ID and turn index, rather than the entire turn context. When surrounding conversation is needed to answer accurately, Pi calls `fetch_session` with that session ID and the smallest useful turn-index range. When an initial literal search is empty, Pi may make up to two additional local searches using reasoned alternatives—such as abbreviations, expansions, aliases, translations, or likely task wording—while retaining the original filters.
|
|
94
110
|
|
|
95
|
-
When recall returns a durable memory, Pi is instructed to naturally communicate a relevant remembered conclusion and provenance when useful.
|
|
111
|
+
When recall returns a durable memory, Pi is instructed to naturally communicate a relevant remembered conclusion and provenance when useful. It reports later activity in the memory's source session separately from newer query-relevant evidence to compare; that evidence can come from the original session or another newer session. Pi compares the old memory with the evidence as a possible confirmation, supplement, conflict, or replacement, then asks whether you want to keep, confirm, or replace it. It never claims a memory was updated or superseded without your explicit choice.
|
|
96
112
|
|
|
97
113
|
### Inspect and control memory
|
|
98
114
|
|
|
@@ -116,7 +132,7 @@ Recall and freshness explanations are automatic model behavior. The commands bel
|
|
|
116
132
|
- `/remember <text>` saves an explicit durable `fact` scoped to the current project.
|
|
117
133
|
- `/memory-pin <turn-id>` promotes a historical turn to a durable fact and records its source session, source turn ID, and a hash of the pinned evidence.
|
|
118
134
|
- `/memory-list [kind]` displays durable memories, optionally limited to `preference`, `decision`, `fact`, `project_state`, `task`, or `lesson`.
|
|
119
|
-
- Recall
|
|
135
|
+
- Recall distinguishes later activity in a memory's source session from newer query-relevant evidence to compare. That evidence may come from the original session or another newer session; it is a review signal, not an automatic update.
|
|
120
136
|
- `/memory-confirm <memory-id>` records that an active memory remains current by updating `last_confirmed_at`.
|
|
121
137
|
- `/memory-supersede <old-memory-id> <new-memory-id>` explicitly replaces an active memory while retaining the old record for history; superseded memories are excluded from normal recall.
|
|
122
138
|
- `/memory-history <memory-id>` displays the complete oldest-to-newest supersession chain.
|
|
@@ -152,6 +168,8 @@ Conversation data is stored and queried locally. This package does not add a rem
|
|
|
152
168
|
|
|
153
169
|
| Version | Highlights |
|
|
154
170
|
| --- | --- |
|
|
171
|
+
| `0.3.0` | Replaces source-session-only freshness hints with provenance-linked evidence comparison across newer same-session and cross-session turns. This changes recall output and `freshness_candidate` semantics, but keeps tool inputs, slash commands, SQLite data, and explicit user-controlled memory mutation compatible; no migration is required. |
|
|
172
|
+
| `0.2.1` | Pages `recall_memory` results in explicit five-result `offset` windows while still evaluating the complete local match set; npm publishing now uses a runtime-file allowlist. |
|
|
155
173
|
| `0.2.0` | Added cross-client SQLite recall and durable-memory controls, plus native current-project Codex-to-Pi session migration for `/resume`. |
|
|
156
174
|
| `0.1.4` | Automatically syncs new or changed Pi, Claude Code, and Codex history when Pi starts; `/memory-backfill` forces a full rescan. |
|
|
157
175
|
| `0.1.3` | Improved package documentation and installation guidance. |
|
|
@@ -159,6 +177,18 @@ Conversation data is stored and queried locally. This package does not add a rem
|
|
|
159
177
|
| `0.1.1` | Added repository and package metadata for public distribution. |
|
|
160
178
|
| `0.1.0` | Initial release: local SQLite memory, Pi live persistence, historical import, and `recall_memory` retrieval. |
|
|
161
179
|
|
|
180
|
+
## Release compatibility review
|
|
181
|
+
|
|
182
|
+
Before every significant release, review these compatibility surfaces and record any migration or versioning decision:
|
|
183
|
+
|
|
184
|
+
1. **Install/package:** package name, Pi manifest, runtime dependencies, and published file allowlist.
|
|
185
|
+
2. **Persistent data:** SQLite schema/migrations, JSONL-import compatibility, and any data rewrite.
|
|
186
|
+
3. **Agent tools and commands:** tool names, input schemas, result/details contracts, and slash commands.
|
|
187
|
+
4. **Retrieval and agent behavior:** ranking, pagination, freshness/evidence semantics, prompt policy, and automatic side effects.
|
|
188
|
+
5. **Public TypeScript/module API:** exported types/functions and required result fields.
|
|
189
|
+
|
|
190
|
+
The `0.3.0` review found no installation, SQLite, command, or tool-input breaking change. It intentionally changes recall result semantics and adds evidence fields, so it is released as a minor `0.x` version rather than a patch.
|
|
191
|
+
|
|
162
192
|
## Development
|
|
163
193
|
|
|
164
194
|
```bash
|
package/extensions/index.ts
CHANGED
|
@@ -2,7 +2,7 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
|
|
|
2
2
|
import { Type } from "typebox";
|
|
3
3
|
import { writeTurn } from "../src/writer.ts";
|
|
4
4
|
import { confirmMemory, createMemory, deleteMemory, deleteTurn, getMemoryHistory, getMemoryStats, getSession, listMemories, pinTurnAsMemory, supersedeMemory, type MemoryKind } from "../src/db.ts";
|
|
5
|
-
import { recallMemories, formatRecallResults } from "../src/retriever.ts";
|
|
5
|
+
import { recallMemories, formatRecallResults, paginateRecallResults } from "../src/retriever.ts";
|
|
6
6
|
import { backfillAll, syncChangedHistory, type BackfillStats } from "../src/backfill.ts";
|
|
7
7
|
import { migrateCodexProjectSessions, type ProjectSessionMigrationStats } from "../src/session-migration.ts";
|
|
8
8
|
import { SESSION_MEMORY_HELP } from "../src/helper.ts";
|
|
@@ -57,13 +57,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
57
57
|
});
|
|
58
58
|
|
|
59
59
|
pi.registerCommand("memory-search", {
|
|
60
|
-
description: "Search local memory with a literal query",
|
|
61
|
-
/** Search stored memory from a literal command query. */
|
|
60
|
+
description: "Search local memory with a literal query and show its first five results",
|
|
61
|
+
/** Search stored memory from a literal command query without rendering the entire match set. */
|
|
62
62
|
handler: async (args, ctx) => {
|
|
63
63
|
const query = args.trim();
|
|
64
64
|
if (!query) throw new Error("Usage: /memory-search <query>");
|
|
65
|
-
const
|
|
66
|
-
ctx.ui.notify(formatRecallResults(results), "info");
|
|
65
|
+
const page = paginateRecallResults(recallMemories({ query }));
|
|
66
|
+
ctx.ui.notify(formatRecallResults(page.results, { query }, page), "info");
|
|
67
67
|
},
|
|
68
68
|
});
|
|
69
69
|
|
|
@@ -211,9 +211,9 @@ Invocation policy:
|
|
|
211
211
|
|
|
212
212
|
Memory-aware response policy:
|
|
213
213
|
1. Treat returned durable memories as reusable evidence, not as invisible context. In your natural-language answer, briefly state the relevant remembered conclusion and identify its source turn/session when that provenance matters to the answer.
|
|
214
|
-
2.
|
|
215
|
-
3. After explaining a meaningful
|
|
216
|
-
4. When an existing durable memory resolves the question and has no
|
|
214
|
+
2. A durable memory can report `source_session_changed` when its original session has later activity; this alone does not mean the memory is stale. When it includes **Newer evidence to compare**, compare that evidence with the memory: it may confirm, supplement, conflict with, or replace the old conclusion. Evidence can come from another newer session as well as the original session. Do not claim that the memory was updated, confirmed, or superseded unless the user explicitly chose that action.
|
|
215
|
+
3. After explaining a meaningful comparison, offer clear control: keep the current memory, confirm that it remains current, or create/pin a replacement and supersede the old memory. Ask which outcome they want before any persistent memory-management action.
|
|
216
|
+
4. When an existing durable memory resolves the question and has no comparison evidence, use it directly and avoid repeating its identical source turn. Do not mention memory mechanics unless provenance or evidence comparison is useful to the user.
|
|
217
217
|
5. Slash commands are user-controlled management actions. Do not instruct the user to execute a command merely to answer their question; mention the relevant command only when they want to inspect, confirm, replace, or delete a memory.
|
|
218
218
|
|
|
219
219
|
Session-expansion policy:
|
|
@@ -221,6 +221,10 @@ Session-expansion policy:
|
|
|
221
221
|
2. Call \`fetch_session\` only when a candidate's surrounding conversation is necessary to answer accurately, verify a conclusion, resolve a conflict, or inspect context around a matched turn. Use its turn bounds to request the smallest useful range.
|
|
222
222
|
3. Do not fetch a session when a durable memory or returned excerpt already answers the question. Do not fetch unrelated sessions merely because they were listed.
|
|
223
223
|
|
|
224
|
+
Pagination policy:
|
|
225
|
+
1. Each invocation returns five results. Local retrieval still evaluates every match before selecting that page.
|
|
226
|
+
2. When the result reports a \`nextOffset\`, call \`recall_memory\` again with the exact same query and filters plus that offset only when more candidates are needed. Do not request pages merely to exhaust the result set.
|
|
227
|
+
|
|
224
228
|
Extract 2–5 specific entities from the user's topic: project names, tool names, technologies, domain terms, or identifiers.`,
|
|
225
229
|
promptSnippet: "Search cross-client Pi, Claude Code, and Codex history when the user asks about prior discussions or work.",
|
|
226
230
|
|
|
@@ -240,15 +244,17 @@ Extract 2–5 specific entities from the user's topic: project names, tool names
|
|
|
240
244
|
cwd: Type.Optional(Type.String({ minLength: 1, description: "Exact project working directory to restrict results." })),
|
|
241
245
|
after: Type.Optional(Type.Number({ description: "Inclusive Unix timestamp in milliseconds." })),
|
|
242
246
|
before: Type.Optional(Type.Number({ description: "Inclusive Unix timestamp in milliseconds." })),
|
|
247
|
+
offset: Type.Optional(Type.Integer({ minimum: 0, description: "Zero-based result offset. Each call returns five results; use the returned nextOffset with identical search and filter inputs only when more candidates are needed." })),
|
|
243
248
|
}),
|
|
244
249
|
|
|
245
|
-
/** Resolve an agent memory request into
|
|
246
|
-
async execute(_toolCallId, { query, entities, sources, cwd, after, before }) {
|
|
247
|
-
const results = recallMemories({ query, entities, sources, cwd, after, before
|
|
248
|
-
const
|
|
250
|
+
/** Resolve an agent memory request into one explicit page of a fully evaluated local result set. */
|
|
251
|
+
async execute(_toolCallId, { query, entities, sources, cwd, after, before, offset }) {
|
|
252
|
+
const results = recallMemories({ query, entities, sources, cwd, after, before });
|
|
253
|
+
const page = paginateRecallResults(results, offset);
|
|
254
|
+
const text = formatRecallResults(page.results, { query, entities, sources, cwd, after, before }, page);
|
|
249
255
|
return {
|
|
250
256
|
content: [{ type: "text" as const, text }],
|
|
251
|
-
details: { query, entities, sources, cwd, after, before,
|
|
257
|
+
details: { query, entities, sources, cwd, after, before, offset: page.offset, pageSize: page.results.length, totalResults: page.totalResults, nextOffset: page.nextOffset },
|
|
252
258
|
};
|
|
253
259
|
},
|
|
254
260
|
});
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-session-memory",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Persistent, local-first cross-session memory for Pi, with SQLite-backed recall across Pi, Claude Code, and Codex conversations",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Persistent, local-first cross-session memory for Pi, with SQLite-backed paginated recall and freshness-evidence comparison across Pi, Claude Code, and Codex conversations",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package"
|
|
7
7
|
],
|
|
@@ -15,6 +15,10 @@
|
|
|
15
15
|
"url": "https://github.com/shengxiao20/pi-session-memory/issues"
|
|
16
16
|
},
|
|
17
17
|
"type": "module",
|
|
18
|
+
"files": [
|
|
19
|
+
"extensions/",
|
|
20
|
+
"src/"
|
|
21
|
+
],
|
|
18
22
|
"scripts": {
|
|
19
23
|
"test": "tsx tests/core.test.ts"
|
|
20
24
|
},
|
package/src/retriever.ts
CHANGED
|
@@ -6,12 +6,10 @@ export type MemorySource = "pi" | "claude" | "codex";
|
|
|
6
6
|
export interface RecallOptions {
|
|
7
7
|
query: string;
|
|
8
8
|
entities?: string[];
|
|
9
|
-
topK?: number;
|
|
10
9
|
sources?: MemorySource[];
|
|
11
10
|
cwd?: string;
|
|
12
11
|
after?: number;
|
|
13
12
|
before?: number;
|
|
14
|
-
diversify?: boolean;
|
|
15
13
|
}
|
|
16
14
|
|
|
17
15
|
export interface RecallTurnResult {
|
|
@@ -28,6 +26,16 @@ export interface RecallTurnResult {
|
|
|
28
26
|
score: number;
|
|
29
27
|
}
|
|
30
28
|
|
|
29
|
+
export interface FreshnessEvidence {
|
|
30
|
+
turn_id: string;
|
|
31
|
+
session_id: string;
|
|
32
|
+
turn_index: number;
|
|
33
|
+
source: MemorySource;
|
|
34
|
+
ts: number;
|
|
35
|
+
relation: "same_source_session_later" | "newer_cross_session";
|
|
36
|
+
excerpt: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
31
39
|
export interface RecallDurableMemoryResult {
|
|
32
40
|
type: "memory";
|
|
33
41
|
memory_id: string;
|
|
@@ -38,7 +46,9 @@ export interface RecallDurableMemoryResult {
|
|
|
38
46
|
source_session_id: string | null;
|
|
39
47
|
source_content_hash: string | null;
|
|
40
48
|
source_turn_index: number | null;
|
|
49
|
+
source_session_changed: boolean;
|
|
41
50
|
freshness_candidate: boolean;
|
|
51
|
+
freshness_evidence: FreshnessEvidence[];
|
|
42
52
|
created_at: number;
|
|
43
53
|
last_confirmed_at: number;
|
|
44
54
|
importance: number;
|
|
@@ -48,41 +58,59 @@ export interface RecallDurableMemoryResult {
|
|
|
48
58
|
|
|
49
59
|
export type RecallResult = RecallDurableMemoryResult | RecallTurnResult;
|
|
50
60
|
|
|
51
|
-
|
|
52
|
-
|
|
61
|
+
export const RECALL_PAGE_SIZE = 5;
|
|
62
|
+
|
|
63
|
+
export interface RecallPage {
|
|
64
|
+
results: RecallResult[];
|
|
65
|
+
offset: number;
|
|
66
|
+
totalResults: number;
|
|
67
|
+
nextOffset: number | null;
|
|
68
|
+
}
|
|
69
|
+
|
|
53
70
|
// Recency is a bounded tie-breaker, not a replacement for literal relevance.
|
|
54
71
|
const RECENCY_WINDOW_MS = 90 * 24 * 60 * 60 * 1000;
|
|
55
72
|
|
|
56
73
|
/** Retained for compatibility with the v0.1 public retrieval helper. */
|
|
57
|
-
export function recallTurns(entities: string[]
|
|
58
|
-
return _recallTurns({ query: entities.join(" "), entities
|
|
74
|
+
export function recallTurns(entities: string[]): RecallTurnResult[] {
|
|
75
|
+
return _recallTurns({ query: entities.join(" "), entities });
|
|
59
76
|
}
|
|
60
77
|
|
|
61
78
|
/** Retrieve active durable memories, then raw turns not already represented by unchanged source evidence. */
|
|
62
79
|
export function recallMemories(options: RecallOptions): RecallResult[] {
|
|
63
80
|
const durableMemories = _recallDurableMemories(options);
|
|
64
81
|
const recalledTurns = _recallTurns(options);
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
82
|
+
const memories = durableMemories.map((memory) => {
|
|
83
|
+
const freshness_evidence = _freshnessEvidence(memory, recalledTurns);
|
|
84
|
+
return {
|
|
85
|
+
...memory,
|
|
86
|
+
source_session_changed: _sourceSessionChanged(memory),
|
|
87
|
+
freshness_candidate: freshness_evidence.length > 0,
|
|
88
|
+
freshness_evidence,
|
|
89
|
+
};
|
|
90
|
+
});
|
|
72
91
|
const coveredSourceHashes = new Map(
|
|
73
92
|
memories
|
|
74
93
|
.filter((memory) => memory.source_turn_id && memory.source_content_hash)
|
|
75
94
|
.map((memory) => [memory.source_turn_id!, memory.source_content_hash!]),
|
|
76
95
|
);
|
|
77
96
|
const rawTurns = recalledTurns.filter((turn) => coveredSourceHashes.get(turn.turn_id) !== _turnContentHash(turn));
|
|
78
|
-
return [...memories, ...rawTurns]
|
|
97
|
+
return [...memories, ...rawTurns];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Select one fixed-size recall page without limiting the complete local retrieval result. */
|
|
101
|
+
export function paginateRecallResults(results: RecallResult[], offset = 0): RecallPage {
|
|
102
|
+
if (!Number.isInteger(offset) || offset < 0) throw new Error("Recall offset must be a non-negative integer");
|
|
103
|
+
const pageResults = results.slice(offset, offset + RECALL_PAGE_SIZE);
|
|
104
|
+
const nextOffset = offset + pageResults.length < results.length ? offset + pageResults.length : null;
|
|
105
|
+
return { results: pageResults, offset, totalResults: results.length, nextOffset };
|
|
79
106
|
}
|
|
80
107
|
|
|
81
108
|
/** Render the exact query inputs and recall results as concise Markdown for a command notification or tool response. */
|
|
82
|
-
export function formatRecallResults(results: RecallResult[], options?: Pick<RecallOptions, "query" | "entities" | "sources" | "cwd" | "after" | "before">): string {
|
|
109
|
+
export function formatRecallResults(results: RecallResult[], options?: Pick<RecallOptions, "query" | "entities" | "sources" | "cwd" | "after" | "before">, page?: Omit<RecallPage, "results">): string {
|
|
83
110
|
const lines = options ? [_formatRecallQuery(options), ""] : [];
|
|
84
|
-
if (results.length === 0) return [...lines, "No relevant past conversations found."].join("\n");
|
|
111
|
+
if (results.length === 0) return [...lines, page && page.totalResults > 0 ? `No results at offset ${page.offset}; the matching result set contains ${page.totalResults} result(s).` : "No relevant past conversations found."].join("\n");
|
|
85
112
|
|
|
113
|
+
if (page) lines.push(`**Results:** ${page.offset + 1}–${page.offset + results.length} of ${page.totalResults} (five results per page)\n`);
|
|
86
114
|
lines.push("## Relevant past memories\n");
|
|
87
115
|
for (const result of results) {
|
|
88
116
|
if (result.type === "memory") {
|
|
@@ -91,7 +119,14 @@ export function formatRecallResults(results: RecallResult[], options?: Pick<Reca
|
|
|
91
119
|
lines.push(`**Memory ID:** ${result.memory_id}`);
|
|
92
120
|
if (result.source_turn_id) lines.push(`**Source turn:** ${result.source_turn_id}`);
|
|
93
121
|
if (result.source_session_id) lines.push(`**Source session:** ${result.source_session_id}`);
|
|
94
|
-
if (result.
|
|
122
|
+
if (result.source_session_changed) lines.push("**Source session changed:** later turns exist; this alone does not mean the memory is stale.");
|
|
123
|
+
if (result.freshness_candidate) {
|
|
124
|
+
lines.push("**Newer evidence to compare:**");
|
|
125
|
+
for (const evidence of result.freshness_evidence) {
|
|
126
|
+
lines.push(`- [${evidence.relation.replaceAll("_", " ")} · ${evidence.source} · ${new Date(evidence.ts).toLocaleString()} · ${evidence.session_id} · turn ${evidence.turn_index}] ${evidence.excerpt}`);
|
|
127
|
+
}
|
|
128
|
+
lines.push("Compare this evidence with the durable memory; it may confirm, supplement, conflict with, or replace it. Do not change the memory without the user's explicit choice.");
|
|
129
|
+
}
|
|
95
130
|
} else {
|
|
96
131
|
const date = new Date(result.ts).toLocaleString();
|
|
97
132
|
lines.push(`### [${result.source} · ${date}]`);
|
|
@@ -101,6 +136,9 @@ export function formatRecallResults(results: RecallResult[], options?: Pick<Reca
|
|
|
101
136
|
}
|
|
102
137
|
lines.push("");
|
|
103
138
|
}
|
|
139
|
+
if (page?.nextOffset !== null && page?.nextOffset !== undefined) {
|
|
140
|
+
lines.push(`More matching results exist. To retrieve the next five, call \`recall_memory\` again with every same search/filter parameter and \`offset: ${page.nextOffset}\`.`);
|
|
141
|
+
}
|
|
104
142
|
return lines.join("\n");
|
|
105
143
|
}
|
|
106
144
|
|
|
@@ -149,9 +187,46 @@ function _recallDurableMemories(options: RecallOptions): RecallDurableMemoryResu
|
|
|
149
187
|
FROM memories
|
|
150
188
|
WHERE ${filters.join(" AND ")}
|
|
151
189
|
ORDER BY hits DESC, importance DESC, last_confirmed_at DESC
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
190
|
+
`).all(...parameters, ...filterParameters)
|
|
191
|
+
.map((memory) => ({ ...memory, type: "memory" as const, source_session_changed: false, freshness_candidate: false, freshness_evidence: [], score: memory.hits + memory.importance })) as RecallDurableMemoryResult[];
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Detect later activity in the original session independently of this recall query and ranking. */
|
|
195
|
+
function _sourceSessionChanged(memory: RecallDurableMemoryResult): boolean {
|
|
196
|
+
if (!memory.source_session_id || memory.source_turn_index === null) return false;
|
|
197
|
+
return getDb().prepare(`
|
|
198
|
+
SELECT 1 FROM turns
|
|
199
|
+
WHERE session_id = ? AND turn_index > ?
|
|
200
|
+
LIMIT 1
|
|
201
|
+
`).get(memory.source_session_id, memory.source_turn_index) !== undefined;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Associate each memory with all newer query-relevant turns without deciding their semantic relationship. */
|
|
205
|
+
function _freshnessEvidence(memory: RecallDurableMemoryResult, recalledTurns: RecallTurnResult[]): FreshnessEvidence[] {
|
|
206
|
+
const baselineTs = _memoryEvidenceTimestamp(memory);
|
|
207
|
+
return recalledTurns
|
|
208
|
+
.filter((turn) => {
|
|
209
|
+
if (memory.source_session_id === turn.session_id && memory.source_turn_index !== null) {
|
|
210
|
+
return turn.turn_index > memory.source_turn_index;
|
|
211
|
+
}
|
|
212
|
+
return turn.ts > baselineTs;
|
|
213
|
+
})
|
|
214
|
+
.map((turn) => ({
|
|
215
|
+
turn_id: turn.turn_id,
|
|
216
|
+
session_id: turn.session_id,
|
|
217
|
+
turn_index: turn.turn_index,
|
|
218
|
+
source: turn.source,
|
|
219
|
+
ts: turn.ts,
|
|
220
|
+
relation: memory.source_session_id === turn.session_id ? "same_source_session_later" as const : "newer_cross_session" as const,
|
|
221
|
+
excerpt: _excerpt(turn.user_text || turn.reply_text),
|
|
222
|
+
}));
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Use source-turn time when available; explicit memories become comparable from their creation time. */
|
|
226
|
+
function _memoryEvidenceTimestamp(memory: RecallDurableMemoryResult): number {
|
|
227
|
+
if (!memory.source_turn_id) return memory.created_at;
|
|
228
|
+
const source = getDb().prepare("SELECT ts FROM turns WHERE turn_id = ?").get(memory.source_turn_id) as { ts: number } | undefined;
|
|
229
|
+
return source?.ts ?? memory.created_at;
|
|
155
230
|
}
|
|
156
231
|
|
|
157
232
|
/** Retrieve and rank locally stored turns using literal query terms and optional scopes. */
|
|
@@ -185,11 +260,11 @@ function _recallTurns(options: RecallOptions): RecallTurnResult[] {
|
|
|
185
260
|
(${scoreExpression}) AS hits
|
|
186
261
|
FROM turns JOIN sessions ON sessions.session_id = turns.session_id
|
|
187
262
|
WHERE ${filters.join(" AND ")}
|
|
188
|
-
ORDER BY hits DESC, turns.ts DESC
|
|
189
|
-
`).all(...scoreParameters, ...filterParameters
|
|
263
|
+
ORDER BY hits DESC, turns.ts DESC
|
|
264
|
+
`).all(...scoreParameters, ...filterParameters) as Array<Omit<RecallTurnResult, "type" | "score">>;
|
|
190
265
|
const newestTs = candidates.reduce((newest, result) => Math.max(newest, result.ts), 0);
|
|
191
266
|
const results = candidates.map((result) => ({ ...result, type: "turn" as const, score: result.hits + _recencyScore(result.ts, newestTs) + (options.cwd === result.cwd ? 0.8 : 0) })).sort((left, right) => right.score - left.score || right.ts - left.ts);
|
|
192
|
-
return
|
|
267
|
+
return results;
|
|
193
268
|
}
|
|
194
269
|
|
|
195
270
|
/** Hash the current raw turn evidence using the same representation captured during pinning. */
|
|
@@ -217,14 +292,3 @@ function _likePattern(term: string): string {
|
|
|
217
292
|
function _recencyScore(ts: number, newestTs: number): number {
|
|
218
293
|
return Math.max(0, 0.5 * (1 - (newestTs - ts) / RECENCY_WINDOW_MS));
|
|
219
294
|
}
|
|
220
|
-
|
|
221
|
-
/** Limit ranked output to prevent any one session from dominating the recall window. */
|
|
222
|
-
function _diversify(results: RecallTurnResult[], topK: number): RecallTurnResult[] {
|
|
223
|
-
const counts = new Map<string, number>();
|
|
224
|
-
return results.filter((result) => {
|
|
225
|
-
const count = counts.get(result.session_id) ?? 0;
|
|
226
|
-
if (count >= MAX_TURNS_PER_SESSION) return false;
|
|
227
|
-
counts.set(result.session_id, count + 1);
|
|
228
|
-
return true;
|
|
229
|
-
}).slice(0, topK);
|
|
230
|
-
}
|
package/AGENTS.md
DELETED
|
@@ -1,5 +0,0 @@
|
|
|
1
|
-
# Project Instructions
|
|
2
|
-
|
|
3
|
-
## Command and tool discoverability
|
|
4
|
-
|
|
5
|
-
Every user-facing command must have a precise `description` explaining what it does and when to use it. Commands exist so users can invoke them manually, but an equivalent capability that an agent may need must also be exposed as a clearly described Pi tool. Tool descriptions must state the appropriate invocation conditions so Pi can discover and call them without relying on hidden knowledge.
|
package/spec.md
DELETED
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
# Native Codex-to-Pi Project Session Migration — Spec
|
|
2
|
-
|
|
3
|
-
## Goal
|
|
4
|
-
|
|
5
|
-
Allow a user to convert each historical Codex session for the active project into a separate, native Pi session that can be selected through Pi `/resume` and continued normally.
|
|
6
|
-
|
|
7
|
-
This is distinct from SQLite historical import and `recall_memory`:
|
|
8
|
-
|
|
9
|
-
- Native migration writes Pi session JSONL files for direct continuation in Pi.
|
|
10
|
-
- `recall_memory` searches local SQLite excerpts and does not restore a client session.
|
|
11
|
-
|
|
12
|
-
## Design
|
|
13
|
-
|
|
14
|
-
- Export `migrateCodexProjectSessions(cwd)` from `src/session-migration.ts`.
|
|
15
|
-
- Scan Codex JSONL sessions and select only sessions with `session.cwd === cwd`.
|
|
16
|
-
- Create one Pi v3 session JSONL per Codex session under Pi's default session directory for that cwd.
|
|
17
|
-
- Write a `Migrated from Codex: <session-id>` session name so it is recognizable in `/resume`.
|
|
18
|
-
- Convert user and assistant textual messages only. Do not represent Codex system/developer prompts, tool calls, or tool results as Pi conversation messages.
|
|
19
|
-
- Use deterministic output file names and skip an already migrated Codex session, making reruns idempotent.
|
|
20
|
-
- Register `/project-session-migration` for users and `migrate_codex_project_sessions` for Pi agents. Both descriptions must state that this is native Pi continuation, not ordinary recall.
|
|
21
|
-
|
|
22
|
-
## Acceptance criteria
|
|
23
|
-
|
|
24
|
-
1. Each current-project Codex fixture produces one independently resumable Pi session with the expected user/assistant message sequence.
|
|
25
|
-
2. Another project's Codex session is not migrated.
|
|
26
|
-
3. A second migration skips existing output session files.
|
|
27
|
-
4. A malformed source file becomes an isolated issue and does not block other sessions.
|
|
28
|
-
5. `npm test` and `git diff --check` pass.
|
package/tests/core.test.ts
DELETED
|
@@ -1,287 +0,0 @@
|
|
|
1
|
-
import assert from "node:assert/strict";
|
|
2
|
-
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
-
import { tmpdir } from "node:os";
|
|
4
|
-
import { join } from "node:path";
|
|
5
|
-
|
|
6
|
-
const dbPath = join(tmpdir(), `pi-session-memory-${process.pid}.db`);
|
|
7
|
-
const historyHome = join(tmpdir(), `pi-session-memory-home-${process.pid}`);
|
|
8
|
-
process.env.MEMORY_DB_PATH = dbPath;
|
|
9
|
-
process.env.HOME = historyHome;
|
|
10
|
-
|
|
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");
|
|
16
|
-
|
|
17
|
-
/** Remove the temporary SQLite database and its WAL sidecar files after this test. */
|
|
18
|
-
function cleanup(): void {
|
|
19
|
-
for (const suffix of ["", "-wal", "-shm"]) {
|
|
20
|
-
const path = `${dbPath}${suffix}`;
|
|
21
|
-
if (existsSync(path)) rmSync(path);
|
|
22
|
-
}
|
|
23
|
-
if (existsSync(historyHome)) rmSync(historyHome, { recursive: true });
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
cleanup();
|
|
27
|
-
|
|
28
|
-
upsertSession({
|
|
29
|
-
session_id: "pi:test",
|
|
30
|
-
source: "pi",
|
|
31
|
-
cwd: "/tmp",
|
|
32
|
-
started_at: 1,
|
|
33
|
-
model_id: null,
|
|
34
|
-
jsonl_path: "/tmp/test.jsonl",
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
assert.equal(insertTurn({
|
|
38
|
-
turn_id: "pi:test:user-1",
|
|
39
|
-
session_id: "pi:test",
|
|
40
|
-
turn_index: 0,
|
|
41
|
-
ts: 1,
|
|
42
|
-
user_text: "literal 100% and a_b",
|
|
43
|
-
reply_text: "first reply",
|
|
44
|
-
tool_names: null,
|
|
45
|
-
user_message_id: "user-1",
|
|
46
|
-
}), true);
|
|
47
|
-
|
|
48
|
-
assert.equal(insertTurn({
|
|
49
|
-
turn_id: "pi:test:user-2",
|
|
50
|
-
session_id: "pi:test",
|
|
51
|
-
turn_index: 1,
|
|
52
|
-
ts: 2,
|
|
53
|
-
user_text: "wildcard 100x and acb",
|
|
54
|
-
reply_text: "second reply",
|
|
55
|
-
tool_names: null,
|
|
56
|
-
user_message_id: "user-2",
|
|
57
|
-
}), true);
|
|
58
|
-
|
|
59
|
-
assert.equal(insertTurn({
|
|
60
|
-
turn_id: "pi:test:user-1",
|
|
61
|
-
session_id: "pi:test",
|
|
62
|
-
turn_index: 0,
|
|
63
|
-
ts: 1,
|
|
64
|
-
user_text: "duplicate",
|
|
65
|
-
reply_text: "duplicate",
|
|
66
|
-
tool_names: null,
|
|
67
|
-
user_message_id: "user-1",
|
|
68
|
-
}), false);
|
|
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
|
-
|
|
142
|
-
assert.deepEqual(recallTurns(["100%"], 10).map((result) => result.turn_id), ["pi:test:user-1"]);
|
|
143
|
-
assert.deepEqual(recallTurns(["a_b"], 10).map((result) => result.turn_id), ["pi:test:user-1"]);
|
|
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);
|
|
285
|
-
|
|
286
|
-
cleanup();
|
|
287
|
-
console.log("core.test.ts: passed");
|