pi-recollect 1.0.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/index.ts ADDED
@@ -0,0 +1,10 @@
1
+ /**
2
+ * pi-recall extension entry point.
3
+ * Delegates all registration to src/extension/register.ts.
4
+ */
5
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
6
+ import { registerPiMemory } from "./src/extension/register.ts";
7
+
8
+ export default function (pi: ExtensionAPI): void {
9
+ registerPiMemory(pi);
10
+ }
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "pi-recollect",
3
+ "version": "1.0.0",
4
+ "description": "Persistent memory extension for Pi \u2014 semantic search, knowledge compounding, session continuity",
5
+ "author": "baphuongna",
6
+ "license": "MIT",
7
+ "type": "module",
8
+ "keywords": [
9
+ "pi-package",
10
+ "pi",
11
+ "pi-coding-agent",
12
+ "memory",
13
+ "semantic-search",
14
+ "knowledge"
15
+ ],
16
+ "files": [
17
+ "*.ts",
18
+ "src/**/*.ts",
19
+ "skills/**/*",
20
+ "README.md",
21
+ "tsconfig.json",
22
+ "LICENSE"
23
+ ],
24
+ "scripts": {
25
+ "check": "npm run typecheck && npm test",
26
+ "typecheck": "tsc --noEmit",
27
+ "test": "node --experimental-strip-types --test --test-concurrency=1 --test-timeout=30000 test/unit/*.test.ts"
28
+ },
29
+ "pi": {
30
+ "extensions": [
31
+ "./index.ts"
32
+ ],
33
+ "skills": [
34
+ "./skills"
35
+ ]
36
+ },
37
+ "peerDependencies": {
38
+ "@mariozechner/pi-coding-agent": "*"
39
+ },
40
+ "dependencies": {
41
+ "better-sqlite3": "^11.7.0",
42
+ "typebox": "^1.1.24"
43
+ },
44
+ "devDependencies": {
45
+ "@types/better-sqlite3": "^7.6.12",
46
+ "@mariozechner/pi-coding-agent": "^0.65.0",
47
+ "typescript": "^5.9.3"
48
+ },
49
+ "peerDependenciesMeta": {
50
+ "@mariozechner/pi-coding-agent": {
51
+ "optional": true
52
+ }
53
+ }
54
+ }
@@ -0,0 +1,32 @@
1
+ # compound-note
2
+
3
+ Knowledge compounding: automatically document solutions, patterns, and decisions.
4
+
5
+ ## When to use
6
+
7
+ - At session shutdown, pi-recall automatically analyzes the session
8
+ - Manual compound notes can be triggered via memory_store with appropriate categories
9
+ - When you want to explicitly capture a solution for future reference
10
+
11
+ ## How it works
12
+
13
+ 1. Session events (errors, edits, decisions) are tracked automatically
14
+ 2. At session shutdown, events are analyzed for patterns
15
+ 3. Findings are routed as bug/knowledge/decision solutions
16
+ 4. Duplicate solutions are merged; new ones are written to .pi-recall/solutions/
17
+ 5. PI_MEMORY.md is updated with the latest summary
18
+
19
+ ## YAML solution format
20
+
21
+ Solutions are stored in `.pi-recall/solutions/` with YAML frontmatter:
22
+
23
+ ```yaml
24
+ type: bug|knowledge|decision
25
+ title: "..."
26
+ created: YYYY-MM-DD
27
+ ---
28
+ problem: |
29
+ ...
30
+ fix: |
31
+ ...
32
+ ```
@@ -0,0 +1,24 @@
1
+ # memory_search
2
+
3
+ Search persistent memory for relevant knowledge, solutions, decisions, and patterns.
4
+
5
+ ## When to use
6
+
7
+ - When you need to recall past solutions to similar problems
8
+ - When looking for project conventions or gotchas
9
+ - When investigating decisions made in previous sessions
10
+
11
+ ## Parameters
12
+
13
+ - `query` (required): Natural language search query
14
+ - `maxResults`: Maximum results to return (default: 5)
15
+ - `scope`: Filter by "all", "solutions", "decisions", "gotchas", "conventions"
16
+ - `detail`: "compact" (title+score), "medium" (preview), "full" (complete content)
17
+
18
+ ## Examples
19
+
20
+ ```
21
+ memory_search(query="auth token refresh", detail="medium")
22
+ memory_search(query="how to handle errors", scope="gotchas")
23
+ memory_search(query="database choice", scope="decisions")
24
+ ```
@@ -0,0 +1,26 @@
1
+ # memory_store
2
+
3
+ Store knowledge in persistent memory for future sessions.
4
+
5
+ ## When to use
6
+
7
+ - After discovering an important gotcha or workaround
8
+ - When documenting a project convention
9
+ - After making an architectural decision
10
+ - When capturing a reusable pattern
11
+
12
+ ## Parameters
13
+
14
+ - `category` (required): "gotcha" | "convention" | "decision" | "pattern" | "architecture"
15
+ - `title` (required): Descriptive title
16
+ - `content` (required): Detailed content
17
+ - `metadata.files`: Related file paths
18
+ - `metadata.tags`: Searchable tags
19
+ - `metadata.severity`: "low" | "medium" | "high" | "critical"
20
+
21
+ ## Examples
22
+
23
+ ```
24
+ memory_store(category="gotcha", title="Token refresh cache bug", content="TokenManager.refresh() must clear cache before setTokens", metadata={severity: "high", tags: ["auth", "cache"]})
25
+ memory_store(category="convention", title="Error handling pattern", content="Use Result<T,E> for all service methods", metadata={tags: ["error-handling", "conventions"]})
26
+ ```
@@ -0,0 +1,170 @@
1
+ import * as crypto from "node:crypto";
2
+ import type Database from "better-sqlite3";
3
+ import { getSessionEvents } from "../store/events.ts";
4
+ import { routeSessionFindings, type RoutedFinding } from "./router.ts";
5
+ import { extractBugSolution, extractDecision, extractKnowledge, type Solution } from "./extractor.ts";
6
+ import { findPotentialDuplicates, assessOverlap, shouldDedup, type SolutionForDedup } from "./dedup.ts";
7
+ import { writeSolution } from "./writer.ts";
8
+ import { indexContent } from "../store/fts5-index.ts";
9
+
10
+ export interface AnalysisResult {
11
+ solutionsFound: number;
12
+ solutionsWritten: number;
13
+ duplicatesMerged: number;
14
+ }
15
+
16
+ /**
17
+ * Analyze a session at shutdown time:
18
+ * 1. Extract findings from session events
19
+ * 2. Route findings to bug/knowledge/decision types
20
+ * 3. Dedup against existing solutions
21
+ * 4. Write new solutions
22
+ */
23
+ export function analyzeSession(
24
+ db: Database.Database,
25
+ cwd: string,
26
+ sessionId: string,
27
+ dedupThreshold: number = 0.7,
28
+ ): AnalysisResult {
29
+ const events = getSessionEvents(db, sessionId);
30
+ if (events.length === 0) return { solutionsFound: 0, solutionsWritten: 0, duplicatesMerged: 0 };
31
+
32
+ const findings = routeSessionFindings(events);
33
+ let solutionsFound = 0;
34
+ let solutionsWritten = 0;
35
+ let duplicatesMerged = 0;
36
+
37
+ for (const finding of findings) {
38
+ const solution = extractSolution(finding);
39
+ if (!solution) continue;
40
+ solutionsFound++;
41
+
42
+ const title = buildTitle(finding, finding.events);
43
+ const solForDedup: SolutionForDedup = {
44
+ title,
45
+ content: solutionContent(solution),
46
+ files: solution.files,
47
+ tags: solution.tags,
48
+ };
49
+
50
+ // Check for duplicates
51
+ const dupes = findPotentialDuplicates(db, title);
52
+ let isDuplicate = false;
53
+ for (const dupe of dupes) {
54
+ const dupeForCompare: SolutionForDedup = {
55
+ title: dupe.title,
56
+ content: dupe.content,
57
+ files: dupe.files ? JSON.parse(dupe.files) as string[] : [],
58
+ tags: dupe.tags ? JSON.parse(dupe.tags) as string[] : [],
59
+ };
60
+ const overlap = assessOverlap(solForDedup, dupeForCompare);
61
+ if (shouldDedup(overlap, dedupThreshold)) {
62
+ isDuplicate = true;
63
+ duplicatesMerged++;
64
+ // Increment access count on the existing solution
65
+ db.prepare(
66
+ `UPDATE solutions SET access_count = access_count + 1, last_accessed_at = ? WHERE title = ?`,
67
+ ).run(new Date().toISOString(), dupe.title);
68
+ break;
69
+ }
70
+ }
71
+
72
+ if (!isDuplicate) {
73
+ writeSolutionToDb(db, title, solution);
74
+ writeSolution(cwd, solution, title);
75
+ solutionsWritten++;
76
+ }
77
+ }
78
+
79
+ return { solutionsFound, solutionsWritten, duplicatesMerged };
80
+ }
81
+
82
+ function extractSolution(finding: RoutedFinding): Solution | null {
83
+ const events = finding.events;
84
+ switch (finding.type) {
85
+ case "bug": {
86
+ const error = events.find((e) => e.type === "error");
87
+ const fix = events.find((e) => e.type === "command_success") ?? null;
88
+ if (!error) return null;
89
+ return extractBugSolution(error, fix);
90
+ }
91
+ case "decision": {
92
+ const decision = events.find((e) => e.type === "user_decision");
93
+ if (!decision) return null;
94
+ return extractDecision(decision);
95
+ }
96
+ case "knowledge":
97
+ return extractKnowledge(events);
98
+ default:
99
+ return null;
100
+ }
101
+ }
102
+
103
+ function buildTitle(finding: RoutedFinding, events: typeof finding.events): string {
104
+ switch (finding.type) {
105
+ case "bug": {
106
+ const error = events.find((e) => e.type === "error");
107
+ if (!error) return "Unknown bug";
108
+ try {
109
+ const data = JSON.parse(error.data) as Record<string, unknown>;
110
+ const msg = typeof data.errorMessage === "string" ? data.errorMessage : "error";
111
+ return msg.slice(0, 80);
112
+ } catch {
113
+ return "Unknown bug";
114
+ }
115
+ }
116
+ case "decision": {
117
+ const d = events.find((e) => e.type === "user_decision");
118
+ if (!d) return "Decision";
119
+ try {
120
+ const data = JSON.parse(d.data) as Record<string, unknown>;
121
+ return typeof data.message_summary === "string" ? data.message_summary.slice(0, 80) : "Decision";
122
+ } catch {
123
+ return "Decision";
124
+ }
125
+ }
126
+ case "knowledge": {
127
+ const files = events
128
+ .map((e) => { try { return (JSON.parse(e.data) as Record<string, unknown>).file as string; } catch { return ""; } })
129
+ .filter((f) => f.length > 0);
130
+ return files.length > 0 ? `Pattern: ${files.join(", ")}` : "Knowledge pattern";
131
+ }
132
+ default:
133
+ return "Unknown finding";
134
+ }
135
+ }
136
+
137
+ function solutionContent(solution: Solution): string {
138
+ switch (solution.type) {
139
+ case "bug": return `${solution.problem}\n${solution.rootCause}\n${solution.fix}`;
140
+ case "knowledge": return `${solution.whenToUse}\n${solution.how}`;
141
+ case "decision": return `${solution.context}\n${solution.reasoning}`;
142
+ }
143
+ }
144
+
145
+ function writeSolutionToDb(db: Database.Database, title: string, solution: Solution): void {
146
+ const id = crypto.randomUUID();
147
+ const now = new Date().toISOString();
148
+ const content = solutionContent(solution);
149
+ const overlapHash = crypto.createHash("sha256").update(title + content).digest("hex");
150
+
151
+ db.prepare(`
152
+ INSERT INTO solutions (id, problem_type, category, title, content, files, tags, severity, overlap_hash, created_at, updated_at)
153
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
154
+ `).run(
155
+ id,
156
+ solution.type,
157
+ solution.type,
158
+ title,
159
+ content,
160
+ JSON.stringify(solution.files),
161
+ JSON.stringify(solution.tags),
162
+ "medium",
163
+ overlapHash,
164
+ now,
165
+ now,
166
+ );
167
+
168
+ // Index for search
169
+ indexContent(db, id, title, content, solution.type);
170
+ }
@@ -0,0 +1,111 @@
1
+ import type Database from "better-sqlite3";
2
+ import { porterSearch } from "../store/search.ts";
3
+
4
+ // ── Types ──────────────────────────────────────────────────────────────────────
5
+
6
+ export interface OverlapAssessment {
7
+ problemOverlap: number;
8
+ rootCauseOverlap: number;
9
+ solutionOverlap: number;
10
+ filesOverlap: number;
11
+ preventionOverlap: number;
12
+ }
13
+
14
+ export interface SolutionForDedup {
15
+ title: string;
16
+ content: string;
17
+ files: string[];
18
+ tags: string[];
19
+ }
20
+
21
+ // ── Overlap assessment ─────────────────────────────────────────────────────────
22
+
23
+ /**
24
+ * Compute text similarity between two strings using simple token overlap.
25
+ */
26
+ function textSimilarity(a: string, b: string): number {
27
+ const tokenize = (s: string): Set<string> =>
28
+ new Set(s.toLowerCase().split(/\s+/).filter((t) => t.length > 2));
29
+ const setA = tokenize(a);
30
+ const setB = tokenize(b);
31
+ if (setA.size === 0 && setB.size === 0) return 1;
32
+ if (setA.size === 0 || setB.size === 0) return 0;
33
+ let intersection = 0;
34
+ for (const token of setA) {
35
+ if (setB.has(token)) intersection++;
36
+ }
37
+ const union = setA.size + setB.size - intersection;
38
+ return union > 0 ? intersection / union : 0;
39
+ }
40
+
41
+ /**
42
+ * Compute file set overlap (Jaccard similarity).
43
+ */
44
+ function fileOverlap(filesA: string[], filesB: string[]): number {
45
+ if (filesA.length === 0 && filesB.length === 0) return 0;
46
+ if (filesA.length === 0 || filesB.length === 0) return 0;
47
+ const setA = new Set(filesA);
48
+ const setB = new Set(filesB);
49
+ let intersection = 0;
50
+ for (const f of setA) {
51
+ if (setB.has(f)) intersection++;
52
+ }
53
+ const union = setA.size + setB.size - intersection;
54
+ return union > 0 ? intersection / union : 0;
55
+ }
56
+
57
+ /**
58
+ * Assess overlap between a new solution and an existing solution.
59
+ * Uses token-based text similarity + file set intersection.
60
+ */
61
+ export function assessOverlap(
62
+ newSol: SolutionForDedup,
63
+ existingSol: SolutionForDedup,
64
+ ): OverlapAssessment {
65
+ return {
66
+ problemOverlap: textSimilarity(newSol.title, existingSol.title),
67
+ rootCauseOverlap: textSimilarity(
68
+ newSol.content.slice(0, 500),
69
+ existingSol.content.slice(0, 500),
70
+ ),
71
+ solutionOverlap: textSimilarity(newSol.content, existingSol.content),
72
+ filesOverlap: fileOverlap(newSol.files, existingSol.files),
73
+ preventionOverlap: textSimilarity(
74
+ newSol.tags.join(" "),
75
+ existingSol.tags.join(" "),
76
+ ),
77
+ };
78
+ }
79
+
80
+ /**
81
+ * Determine if two solutions are duplicates using weighted 5-dimension check.
82
+ */
83
+ export function shouldDedup(overlap: OverlapAssessment, threshold: number = 0.7): boolean {
84
+ const weighted =
85
+ overlap.problemOverlap * 0.3 +
86
+ overlap.rootCauseOverlap * 0.3 +
87
+ overlap.solutionOverlap * 0.2 +
88
+ overlap.filesOverlap * 0.1 +
89
+ overlap.preventionOverlap * 0.1;
90
+ return weighted > threshold;
91
+ }
92
+
93
+ /**
94
+ * Find potential duplicates in the database using FTS5 search.
95
+ */
96
+ export function findPotentialDuplicates(
97
+ db: Database.Database,
98
+ title: string,
99
+ ): Array<{ id: string; title: string; content: string; files: string; tags: string }> {
100
+ try {
101
+ const results = porterSearch(db, title, 5);
102
+ const ids = results.map((r) => r.id);
103
+ if (ids.length === 0) return [];
104
+ const placeholders = ids.map(() => "?").join(",");
105
+ return db.prepare(
106
+ `SELECT id, title, content, files, tags FROM solutions WHERE id IN (${placeholders})`,
107
+ ).all(...ids) as Array<{ id: string; title: string; content: string; files: string; tags: string }>;
108
+ } catch {
109
+ return [];
110
+ }
111
+ }
@@ -0,0 +1,110 @@
1
+ import type { SessionEvent } from "../store/events.ts";
2
+
3
+ // ── Solution types ─────────────────────────────────────────────────────────────
4
+
5
+ export interface BugSolution {
6
+ type: "bug";
7
+ problem: string;
8
+ rootCause: string;
9
+ fix: string;
10
+ files: string[];
11
+ tags: string[];
12
+ }
13
+
14
+ export interface KnowledgeSolution {
15
+ type: "knowledge";
16
+ whenToUse: string;
17
+ how: string;
18
+ tradeoffs: { pro: string[]; con: string[] };
19
+ files: string[];
20
+ tags: string[];
21
+ }
22
+
23
+ export interface DecisionSolution {
24
+ type: "decision";
25
+ context: string;
26
+ options: Array<{ name: string; pros: string[]; cons: string[] }>;
27
+ choice: string;
28
+ reasoning: string;
29
+ files: string[];
30
+ tags: string[];
31
+ }
32
+
33
+ export type Solution = BugSolution | KnowledgeSolution | DecisionSolution;
34
+
35
+ // ── Extractors ─────────────────────────────────────────────────────────────────
36
+
37
+ /**
38
+ * Extract a bug solution from error + fix events.
39
+ */
40
+ export function extractBugSolution(
41
+ errorEvent: SessionEvent,
42
+ fixEvent: SessionEvent | null,
43
+ ): BugSolution | null {
44
+ let errorData: Record<string, unknown>;
45
+ try { errorData = JSON.parse(errorEvent.data) as Record<string, unknown>; } catch { return null; }
46
+
47
+ const errorMessage = typeof errorData.errorMessage === "string" ? errorData.errorMessage :
48
+ typeof errorData.message === "string" ? errorData.message : "Unknown error";
49
+ const tool = typeof errorData.tool === "string" ? errorData.tool : "unknown";
50
+
51
+ let fix = "No fix recorded.";
52
+ if (fixEvent) {
53
+ try {
54
+ const fixData = JSON.parse(fixEvent.data) as Record<string, unknown>;
55
+ const output = typeof fixData.output_summary === "string" ? fixData.output_summary :
56
+ typeof fixData.command === "string" ? `Ran: ${fixData.command}` : "";
57
+ if (output) fix = output;
58
+ } catch { /* use default */ }
59
+ }
60
+
61
+ return {
62
+ type: "bug",
63
+ problem: errorMessage,
64
+ rootCause: `Error from tool: ${tool}`,
65
+ fix,
66
+ files: [],
67
+ tags: [tool, "auto-extracted"],
68
+ };
69
+ }
70
+
71
+ /**
72
+ * Extract a knowledge pattern from file edit events.
73
+ */
74
+ export function extractKnowledge(events: SessionEvent[]): KnowledgeSolution | null {
75
+ if (events.length === 0) return null;
76
+
77
+ const files = events
78
+ .map((e) => { try { return (JSON.parse(e.data) as Record<string, unknown>).file as string; } catch { return ""; } })
79
+ .filter((f) => f.length > 0);
80
+
81
+ return {
82
+ type: "knowledge",
83
+ whenToUse: `When working with: ${files.join(", ")}`,
84
+ how: "Pattern detected from file edit sequence.",
85
+ tradeoffs: { pro: [], con: [] },
86
+ files,
87
+ tags: ["auto-extracted"],
88
+ };
89
+ }
90
+
91
+ /**
92
+ * Extract a decision from user decision events.
93
+ */
94
+ export function extractDecision(event: SessionEvent): DecisionSolution | null {
95
+ let data: Record<string, unknown>;
96
+ try { data = JSON.parse(event.data) as Record<string, unknown>; } catch { return null; }
97
+
98
+ const message = typeof data.message_summary === "string" ? data.message_summary :
99
+ typeof data.message === "string" ? data.message : "Decision made";
100
+
101
+ return {
102
+ type: "decision",
103
+ context: message,
104
+ options: [],
105
+ choice: message,
106
+ reasoning: "User decision captured from session.",
107
+ files: [],
108
+ tags: ["auto-extracted", "user-decision"],
109
+ };
110
+ }
@@ -0,0 +1,82 @@
1
+ import type { SessionEvent } from "../store/events.ts";
2
+
3
+ export type FindingType = "bug" | "knowledge" | "decision";
4
+
5
+ export interface RoutedFinding {
6
+ type: FindingType;
7
+ confidence: number;
8
+ events: SessionEvent[];
9
+ }
10
+
11
+ /**
12
+ * Route session events to the appropriate finding type.
13
+ * Classifies as bug/knowledge/decision based on event patterns.
14
+ */
15
+ export function routeFinding(events: SessionEvent[]): RoutedFinding | null {
16
+ if (events.length === 0) return null;
17
+
18
+ const types = events.map((e) => e.type);
19
+ const hasError = types.includes("error");
20
+ const hasSuccess = types.includes("command_success");
21
+ const hasUserDecision = types.includes("user_decision");
22
+ const hasFileEdit = types.includes("file_edit");
23
+
24
+ // Error followed by success → bug pattern
25
+ if (hasError && hasSuccess && hasFileEdit) {
26
+ return { type: "bug", confidence: 0.8, events };
27
+ }
28
+
29
+ // User decision events → decision
30
+ if (hasUserDecision) {
31
+ return { type: "decision", confidence: 0.7, events };
32
+ }
33
+
34
+ // File edits without errors → knowledge pattern
35
+ if (hasFileEdit && !hasError) {
36
+ return { type: "knowledge", confidence: 0.5, events };
37
+ }
38
+
39
+ // Error without clear resolution → low-confidence bug
40
+ if (hasError) {
41
+ return { type: "bug", confidence: 0.3, events };
42
+ }
43
+
44
+ return null;
45
+ }
46
+
47
+ /**
48
+ * Route multiple sessions' events and return all routed findings.
49
+ */
50
+ export function routeSessionFindings(events: SessionEvent[]): RoutedFinding[] {
51
+ const findings: RoutedFinding[] = [];
52
+
53
+ // Group consecutive events by context
54
+ const errorEvents: SessionEvent[] = [];
55
+ const decisionEvents: SessionEvent[] = [];
56
+ const editEvents: SessionEvent[] = [];
57
+
58
+ for (const event of events) {
59
+ if (event.type === "error" || event.type === "command_success") {
60
+ errorEvents.push(event);
61
+ } else if (event.type === "user_decision") {
62
+ decisionEvents.push(event);
63
+ } else if (event.type === "file_edit") {
64
+ editEvents.push(event);
65
+ }
66
+ }
67
+
68
+ if (errorEvents.length > 0) {
69
+ const f = routeFinding(errorEvents);
70
+ if (f) findings.push(f);
71
+ }
72
+
73
+ if (decisionEvents.length > 0) {
74
+ findings.push({ type: "decision", confidence: 0.7, events: decisionEvents });
75
+ }
76
+
77
+ if (editEvents.length > 0 && errorEvents.length === 0) {
78
+ findings.push({ type: "knowledge", confidence: 0.5, events: editEvents });
79
+ }
80
+
81
+ return findings;
82
+ }