@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/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
+ }
@@ -1,18 +1,54 @@
1
- import { readFile, writeFile, mkdir, readdir, unlink } from "node:fs/promises";
2
- import { join, dirname } from "node:path";
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 { parseIndex, serializeIndex, addEntry, removeEntryByTitle, matchEntryByTitle, checkCapacity, type IndexEntry } from "./index-file";
8
- import { buildFrontmatter, appendContent, updateFrontmatterDate, removeEntrySection, hasEntries, parseEntries } from "./topic-file";
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 { content: string; topic: string; title: string; maxLines: number; maxBytes: number; }
12
- export interface RemoveParams { entry: string; }
13
- export interface ReadParams { topic?: string; entry?: string; }
14
- export interface ReadResult { ok: boolean; error?: string; content?: string; }
15
- export interface ActionResult { ok: boolean; error?: string; entries?: IndexEntry[]; }
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, p.topic);
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 next = addEntry(entries, { title: p.title, topic: p.topic, raw: "" });
44
- if (!checkCapacity(next, p.maxLines, p.maxBytes)) {
45
- return {
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
- const out = `${buildFrontmatter({ updated: today() })}${appendContent(null, p.title, p.content)}`;
55
- await writeFile(topicPath, out, "utf8");
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
- const updated = updateFrontmatterDate(existing, today());
58
- const out = appendContent(updated, p.title, p.content);
59
- await writeFile(topicPath, out, "utf8");
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) + "\n", "utf8");
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
- const match = matchEntryByTitle(entries, p.entry);
71
- if (!match.entry) {
72
- return { ok: false, error: `Entry "${p.entry}" not found in index` };
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 (!match.unique) {
75
- const matchingTopics = entries
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
- const topicFile = match.entry.topic;
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
- // remove index line
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: () => { memIndexMaxLines: number; memIndexMaxBytes: number; sessionSearch: { maxSessions: number; maxMatches: number } };
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: "Read/write project memory across sessions (add/remove/search/read). Only index titles are injected — make titles self-descriptive.",
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(Type.String({ description: "Target topic filename, e.g. 'debugging.md'. Auto-created if new (add/read)." })),
183
- title: Type.Optional(Type.String({ description: "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." })),
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(Type.String({ description: "Entry title to remove. Exact match on MEMORY.md index line (remove/read)." })),
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
- renderResult(result: any, { expanded }: any, theme: any) {
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, { content: params.content, topic: params.topic, title: params.title, maxLines: cfg.memIndexMaxLines, maxBytes: cfg.memIndexMaxBytes });
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 { lastDreamAt: string; sessionCountAtDream: number; }
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 { join, resolve, normalize, sep } from "node:path";
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
  }