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/src/retriever.ts CHANGED
@@ -1,293 +1,29 @@
1
- import { createHash } from "node:crypto";
2
- import { getDb, type MemoryKind } from "./db.ts";
1
+ import { getDb } from "./db.ts";
3
2
 
4
- export type MemorySource = "pi" | "claude" | "codex";
3
+ export type HistorySource = "pi" | "claude" | "codex";
4
+ export interface RecallOptions { entities: string[]; sources?: HistorySource[]; cwd?: string; after?: number; before?: number; }
5
+ export interface RecallTurnResult { turn_id: string; session_id: string; turn_index: number; source: HistorySource; cwd: string; ts: number; user_text: string; reply_text: string; score: number; }
5
6
 
6
- export interface RecallOptions {
7
- /** High-signal literal alternatives; a result may match any entity. */
8
- entities: string[];
9
- sources?: MemorySource[];
10
- cwd?: string;
11
- after?: number;
12
- before?: number;
13
- }
14
-
15
- export interface RecallTurnResult {
16
- type: "turn";
17
- turn_id: string;
18
- session_id: string;
19
- turn_index: number;
20
- source: MemorySource;
21
- cwd: string;
22
- ts: number;
23
- user_text: string;
24
- reply_text: string;
25
- hits: number;
26
- score: number;
27
- }
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
-
39
- export interface RecallDurableMemoryResult {
40
- type: "memory";
41
- memory_id: string;
42
- kind: MemoryKind;
43
- content: string;
44
- project_key: string;
45
- source_turn_id: string | null;
46
- source_session_id: string | null;
47
- source_content_hash: string | null;
48
- source_turn_index: number | null;
49
- source_session_changed: boolean;
50
- freshness_candidate: boolean;
51
- freshness_evidence: FreshnessEvidence[];
52
- created_at: number;
53
- last_confirmed_at: number;
54
- importance: number;
55
- hits: number;
56
- score: number;
57
- }
58
-
59
- export type RecallResult = RecallDurableMemoryResult | RecallTurnResult;
60
-
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
-
70
- // Recency is a bounded tie-breaker, not a replacement for literal relevance.
71
- const RECENCY_WINDOW_MS = 90 * 24 * 60 * 60 * 1000;
72
-
73
- /** Retained for compatibility with the v0.1 public retrieval helper. */
74
- export function recallTurns(entities: string[]): RecallTurnResult[] {
75
- return _recallTurns({ entities });
76
- }
77
-
78
- /** Retrieve active durable memories, then raw turns not already represented by unchanged source evidence. */
79
- export function recallMemories(options: RecallOptions): RecallResult[] {
80
- const durableMemories = _recallDurableMemories(options);
81
- const recalledTurns = _recallTurns(options);
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
- });
91
- const coveredSourceHashes = new Map(
92
- memories
93
- .filter((memory) => memory.source_turn_id && memory.source_content_hash)
94
- .map((memory) => [memory.source_turn_id!, memory.source_content_hash!]),
95
- );
96
- const rawTurns = recalledTurns.filter((turn) => coveredSourceHashes.get(turn.turn_id) !== _turnContentHash(turn));
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 };
106
- }
107
-
108
- /** Render the exact entity inputs and recall results as concise Markdown for a command notification or tool response. */
109
- export function formatRecallResults(results: RecallResult[], options?: Pick<RecallOptions, "entities" | "sources" | "cwd" | "after" | "before">, page?: Omit<RecallPage, "results">): string {
110
- const lines = options ? [_formatRecallQuery(options), ""] : [];
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");
112
-
113
- if (page) lines.push(`**Results:** ${page.offset + 1}–${page.offset + results.length} of ${page.totalResults} (five results per page)\n`);
114
- lines.push("## Relevant past memories\n");
115
- for (const result of results) {
116
- if (result.type === "memory") {
117
- lines.push(`### [durable ${result.kind} · ${new Date(result.last_confirmed_at).toLocaleString()}]`);
118
- lines.push(result.content);
119
- lines.push(`**Memory ID:** ${result.memory_id}`);
120
- if (result.source_turn_id) lines.push(`**Source turn:** ${result.source_turn_id}`);
121
- if (result.source_session_id) lines.push(`**Source session:** ${result.source_session_id}`);
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
- }
130
- } else {
131
- const date = new Date(result.ts).toLocaleString();
132
- lines.push(`### [${result.source} · ${date}]`);
133
- lines.push(`**Session:** ${result.session_id} · **Turn:** ${result.turn_index}`);
134
- lines.push(`**Excerpt:** ${_excerpt(result.user_text || result.reply_text)}`);
135
- lines.push("Use `fetch_session` with this session ID when the surrounding conversation is needed.");
136
- }
137
- lines.push("");
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
- }
142
- return lines.join("\n");
143
- }
144
-
145
- /** Make each tool invocation auditable by showing its literal entities separately from its scope. */
146
- function _formatRecallQuery(options: Pick<RecallOptions, "entities" | "sources" | "cwd" | "after" | "before">): string {
147
- const scope = [
148
- options.sources?.length ? `sources: ${options.sources.join(", ")}` : null,
149
- options.cwd ? `cwd: \`${options.cwd}\`` : null,
150
- options.after !== undefined ? `after: ${new Date(options.after).toISOString()}` : null,
151
- options.before !== undefined ? `before: ${new Date(options.before).toISOString()}` : null,
152
- ].filter(Boolean);
153
- return `**Search entities:** ${options.entities.map((entity) => `\`${entity}\``).join(", ")}${scope.length ? ` \\n**Scope:** ${scope.join(" · ")}` : ""}`;
154
- }
155
-
156
- /** Keep discovery results small; full persisted turn text belongs to fetch_session. */
157
- function _excerpt(text: string, maxLength = 240): string {
158
- const normalized = text.replaceAll(/\s+/g, " ").trim();
159
- return normalized.length > maxLength ? `${normalized.slice(0, maxLength)}…` : normalized;
160
- }
161
-
162
- /** Search active durable memories with the same escaped literal matching used for transcript recall. */
163
- function _recallDurableMemories(options: RecallOptions): RecallDurableMemoryResult[] {
164
- const terms = _terms(options);
165
- if (terms.length === 0) return [];
166
- const scoreExpression = terms.map(() => "CASE WHEN LOWER(content) LIKE ? ESCAPE '\\' THEN 1 ELSE 0 END").join(" + ");
167
- const parameters = terms.map(_likePattern);
168
- const filters = [`(${terms.map(() => "LOWER(content) LIKE ? ESCAPE '\\'").join(" OR ")})`];
169
- const filterParameters: Array<string | number> = terms.map(_likePattern);
170
- filters.push("superseded_by IS NULL");
171
- if (options.cwd) {
172
- filters.push("project_key = ?");
173
- filterParameters.push(options.cwd);
174
- }
175
- if (options.after !== undefined) {
176
- filters.push("last_confirmed_at >= ?");
177
- filterParameters.push(options.after);
178
- }
179
- if (options.before !== undefined) {
180
- filters.push("last_confirmed_at <= ?");
181
- filterParameters.push(options.before);
182
- }
183
- return getDb().prepare(`
184
- SELECT memory_id, kind, content, project_key, source_turn_id, source_session_id, source_content_hash, source_turn_index, created_at, last_confirmed_at, importance,
185
- (${scoreExpression}) AS hits
186
- FROM memories
187
- WHERE ${filters.join(" AND ")}
188
- ORDER BY hits DESC, importance DESC, last_confirmed_at DESC
189
- `).all(...parameters, ...filterParameters)
190
- .map((memory) => ({ ...memory, type: "memory" as const, source_session_changed: false, freshness_candidate: false, freshness_evidence: [], score: memory.hits + memory.importance })) as RecallDurableMemoryResult[];
191
- }
192
-
193
- /** Detect later activity in the original session independently of this recall query and ranking. */
194
- function _sourceSessionChanged(memory: RecallDurableMemoryResult): boolean {
195
- if (!memory.source_session_id || memory.source_turn_index === null) return false;
196
- return getDb().prepare(`
197
- SELECT 1 FROM turns
198
- WHERE session_id = ? AND turn_index > ?
199
- LIMIT 1
200
- `).get(memory.source_session_id, memory.source_turn_index) !== undefined;
201
- }
202
-
203
- /** Associate each memory with all newer query-relevant turns without deciding their semantic relationship. */
204
- function _freshnessEvidence(memory: RecallDurableMemoryResult, recalledTurns: RecallTurnResult[]): FreshnessEvidence[] {
205
- const baselineTs = _memoryEvidenceTimestamp(memory);
206
- return recalledTurns
207
- .filter((turn) => {
208
- if (memory.source_session_id === turn.session_id && memory.source_turn_index !== null) {
209
- return turn.turn_index > memory.source_turn_index;
210
- }
211
- return turn.ts > baselineTs;
212
- })
213
- .map((turn) => ({
214
- turn_id: turn.turn_id,
215
- session_id: turn.session_id,
216
- turn_index: turn.turn_index,
217
- source: turn.source,
218
- ts: turn.ts,
219
- relation: memory.source_session_id === turn.session_id ? "same_source_session_later" as const : "newer_cross_session" as const,
220
- excerpt: _excerpt(turn.user_text || turn.reply_text),
221
- }));
222
- }
223
-
224
- /** Use source-turn time when available; explicit memories become comparable from their creation time. */
225
- function _memoryEvidenceTimestamp(memory: RecallDurableMemoryResult): number {
226
- if (!memory.source_turn_id) return memory.created_at;
227
- const source = getDb().prepare("SELECT ts FROM turns WHERE turn_id = ?").get(memory.source_turn_id) as { ts: number } | undefined;
228
- return source?.ts ?? memory.created_at;
229
- }
230
-
231
- /** Retrieve and rank locally stored turns using literal query terms and optional scopes. */
232
- function _recallTurns(options: RecallOptions): RecallTurnResult[] {
233
- const terms = _terms(options);
7
+ /** Search every matching raw local transcript turn using literal entity alternatives and strict optional scope filters. */
8
+ export function recallTurns(options: RecallOptions | string[]): RecallTurnResult[] {
9
+ const normalized = Array.isArray(options) ? { entities: options } : options;
10
+ const terms = normalized.entities.map((entity) => entity.toLowerCase());
234
11
  if (terms.length === 0) return [];
235
- const scoreExpression = terms.map(() => `(CASE WHEN LOWER(turns.user_text) LIKE ? ESCAPE '\\' THEN 2 ELSE 0 END + CASE WHEN LOWER(turns.reply_text) LIKE ? ESCAPE '\\' THEN 1 ELSE 0 END)`).join(" + ");
236
- const scoreParameters = terms.flatMap((term) => _likeParameters(term));
237
- const whereExpressions = terms.map(() => "(LOWER(turns.user_text) LIKE ? ESCAPE '\\' OR LOWER(turns.reply_text) LIKE ? ESCAPE '\\')");
238
- const whereParameters = terms.flatMap((term) => _likeParameters(term));
239
- const filters = [`(${whereExpressions.join(" OR ")})`];
240
- const filterParameters: Array<string | number> = [...whereParameters];
241
- if (options.sources?.length) {
242
- filters.push(`sessions.source IN (${options.sources.map(() => "?").join(", ")})`);
243
- filterParameters.push(...options.sources);
244
- }
245
- if (options.cwd) {
246
- filters.push("sessions.cwd = ?");
247
- filterParameters.push(options.cwd);
248
- }
249
- if (options.after !== undefined) {
250
- filters.push("turns.ts >= ?");
251
- filterParameters.push(options.after);
252
- }
253
- if (options.before !== undefined) {
254
- filters.push("turns.ts <= ?");
255
- filterParameters.push(options.before);
256
- }
257
- const candidates = getDb().prepare(`
258
- SELECT turns.turn_id, turns.session_id, turns.turn_index, sessions.source, sessions.cwd, turns.ts, turns.user_text, turns.reply_text,
259
- (${scoreExpression}) AS hits
260
- FROM turns JOIN sessions ON sessions.session_id = turns.session_id
261
- WHERE ${filters.join(" AND ")}
262
- ORDER BY hits DESC, turns.ts DESC
263
- `).all(...scoreParameters, ...filterParameters) as Array<Omit<RecallTurnResult, "type" | "score">>;
264
- const newestTs = candidates.reduce((newest, result) => Math.max(newest, result.ts), 0);
265
- 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);
266
- return results;
267
- }
268
-
269
- /** Hash the current raw turn evidence using the same representation captured during pinning. */
270
- function _turnContentHash(turn: RecallTurnResult): string {
271
- return createHash("sha256").update(JSON.stringify([turn.user_text, turn.reply_text])).digest("hex");
272
- }
273
-
274
- /** Build a de-duplicated set of non-empty literal search entities from the request. */
275
- function _terms(options: RecallOptions): string[] {
276
- return [...new Set(options.entities.map((entity) => entity.trim()).filter(Boolean))];
277
- }
278
-
279
- /** Produce matching user and assistant SQL LIKE parameters for one term. */
280
- function _likeParameters(term: string): [string, string] {
281
- const pattern = _likePattern(term);
282
- return [pattern, pattern];
283
- }
284
-
285
- /** Convert one literal search term into an escaped, case-normalized SQL LIKE pattern. */
286
- function _likePattern(term: string): string {
287
- return `%${term.toLowerCase().replaceAll("\\", "\\\\").replaceAll("%", "\\%").replaceAll("_", "\\_")}%`;
288
- }
289
-
290
- /** Return a bounded recency bonus relative to the newest candidate timestamp. */
291
- function _recencyScore(ts: number, newestTs: number): number {
292
- return Math.max(0, 0.5 * (1 - (newestTs - ts) / RECENCY_WINDOW_MS));
12
+ const scoreExpression = terms.map(() => "CASE WHEN instr(lower(turns.user_text || ' ' || turns.reply_text), ?) > 0 THEN 1 ELSE 0 END").join(" + ");
13
+ const filters = ["(" + terms.map(() => "instr(lower(turns.user_text || ' ' || turns.reply_text), ?) > 0").join(" OR ") + ")"];
14
+ const scopedParameters: Array<string | number> = [];
15
+ if (normalized.sources?.length) { filters.push(`sessions.source IN (${normalized.sources.map(() => "?").join(", ")})`); scopedParameters.push(...normalized.sources); }
16
+ if (normalized.cwd !== undefined) { filters.push("sessions.cwd = ?"); scopedParameters.push(normalized.cwd); }
17
+ if (normalized.after !== undefined) { filters.push("turns.ts >= ?"); scopedParameters.push(normalized.after); }
18
+ if (normalized.before !== undefined) { filters.push("turns.ts <= ?"); scopedParameters.push(normalized.before); }
19
+ return getDb().prepare(`SELECT turns.turn_id, turns.session_id, turns.turn_index, sessions.source, sessions.cwd, turns.ts, turns.user_text, turns.reply_text, (${scoreExpression}) AS score FROM turns JOIN sessions ON sessions.session_id = turns.session_id WHERE ${filters.join(" AND ")} ORDER BY score DESC, turns.ts DESC, turns.turn_id`).all(...terms, ...terms, ...scopedParameters) as RecallTurnResult[];
20
+ }
21
+
22
+ /** Render all raw transcript matches without implying that they are persistent summary memories. */
23
+ export function formatRecallResults(results: RecallTurnResult[], options: RecallOptions = { entities: [] }): string {
24
+ const header = ["# Recall results", `**Entities:** ${options.entities.map((entity) => `\`${entity}\``).join(", ")}`, `**Results:** ${results.length}`].join("\n");
25
+ const turns = results.length
26
+ ? results.map((turn) => `### ${turn.session_id} · turn ${turn.turn_index}\n**Source turn ID:** \`${turn.turn_id}\`\n**You:** ${turn.user_text}\n${turn.reply_text ? `**Assistant:** ${turn.reply_text}` : ""}`).join("\n\n")
27
+ : "No matching conversation history.";
28
+ return `${header}\n\n## Matching raw conversation history\n${turns}`;
293
29
  }
package/src/writer.ts CHANGED
@@ -2,7 +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
+ /** Persist the latest Pi user request and assistant output until the next user message as one turn. */
6
6
  export function writeTurn(ctx: ExtensionContext): void {
7
7
  const sessionManager = ctx.sessionManager;
8
8
  const sessionId = `pi:${sessionManager.getSessionId()}`;
@@ -19,7 +19,10 @@ export function writeTurn(ctx: ExtensionContext): void {
19
19
  const toolNames: string[] = [];
20
20
  const userIndex = branch.indexOf(userEntry);
21
21
  for (const entry of branch.slice(userIndex + 1)) {
22
- if (entry.type !== "message" || entry.message.role !== "assistant") continue;
22
+ if (entry.type !== "message") continue;
23
+ // A later user message begins a different turn and must never be aggregated.
24
+ if (entry.message.role === "user") break;
25
+ if (entry.message.role !== "assistant") continue;
23
26
  const message = entry.message as AssistantMessage;
24
27
  for (const block of message.content) {
25
28
  if (block.type === "text" && block.text.trim()) {