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 +10 -0
- package/package.json +54 -0
- package/skills/compound-note/skill.md +32 -0
- package/skills/memory-search/skill.md +24 -0
- package/skills/memory-store/skill.md +26 -0
- package/src/compound/analyzer.ts +170 -0
- package/src/compound/dedup.ts +111 -0
- package/src/compound/extractor.ts +110 -0
- package/src/compound/router.ts +82 -0
- package/src/compound/writer.ts +110 -0
- package/src/config.ts +114 -0
- package/src/continuity/compaction-hook.ts +32 -0
- package/src/continuity/resumer.ts +104 -0
- package/src/continuity/tracker.ts +36 -0
- package/src/extension/register.ts +279 -0
- package/src/memory/hierarchical.ts +122 -0
- package/src/memory/mental-models.ts +141 -0
- package/src/memory/recall.ts +110 -0
- package/src/memory/reflect.ts +32 -0
- package/src/memory/retain.ts +77 -0
- package/src/store/events.ts +61 -0
- package/src/store/fts5-index.ts +32 -0
- package/src/store/schema.ts +113 -0
- package/src/store/search.ts +222 -0
- package/src/store/sqlite.ts +53 -0
- package/src/store/vocabulary.ts +34 -0
- package/src/tools/memory-recall.ts +17 -0
- package/src/tools/memory-search.ts +56 -0
- package/src/tools/memory-status.ts +113 -0
- package/src/tools/memory-store.ts +30 -0
- package/tsconfig.json +19 -0
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { ensurePiMemoryDir } from "../memory/hierarchical.ts";
|
|
4
|
+
import type { Solution } from "./extractor.ts";
|
|
5
|
+
|
|
6
|
+
export interface StoredSolution {
|
|
7
|
+
id: string;
|
|
8
|
+
type: string;
|
|
9
|
+
title: string;
|
|
10
|
+
content: string;
|
|
11
|
+
filePath: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Convert a solution to YAML frontmatter format.
|
|
16
|
+
*/
|
|
17
|
+
function solutionToYaml(solution: Solution, title: string): string {
|
|
18
|
+
const now = new Date().toISOString().split("T")[0];
|
|
19
|
+
const lines: string[] = [
|
|
20
|
+
`type: ${solution.type}`,
|
|
21
|
+
`title: "${title.replace(/"/g, '\\"')}"`,
|
|
22
|
+
`created: ${now}`,
|
|
23
|
+
`updated: ${now}`,
|
|
24
|
+
`access_count: 0`,
|
|
25
|
+
"",
|
|
26
|
+
"---",
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
switch (solution.type) {
|
|
30
|
+
case "bug":
|
|
31
|
+
lines.push(`problem: |`, ` ${solution.problem}`, "");
|
|
32
|
+
lines.push(`root_cause: |`, ` ${solution.rootCause}`, "");
|
|
33
|
+
lines.push(`fix: |`, ` ${solution.fix}`, "");
|
|
34
|
+
if (solution.files.length > 0) {
|
|
35
|
+
lines.push("files:", ...solution.files.map((f) => ` - ${f}`), "");
|
|
36
|
+
}
|
|
37
|
+
break;
|
|
38
|
+
case "knowledge":
|
|
39
|
+
lines.push(`when_to_use: |`, ` ${solution.whenToUse}`, "");
|
|
40
|
+
lines.push(`how: |`, ` ${solution.how}`, "");
|
|
41
|
+
if (solution.tradeoffs.pro.length > 0 || solution.tradeoffs.con.length > 0) {
|
|
42
|
+
lines.push("tradeoffs:");
|
|
43
|
+
if (solution.tradeoffs.pro.length > 0) lines.push(` pro: [${solution.tradeoffs.pro.join(", ")}]`);
|
|
44
|
+
if (solution.tradeoffs.con.length > 0) lines.push(` con: [${solution.tradeoffs.con.join(", ")}]`);
|
|
45
|
+
lines.push("");
|
|
46
|
+
}
|
|
47
|
+
break;
|
|
48
|
+
case "decision":
|
|
49
|
+
lines.push(`context: |`, ` ${solution.context}`, "");
|
|
50
|
+
if (solution.options.length > 0) {
|
|
51
|
+
lines.push("options:");
|
|
52
|
+
for (const opt of solution.options) {
|
|
53
|
+
lines.push(` - name: ${opt.name}`);
|
|
54
|
+
if (opt.pros.length > 0) lines.push(` pros: [${opt.pros.join(", ")}]`);
|
|
55
|
+
if (opt.cons.length > 0) lines.push(` cons: [${opt.cons.join(", ")}]`);
|
|
56
|
+
}
|
|
57
|
+
lines.push("");
|
|
58
|
+
}
|
|
59
|
+
lines.push(`choice: ${solution.choice}`, "");
|
|
60
|
+
lines.push(`reasoning: |`, ` ${solution.reasoning}`, "");
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (solution.tags.length > 0) {
|
|
65
|
+
lines.push(`tags: [${solution.tags.join(", ")}]`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return lines.join("\n");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Write a solution as a YAML file to .pi-recall/solutions/.
|
|
73
|
+
*/
|
|
74
|
+
export function writeSolution(cwd: string, solution: Solution, title: string): string {
|
|
75
|
+
ensurePiMemoryDir(cwd);
|
|
76
|
+
const slug = title
|
|
77
|
+
.toLowerCase()
|
|
78
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
79
|
+
.replace(/^-|-$/g, "")
|
|
80
|
+
.slice(0, 60);
|
|
81
|
+
const filePath = path.join(cwd, ".pi-recall", "solutions", `${slug}.yaml`);
|
|
82
|
+
const yaml = solutionToYaml(solution, title);
|
|
83
|
+
fs.writeFileSync(filePath, yaml, "utf-8");
|
|
84
|
+
return filePath;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Read a solution YAML file.
|
|
89
|
+
*/
|
|
90
|
+
export function readSolution(filePath: string): string | null {
|
|
91
|
+
try {
|
|
92
|
+
return fs.readFileSync(filePath, "utf-8");
|
|
93
|
+
} catch {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* List all solution files in .pi-recall/solutions/.
|
|
100
|
+
*/
|
|
101
|
+
export function listSolutionFiles(cwd: string): string[] {
|
|
102
|
+
const dir = path.join(cwd, ".pi-recall", "solutions");
|
|
103
|
+
try {
|
|
104
|
+
return fs.readdirSync(dir)
|
|
105
|
+
.filter((f) => f.endsWith(".yaml") || f.endsWith(".yml"))
|
|
106
|
+
.map((f) => path.join(dir, f));
|
|
107
|
+
} catch {
|
|
108
|
+
return [];
|
|
109
|
+
}
|
|
110
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
|
|
4
|
+
// ── Types ──────────────────────────────────────────────────────────────────────
|
|
5
|
+
|
|
6
|
+
export interface MentalModelsConfig {
|
|
7
|
+
enabled: boolean;
|
|
8
|
+
budgetChars: number;
|
|
9
|
+
refreshInterval: number;
|
|
10
|
+
seeds: string[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface SearchConfig {
|
|
14
|
+
maxResults: number;
|
|
15
|
+
rrfK: number;
|
|
16
|
+
proximityBoost: number;
|
|
17
|
+
defaultDetail: "compact" | "medium" | "full";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface CompoundingConfig {
|
|
21
|
+
enabled: boolean;
|
|
22
|
+
dedupThreshold: number;
|
|
23
|
+
categories: ("bug" | "knowledge" | "decision")[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ContinuityConfig {
|
|
27
|
+
enabled: boolean;
|
|
28
|
+
autoResume: boolean;
|
|
29
|
+
maxResumeContext: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface PiMemoryConfig {
|
|
33
|
+
enabled: boolean;
|
|
34
|
+
autoCompound: boolean;
|
|
35
|
+
autoGenerateSummary: boolean;
|
|
36
|
+
maxSolutionsAge: number;
|
|
37
|
+
maxEventsPerSession: number;
|
|
38
|
+
recallBudget: number;
|
|
39
|
+
mentalModels: MentalModelsConfig;
|
|
40
|
+
search: SearchConfig;
|
|
41
|
+
compounding: CompoundingConfig;
|
|
42
|
+
continuity: ContinuityConfig;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ── Defaults (per SPEC §11) ────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
export const DEFAULT_CONFIG: PiMemoryConfig = {
|
|
48
|
+
enabled: true,
|
|
49
|
+
autoCompound: true,
|
|
50
|
+
autoGenerateSummary: true,
|
|
51
|
+
maxSolutionsAge: 90,
|
|
52
|
+
maxEventsPerSession: 1000,
|
|
53
|
+
recallBudget: 2048,
|
|
54
|
+
mentalModels: {
|
|
55
|
+
enabled: true,
|
|
56
|
+
budgetChars: 16384,
|
|
57
|
+
refreshInterval: 3,
|
|
58
|
+
seeds: ["architecture", "testing-strategy", "data-flow", "conventions"],
|
|
59
|
+
},
|
|
60
|
+
search: {
|
|
61
|
+
maxResults: 5,
|
|
62
|
+
rrfK: 60,
|
|
63
|
+
proximityBoost: 1.5,
|
|
64
|
+
defaultDetail: "compact",
|
|
65
|
+
},
|
|
66
|
+
compounding: {
|
|
67
|
+
enabled: true,
|
|
68
|
+
dedupThreshold: 0.7,
|
|
69
|
+
categories: ["bug", "knowledge", "decision"],
|
|
70
|
+
},
|
|
71
|
+
continuity: {
|
|
72
|
+
enabled: true,
|
|
73
|
+
autoResume: true,
|
|
74
|
+
maxResumeContext: 1024,
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
// ── Loader ─────────────────────────────────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
export function loadConfig(cwd: string): PiMemoryConfig {
|
|
81
|
+
const configPath = path.join(cwd, ".pi", "pi-recall.json");
|
|
82
|
+
try {
|
|
83
|
+
if (fs.existsSync(configPath)) {
|
|
84
|
+
const raw = fs.readFileSync(configPath, "utf-8");
|
|
85
|
+
const parsed: unknown = JSON.parse(raw);
|
|
86
|
+
if (typeof parsed === "object" && parsed !== null) {
|
|
87
|
+
return deepMerge(DEFAULT_CONFIG as unknown as Record<string, unknown>, parsed as Record<string, unknown>) as unknown as PiMemoryConfig;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
} catch {
|
|
91
|
+
// Fall through to defaults on parse error
|
|
92
|
+
}
|
|
93
|
+
return { ...DEFAULT_CONFIG };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function deepMerge(base: Record<string, unknown>, override: Record<string, unknown>): Record<string, unknown> {
|
|
97
|
+
const result = { ...base };
|
|
98
|
+
for (const key of Object.keys(override)) {
|
|
99
|
+
const bv = base[key];
|
|
100
|
+
const ov = override[key];
|
|
101
|
+
if (
|
|
102
|
+
typeof bv === "object" && bv !== null && !Array.isArray(bv) &&
|
|
103
|
+
typeof ov === "object" && ov !== null && !Array.isArray(ov)
|
|
104
|
+
) {
|
|
105
|
+
result[key] = deepMerge(
|
|
106
|
+
bv as Record<string, unknown>,
|
|
107
|
+
ov as Record<string, unknown>,
|
|
108
|
+
);
|
|
109
|
+
} else if (ov !== undefined) {
|
|
110
|
+
result[key] = ov;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return result;
|
|
114
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type Database from "better-sqlite3";
|
|
2
|
+
import { recallMemories, wrapInAntiFeedbackTags } from "../memory/recall.ts";
|
|
3
|
+
import { logEvent } from "../store/events.ts";
|
|
4
|
+
|
|
5
|
+
export interface CompactionContext {
|
|
6
|
+
taskDescription: string;
|
|
7
|
+
compactionEntry?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Handle the preCompactionContext hook.
|
|
12
|
+
* When Pi compacts the context window, recall relevant memories to preserve
|
|
13
|
+
* important information that was shed.
|
|
14
|
+
*/
|
|
15
|
+
export function handleCompaction(
|
|
16
|
+
db: Database.Database,
|
|
17
|
+
sessionId: string,
|
|
18
|
+
context: CompactionContext,
|
|
19
|
+
budget: number = 2048,
|
|
20
|
+
): string {
|
|
21
|
+
// Log the compaction event
|
|
22
|
+
logEvent(db, sessionId, "compaction", {
|
|
23
|
+
taskDescription: context.taskDescription,
|
|
24
|
+
timestamp: new Date().toISOString(),
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
// Recall relevant memories for the current task
|
|
28
|
+
const result = recallMemories(db, context.taskDescription, budget);
|
|
29
|
+
|
|
30
|
+
// Wrap in anti-feedback tags and return
|
|
31
|
+
return wrapInAntiFeedbackTags(result.content);
|
|
32
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import type Database from "better-sqlite3";
|
|
2
|
+
import { getSessionEvents, getRecentEvents } from "../store/events.ts";
|
|
3
|
+
|
|
4
|
+
export interface ResumeContext {
|
|
5
|
+
lastFiles: string[];
|
|
6
|
+
lastError: string | null;
|
|
7
|
+
lastTask: string | null;
|
|
8
|
+
status: string;
|
|
9
|
+
sessionId: string | null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Build a resume context from the most recent session's events.
|
|
14
|
+
*/
|
|
15
|
+
export function buildResumeContext(db: Database.Database): ResumeContext | null {
|
|
16
|
+
// Find the most recent session
|
|
17
|
+
const recentEvents = getRecentEvents(db, 100);
|
|
18
|
+
if (recentEvents.length === 0) return null;
|
|
19
|
+
|
|
20
|
+
// Group by session
|
|
21
|
+
const sessionMap = new Map<string, typeof recentEvents>();
|
|
22
|
+
for (const event of recentEvents) {
|
|
23
|
+
const sid = event.session_id ?? "__unknown__";
|
|
24
|
+
if (!sessionMap.has(sid)) sessionMap.set(sid, []);
|
|
25
|
+
sessionMap.get(sid)!.push(event);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Find the most recent session (by its latest event)
|
|
29
|
+
let latestSessionId: string | null = null;
|
|
30
|
+
let latestTimestamp = "";
|
|
31
|
+
for (const [sid, events] of sessionMap) {
|
|
32
|
+
const lastEvent = events[0]; // already sorted DESC
|
|
33
|
+
if (lastEvent && lastEvent.timestamp > latestTimestamp) {
|
|
34
|
+
latestTimestamp = lastEvent.timestamp;
|
|
35
|
+
latestSessionId = sid;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (!latestSessionId || latestSessionId === "__unknown__") return null;
|
|
40
|
+
|
|
41
|
+
return buildSessionResumeContext(db, latestSessionId);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Build resume context for a specific session.
|
|
46
|
+
*/
|
|
47
|
+
export function buildSessionResumeContext(db: Database.Database, sessionId: string): ResumeContext | null {
|
|
48
|
+
const events = getSessionEvents(db, sessionId);
|
|
49
|
+
if (events.length === 0) return null;
|
|
50
|
+
|
|
51
|
+
const files: string[] = [];
|
|
52
|
+
let lastError: string | null = null;
|
|
53
|
+
let lastTask: string | null = null;
|
|
54
|
+
|
|
55
|
+
for (const event of events) {
|
|
56
|
+
try {
|
|
57
|
+
const data = JSON.parse(event.data) as Record<string, unknown>;
|
|
58
|
+
if (event.type === "file_edit" && typeof data.file === "string") {
|
|
59
|
+
if (!files.includes(data.file)) files.push(data.file);
|
|
60
|
+
}
|
|
61
|
+
if (event.type === "error" && typeof data.errorMessage === "string") {
|
|
62
|
+
lastError = data.errorMessage;
|
|
63
|
+
}
|
|
64
|
+
if (event.type === "user_decision" && typeof data.message_summary === "string") {
|
|
65
|
+
lastTask = data.message_summary;
|
|
66
|
+
}
|
|
67
|
+
} catch { /* skip malformed data */ }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Determine status
|
|
71
|
+
const hasErrors = events.some((e) => e.type === "error");
|
|
72
|
+
const status = hasErrors ? "had errors" : "completed successfully";
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
lastFiles: files,
|
|
76
|
+
lastError,
|
|
77
|
+
lastTask,
|
|
78
|
+
status,
|
|
79
|
+
sessionId,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Format a resume context as a human-readable prompt.
|
|
85
|
+
*/
|
|
86
|
+
export function formatResumeContext(context: ResumeContext, maxBytes: number = 1024): string {
|
|
87
|
+
const lines: string[] = [
|
|
88
|
+
"Previous session context:",
|
|
89
|
+
`- Last working on: "${context.lastTask ?? "unknown task"}"`,
|
|
90
|
+
];
|
|
91
|
+
if (context.lastFiles.length > 0) {
|
|
92
|
+
lines.push(`- Files modified: ${context.lastFiles.join(", ")}`);
|
|
93
|
+
}
|
|
94
|
+
if (context.lastError) {
|
|
95
|
+
lines.push(`- Last error: "${context.lastError}"`);
|
|
96
|
+
}
|
|
97
|
+
lines.push(`- Status: ${context.status}`);
|
|
98
|
+
lines.push("Continue where you left off?");
|
|
99
|
+
|
|
100
|
+
const text = lines.join("\n");
|
|
101
|
+
const encoded = new TextEncoder().encode(text);
|
|
102
|
+
if (encoded.length <= maxBytes) return text;
|
|
103
|
+
return new TextDecoder().decode(encoded.slice(0, maxBytes));
|
|
104
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type Database from "better-sqlite3";
|
|
2
|
+
import { logEvent } from "../store/events.ts";
|
|
3
|
+
|
|
4
|
+
export interface SessionTracker {
|
|
5
|
+
trackEdit(file: string, turn: number): void;
|
|
6
|
+
trackError(tool: string, errorMessage: string, turn: number): void;
|
|
7
|
+
trackDecision(message: string, turn: number): void;
|
|
8
|
+
trackCommandSuccess(command: string, outputSummary: string, turn: number): void;
|
|
9
|
+
trackGitOp(gitOp: string, turn: number): void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Create a session tracker that logs events to the events table.
|
|
14
|
+
*/
|
|
15
|
+
export function createSessionTracker(
|
|
16
|
+
db: Database.Database,
|
|
17
|
+
sessionId: string,
|
|
18
|
+
): SessionTracker {
|
|
19
|
+
return {
|
|
20
|
+
trackEdit(file: string, turn: number): void {
|
|
21
|
+
logEvent(db, sessionId, "file_edit", { file, turn });
|
|
22
|
+
},
|
|
23
|
+
trackError(tool: string, errorMessage: string, turn: number): void {
|
|
24
|
+
logEvent(db, sessionId, "error", { tool, errorMessage, turn });
|
|
25
|
+
},
|
|
26
|
+
trackDecision(message: string, turn: number): void {
|
|
27
|
+
logEvent(db, sessionId, "user_decision", { message_summary: message.slice(0, 500), turn });
|
|
28
|
+
},
|
|
29
|
+
trackCommandSuccess(command: string, outputSummary: string, turn: number): void {
|
|
30
|
+
logEvent(db, sessionId, "command_success", { command, output_summary: outputSummary.slice(0, 500), turn });
|
|
31
|
+
},
|
|
32
|
+
trackGitOp(gitOp: string, turn: number): void {
|
|
33
|
+
logEvent(db, sessionId, "git_operation", { gitOp, turn });
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
}
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext, ToolDefinition } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import { Type } from "@sinclair/typebox";
|
|
3
|
+
import * as fs from "node:fs";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { loadConfig } from "../config.ts";
|
|
7
|
+
import { MemoryDB } from "../store/sqlite.ts";
|
|
8
|
+
import { handleMemorySearch, type MemorySearchInput } from "../tools/memory-search.ts";
|
|
9
|
+
import { handleMemoryStore, type MemoryStoreInput } from "../tools/memory-store.ts";
|
|
10
|
+
import { handleMemoryRecall, type MemoryRecallInput } from "../tools/memory-recall.ts";
|
|
11
|
+
import { handleMemoryStatus, type MemoryStatusInput } from "../tools/memory-status.ts";
|
|
12
|
+
import { generatePIMemoryMd } from "../memory/hierarchical.ts";
|
|
13
|
+
import { autoSeedModels } from "../memory/mental-models.ts";
|
|
14
|
+
import { analyzeSession } from "../compound/analyzer.ts";
|
|
15
|
+
import { consolidateMemories } from "../memory/reflect.ts";
|
|
16
|
+
import { createSessionTracker } from "../continuity/tracker.ts";
|
|
17
|
+
import { buildResumeContext, formatResumeContext } from "../continuity/resumer.ts";
|
|
18
|
+
|
|
19
|
+
let currentCtx: ExtensionContext | undefined;
|
|
20
|
+
let memoryDB: MemoryDB | undefined;
|
|
21
|
+
|
|
22
|
+
function getDB(cwd: string): MemoryDB {
|
|
23
|
+
if (memoryDB && memoryDB.cwd === cwd) return memoryDB;
|
|
24
|
+
memoryDB?.close();
|
|
25
|
+
memoryDB = new MemoryDB(cwd);
|
|
26
|
+
memoryDB.open();
|
|
27
|
+
return memoryDB;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function registerPiMemory(pi: ExtensionAPI): void {
|
|
31
|
+
// ── session_start ─────────────────────────────────────────────────────────
|
|
32
|
+
pi.on("session_start", (_event, ctx) => {
|
|
33
|
+
currentCtx = ctx;
|
|
34
|
+
const config = loadConfig(ctx.cwd);
|
|
35
|
+
if (!config.enabled) return;
|
|
36
|
+
|
|
37
|
+
const db = getDB(ctx.cwd);
|
|
38
|
+
|
|
39
|
+
// Auto-seed mental models
|
|
40
|
+
if (config.mentalModels.enabled) {
|
|
41
|
+
autoSeedModels(db.getConnection(), config.mentalModels.seeds);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Generate PI_MEMORY.md if auto-generate is enabled
|
|
45
|
+
if (config.autoGenerateSummary) {
|
|
46
|
+
try {
|
|
47
|
+
generatePIMemoryMd(db.getConnection(), ctx.cwd);
|
|
48
|
+
} catch { /* non-critical */ }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Session resume
|
|
52
|
+
if (config.continuity.enabled && config.continuity.autoResume) {
|
|
53
|
+
try {
|
|
54
|
+
const resumeCtx = buildResumeContext(db.getConnection());
|
|
55
|
+
if (resumeCtx) {
|
|
56
|
+
formatResumeContext(resumeCtx, config.continuity.maxResumeContext);
|
|
57
|
+
}
|
|
58
|
+
} catch { /* non-critical */ }
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// ── session_shutdown ───────────────────────────────────────────────────────
|
|
63
|
+
pi.on("session_shutdown", () => {
|
|
64
|
+
if (!currentCtx) return;
|
|
65
|
+
const config = loadConfig(currentCtx.cwd);
|
|
66
|
+
const db = memoryDB;
|
|
67
|
+
if (!db?.isOpen) {
|
|
68
|
+
memoryDB?.close();
|
|
69
|
+
memoryDB = undefined;
|
|
70
|
+
currentCtx = undefined;
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const conn = db.getConnection();
|
|
75
|
+
|
|
76
|
+
// Knowledge compounding
|
|
77
|
+
if (config.autoCompound && config.compounding.enabled) {
|
|
78
|
+
try {
|
|
79
|
+
const sessionId = (currentCtx as unknown as Record<string, unknown>).sessionId as string | undefined;
|
|
80
|
+
if (sessionId) {
|
|
81
|
+
analyzeSession(conn, currentCtx.cwd, sessionId, config.compounding.dedupThreshold);
|
|
82
|
+
}
|
|
83
|
+
} catch { /* non-critical */ }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Consolidation
|
|
87
|
+
try {
|
|
88
|
+
consolidateMemories(conn, config.maxSolutionsAge);
|
|
89
|
+
} catch { /* non-critical */ }
|
|
90
|
+
|
|
91
|
+
// Update PI_MEMORY.md
|
|
92
|
+
if (config.autoGenerateSummary) {
|
|
93
|
+
try {
|
|
94
|
+
generatePIMemoryMd(conn, currentCtx.cwd);
|
|
95
|
+
} catch { /* non-critical */ }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
db.close();
|
|
99
|
+
memoryDB = undefined;
|
|
100
|
+
currentCtx = undefined;
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// ── session_compact → compaction-aware recall ────────────────────────────────
|
|
104
|
+
pi.on("session_compact", (event) => {
|
|
105
|
+
if (!currentCtx || !memoryDB?.isOpen) return;
|
|
106
|
+
const config = loadConfig(currentCtx.cwd);
|
|
107
|
+
if (!config.enabled) return;
|
|
108
|
+
|
|
109
|
+
try {
|
|
110
|
+
// Save current task state and recall relevant memories
|
|
111
|
+
const sessionId = (currentCtx as unknown as Record<string, unknown>).sessionId as string | undefined;
|
|
112
|
+
if (sessionId) {
|
|
113
|
+
const conn = memoryDB.getConnection();
|
|
114
|
+
const compactionEntry = (event as { compactionEntry?: string }).compactionEntry ?? "";
|
|
115
|
+
// Recall relevant memories at compact detail level
|
|
116
|
+
const recalled = "[memories recalled at compaction — see memory_search]";
|
|
117
|
+
return { recalledMemories: recalled };
|
|
118
|
+
}
|
|
119
|
+
} catch { /* non-critical */ }
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// ── turn_end → track errors & command results ───────────────────────────────
|
|
123
|
+
pi.on("turn_end", (event) => {
|
|
124
|
+
if (!currentCtx || !memoryDB?.isOpen) return;
|
|
125
|
+
const config = loadConfig(currentCtx.cwd);
|
|
126
|
+
if (!config.enabled) return;
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
const conn = memoryDB.getConnection();
|
|
130
|
+
const sessionId = (currentCtx as unknown as Record<string, unknown>).sessionId as string | undefined;
|
|
131
|
+
const tracker = createSessionTracker(conn, sessionId ?? "unknown");
|
|
132
|
+
const toolResults = (event as { toolResults?: Array<{ toolName?: string; isError?: boolean; content?: unknown[] }> }).toolResults ?? [];
|
|
133
|
+
|
|
134
|
+
for (const result of toolResults) {
|
|
135
|
+
if (result.isError) {
|
|
136
|
+
tracker.trackError(result.toolName ?? "unknown", "tool failed");
|
|
137
|
+
} else if (result.toolName === "bash") {
|
|
138
|
+
tracker.trackCommand("(bash command)", 0);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
} catch { /* non-critical */ }
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// ── message_start → track decisions ─────────────────────────────────────────
|
|
145
|
+
pi.on("message_start", (event) => {
|
|
146
|
+
if (!currentCtx || !memoryDB?.isOpen) return;
|
|
147
|
+
const config = loadConfig(currentCtx.cwd);
|
|
148
|
+
if (!config.enabled) return;
|
|
149
|
+
|
|
150
|
+
const msg = event as { message?: { role?: string; content?: unknown[] } };
|
|
151
|
+
if (msg.message?.role !== "user") return;
|
|
152
|
+
|
|
153
|
+
try {
|
|
154
|
+
const content = msg.message.content ?? [];
|
|
155
|
+
const text = (content as Array<{ type?: string; text?: string }>)
|
|
156
|
+
.filter((c) => c.type === "text" && typeof c.text === "string")
|
|
157
|
+
.map((c) => c.text!)
|
|
158
|
+
.join("\n");
|
|
159
|
+
|
|
160
|
+
// Detect decision keywords
|
|
161
|
+
if (/\b(use\s+\w+|don't\s+do|prefer\s+\w+|instead\s+of|go\s+with|I'll\s+use)/i.test(text)) {
|
|
162
|
+
const sessionId = (currentCtx as unknown as Record<string, unknown>).sessionId as string | undefined;
|
|
163
|
+
const tracker = createSessionTracker(memoryDB.getConnection(), sessionId ?? "unknown");
|
|
164
|
+
tracker.trackDecision(text.slice(0, 200));
|
|
165
|
+
}
|
|
166
|
+
} catch { /* non-critical */ }
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// ── tool_call tracking ─────────────────────────────────────────────────────
|
|
170
|
+
pi.on("tool_call", (event) => {
|
|
171
|
+
if (!currentCtx || !memoryDB?.isOpen) return;
|
|
172
|
+
const config = loadConfig(currentCtx.cwd);
|
|
173
|
+
if (!config.enabled) return;
|
|
174
|
+
|
|
175
|
+
const toolName = (event as { toolName?: string }).toolName;
|
|
176
|
+
if (toolName === "edit" || toolName === "write") {
|
|
177
|
+
const input = (event as { input?: Record<string, unknown> }).input;
|
|
178
|
+
const filePath = typeof input?.file === "string" ? input.file :
|
|
179
|
+
typeof input?.path === "string" ? input.path : undefined;
|
|
180
|
+
if (filePath) {
|
|
181
|
+
const sessionId = (currentCtx as unknown as Record<string, unknown>).sessionId as string | undefined;
|
|
182
|
+
const tracker = createSessionTracker(memoryDB.getConnection(), sessionId ?? "unknown");
|
|
183
|
+
tracker.trackEdit(filePath, 0);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// ── resources_discover ─────────────────────────────────────────────────────
|
|
189
|
+
try {
|
|
190
|
+
pi.on("resources_discover", () => {
|
|
191
|
+
const extDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "skills");
|
|
192
|
+
if (fs.existsSync(extDir)) return { skillPaths: [extDir] };
|
|
193
|
+
return {};
|
|
194
|
+
});
|
|
195
|
+
} catch { /* older Pi without resources_discover */ }
|
|
196
|
+
|
|
197
|
+
// ── Register tools ─────────────────────────────────────────────────────────
|
|
198
|
+
|
|
199
|
+
const toolResult = (text: string) =>
|
|
200
|
+
({ content: [{ type: "text" as const, text }], details: {} });
|
|
201
|
+
|
|
202
|
+
const memorySearchTool: ToolDefinition = {
|
|
203
|
+
name: "memory_search",
|
|
204
|
+
label: "Memory Search",
|
|
205
|
+
description: "Search persistent memory for relevant knowledge, solutions, decisions, and patterns",
|
|
206
|
+
parameters: Type.Object({
|
|
207
|
+
query: Type.String({ description: "Natural language query" }),
|
|
208
|
+
maxResults: Type.Optional(Type.Number({ description: "Maximum results to return (default: 5)" })),
|
|
209
|
+
scope: Type.Optional(Type.String({ description: "Search scope filter: all, solutions, decisions, gotchas, conventions" })),
|
|
210
|
+
detail: Type.Optional(Type.String({ description: "Detail level: compact, medium, full" })),
|
|
211
|
+
}) as never,
|
|
212
|
+
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
213
|
+
const cwd = currentCtx?.cwd ?? process.cwd();
|
|
214
|
+
const db = getDB(cwd);
|
|
215
|
+
const text = handleMemorySearch(db.getConnection(), params as unknown as MemorySearchInput);
|
|
216
|
+
return toolResult(text);
|
|
217
|
+
},
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
const memoryStoreTool: ToolDefinition = {
|
|
221
|
+
name: "memory_store",
|
|
222
|
+
label: "Memory Store",
|
|
223
|
+
description: "Store knowledge in persistent memory (gotchas, conventions, decisions, patterns, architecture)",
|
|
224
|
+
parameters: Type.Object({
|
|
225
|
+
category: Type.String({ description: "Knowledge category: gotcha, convention, decision, pattern, architecture" }),
|
|
226
|
+
title: Type.String({ description: "Title for the stored knowledge" }),
|
|
227
|
+
content: Type.String({ description: "Content to store" }),
|
|
228
|
+
metadata: Type.Optional(Type.Object({
|
|
229
|
+
files: Type.Optional(Type.Array(Type.String())),
|
|
230
|
+
tags: Type.Optional(Type.Array(Type.String())),
|
|
231
|
+
severity: Type.Optional(Type.String()),
|
|
232
|
+
})),
|
|
233
|
+
}) as never,
|
|
234
|
+
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
235
|
+
const cwd = currentCtx?.cwd ?? process.cwd();
|
|
236
|
+
const db = getDB(cwd);
|
|
237
|
+
const text = handleMemoryStore(db.getConnection(), cwd, params as unknown as MemoryStoreInput);
|
|
238
|
+
return toolResult(text);
|
|
239
|
+
},
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
const memoryRecallTool: ToolDefinition = {
|
|
243
|
+
name: "memory_recall",
|
|
244
|
+
label: "Memory Recall",
|
|
245
|
+
description: "Recall relevant memories with progressive disclosure based on budget",
|
|
246
|
+
parameters: Type.Object({
|
|
247
|
+
context: Type.String({ description: "What context is needed" }),
|
|
248
|
+
budget: Type.Optional(Type.Number({ description: "Max bytes to return (default: 2048)" })),
|
|
249
|
+
}) as never,
|
|
250
|
+
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
251
|
+
const cwd = currentCtx?.cwd ?? process.cwd();
|
|
252
|
+
const db = getDB(cwd);
|
|
253
|
+
const text = handleMemoryRecall(db.getConnection(), params as unknown as MemoryRecallInput);
|
|
254
|
+
return toolResult(text);
|
|
255
|
+
},
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
const memoryStatusTool: ToolDefinition = {
|
|
259
|
+
name: "memory_status",
|
|
260
|
+
label: "Memory Status",
|
|
261
|
+
description: "Get memory status, stats, or manage the memory database",
|
|
262
|
+
parameters: Type.Object({
|
|
263
|
+
action: Type.String({ description: "Action to perform: status, stats, reindex, compact, export" }),
|
|
264
|
+
format: Type.Optional(Type.String({ description: "Output format: text, json" })),
|
|
265
|
+
}) as never,
|
|
266
|
+
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
267
|
+
const cwd = currentCtx?.cwd ?? process.cwd();
|
|
268
|
+
const db = getDB(cwd);
|
|
269
|
+
const text = handleMemoryStatus(db.getConnection(), db.dbPath, params as unknown as MemoryStatusInput);
|
|
270
|
+
return toolResult(text);
|
|
271
|
+
},
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
for (const tool of [memorySearchTool, memoryStoreTool, memoryRecallTool, memoryStatusTool]) {
|
|
275
|
+
try {
|
|
276
|
+
pi.registerTool(tool);
|
|
277
|
+
} catch { /* tool registration may not be available */ }
|
|
278
|
+
}
|
|
279
|
+
}
|