pi-session-memory 0.4.0 → 0.6.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 +31 -184
- package/extensions/index.ts +67 -291
- package/package.json +5 -3
- package/src/backfill.ts +16 -4
- package/src/db.ts +33 -336
- package/src/fetch-session.ts +8 -0
- package/src/helper.ts +21 -41
- package/src/retriever.ts +25 -289
- package/src/writer.ts +5 -2
package/extensions/index.ts
CHANGED
|
@@ -1,336 +1,112 @@
|
|
|
1
1
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { Type } from "typebox";
|
|
3
3
|
import { writeTurn } from "../src/writer.ts";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
4
|
+
import { getHistoryStats } from "../src/db.ts";
|
|
5
|
+
import { fetchSession } from "../src/fetch-session.ts";
|
|
6
|
+
import { formatRecallResults, recallTurns } from "../src/retriever.ts";
|
|
6
7
|
import { backfillAll, syncChangedHistory, type BackfillStats } from "../src/backfill.ts";
|
|
7
8
|
import { migrateClaudeProjectSessions, migrateCodexProjectSessions, type ProjectSessionMigrationStats } from "../src/session-migration.ts";
|
|
8
9
|
import { SESSION_MEMORY_HELP } from "../src/helper.ts";
|
|
9
10
|
|
|
10
|
-
/** Register
|
|
11
|
+
/** Register local cross-session transcript retrieval and source-session migration features. */
|
|
11
12
|
export default function (pi: ExtensionAPI) {
|
|
12
|
-
|
|
13
|
-
/** Synchronize changed external session history whenever a Pi session starts. */
|
|
14
13
|
pi.on("session_start", async (event, ctx) => {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
? `synced ${stats.turns} turns from ${stats.scannedFiles} changed session files`
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
);
|
|
24
|
-
}
|
|
25
|
-
for (const issue of stats.issues) ctx.ui.notify(`[session-memory] ${issue.error}`, "error");
|
|
14
|
+
try {
|
|
15
|
+
const stats = syncChangedHistory();
|
|
16
|
+
if (event.reason === "startup" || stats.scannedFiles > 0) {
|
|
17
|
+
const summary = stats.scannedFiles > 0 ? `synced ${stats.turns} turns from ${stats.scannedFiles} changed session files` : "ready";
|
|
18
|
+
ctx.ui.notify(`[session-memory] ${summary}. Run /pi-session-memory-helper for cross-session history features.`, "info");
|
|
19
|
+
}
|
|
20
|
+
for (const issue of stats.issues) ctx.ui.notify(`[session-memory] ${issue.error}`, "error");
|
|
21
|
+
} catch (err) { ctx.ui.notify(`[session-memory] history sync failed: ${String(err)}`, "error"); }
|
|
26
22
|
});
|
|
27
23
|
|
|
28
|
-
// ── Write: persist each completed agent run to SQLite ────────────────────
|
|
29
|
-
/** Persist the just-completed live Pi turn after the agent has settled. */
|
|
30
24
|
pi.on("agent_settled", async (_event, ctx) => {
|
|
31
|
-
try {
|
|
32
|
-
writeTurn(ctx);
|
|
33
|
-
} catch (err) {
|
|
34
|
-
ctx.ui.notify(`[session-memory] write failed: ${String(err)}`, "error");
|
|
35
|
-
}
|
|
25
|
+
try { writeTurn(ctx); } catch (err) { ctx.ui.notify(`[session-memory] write failed: ${String(err)}`, "error"); }
|
|
36
26
|
});
|
|
37
27
|
|
|
38
28
|
pi.registerCommand("pi-session-memory-helper", {
|
|
39
|
-
description: "Show
|
|
40
|
-
|
|
41
|
-
handler: async (_args, ctx) => {
|
|
42
|
-
ctx.ui.notify(SESSION_MEMORY_HELP, "info");
|
|
43
|
-
},
|
|
29
|
+
description: "Show cross-session history search and migration features",
|
|
30
|
+
handler: async (_args, ctx) => { ctx.ui.notify(SESSION_MEMORY_HELP, "info"); },
|
|
44
31
|
});
|
|
45
|
-
|
|
46
32
|
pi.registerCommand("memory-status", {
|
|
47
|
-
description: "Show local
|
|
48
|
-
|
|
49
|
-
handler: async (_args, ctx) => {
|
|
50
|
-
const stats = getMemoryStats();
|
|
51
|
-
const sources = stats.sources.length
|
|
52
|
-
? stats.sources.map((source) => `${source.source}: ${source.turns} turns / ${source.sessions} sessions`).join(", ")
|
|
53
|
-
: "no imported sources";
|
|
54
|
-
const newest = stats.newestTs ? new Date(stats.newestTs).toLocaleString() : "n/a";
|
|
55
|
-
ctx.ui.notify(`[session-memory] ${stats.turns} turns across ${stats.sessions} sessions; ${sources}; newest: ${newest}`, "info");
|
|
56
|
-
},
|
|
33
|
+
description: "Show local cross-session history storage and source statistics",
|
|
34
|
+
handler: async (_args, ctx) => { ctx.ui.notify(_historyStatsSummary(getHistoryStats()), "info"); },
|
|
57
35
|
});
|
|
58
|
-
|
|
59
36
|
pi.registerCommand("memory-search", {
|
|
60
|
-
description: "Search local
|
|
61
|
-
/** Search stored memory from a literal command query without rendering the entire match set. */
|
|
37
|
+
description: "Search local cross-session transcript history with a literal query",
|
|
62
38
|
handler: async (args, ctx) => {
|
|
63
39
|
const query = args.trim();
|
|
64
40
|
if (!query) throw new Error("Usage: /memory-search <query>");
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
ctx.ui.notify(formatRecallResults(page.results, { entities }, page), "info");
|
|
68
|
-
},
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
pi.registerCommand("remember", {
|
|
72
|
-
description: "Create an explicit durable fact memory in the current project",
|
|
73
|
-
/** Store user-authored reusable knowledge independently from transcript retention. */
|
|
74
|
-
handler: async (args, ctx) => {
|
|
75
|
-
const content = args.trim();
|
|
76
|
-
if (!content) throw new Error("Usage: /remember <text>");
|
|
77
|
-
const memory = createMemory({ kind: "fact", content, project_key: ctx.sessionManager.getCwd(), source_turn_id: null, importance: 1 });
|
|
78
|
-
ctx.ui.notify(`[session-memory] remembered ${memory.memory_id}`, "info");
|
|
79
|
-
},
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
pi.registerCommand("memory-pin", {
|
|
83
|
-
description: "Promote a historical turn into a durable fact memory",
|
|
84
|
-
/** Preserve a selected transcript turn as an independent durable memory with provenance. */
|
|
85
|
-
handler: async (args, ctx) => {
|
|
86
|
-
const turnId = args.trim();
|
|
87
|
-
if (!turnId) throw new Error("Usage: /memory-pin <turn-id>");
|
|
88
|
-
const memory = pinTurnAsMemory(turnId);
|
|
89
|
-
ctx.ui.notify(`[session-memory] pinned ${turnId} as ${memory.memory_id}`, "info");
|
|
90
|
-
},
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
pi.registerCommand("memory-list", {
|
|
94
|
-
description: "List durable memories, optionally filtered by kind",
|
|
95
|
-
/** Present durable memories and their provenance for direct management. */
|
|
96
|
-
handler: async (args, ctx) => {
|
|
97
|
-
const kind = args.trim() as MemoryKind | "";
|
|
98
|
-
if (kind && !["preference", "decision", "fact", "project_state", "task", "lesson"].includes(kind)) {
|
|
99
|
-
throw new Error("Usage: /memory-list [preference|decision|fact|project_state|task|lesson]");
|
|
100
|
-
}
|
|
101
|
-
const memories = listMemories(kind || undefined);
|
|
102
|
-
const text = memories.length
|
|
103
|
-
? memories.map((memory) => `[${memory.kind}] ${memory.memory_id}: ${memory.content}${memory.source_turn_id ? ` (source: ${memory.source_turn_id})` : ""}`).join("\n")
|
|
104
|
-
: "No durable memories found.";
|
|
105
|
-
ctx.ui.notify(text, "info");
|
|
106
|
-
},
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
pi.registerCommand("memory-confirm", {
|
|
110
|
-
description: "Confirm an active durable memory remains current",
|
|
111
|
-
/** Explicitly acknowledge current evidence without rewriting the memory or its provenance. */
|
|
112
|
-
handler: async (args, ctx) => {
|
|
113
|
-
const memoryId = args.trim();
|
|
114
|
-
if (!memoryId) throw new Error("Usage: /memory-confirm <memory-id>");
|
|
115
|
-
const memory = confirmMemory(memoryId);
|
|
116
|
-
ctx.ui.notify(`[session-memory] confirmed ${memory.memory_id}`, "info");
|
|
117
|
-
},
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
pi.registerCommand("memory-supersede", {
|
|
121
|
-
description: "Replace one durable memory with another while retaining history",
|
|
122
|
-
/** Make supersession explicit so obsolete decisions remain inspectable instead of being silently overwritten. */
|
|
123
|
-
handler: async (args, ctx) => {
|
|
124
|
-
const [oldMemoryId, newMemoryId, ...extra] = args.trim().split(/\s+/);
|
|
125
|
-
if (!oldMemoryId || !newMemoryId || extra.length) throw new Error("Usage: /memory-supersede <old-memory-id> <new-memory-id>");
|
|
126
|
-
supersedeMemory(oldMemoryId, newMemoryId);
|
|
127
|
-
ctx.ui.notify(`[session-memory] superseded ${oldMemoryId} with ${newMemoryId}`, "info");
|
|
128
|
-
},
|
|
129
|
-
});
|
|
130
|
-
|
|
131
|
-
pi.registerCommand("memory-history", {
|
|
132
|
-
description: "Show the replacement chain containing a durable memory",
|
|
133
|
-
/** Render the full predecessor-to-successor chain for an active or superseded memory. */
|
|
134
|
-
handler: async (args, ctx) => {
|
|
135
|
-
const memoryId = args.trim();
|
|
136
|
-
if (!memoryId) throw new Error("Usage: /memory-history <memory-id>");
|
|
137
|
-
const history = getMemoryHistory(memoryId);
|
|
138
|
-
ctx.ui.notify(history.map((memory) => `[${memory.superseded_by ? "superseded" : "active"}] ${memory.memory_id}: ${memory.content}`).join("\n"), "info");
|
|
139
|
-
},
|
|
140
|
-
});
|
|
141
|
-
|
|
142
|
-
pi.registerCommand("memory-forget", {
|
|
143
|
-
description: "Permanently delete a durable memory by memory ID",
|
|
144
|
-
/** Delete only the selected durable memory; its provenance turn is retained. */
|
|
145
|
-
handler: async (args, ctx) => {
|
|
146
|
-
const memoryId = args.trim();
|
|
147
|
-
if (!memoryId) throw new Error("Usage: /memory-forget <memory-id>");
|
|
148
|
-
if (!deleteMemory(memoryId)) throw new Error(`Durable memory not found: ${memoryId}`);
|
|
149
|
-
ctx.ui.notify(`[session-memory] forgot ${memoryId}`, "info");
|
|
41
|
+
const results = recallTurns({ entities: [query] });
|
|
42
|
+
ctx.ui.notify(formatRecallResults(results, { entities: [query] }), "info");
|
|
150
43
|
},
|
|
151
44
|
});
|
|
152
|
-
|
|
153
|
-
pi.registerCommand("memory-delete-turn", {
|
|
154
|
-
description: "Permanently delete a raw transcript turn by turn ID",
|
|
155
|
-
/** Keep raw transcript deletion explicit and separate from durable-memory deletion. */
|
|
156
|
-
handler: async (args, ctx) => {
|
|
157
|
-
const turnId = args.trim();
|
|
158
|
-
if (!turnId) throw new Error("Usage: /memory-delete-turn <turn-id>");
|
|
159
|
-
if (!deleteTurn(turnId)) throw new Error(`Memory turn not found: ${turnId}`);
|
|
160
|
-
ctx.ui.notify(`[session-memory] deleted transcript turn ${turnId}`, "info");
|
|
161
|
-
},
|
|
162
|
-
});
|
|
163
|
-
|
|
164
45
|
pi.registerCommand("memory-backfill", {
|
|
165
|
-
description: "Import historical Pi, Claude Code, and Codex sessions into
|
|
166
|
-
|
|
167
|
-
handler: async (_args, ctx) => {
|
|
168
|
-
_notifyBackfill(ctx, backfillAll(), "imported");
|
|
169
|
-
},
|
|
46
|
+
description: "Import historical Pi, Claude Code, and Codex transcript sessions into the local index",
|
|
47
|
+
handler: async (_args, ctx) => { _notifyBackfill(ctx, backfillAll(), "imported"); },
|
|
170
48
|
});
|
|
171
|
-
|
|
172
49
|
pi.registerCommand("project-session-migration", {
|
|
173
50
|
description: "Convert current-project Codex sessions into separate native Pi sessions for /resume",
|
|
174
|
-
|
|
175
|
-
handler: async (_args, ctx) => {
|
|
176
|
-
_notifySessionMigration(ctx, "Codex", migrateCodexProjectSessions(ctx.sessionManager.getCwd()));
|
|
177
|
-
},
|
|
51
|
+
handler: async (_args, ctx) => { _notifySessionMigration(ctx, "Codex", migrateCodexProjectSessions(ctx.sessionManager.getCwd())); },
|
|
178
52
|
});
|
|
179
|
-
|
|
180
53
|
pi.registerCommand("project-claude-session-migration", {
|
|
181
54
|
description: "Convert current-project Claude Code sessions into separate native Pi sessions for /resume",
|
|
182
|
-
|
|
183
|
-
handler: async (_args, ctx) => {
|
|
184
|
-
_notifySessionMigration(ctx, "Claude Code", migrateClaudeProjectSessions(ctx.sessionManager.getCwd()));
|
|
185
|
-
},
|
|
55
|
+
handler: async (_args, ctx) => { _notifySessionMigration(ctx, "Claude Code", migrateClaudeProjectSessions(ctx.sessionManager.getCwd())); },
|
|
186
56
|
});
|
|
187
57
|
|
|
188
58
|
pi.registerTool({
|
|
189
|
-
name: "migrate_codex_project_sessions",
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
parameters: Type.Object({}),
|
|
194
|
-
/** Give the agent the same native Codex-to-Pi migration available through /project-session-migration. */
|
|
195
|
-
async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
|
|
196
|
-
const stats = migrateCodexProjectSessions(ctx.sessionManager.getCwd());
|
|
197
|
-
return {
|
|
198
|
-
content: [{ type: "text" as const, text: _sessionMigrationSummary("Codex", stats) }],
|
|
199
|
-
details: stats,
|
|
200
|
-
};
|
|
201
|
-
},
|
|
59
|
+
name: "migrate_codex_project_sessions", label: "Migrate Codex Project Sessions",
|
|
60
|
+
description: "Convert each Codex session for the current project into a separate native Pi session selectable with /resume. Use only when the user explicitly wants native Pi continuation of prior Codex work.",
|
|
61
|
+
promptSnippet: "Use only when the user explicitly requests native Pi continuation of this project's prior Codex sessions.", parameters: Type.Object({}),
|
|
62
|
+
async execute(_id, _params, _signal, _update, ctx) { const stats = migrateCodexProjectSessions(ctx.sessionManager.getCwd()); return { content: [{ type: "text" as const, text: _sessionMigrationSummary("Codex", stats) }], details: stats }; },
|
|
202
63
|
});
|
|
203
|
-
|
|
204
64
|
pi.registerTool({
|
|
205
|
-
name: "migrate_claude_project_sessions",
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
parameters: Type.Object({}),
|
|
210
|
-
/** Give the agent an explicit Claude Code-to-Pi native-session migration capability. */
|
|
211
|
-
async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
|
|
212
|
-
const stats = migrateClaudeProjectSessions(ctx.sessionManager.getCwd());
|
|
213
|
-
return {
|
|
214
|
-
content: [{ type: "text" as const, text: _sessionMigrationSummary("Claude Code", stats) }],
|
|
215
|
-
details: stats,
|
|
216
|
-
};
|
|
217
|
-
},
|
|
65
|
+
name: "migrate_claude_project_sessions", label: "Migrate Claude Code Project Sessions",
|
|
66
|
+
description: "Convert each Claude Code session for the current project into a separate native Pi session selectable with /resume. Use only when the user explicitly wants native Pi continuation of prior Claude Code work.",
|
|
67
|
+
promptSnippet: "Use only when the user explicitly requests native Pi continuation of this project's prior Claude Code sessions.", parameters: Type.Object({}),
|
|
68
|
+
async execute(_id, _params, _signal, _update, ctx) { const stats = migrateClaudeProjectSessions(ctx.sessionManager.getCwd()); return { content: [{ type: "text" as const, text: _sessionMigrationSummary("Claude Code", stats) }], details: stats }; },
|
|
218
69
|
});
|
|
219
|
-
|
|
220
|
-
// ── Read: recall_memory tool ──────────────────────────────────────────────
|
|
221
70
|
pi.registerTool({
|
|
222
|
-
name: "recall_memory",
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
Invocation policy:
|
|
227
|
-
1. Call this tool immediately when the user explicitly asks to review, remember, summarize, continue, or compare a previous discussion about a topic. Examples:
|
|
228
|
-
- "我之前关于 xxx 的实践" / "I previously worked on xxx"
|
|
229
|
-
- "上次我们讨论过..." / "last time we discussed..."
|
|
230
|
-
- "你还记得那个 xxx 项目吗" / "remember that xxx project?"
|
|
231
|
-
- "之前那个方案怎么说的" / "what was that plan we had?"
|
|
232
|
-
2. When you cannot confidently answer from the current conversation and your general knowledge, but the user may have discussed the topic in prior sessions, first ask whether they want you to search their conversation history. Call this tool only after they agree.
|
|
233
|
-
3. Do not search history merely because a question is difficult when the user's prior discussions are not relevant.
|
|
234
|
-
4. Use 2–8 specific, high-signal literal entities, not the user's entire request or generic conversational words such as "问题", "开发", "有哪些", or "help". Include useful aliases, abbreviations, or Chinese/English equivalents where relevant, for example ["bug", "缺陷", "错误", "fix", "修复"].
|
|
235
|
-
5. If a recall returns no results, use your judgment to make up to two additional recall calls before concluding the history has no answer. Each retry must use distinct entities chosen from semantic alternatives: abbreviations or expansions, aliases, translations, product or project names, and likely wording of the underlying task or decision. For example, after no result for "SAP BTP", try alternatives such as "BTP", "Business Technology Platform", and the specific platform/topic implied by the user's question.
|
|
236
|
-
6. Preserve every source, project, and time filter from the original request on retries. Do not repeat equivalent entities, search indefinitely, claim a result that was not returned, or say history was searched exhaustively after fewer than three total attempts.
|
|
237
|
-
|
|
238
|
-
Memory-aware response policy:
|
|
239
|
-
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.
|
|
240
|
-
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.
|
|
241
|
-
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.
|
|
242
|
-
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.
|
|
243
|
-
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.
|
|
244
|
-
|
|
245
|
-
Session-expansion policy:
|
|
246
|
-
1. \`recall_memory\` is a discovery tool. Raw-turn results are intentionally short excerpts with session ID and turn index.
|
|
247
|
-
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.
|
|
248
|
-
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.
|
|
249
|
-
|
|
250
|
-
Pagination policy:
|
|
251
|
-
1. Each invocation returns five results. Local retrieval still evaluates every match before selecting that page.
|
|
252
|
-
2. When the result reports a \`nextOffset\`, call \`recall_memory\` again with the exact same entities and scope filters plus that offset only when more candidates are needed. Do not request pages merely to exhaust the result set.
|
|
253
|
-
|
|
254
|
-
Extract 2–8 specific, high-signal entities from the user's topic: project names, tool names, technologies, domain terms, identifiers, and useful Chinese/English equivalents, aliases, or abbreviations.`,
|
|
255
|
-
promptSnippet: "Search cross-client Pi, Claude Code, and Codex history when the user asks about prior discussions or work.",
|
|
256
|
-
|
|
71
|
+
name: "recall_memory", label: "Search Cross-Session History",
|
|
72
|
+
description: "Search past Pi, Claude Code, and Codex transcript history using 2-8 specific literal entities. Call when the user explicitly asks about a prior discussion, or after the user agrees to search history. Returns every on-demand raw transcript match; results are never automatic prompt context.",
|
|
73
|
+
promptSnippet: "Use 2-8 high-signal literal entities for a request about prior work. Search is on demand; fetch a smallest useful session range only when excerpts need context.",
|
|
257
74
|
parameters: Type.Object({
|
|
258
|
-
entities: Type.Array(
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
maxItems: 8,
|
|
264
|
-
},
|
|
265
|
-
),
|
|
266
|
-
sources: Type.Optional(Type.Array(Type.Union([
|
|
267
|
-
Type.Literal("pi"), Type.Literal("claude"), Type.Literal("codex"),
|
|
268
|
-
]))),
|
|
269
|
-
cwd: Type.Optional(Type.String({ minLength: 1, description: "Exact project working directory to restrict results." })),
|
|
270
|
-
after: Type.Optional(Type.Number({ description: "Inclusive Unix timestamp in milliseconds." })),
|
|
271
|
-
before: Type.Optional(Type.Number({ description: "Inclusive Unix timestamp in milliseconds." })),
|
|
272
|
-
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." })),
|
|
75
|
+
entities: Type.Array(Type.String({ minLength: 1 }), { minItems: 2, maxItems: 8 }),
|
|
76
|
+
sources: Type.Optional(Type.Array(Type.Union([Type.Literal("pi"), Type.Literal("claude"), Type.Literal("codex")]))),
|
|
77
|
+
cwd: Type.Optional(Type.String({ minLength: 1 })),
|
|
78
|
+
after: Type.Optional(Type.Number()),
|
|
79
|
+
before: Type.Optional(Type.Number()),
|
|
273
80
|
}),
|
|
274
|
-
|
|
275
|
-
/** Resolve an agent memory request into one explicit page of a fully evaluated local result set. */
|
|
276
|
-
async execute(_toolCallId, { entities, sources, cwd, after, before, offset }) {
|
|
277
|
-
const results = recallMemories({ entities, sources, cwd, after, before });
|
|
278
|
-
const page = paginateRecallResults(results, offset);
|
|
279
|
-
const text = formatRecallResults(page.results, { entities, sources, cwd, after, before }, page);
|
|
280
|
-
return {
|
|
281
|
-
content: [{ type: "text" as const, text }],
|
|
282
|
-
details: { entities, sources, cwd, after, before, offset: page.offset, pageSize: page.results.length, totalResults: page.totalResults, nextOffset: page.nextOffset },
|
|
283
|
-
};
|
|
284
|
-
},
|
|
81
|
+
async execute(_id, options) { const results = recallTurns(options); return { content: [{ type: "text" as const, text: formatRecallResults(results, options) }], details: { totalResults: results.length, results } }; },
|
|
285
82
|
});
|
|
286
|
-
|
|
287
83
|
pi.registerTool({
|
|
288
|
-
name: "fetch_session",
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
/** Expand a specifically selected persisted session without coupling search ranking to context payload size. */
|
|
298
|
-
async execute(_toolCallId, { session_id, from_turn_index, to_turn_index }) {
|
|
299
|
-
const stored = getSession(session_id, from_turn_index, to_turn_index);
|
|
300
|
-
const header = `## Session ${stored.session.session_id}\n**Source:** ${stored.session.source} · **Project:** \`${stored.session.cwd}\`\n**Turns:** ${stored.turns.length}`;
|
|
301
|
-
const turns = stored.turns.length
|
|
302
|
-
? stored.turns.map((turn) => [
|
|
303
|
-
`### Turn ${turn.turn_index} · ${new Date(turn.ts).toLocaleString()}`,
|
|
304
|
-
`**You:** ${turn.user_text}`,
|
|
305
|
-
turn.reply_text ? `**Assistant:** ${turn.reply_text}` : "",
|
|
306
|
-
].filter(Boolean).join("\n")).join("\n\n")
|
|
307
|
-
: "No persisted turns in the requested range.";
|
|
308
|
-
return {
|
|
309
|
-
content: [{ type: "text" as const, text: `${header}\n\n${turns}` }],
|
|
310
|
-
details: { session_id, from_turn_index, to_turn_index, turnCount: stored.turns.length },
|
|
311
|
-
};
|
|
84
|
+
name: "fetch_session", label: "Fetch Session",
|
|
85
|
+
description: "Fetch ordered persisted transcript turns from a session returned by recall_memory. Use only when a recall excerpt lacks necessary context, and request the smallest useful inclusive range. This is read-only and never creates a memory.",
|
|
86
|
+
promptSnippet: "Expand a recall result only when its excerpt is insufficient, using the smallest useful range. This reads source evidence only.",
|
|
87
|
+
parameters: Type.Object({ session_id: Type.String({ minLength: 1, description: "Exact session ID returned by recall_memory." }), from_turn_index: Type.Optional(Type.Number({ minimum: 0 })), to_turn_index: Type.Optional(Type.Number({ minimum: 0 })) }),
|
|
88
|
+
async execute(_id, { session_id, from_turn_index, to_turn_index }) {
|
|
89
|
+
const { stored } = fetchSession(session_id, from_turn_index, to_turn_index);
|
|
90
|
+
const header = `## Source transcript session ${stored.session.session_id}\n**Source:** ${stored.session.source} · **Project:** \`${stored.session.cwd}\`\n**Turns fetched:** ${stored.turns.length}\n**Storage action:** none. This is read-only source evidence.`;
|
|
91
|
+
const turns = stored.turns.length ? stored.turns.map((turn) => [`### Turn ${turn.turn_index} · ${new Date(turn.ts).toLocaleString()}`, `**Source turn ID:** \`${turn.turn_id}\``, `**You:** ${turn.user_text}`, turn.reply_text ? `**Assistant:** ${turn.reply_text}` : ""].filter(Boolean).join("\n")).join("\n\n") : "No persisted turns in the requested range.";
|
|
92
|
+
return { content: [{ type: "text" as const, text: `${header}\n\n${turns}` }], details: { session_id, from_turn_index, to_turn_index, turnCount: stored.turns.length, fetchedTurnIds: stored.turns.map((turn) => turn.turn_id) } };
|
|
312
93
|
},
|
|
313
94
|
});
|
|
95
|
+
pi.registerTool({
|
|
96
|
+
name: "get_memory_stats", label: "Get History Storage Statistics",
|
|
97
|
+
description: "Report locally indexed cross-session transcript storage totals. Use when the user asks how much Pi, Claude Code, or Codex history is stored.",
|
|
98
|
+
promptSnippet: "Use when the user asks for locally indexed history totals.", parameters: Type.Object({}),
|
|
99
|
+
async execute() { const stats = getHistoryStats(); return { content: [{ type: "text" as const, text: _historyStatsSummary(stats) }], details: stats }; },
|
|
100
|
+
});
|
|
101
|
+
pi.registerTool({
|
|
102
|
+
name: "backfill_memory", label: "Import Historical Sessions",
|
|
103
|
+
description: "Import all historical Pi, Claude Code, and Codex transcript sessions into the local index. Use only when the user explicitly asks to import, backfill, or rescan history.",
|
|
104
|
+
promptSnippet: "Use only for an explicit request to import, backfill, or rescan historical conversation data.", parameters: Type.Object({}),
|
|
105
|
+
async execute() { const stats = backfillAll(); return { content: [{ type: "text" as const, text: _backfillSummary(stats, "imported") }], details: stats }; },
|
|
106
|
+
});
|
|
314
107
|
}
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
function
|
|
318
|
-
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
/** Notify command users of one backfill summary and every isolated import issue. */
|
|
322
|
-
function _notifyBackfill(ctx: ExtensionContext, stats: BackfillStats, action: string): void {
|
|
323
|
-
ctx.ui.notify(_backfillSummary(stats, action), "info");
|
|
324
|
-
for (const issue of stats.issues) ctx.ui.notify(`[session-memory] ${issue.error}`, "error");
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
/** Report native source-to-Pi session migration results and isolated conversion failures. */
|
|
328
|
-
function _notifySessionMigration(ctx: ExtensionContext, sourceLabel: string, stats: ProjectSessionMigrationStats): void {
|
|
329
|
-
ctx.ui.notify(_sessionMigrationSummary(sourceLabel, stats), "info");
|
|
330
|
-
for (const issue of stats.issues) ctx.ui.notify(`[session-memory] ${sourceLabel} migration failed (${issue.path}): ${issue.error}`, "error");
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
/** Format a concise native-session migration result for commands and tools. */
|
|
334
|
-
function _sessionMigrationSummary(sourceLabel: string, stats: ProjectSessionMigrationStats): string {
|
|
335
|
-
return `[session-memory] migrated ${stats.migratedSessions} ${sourceLabel} sessions (${stats.migratedMessages} messages); skipped ${stats.skippedSessions} already migrated sessions from ${stats.scannedFiles} scanned files. Use /resume to select a migrated Pi session.`;
|
|
336
|
-
}
|
|
108
|
+
function _historyStatsSummary(stats: ReturnType<typeof getHistoryStats>): string { const sources = stats.sources.length ? stats.sources.map((source) => `${source.source}: ${source.turns} turns / ${source.sessions} sessions`).join(", ") : "no imported sources"; const newest = stats.newestTs ? new Date(stats.newestTs).toLocaleString() : "n/a"; return `[session-memory] ${stats.turns} turns across ${stats.sessions} sessions; ${sources}; newest: ${newest}`; }
|
|
109
|
+
function _backfillSummary(stats: BackfillStats, action: string): string { return `[session-memory] ${action} ${stats.turns} turns from ${stats.scannedFiles} files: ${stats.pi} Pi, ${stats.claude} Claude, ${stats.codex} Codex sessions`; }
|
|
110
|
+
function _notifyBackfill(ctx: ExtensionContext, stats: BackfillStats, action: string): void { ctx.ui.notify(_backfillSummary(stats, action), "info"); for (const issue of stats.issues) ctx.ui.notify(`[session-memory] ${issue.error}`, "error"); }
|
|
111
|
+
function _sessionMigrationSummary(sourceLabel: string, stats: ProjectSessionMigrationStats): string { return `[session-memory] migrated ${stats.migratedSessions} ${sourceLabel} sessions (${stats.migratedMessages} messages); skipped ${stats.skippedSessions} already migrated sessions from ${stats.scannedFiles} scanned files. Use /resume to select a migrated Pi session.`; }
|
|
112
|
+
function _notifySessionMigration(ctx: ExtensionContext, sourceLabel: string, stats: ProjectSessionMigrationStats): void { ctx.ui.notify(_sessionMigrationSummary(sourceLabel, stats), "info"); for (const issue of stats.issues) ctx.ui.notify(`[session-memory] ${sourceLabel} migration failed (${issue.path}): ${issue.error}`, "error"); }
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-session-memory",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.6.0",
|
|
4
|
+
"description": "Local-first cross-session transcript search and native Pi session migration for Pi, Claude Code, and Codex",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package"
|
|
7
7
|
],
|
|
@@ -20,7 +20,9 @@
|
|
|
20
20
|
"src/"
|
|
21
21
|
],
|
|
22
22
|
"scripts": {
|
|
23
|
-
"test": "tsx tests/core.test.ts"
|
|
23
|
+
"test": "tsx tests/core.test.ts",
|
|
24
|
+
"test:e2e": "tsx tests/e2e.test.ts",
|
|
25
|
+
"test:all": "npm test && npm run test:e2e"
|
|
24
26
|
},
|
|
25
27
|
"devDependencies": {
|
|
26
28
|
"tsx": "^4.23.13"
|
package/src/backfill.ts
CHANGED
|
@@ -342,12 +342,24 @@ function _sha256(jsonlPath: string): string {
|
|
|
342
342
|
return createHash("sha256").update(readFileSync(jsonlPath)).digest("hex");
|
|
343
343
|
}
|
|
344
344
|
|
|
345
|
-
/** Read every non-empty JSONL line into its ordered
|
|
346
|
-
function _readJsonl(jsonlPath: string):
|
|
345
|
+
/** Read every non-empty JSONL line into its ordered object record. */
|
|
346
|
+
function _readJsonl(jsonlPath: string): Record<string, unknown>[] {
|
|
347
347
|
return readFileSync(jsonlPath, "utf8")
|
|
348
348
|
.split("\n")
|
|
349
|
-
.
|
|
350
|
-
.
|
|
349
|
+
.map((line, index) => ({ line: line.trim(), lineNumber: index + 1 }))
|
|
350
|
+
.filter(({ line }) => line.length > 0)
|
|
351
|
+
.map(({ line, lineNumber }) => {
|
|
352
|
+
let entry: unknown;
|
|
353
|
+
try {
|
|
354
|
+
entry = JSON.parse(line);
|
|
355
|
+
} catch {
|
|
356
|
+
throw new Error(`Invalid JSONL at line ${lineNumber}`);
|
|
357
|
+
}
|
|
358
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
|
359
|
+
throw new Error(`Invalid JSONL record at line ${lineNumber}: expected an object`);
|
|
360
|
+
}
|
|
361
|
+
return entry as Record<string, unknown>;
|
|
362
|
+
});
|
|
351
363
|
}
|
|
352
364
|
|
|
353
365
|
/** Recursively discover JSONL session files under a source root. */
|