@yandy0725/pi-memory 0.3.1 → 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/README.md +110 -16
- package/README.zh.md +120 -28
- package/index.ts +128 -18
- package/package.json +3 -2
- package/src/agent-types.ts +44 -0
- package/src/config.ts +36 -3
- package/src/dream.ts +116 -101
- package/src/extract.ts +110 -0
- package/src/index-file.ts +66 -57
- package/src/inject.ts +233 -1
- package/src/memory-tool.ts +162 -55
- package/src/nudge.ts +9 -6
- package/src/paths.ts +3 -6
- package/src/session-search.ts +19 -4
- package/src/topic-file.ts +87 -64
package/src/inject.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import { readFile } from "node:fs/promises";
|
|
1
|
+
import { readdir, readFile, stat } from "node:fs/promises";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
+
import { getSubagentsService } from "@yandy0725/pi-subagents";
|
|
3
4
|
import { truncateForInjection } from "./index-file";
|
|
5
|
+
import { parseFrontmatter } from "./topic-file";
|
|
4
6
|
|
|
5
7
|
export async function loadIndexSnapshot(memoryDir: string, maxLines: number, maxBytes: number): Promise<string> {
|
|
6
8
|
try {
|
|
@@ -16,3 +18,233 @@ export function buildInjection(systemPrompt: string, snapshot: string): string {
|
|
|
16
18
|
if (!snapshot) return systemPrompt;
|
|
17
19
|
return `${systemPrompt}\n\n${snapshot}`;
|
|
18
20
|
}
|
|
21
|
+
|
|
22
|
+
export interface TopicManifest {
|
|
23
|
+
filename: string;
|
|
24
|
+
name: string;
|
|
25
|
+
description: string;
|
|
26
|
+
type: string;
|
|
27
|
+
mtimeMs: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function scanTopics(memoryDir: string): Promise<TopicManifest[]> {
|
|
31
|
+
const files = (await readdir(memoryDir).catch(() => [])).filter((f) => f.endsWith(".md") && f !== "MEMORY.md");
|
|
32
|
+
|
|
33
|
+
const manifests: TopicManifest[] = [];
|
|
34
|
+
for (const f of files.slice(0, 200)) {
|
|
35
|
+
try {
|
|
36
|
+
const raw = await readFile(join(memoryDir, f), "utf8");
|
|
37
|
+
const meta = parseFrontmatter(raw);
|
|
38
|
+
if (!meta) continue;
|
|
39
|
+
let mtimeMs = 0;
|
|
40
|
+
try {
|
|
41
|
+
const s = await stat(join(memoryDir, f));
|
|
42
|
+
mtimeMs = s.mtimeMs;
|
|
43
|
+
} catch {
|
|
44
|
+
/* ignore */
|
|
45
|
+
}
|
|
46
|
+
manifests.push({
|
|
47
|
+
filename: f,
|
|
48
|
+
name: meta.name,
|
|
49
|
+
description: meta.description,
|
|
50
|
+
type: meta.type,
|
|
51
|
+
mtimeMs,
|
|
52
|
+
});
|
|
53
|
+
} catch {
|
|
54
|
+
/* skip unreadable files */
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return manifests.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function buildSurfacingPrompt(
|
|
61
|
+
manifest: TopicManifest[],
|
|
62
|
+
userPrompt: string,
|
|
63
|
+
injectedTopics: Set<string>,
|
|
64
|
+
): string {
|
|
65
|
+
const lines = manifest.map((t) => {
|
|
66
|
+
const marker = injectedTopics.has(t.filename) ? " [already injected]" : "";
|
|
67
|
+
return `[${t.type}] ${t.filename} — ${t.description.slice(0, 80)}${marker}`;
|
|
68
|
+
});
|
|
69
|
+
const alreadyInjected =
|
|
70
|
+
injectedTopics.size > 0
|
|
71
|
+
? `\n\nNote: ${injectedTopics.size} topic file(s) have already been injected in this session and are marked [already injected]. Prefer selecting uninjected topics.`
|
|
72
|
+
: "";
|
|
73
|
+
|
|
74
|
+
return [
|
|
75
|
+
"You are a memory relevance selector. Below is a list of memory topic files and a user message.",
|
|
76
|
+
"Select up to N topic files that are relevant to the user's current query.",
|
|
77
|
+
"Response format: JSON with a 'selected_files' array of filenames.",
|
|
78
|
+
alreadyInjected,
|
|
79
|
+
"",
|
|
80
|
+
"=== Topic Files ===",
|
|
81
|
+
...lines,
|
|
82
|
+
"",
|
|
83
|
+
"=== User Message ===",
|
|
84
|
+
userPrompt,
|
|
85
|
+
"",
|
|
86
|
+
'Return: {"selected_files": ["a.md", "b.md"]}',
|
|
87
|
+
].join("\n");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function injectSurfacedContent(
|
|
91
|
+
memoryDir: string,
|
|
92
|
+
selectedFiles: string[],
|
|
93
|
+
maxTopicBytes: number,
|
|
94
|
+
maxInjectionBytes: number,
|
|
95
|
+
): Promise<string> {
|
|
96
|
+
const blocks: string[] = [];
|
|
97
|
+
let totalBytes = 0;
|
|
98
|
+
|
|
99
|
+
for (const f of selectedFiles) {
|
|
100
|
+
try {
|
|
101
|
+
const raw = await readFile(join(memoryDir, f), "utf8");
|
|
102
|
+
const { content } = truncateForInjection(raw, 999999, maxTopicBytes);
|
|
103
|
+
const block = `## ${f}\n${content}`;
|
|
104
|
+
const blockBytes = Buffer.byteLength(block, "utf8");
|
|
105
|
+
if (totalBytes + blockBytes > maxInjectionBytes) break;
|
|
106
|
+
blocks.push(block);
|
|
107
|
+
totalBytes += blockBytes;
|
|
108
|
+
} catch {
|
|
109
|
+
/* skip unreadable files */
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (blocks.length === 0) return "";
|
|
114
|
+
return `<relevant_memories>\n${blocks.join("\n\n")}\n</relevant_memories>`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Run a lightweight LLM side-query to select relevant topic files.
|
|
118
|
+
* Uses pi-subagents spawn + Promise pattern (same as dream.ts).
|
|
119
|
+
* Falls back to keyword matching if subagent service is unavailable. */
|
|
120
|
+
export async function runSideQuery(
|
|
121
|
+
prompt: string,
|
|
122
|
+
manifest: TopicManifest[],
|
|
123
|
+
maxFiles: number,
|
|
124
|
+
// biome-ignore lint/suspicious/noExplicitAny: pi events API handler
|
|
125
|
+
events?: { on(channel: string, handler: (data: any) => void): () => void },
|
|
126
|
+
): Promise<string[]> {
|
|
127
|
+
// Filter to uninjected candidates only
|
|
128
|
+
const candidates = manifest.filter((t) => {
|
|
129
|
+
// already-injected topics are marked in the prompt, but we also
|
|
130
|
+
// filter here as a safety net
|
|
131
|
+
return !prompt.includes(`[already injected] ${t.filename}`);
|
|
132
|
+
});
|
|
133
|
+
if (candidates.length === 0) return [];
|
|
134
|
+
|
|
135
|
+
// Build a minimal selection prompt — only current user message + topic manifest.
|
|
136
|
+
// With prompt_mode=replace, this IS the entire system prompt (no inheritance).
|
|
137
|
+
const task = [
|
|
138
|
+
"Respond with ONLY a JSON object.",
|
|
139
|
+
"",
|
|
140
|
+
"Below is a list of memory topic files and a user query.",
|
|
141
|
+
`Select up to ${maxFiles} topic files MOST relevant to the user's current query.`,
|
|
142
|
+
'If nothing is relevant, return {"selected_files": []}.',
|
|
143
|
+
"",
|
|
144
|
+
prompt,
|
|
145
|
+
"",
|
|
146
|
+
'Respond with EXACTLY: {"selected_files": [...]}',
|
|
147
|
+
].join("\n");
|
|
148
|
+
|
|
149
|
+
const service = getSubagentsService();
|
|
150
|
+
if (!service) {
|
|
151
|
+
// Fallback: keyword matching when subagent service unavailable
|
|
152
|
+
return keywordMatch(candidates, prompt, maxFiles);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const provider = {
|
|
156
|
+
// biome-ignore lint/suspicious/noExplicitAny: WorkspaceProvider prepare ctx
|
|
157
|
+
async prepare(_ctx: any) {
|
|
158
|
+
return { cwd: process.cwd(), dispose: () => undefined };
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
const unregister = service.registerWorkspaceProvider(provider);
|
|
162
|
+
|
|
163
|
+
try {
|
|
164
|
+
// Subscribe to events BEFORE spawn to prevent race: if the subagent
|
|
165
|
+
// completes between spawn and subscribe, the event is lost and
|
|
166
|
+
// the Promise never resolves except by timeout.
|
|
167
|
+
const result = await new Promise<string[]>((resolve) => {
|
|
168
|
+
const timeout = setTimeout(() => {
|
|
169
|
+
cleanup();
|
|
170
|
+
resolve(keywordMatch(candidates, prompt, maxFiles));
|
|
171
|
+
}, 30_000);
|
|
172
|
+
|
|
173
|
+
let settled = false;
|
|
174
|
+
const cleanup = () => {
|
|
175
|
+
if (settled) return;
|
|
176
|
+
settled = true;
|
|
177
|
+
clearTimeout(timeout);
|
|
178
|
+
unsubCompleted();
|
|
179
|
+
unsubFailed();
|
|
180
|
+
unregister();
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
const onCompleted = (data: { id: string }) => {
|
|
184
|
+
if (data.id !== agentId) return;
|
|
185
|
+
cleanup();
|
|
186
|
+
const record = service.getRecord(agentId);
|
|
187
|
+
const result = record?.result ?? "";
|
|
188
|
+
try {
|
|
189
|
+
const jsonMatch = result.match(/\{[^}]*"selected_files"[^}]*\}/s);
|
|
190
|
+
if (jsonMatch) {
|
|
191
|
+
const parsed = JSON.parse(jsonMatch[0]);
|
|
192
|
+
const files: string[] = parsed.selected_files ?? [];
|
|
193
|
+
const valid = files.filter((f: string) => candidates.some((c) => c.filename === f)).slice(0, maxFiles);
|
|
194
|
+
resolve(valid);
|
|
195
|
+
} else {
|
|
196
|
+
resolve([]);
|
|
197
|
+
}
|
|
198
|
+
} catch {
|
|
199
|
+
resolve([]);
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
const onFailed = (data: { id: string }) => {
|
|
204
|
+
if (data.id !== agentId) return;
|
|
205
|
+
cleanup();
|
|
206
|
+
resolve(keywordMatch(candidates, prompt, maxFiles));
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
const unsubCompleted = events?.on("subagents:completed", onCompleted) ?? (() => {});
|
|
210
|
+
const unsubFailed = events?.on("subagents:failed", onFailed) ?? (() => {});
|
|
211
|
+
|
|
212
|
+
if (!events) {
|
|
213
|
+
cleanup();
|
|
214
|
+
resolve(keywordMatch(candidates, prompt, maxFiles));
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Spawn AFTER subscriptions are active
|
|
219
|
+
const agentId = service.spawn("memory-agent", task, {
|
|
220
|
+
maxTurns: 1,
|
|
221
|
+
inheritContext: false,
|
|
222
|
+
thinkingLevel: "off",
|
|
223
|
+
foreground: true,
|
|
224
|
+
bypassQueue: true,
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
return result;
|
|
229
|
+
} catch {
|
|
230
|
+
unregister();
|
|
231
|
+
return keywordMatch(candidates, prompt, maxFiles);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Keyword-matching fallback for topic selection (no LLM required). */
|
|
236
|
+
function keywordMatch(manifest: TopicManifest[], userPrompt: string, maxFiles: number): string[] {
|
|
237
|
+
const words = userPrompt
|
|
238
|
+
.toLowerCase()
|
|
239
|
+
.split(/\W+/)
|
|
240
|
+
.filter((w) => w.length > 2);
|
|
241
|
+
const scored = manifest
|
|
242
|
+
.map((t) => {
|
|
243
|
+
const desc = `${t.description} ${t.name}`.toLowerCase();
|
|
244
|
+
const score = words.filter((w) => desc.includes(w)).length;
|
|
245
|
+
return { filename: t.filename, score };
|
|
246
|
+
})
|
|
247
|
+
.filter((t) => t.score > 0)
|
|
248
|
+
.sort((a, b) => b.score - a.score);
|
|
249
|
+
return scored.slice(0, maxFiles).map((t) => t.filename);
|
|
250
|
+
}
|
package/src/memory-tool.ts
CHANGED
|
@@ -1,18 +1,54 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
|
|
4
|
-
import { Type } from "typebox";
|
|
1
|
+
import { mkdir, readdir, readFile, unlink, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
5
3
|
import { StringEnum } from "@earendil-works/pi-ai";
|
|
4
|
+
import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
|
|
6
5
|
import { Text } from "@earendil-works/pi-tui";
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
6
|
+
import { Type } from "typebox";
|
|
7
|
+
import {
|
|
8
|
+
checkCapacity,
|
|
9
|
+
findEntryByTopic,
|
|
10
|
+
type IndexEntry,
|
|
11
|
+
parseIndex,
|
|
12
|
+
removeEntryByTopic,
|
|
13
|
+
serializeIndex,
|
|
14
|
+
updateHook,
|
|
15
|
+
upsertEntryByTopic,
|
|
16
|
+
} from "./index-file";
|
|
9
17
|
import { safeTopicPath } from "./paths";
|
|
18
|
+
import {
|
|
19
|
+
appendContent,
|
|
20
|
+
buildFrontmatter,
|
|
21
|
+
hasEntries,
|
|
22
|
+
parseEntries,
|
|
23
|
+
removeEntrySection,
|
|
24
|
+
updateFrontmatterDate,
|
|
25
|
+
} from "./topic-file";
|
|
10
26
|
|
|
11
|
-
export interface AddParams {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
27
|
+
export interface AddParams {
|
|
28
|
+
content: string;
|
|
29
|
+
topic: string;
|
|
30
|
+
title: string;
|
|
31
|
+
type?: string;
|
|
32
|
+
maxLines: number;
|
|
33
|
+
maxBytes: number;
|
|
34
|
+
}
|
|
35
|
+
export interface RemoveParams {
|
|
36
|
+
entry: string;
|
|
37
|
+
}
|
|
38
|
+
export interface ReadParams {
|
|
39
|
+
topic?: string;
|
|
40
|
+
entry?: string;
|
|
41
|
+
}
|
|
42
|
+
export interface ReadResult {
|
|
43
|
+
ok: boolean;
|
|
44
|
+
error?: string;
|
|
45
|
+
content?: string;
|
|
46
|
+
}
|
|
47
|
+
export interface ActionResult {
|
|
48
|
+
ok: boolean;
|
|
49
|
+
error?: string;
|
|
50
|
+
entries?: IndexEntry[];
|
|
51
|
+
}
|
|
16
52
|
|
|
17
53
|
const MEMORY_MD = "MEMORY.md";
|
|
18
54
|
|
|
@@ -31,35 +67,61 @@ function today(): string {
|
|
|
31
67
|
|
|
32
68
|
export async function doAdd(memoryDir: string, p: AddParams): Promise<ActionResult> {
|
|
33
69
|
if (!p.title) return { ok: false, error: "title is required" };
|
|
70
|
+
const memType = p.type ?? "feedback";
|
|
71
|
+
if (!["user", "feedback", "project", "reference"].includes(memType)) {
|
|
72
|
+
return { ok: false, error: `Invalid type "${memType}". Must be one of: user, feedback, project, reference` };
|
|
73
|
+
}
|
|
74
|
+
// Normalize topic: always .md extension (LLM may pass "debugging" or "debugging.md")
|
|
75
|
+
const topic = p.topic.endsWith(".md") ? p.topic : `${p.topic}.md`;
|
|
34
76
|
let topicPath: string;
|
|
35
77
|
try {
|
|
36
|
-
topicPath = safeTopicPath(memoryDir,
|
|
78
|
+
topicPath = safeTopicPath(memoryDir, topic);
|
|
79
|
+
// biome-ignore lint/suspicious/noExplicitAny: error catch
|
|
37
80
|
} catch (e: any) {
|
|
38
81
|
return { ok: false, error: e.message };
|
|
39
82
|
}
|
|
40
83
|
return withFileMutationQueue(join(memoryDir, MEMORY_MD), async () => {
|
|
41
84
|
await mkdir(dirname(topicPath), { recursive: true });
|
|
42
85
|
const entries = await readIndex(memoryDir);
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
ok: false,
|
|
47
|
-
error: `MEMORY.md capacity exceeded (max ${p.maxLines} lines / ${p.maxBytes} bytes). Current entries: ${serializeIndex(entries)}`,
|
|
48
|
-
};
|
|
49
|
-
}
|
|
50
|
-
// write topic file
|
|
51
|
-
let existing: string | null = null;
|
|
52
|
-
try { existing = await readFile(topicPath, "utf8"); } catch { existing = null; }
|
|
86
|
+
const existing = findEntryByTopic(entries, topic);
|
|
87
|
+
|
|
88
|
+
let next: IndexEntry[];
|
|
53
89
|
if (!existing) {
|
|
54
|
-
|
|
55
|
-
|
|
90
|
+
// New topic: create index entry with topic name as display name, entry title as hook
|
|
91
|
+
const name = topic.replace(/\.md$/, "");
|
|
92
|
+
const entry: IndexEntry = { name, topic, hook: p.title, raw: "" };
|
|
93
|
+
next = upsertEntryByTopic(entries, entry);
|
|
94
|
+
if (!checkCapacity(next, p.maxLines, p.maxBytes)) {
|
|
95
|
+
return {
|
|
96
|
+
ok: false,
|
|
97
|
+
error: `MEMORY.md capacity exceeded (max ${p.maxLines} lines / ${p.maxBytes} bytes). Current entries: ${serializeIndex(entries)}`,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
// Create topic file with full frontmatter
|
|
101
|
+
const fm = buildFrontmatter({ name, description: p.title, type: memType, updated: today() });
|
|
102
|
+
const topicContent = appendContent(fm, p.title, p.content);
|
|
103
|
+
await writeFile(topicPath, topicContent, "utf8");
|
|
56
104
|
} else {
|
|
57
|
-
|
|
58
|
-
const
|
|
59
|
-
|
|
105
|
+
// Existing topic: append entry, then regenerate hook from ALL entry titles
|
|
106
|
+
const raw = await readFile(topicPath, "utf8");
|
|
107
|
+
const refreshed = updateFrontmatterDate(raw, today());
|
|
108
|
+
const topicContent = appendContent(refreshed, p.title, p.content);
|
|
109
|
+
await writeFile(topicPath, topicContent, "utf8");
|
|
110
|
+
|
|
111
|
+
// Build hook from all entries (comma-separated titles, trimmed to ~150 chars)
|
|
112
|
+
const allEntries = parseEntries(topicContent);
|
|
113
|
+
const hook = allEntries.map((e) => e.title).join("; ").slice(0, 150);
|
|
114
|
+
next = updateHook(entries, topic, hook);
|
|
115
|
+
if (!checkCapacity(next, p.maxLines, p.maxBytes)) {
|
|
116
|
+
return {
|
|
117
|
+
ok: false,
|
|
118
|
+
error: `MEMORY.md capacity exceeded (max ${p.maxLines} lines / ${p.maxBytes} bytes). Current entries: ${serializeIndex(entries)}`,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
60
121
|
}
|
|
122
|
+
|
|
61
123
|
// write index
|
|
62
|
-
await writeFile(join(memoryDir, MEMORY_MD), serializeIndex(next)
|
|
124
|
+
await writeFile(join(memoryDir, MEMORY_MD), `${serializeIndex(next)}\n`, "utf8");
|
|
63
125
|
return { ok: true, entries: next };
|
|
64
126
|
});
|
|
65
127
|
}
|
|
@@ -67,38 +129,56 @@ export async function doAdd(memoryDir: string, p: AddParams): Promise<ActionResu
|
|
|
67
129
|
export async function doRemove(memoryDir: string, p: RemoveParams): Promise<ActionResult> {
|
|
68
130
|
return withFileMutationQueue(join(memoryDir, MEMORY_MD), async () => {
|
|
69
131
|
const entries = await readIndex(memoryDir);
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
132
|
+
|
|
133
|
+
// Search across all topic files to find which one contains this entry
|
|
134
|
+
const files = await readdir(memoryDir).catch(() => []);
|
|
135
|
+
let foundTopic: string | null = null;
|
|
136
|
+
const foundTopics: string[] = [];
|
|
137
|
+
|
|
138
|
+
for (const f of files) {
|
|
139
|
+
if (!f.endsWith(".md") || f === MEMORY_MD) continue;
|
|
140
|
+
const raw = await readFile(join(memoryDir, f), "utf8").catch(() => "");
|
|
141
|
+
const parsed = parseEntries(raw);
|
|
142
|
+
if (parsed.some((e) => e.title === p.entry)) {
|
|
143
|
+
foundTopics.push(f);
|
|
144
|
+
foundTopic = f;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (foundTopics.length === 0) {
|
|
149
|
+
return { ok: false, error: `Entry "${p.entry}" not found in any topic` };
|
|
73
150
|
}
|
|
74
|
-
if (
|
|
75
|
-
|
|
76
|
-
.filter((e) => e.title === p.entry)
|
|
77
|
-
.map((e) => e.topic)
|
|
78
|
-
.join(", ");
|
|
79
|
-
return { ok: false, error: `Multiple matches for entry "${p.entry}" in topics: ${matchingTopics}` };
|
|
151
|
+
if (foundTopics.length > 1) {
|
|
152
|
+
return { ok: false, error: `Multiple matches for entry "${p.entry}" in topics: ${foundTopics.join(", ")}` };
|
|
80
153
|
}
|
|
81
154
|
|
|
82
|
-
|
|
155
|
+
// biome-ignore lint/style/noNonNullAssertion: foundTopic assigned in guard above
|
|
156
|
+
const topicFile = foundTopic!;
|
|
83
157
|
const topicPath = safeTopicPath(memoryDir, topicFile);
|
|
84
158
|
|
|
85
|
-
//
|
|
86
|
-
const updated = removeEntryByTitle(entries, p.entry);
|
|
87
|
-
await writeFile(join(memoryDir, MEMORY_MD), serializeIndex(updated) + "\n", "utf8");
|
|
88
|
-
|
|
89
|
-
// remove ## block from topic file
|
|
159
|
+
// Remove ## block from topic file
|
|
90
160
|
try {
|
|
91
161
|
const raw = await readFile(topicPath, "utf8");
|
|
92
162
|
const afterRemoval = removeEntrySection(raw, p.entry);
|
|
163
|
+
|
|
93
164
|
if (hasEntries(afterRemoval)) {
|
|
165
|
+
// Still has entries: update hook to remaining first entry, refresh date
|
|
166
|
+
const remaining = parseEntries(afterRemoval);
|
|
167
|
+
const newHook = remaining.length > 0 ? remaining[0].title : "";
|
|
168
|
+
const nextEntries = updateHook(entries, topicFile, newHook);
|
|
94
169
|
const refreshed = updateFrontmatterDate(afterRemoval, today());
|
|
95
170
|
await writeFile(topicPath, refreshed, "utf8");
|
|
171
|
+
await writeFile(join(memoryDir, MEMORY_MD), `${serializeIndex(nextEntries)}\n`, "utf8");
|
|
96
172
|
} else {
|
|
173
|
+
// Last entry removed: delete topic file and remove from index
|
|
174
|
+
const nextEntries = removeEntryByTopic(entries, topicFile);
|
|
97
175
|
await unlink(topicPath).catch(() => {});
|
|
176
|
+
await writeFile(join(memoryDir, MEMORY_MD), `${serializeIndex(nextEntries)}\n`, "utf8");
|
|
98
177
|
}
|
|
178
|
+
// biome-ignore lint/suspicious/noExplicitAny: error catch
|
|
99
179
|
} catch (e: any) {
|
|
100
|
-
// topic file missing — still ok, index already removed
|
|
101
180
|
if ((e as NodeJS.ErrnoException).code !== "ENOENT") throw e;
|
|
181
|
+
return { ok: false, error: `Topic file "${topicFile}" not found` };
|
|
102
182
|
}
|
|
103
183
|
|
|
104
184
|
return { ok: true };
|
|
@@ -111,6 +191,7 @@ export async function doRead(memoryDir: string, p: ReadParams): Promise<ReadResu
|
|
|
111
191
|
let topicPath: string;
|
|
112
192
|
try {
|
|
113
193
|
topicPath = safeTopicPath(memoryDir, topicName);
|
|
194
|
+
// biome-ignore lint/suspicious/noExplicitAny: error catch
|
|
114
195
|
} catch (e: any) {
|
|
115
196
|
return { ok: false, error: e.message };
|
|
116
197
|
}
|
|
@@ -122,9 +203,7 @@ export async function doRead(memoryDir: string, p: ReadParams): Promise<ReadResu
|
|
|
122
203
|
}
|
|
123
204
|
}
|
|
124
205
|
if (p.entry) {
|
|
125
|
-
const files = (await readdir(memoryDir).catch(() => [])).filter(
|
|
126
|
-
(f) => f.endsWith(".md") && f !== MEMORY_MD,
|
|
127
|
-
);
|
|
206
|
+
const files = (await readdir(memoryDir).catch(() => [])).filter((f) => f.endsWith(".md") && f !== MEMORY_MD);
|
|
128
207
|
for (const f of files) {
|
|
129
208
|
const raw = await readFile(join(memoryDir, f), "utf8").catch(() => "");
|
|
130
209
|
const entries = parseEntries(raw);
|
|
@@ -156,7 +235,11 @@ export async function searchMemory(memoryDir: string, query: string): Promise<st
|
|
|
156
235
|
|
|
157
236
|
export interface MemoryToolDeps {
|
|
158
237
|
getMemoryDir: () => string | null;
|
|
159
|
-
getConfig: () => {
|
|
238
|
+
getConfig: () => {
|
|
239
|
+
memIndexMaxLines: number;
|
|
240
|
+
memIndexMaxBytes: number;
|
|
241
|
+
sessionSearch: { maxSessions: number; maxMatches: number };
|
|
242
|
+
};
|
|
160
243
|
getEnabled: () => boolean;
|
|
161
244
|
searchSessions: (cwd: string, query: string, cfg: { maxSessions: number; maxMatches: number }) => Promise<string>;
|
|
162
245
|
cwd: () => string;
|
|
@@ -167,51 +250,74 @@ export function createMemoryTool(deps: MemoryToolDeps) {
|
|
|
167
250
|
name: "memory",
|
|
168
251
|
label: "Memory",
|
|
169
252
|
description:
|
|
170
|
-
"Read/write project memory across sessions. action 'add' appends content under a topic (auto-created) as an entry; 'remove' deletes an entry by title; 'read' loads a topic or entry; 'search' queries memory files or history sessions. IMPORTANT: only MEMORY.md index lines are injected into system prompts — entry titles must be self-contained and descriptive (topic file content is NOT injected).",
|
|
171
|
-
promptSnippet:
|
|
253
|
+
"Read/write project memory across sessions. action 'add' appends content under a topic (auto-created) as an entry; 'remove' deletes an entry by title; 'read' loads a topic or entry; 'search' queries memory files or history sessions. IMPORTANT: only MEMORY.md index lines are injected into system prompts — entry titles must be self-contained and descriptive (topic file content is NOT injected automatically — it is auto-surfaced for relevant queries).",
|
|
254
|
+
promptSnippet:
|
|
255
|
+
"Read/write project memory across sessions (add/remove/search/read). Only index titles are injected — make titles self-descriptive.",
|
|
172
256
|
promptGuidelines: [
|
|
173
257
|
"Use memory to persist project facts, user preferences, and lessons learned across sessions.",
|
|
174
258
|
"Use memory action 'add' with an explicit topic filename and a descriptive, self-contained entry title — only the index line (title + topic) is injected into future prompts, NOT the topic file content. The title alone must convey what was learned.",
|
|
175
259
|
"Use memory action 'search' with scope='sessions' to find past work in history sessions.",
|
|
176
260
|
"Use memory action 'read' with topic or entry to load stored knowledge.",
|
|
261
|
+
"Auto-surfacing: relevant topic files are automatically selected and their content injected into the conversation context. Use 'read' to load additional topics when needed — you don't need to read what's already been surfaced.",
|
|
177
262
|
],
|
|
178
263
|
parameters: Type.Object({
|
|
179
264
|
action: StringEnum(["add", "remove", "search", "read"] as const),
|
|
180
265
|
// add
|
|
181
266
|
content: Type.Optional(Type.String({ description: "Knowledge text to store (add)." })),
|
|
182
|
-
topic: Type.Optional(
|
|
183
|
-
|
|
267
|
+
topic: Type.Optional(
|
|
268
|
+
Type.String({ description: "Target topic filename, e.g. 'debugging.md'. Auto-created if new (add/read)." }),
|
|
269
|
+
),
|
|
270
|
+
title: Type.Optional(
|
|
271
|
+
Type.String({
|
|
272
|
+
description:
|
|
273
|
+
"Descriptive, self-contained title for the MEMORY.md index line. Only index lines are injected into future prompts (NOT topic file content), so the title must convey enough context on its own. Required for add.",
|
|
274
|
+
}),
|
|
275
|
+
),
|
|
276
|
+
type: Type.Optional(StringEnum(["user", "feedback", "project", "reference"] as const)),
|
|
184
277
|
// remove
|
|
185
|
-
entry: Type.Optional(
|
|
278
|
+
entry: Type.Optional(
|
|
279
|
+
Type.String({ description: "Entry title to remove. Exact match on MEMORY.md index line (remove/read)." }),
|
|
280
|
+
),
|
|
186
281
|
// search
|
|
187
282
|
query: Type.Optional(Type.String()),
|
|
188
283
|
scope: Type.Optional(StringEnum(["memory", "sessions"] as const)),
|
|
189
284
|
}),
|
|
285
|
+
// biome-ignore lint/suspicious/noExplicitAny: renderCall args
|
|
190
286
|
renderCall(args: any, theme: any) {
|
|
191
287
|
let t = theme.fg("toolTitle", theme.bold("memory ")) + theme.fg("muted", args.action);
|
|
192
288
|
if (args.topic) t += ` ${theme.fg("accent", args.topic)}`;
|
|
193
289
|
if (args.query) t += ` ${theme.fg("dim", `"${args.query}"`)}`;
|
|
194
290
|
return new Text(t, 0, 0);
|
|
195
291
|
},
|
|
196
|
-
|
|
292
|
+
// biome-ignore lint/suspicious/noExplicitAny: renderResult expanded param
|
|
293
|
+
renderResult(result: any, { expanded: _expanded }: any, theme: any) {
|
|
197
294
|
const txt = result.content?.[0];
|
|
198
295
|
const text = txt?.type === "text" ? txt.text : "";
|
|
199
296
|
if (result.details?.error) return new Text(theme.fg("error", `Error: ${result.details.error}`), 0, 0);
|
|
200
297
|
return new Text(theme.fg("success", "✓ ") + theme.fg("muted", text.split("\n")[0]), 0, 0);
|
|
201
298
|
},
|
|
299
|
+
// biome-ignore lint/suspicious/noExplicitAny: renderResult result param
|
|
202
300
|
async execute(_id: string, params: any, _signal: AbortSignal | undefined, _onUpdate: any, _ctx: any) {
|
|
203
301
|
if (!deps.getEnabled()) throw new Error("Memory is disabled (run /memory on)");
|
|
204
302
|
const dir = deps.getMemoryDir();
|
|
205
303
|
const cfg = deps.getConfig();
|
|
206
304
|
if (!dir) throw new Error("Memory not initialized (no session_start yet)");
|
|
207
305
|
let text: string;
|
|
306
|
+
// biome-ignore lint/suspicious/noExplicitAny: execute params
|
|
208
307
|
let details: any = {};
|
|
209
308
|
switch (params.action) {
|
|
210
309
|
case "add": {
|
|
211
310
|
if (!params.content) throw new Error("content is required for add");
|
|
212
311
|
if (!params.topic) throw new Error("topic is required for add");
|
|
213
312
|
if (!params.title) throw new Error("title is required for add");
|
|
214
|
-
const r = await doAdd(dir, {
|
|
313
|
+
const r = await doAdd(dir, {
|
|
314
|
+
content: params.content,
|
|
315
|
+
topic: params.topic,
|
|
316
|
+
title: params.title,
|
|
317
|
+
type: params.type,
|
|
318
|
+
maxLines: cfg.memIndexMaxLines,
|
|
319
|
+
maxBytes: cfg.memIndexMaxBytes,
|
|
320
|
+
});
|
|
215
321
|
if (!r.ok) throw new Error(r.error);
|
|
216
322
|
text = `Added "${params.title}" to ${params.topic}. Index now has ${r.entries?.length ?? 0} entries.`;
|
|
217
323
|
details = { entries: r.entries?.length };
|
|
@@ -237,6 +343,7 @@ export function createMemoryTool(deps: MemoryToolDeps) {
|
|
|
237
343
|
if (!params.topic && !params.entry) throw new Error("topic or entry is required for read");
|
|
238
344
|
const r = await doRead(dir, { topic: params.topic, entry: params.entry });
|
|
239
345
|
if (!r.ok) throw new Error(r.error);
|
|
346
|
+
// biome-ignore lint/style/noNonNullAssertion: content assertion
|
|
240
347
|
text = r.content!;
|
|
241
348
|
break;
|
|
242
349
|
}
|
package/src/nudge.ts
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import { readFile, writeFile } from "node:fs/promises";
|
|
2
|
-
import { SessionManager } from "@earendil-works/pi-coding-agent";
|
|
3
2
|
import { join } from "node:path";
|
|
3
|
+
import { SessionManager } from "@earendil-works/pi-coding-agent";
|
|
4
4
|
import type { MemoryConfig } from "./config";
|
|
5
5
|
|
|
6
|
-
export interface DreamMeta {
|
|
6
|
+
export interface DreamMeta {
|
|
7
|
+
lastDreamAt: string;
|
|
8
|
+
sessionCountAtDream: number;
|
|
9
|
+
}
|
|
7
10
|
|
|
8
11
|
const META_FILE = ".dream-meta.json";
|
|
9
12
|
|
|
@@ -29,17 +32,17 @@ export async function shouldNudge(
|
|
|
29
32
|
memoryDir: string,
|
|
30
33
|
config: MemoryConfig,
|
|
31
34
|
cwd: string,
|
|
32
|
-
): Promise<{ nudge: boolean; message: string }> {
|
|
35
|
+
): Promise<{ nudge: boolean; message: string; sessions: number; newEntries: number }> {
|
|
33
36
|
const sessions = (await SessionManager.list(cwd)).length;
|
|
34
37
|
const meta = await readDreamMeta(memoryDir);
|
|
35
38
|
const newEntries = meta ? Math.max(0, sessions - meta.sessionCountAtDream) : sessions;
|
|
36
39
|
if (meta) {
|
|
37
40
|
const hoursSince = (Date.now() - new Date(meta.lastDreamAt).getTime()) / 3600_000;
|
|
38
41
|
if (hoursSince >= config.dream.nudgeAfterHours && newEntries >= config.dream.nudgeAfterSessions) {
|
|
39
|
-
return { nudge: true, message: formatNudge(sessions, newEntries) };
|
|
42
|
+
return { nudge: true, message: formatNudge(sessions, newEntries), sessions, newEntries };
|
|
40
43
|
}
|
|
41
44
|
} else if (sessions >= config.dream.nudgeAfterSessions) {
|
|
42
|
-
return { nudge: true, message: formatNudge(sessions, newEntries) };
|
|
45
|
+
return { nudge: true, message: formatNudge(sessions, newEntries), sessions, newEntries };
|
|
43
46
|
}
|
|
44
|
-
return { nudge: false, message: "" };
|
|
47
|
+
return { nudge: false, message: "", sessions: 0, newEntries: 0 };
|
|
45
48
|
}
|
package/src/paths.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { createHash } from "node:crypto";
|
|
2
1
|
import { execFile } from "node:child_process";
|
|
3
|
-
import {
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { join, normalize, resolve, sep } from "node:path";
|
|
4
4
|
import { promisify } from "node:util";
|
|
5
5
|
|
|
6
6
|
const execFileP = promisify(execFile);
|
|
@@ -19,10 +19,7 @@ export async function projectHash(cwd: string): Promise<string> {
|
|
|
19
19
|
return createHash("sha256").update(key).digest("hex").slice(0, 12);
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
export async function resolveMemoryDir(
|
|
23
|
-
config: { memoryDir: string },
|
|
24
|
-
cwd: string,
|
|
25
|
-
): Promise<string> {
|
|
22
|
+
export async function resolveMemoryDir(config: { memoryDir: string }, cwd: string): Promise<string> {
|
|
26
23
|
const hash = await projectHash(cwd);
|
|
27
24
|
return join(config.memoryDir, hash);
|
|
28
25
|
}
|