lsd-pi 1.1.0 → 1.1.1

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.
Files changed (33) hide show
  1. package/dist/resources/extensions/memory/auto-extract.js +154 -0
  2. package/dist/resources/extensions/memory/extension-manifest.json +11 -0
  3. package/dist/resources/extensions/memory/index.js +223 -0
  4. package/dist/resources/extensions/memory/memory-age.js +43 -0
  5. package/dist/resources/extensions/memory/memory-paths.js +49 -0
  6. package/dist/resources/extensions/memory/memory-recall.js +150 -0
  7. package/dist/resources/extensions/memory/memory-scan.js +90 -0
  8. package/dist/resources/extensions/memory/memory-types.js +96 -0
  9. package/dist/resources/extensions/remote-questions/extension-manifest.json +1 -1
  10. package/dist/resources/extensions/remote-questions/index.js +53 -0
  11. package/dist/resources/extensions/remote-questions/telegram-adapter.js +6 -13
  12. package/dist/resources/extensions/remote-questions/telegram-live-relay.js +447 -0
  13. package/dist/resources/extensions/remote-questions/telegram-update-stream.js +67 -0
  14. package/package.json +1 -1
  15. package/packages/pi-coding-agent/package.json +1 -1
  16. package/pkg/package.json +1 -1
  17. package/src/resources/extensions/memory/auto-extract.ts +172 -0
  18. package/src/resources/extensions/memory/extension-manifest.json +11 -0
  19. package/src/resources/extensions/memory/index.ts +263 -0
  20. package/src/resources/extensions/memory/memory-age.ts +43 -0
  21. package/src/resources/extensions/memory/memory-paths.ts +55 -0
  22. package/src/resources/extensions/memory/memory-recall.ts +186 -0
  23. package/src/resources/extensions/memory/memory-scan.ts +118 -0
  24. package/src/resources/extensions/memory/memory-types.ts +106 -0
  25. package/src/resources/extensions/memory/tests/auto-extract.test.ts +141 -0
  26. package/src/resources/extensions/memory/tests/memory-age.test.ts +60 -0
  27. package/src/resources/extensions/memory/tests/memory-paths.test.ts +89 -0
  28. package/src/resources/extensions/memory/tests/memory-scan.test.ts +244 -0
  29. package/src/resources/extensions/remote-questions/extension-manifest.json +1 -1
  30. package/src/resources/extensions/remote-questions/index.ts +65 -0
  31. package/src/resources/extensions/remote-questions/telegram-adapter.ts +6 -14
  32. package/src/resources/extensions/remote-questions/telegram-live-relay.ts +471 -0
  33. package/src/resources/extensions/remote-questions/telegram-update-stream.ts +116 -0
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Auto-extract — fire-and-forget background memory extraction.
3
+ *
4
+ * Runs after a session ends: reads the conversation transcript,
5
+ * spawns a headless agent to identify durable facts worth remembering,
6
+ * and writes memory files to the project's memory directory.
7
+ */
8
+ import { spawn } from 'node:child_process';
9
+ import { mkdirSync, writeFileSync, unlinkSync, existsSync } from 'node:fs';
10
+ import { join, dirname } from 'node:path';
11
+ import { tmpdir } from 'node:os';
12
+ import { randomUUID } from 'node:crypto';
13
+ import { getMemoryDir } from './memory-paths.js';
14
+ import { scanMemoryFiles, formatMemoryManifest } from './memory-scan.js';
15
+ /**
16
+ * Build a plain-text transcript from session entries, keeping only
17
+ * human-readable message content (no tool_use / tool_result blocks).
18
+ *
19
+ * Returns an empty string if the transcript has fewer than 3 messages.
20
+ */
21
+ export function buildTranscriptSummary(entries) {
22
+ const lines = [];
23
+ for (const entry of entries) {
24
+ if (entry.type !== 'message')
25
+ continue;
26
+ const role = entry.message?.role;
27
+ if (role !== 'user' && role !== 'assistant')
28
+ continue;
29
+ const raw = entry.message.content;
30
+ let text = '';
31
+ if (typeof raw === 'string') {
32
+ text = raw;
33
+ }
34
+ else if (Array.isArray(raw)) {
35
+ // Multi-part messages — extract text blocks only, skip tool_use / tool_result
36
+ text = raw
37
+ .filter((part) => part.type === 'text' && typeof part.text === 'string')
38
+ .map((part) => part.text)
39
+ .join('\n');
40
+ }
41
+ if (!text.trim())
42
+ continue;
43
+ // Truncate individual messages to keep the transcript manageable
44
+ const truncated = text.length > 2000 ? text.slice(0, 2000) + '…' : text;
45
+ const label = role === 'user' ? 'User' : 'Assistant';
46
+ lines.push(`${label}: ${truncated}`);
47
+ }
48
+ if (lines.length < 3)
49
+ return '';
50
+ return lines.join('\n\n');
51
+ }
52
+ /**
53
+ * Build the system prompt that instructs the headless extraction agent
54
+ * on what to save (and what to skip).
55
+ */
56
+ export function buildExtractionPrompt(memoryDir, transcript) {
57
+ const existing = scanMemoryFiles(memoryDir);
58
+ const manifest = existing.length > 0 ? formatMemoryManifest(existing) : 'None yet';
59
+ return `You are a memory extraction agent for a coding assistant. Read the conversation transcript and save any durable facts worth remembering.
60
+
61
+ Memory directory: ${memoryDir}
62
+ This directory already exists — write files directly.
63
+
64
+ Rules:
65
+ - Save ONLY: user preferences/role, feedback/corrections, project context (deadlines, decisions), external references
66
+ - Do NOT save: code patterns, architecture, file paths, git history, debugging steps, ephemeral task details
67
+ - Check existing memories below — update existing files rather than creating duplicates
68
+ - Use frontmatter: ---\\nname: ...\\ndescription: ...\\ntype: user|feedback|project|reference\\n---
69
+ - After writing topic files, update MEMORY.md with one-line index entries
70
+ - Be VERY selective — only save things useful in FUTURE conversations
71
+ - If nothing is worth saving, do nothing
72
+
73
+ Existing memories:
74
+ ${manifest}
75
+
76
+ Conversation transcript:
77
+ ${transcript}`;
78
+ }
79
+ /**
80
+ * Resolve the path to the LSD/GSD CLI entry point.
81
+ * Returns null if no valid CLI binary can be found.
82
+ */
83
+ export function resolveCliPath() {
84
+ // Primary: the entry point used to launch the current process
85
+ const argv1 = process.argv[1];
86
+ if (argv1 && existsSync(argv1))
87
+ return argv1;
88
+ // Fallback: walk up from argv1 to find a bin/ sibling
89
+ if (argv1) {
90
+ const binDir = join(dirname(argv1), '..', 'bin');
91
+ for (const name of ['lsd', 'gsd']) {
92
+ const candidate = join(binDir, name);
93
+ if (existsSync(candidate))
94
+ return candidate;
95
+ }
96
+ }
97
+ return null;
98
+ }
99
+ /**
100
+ * Main entry point — called from the session_shutdown hook.
101
+ *
102
+ * Reads the conversation transcript, builds an extraction prompt,
103
+ * and spawns a detached headless agent to process it.
104
+ * Fire-and-forget: the parent can exit without killing the child.
105
+ */
106
+ export function extractMemories(ctx, cwd) {
107
+ // Guard: prevent recursive extraction
108
+ if (process.env.LSD_MEMORY_EXTRACT === '1')
109
+ return;
110
+ // Guard: user opt-out
111
+ if (process.env.CLAUDE_CODE_DISABLE_AUTO_MEMORY)
112
+ return;
113
+ const entries = ctx.sessionManager.getEntries();
114
+ // Guard: need enough user messages to be worth extracting
115
+ const userMessageCount = entries.filter((e) => e.type === 'message' && e.message?.role === 'user').length;
116
+ if (userMessageCount < 3)
117
+ return;
118
+ const transcript = buildTranscriptSummary(entries);
119
+ if (!transcript)
120
+ return;
121
+ const memoryDir = getMemoryDir(cwd);
122
+ mkdirSync(memoryDir, { recursive: true });
123
+ const prompt = buildExtractionPrompt(memoryDir, transcript);
124
+ // Write prompt to a temp file so the spawned agent can read it
125
+ const tmpPromptPath = join(tmpdir(), `lsd-memory-extract-${randomUUID()}.md`);
126
+ writeFileSync(tmpPromptPath, prompt, 'utf-8');
127
+ const cliPath = resolveCliPath();
128
+ if (!cliPath)
129
+ return;
130
+ const proc = spawn(process.execPath, [
131
+ cliPath,
132
+ 'headless',
133
+ '--bare',
134
+ '--context',
135
+ tmpPromptPath,
136
+ '--context-text',
137
+ 'Extract memories from the transcript above. Write any worth-saving memories to the memory directory, then update MEMORY.md.',
138
+ ], {
139
+ cwd,
140
+ detached: true,
141
+ stdio: 'ignore',
142
+ env: { ...process.env, LSD_MEMORY_EXTRACT: '1' },
143
+ });
144
+ proc.unref();
145
+ // Clean up the temp file after the child has had time to read it
146
+ setTimeout(() => {
147
+ try {
148
+ unlinkSync(tmpPromptPath);
149
+ }
150
+ catch {
151
+ // Already cleaned up or inaccessible — safe to ignore
152
+ }
153
+ }, 120_000).unref();
154
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "id": "memory",
3
+ "name": "Persistent Memory",
4
+ "version": "1.0.0",
5
+ "description": "Persistent file-based memory across sessions",
6
+ "tier": "bundled",
7
+ "provides": {
8
+ "hooks": ["session_start", "before_agent_start", "session_shutdown"],
9
+ "commands": ["memories", "remember", "forget"]
10
+ }
11
+ }
@@ -0,0 +1,223 @@
1
+ /**
2
+ * Memory Extension — persistent, file-based memory for LSD agents.
3
+ *
4
+ * Bootstraps a per-project memory directory, injects the memory system prompt
5
+ * into every agent turn, and registers /memories, /remember, /forget commands.
6
+ *
7
+ * Memory files live under ~/.lsd/memory/<sanitized-project-path>/ and are
8
+ * indexed by a MEMORY.md entrypoint that is always loaded into context.
9
+ */
10
+ import { readFileSync, existsSync, writeFileSync } from 'node:fs';
11
+ import { getMemoryDir, getMemoryEntrypoint, ensureMemoryDir } from './memory-paths.js';
12
+ import { MEMORY_FRONTMATTER_EXAMPLE, TYPES_SECTION, WHAT_NOT_TO_SAVE_SECTION, WHEN_TO_ACCESS_SECTION, TRUSTING_RECALL_SECTION, } from './memory-types.js';
13
+ import { scanMemoryFiles } from './memory-scan.js';
14
+ import { memoryAge } from './memory-age.js';
15
+ import { extractMemories } from './auto-extract.js';
16
+ // ── Constants ────────────────────────────────────────────────────────
17
+ /** Name of the entrypoint file that indexes all memories. */
18
+ const ENTRYPOINT_NAME = 'MEMORY.md';
19
+ /** Maximum number of lines loaded from MEMORY.md into context. */
20
+ const MAX_ENTRYPOINT_LINES = 200;
21
+ /** Maximum byte size loaded from MEMORY.md into context. */
22
+ const MAX_ENTRYPOINT_BYTES = 25_000;
23
+ // ── Helpers ──────────────────────────────────────────────────────────
24
+ /**
25
+ * Truncate the raw MEMORY.md content to stay within context-window limits.
26
+ *
27
+ * Applies two caps in order:
28
+ * 1. Line count (MAX_ENTRYPOINT_LINES)
29
+ * 2. Byte size (MAX_ENTRYPOINT_BYTES)
30
+ *
31
+ * If either cap triggers, a warning footer is appended so the agent knows
32
+ * the index was trimmed.
33
+ */
34
+ function truncateEntrypointContent(raw) {
35
+ let wasTruncated = false;
36
+ let content = raw.trim();
37
+ const lines = content.split('\n');
38
+ // Cap 1: line count
39
+ if (lines.length > MAX_ENTRYPOINT_LINES) {
40
+ content = lines.slice(0, MAX_ENTRYPOINT_LINES).join('\n');
41
+ wasTruncated = true;
42
+ }
43
+ // Cap 2: byte size
44
+ if (Buffer.byteLength(content, 'utf-8') > MAX_ENTRYPOINT_BYTES) {
45
+ // Walk backwards to find the last newline within budget
46
+ let cutoff = content.length;
47
+ while (Buffer.byteLength(content.slice(0, cutoff), 'utf-8') > MAX_ENTRYPOINT_BYTES) {
48
+ const idx = content.lastIndexOf('\n', cutoff - 1);
49
+ cutoff = idx > 0 ? idx : 0;
50
+ }
51
+ content = content.slice(0, cutoff);
52
+ wasTruncated = true;
53
+ }
54
+ if (wasTruncated) {
55
+ content +=
56
+ '\n\n> WARNING: MEMORY.md is too large. Only part was loaded. Keep index entries concise.';
57
+ }
58
+ return { content, wasTruncated };
59
+ }
60
+ /**
61
+ * Build the full memory system prompt that is injected via before_agent_start.
62
+ *
63
+ * @param memoryDir Absolute path to the project's memory directory.
64
+ * @param entrypointContent The (possibly truncated) contents of MEMORY.md.
65
+ */
66
+ function buildMemoryPrompt(memoryDir, entrypointContent) {
67
+ const sections = [];
68
+ // ── Header ──
69
+ sections.push(`# Memory
70
+
71
+ You have a persistent, file-based memory system at \`${memoryDir}\`.
72
+ This directory already exists — write to it directly with the file write tool (do not run mkdir or check existence).
73
+
74
+ Build up this memory over time so future conversations have a complete picture of who the user is, how they'd like to collaborate, what to avoid or repeat, and the context behind their work.
75
+
76
+ If the user explicitly asks you to remember something, save it immediately. If they ask you to forget, find and remove it.`);
77
+ // ── Types ──
78
+ sections.push(TYPES_SECTION.join('\n'));
79
+ // ── What not to save ──
80
+ sections.push(WHAT_NOT_TO_SAVE_SECTION.join('\n'));
81
+ // ── How to save ──
82
+ sections.push(`## How to save memories
83
+
84
+ Saving a memory is a two-step process:
85
+
86
+ **Step 1** — write the memory to its own file (e.g., \`user_role.md\`, \`feedback_testing.md\`) using this frontmatter format:
87
+
88
+ ${MEMORY_FRONTMATTER_EXAMPLE.join('\n')}
89
+
90
+ **Step 2** — add a pointer to that file in \`MEMORY.md\`. Each entry should be one line, under ~150 characters: \`- [Title](file.md) — one-line hook\`. Never write memory content directly into \`MEMORY.md\`.
91
+
92
+ - \`MEMORY.md\` is always loaded into your context — lines after ${MAX_ENTRYPOINT_LINES} will be truncated
93
+ - Keep name, description, and type fields up-to-date
94
+ - Organize semantically, not chronologically
95
+ - Update or remove stale memories
96
+ - Check for existing memories before writing duplicates`);
97
+ // ── When to access ──
98
+ sections.push(WHEN_TO_ACCESS_SECTION.join('\n'));
99
+ // ── Trusting recall ──
100
+ sections.push(TRUSTING_RECALL_SECTION.join('\n'));
101
+ // ── Entrypoint content ──
102
+ const body = entrypointContent.trim() ||
103
+ 'Your MEMORY.md is currently empty. When you save new memories, they will appear here.';
104
+ sections.push(`## MEMORY.md\n\n${body}`);
105
+ return sections.join('\n\n');
106
+ }
107
+ // ── Extension entry point ────────────────────────────────────────────
108
+ /**
109
+ * Memory extension for LSD.
110
+ *
111
+ * Lifecycle:
112
+ * session_start → bootstrap memory directory & MEMORY.md
113
+ * before_agent_start → inject memory system prompt
114
+ * session_shutdown → fire-and-forget auto-extract of new memories
115
+ *
116
+ * Commands:
117
+ * /memories — list all saved memories
118
+ * /remember — save a memory immediately
119
+ * /forget — remove a memory by topic
120
+ */
121
+ export default function memoryExtension(pi) {
122
+ let memoryCwd = '';
123
+ let memoryDir = '';
124
+ // ── session_start: bootstrap memory directory ──────────────────────
125
+ pi.on('session_start', async (_event, ctx) => {
126
+ memoryCwd = ctx.cwd;
127
+ memoryDir = getMemoryDir(memoryCwd);
128
+ ensureMemoryDir(memoryCwd);
129
+ // Create MEMORY.md if it doesn't exist
130
+ const entrypoint = getMemoryEntrypoint(memoryCwd);
131
+ if (!existsSync(entrypoint)) {
132
+ writeFileSync(entrypoint, '', 'utf-8');
133
+ }
134
+ });
135
+ // ── before_agent_start: inject memory prompt into system prompt ───
136
+ pi.on('before_agent_start', async (event) => {
137
+ if (!memoryCwd)
138
+ return;
139
+ const entrypoint = getMemoryEntrypoint(memoryCwd);
140
+ let entrypointContent = '';
141
+ try {
142
+ entrypointContent = readFileSync(entrypoint, 'utf-8');
143
+ }
144
+ catch {
145
+ // File may have been deleted between session_start and now
146
+ }
147
+ if (entrypointContent.trim()) {
148
+ const { content } = truncateEntrypointContent(entrypointContent);
149
+ entrypointContent = content;
150
+ }
151
+ const prompt = buildMemoryPrompt(memoryDir, entrypointContent);
152
+ return {
153
+ systemPrompt: event.systemPrompt + '\n\n' + prompt,
154
+ };
155
+ });
156
+ // ── session_shutdown: trigger auto-extract ────────────────────────
157
+ pi.on('session_shutdown', async (_event, ctx) => {
158
+ if (!memoryCwd)
159
+ return;
160
+ // Don't extract if this IS the extraction agent
161
+ if (process.env.LSD_MEMORY_EXTRACT === '1')
162
+ return;
163
+ try {
164
+ extractMemories(ctx, memoryCwd);
165
+ }
166
+ catch {
167
+ // Fire-and-forget — never block shutdown
168
+ }
169
+ });
170
+ // ── Slash commands ────────────────────────────────────────────────
171
+ /**
172
+ * /memories — list all saved memories with type, age, and description.
173
+ */
174
+ pi.registerCommand('memories', {
175
+ description: 'List all saved memories',
176
+ handler: async (_args, ctx) => {
177
+ if (!memoryCwd) {
178
+ ctx.ui?.notify('No memory directory initialized', 'warning');
179
+ return;
180
+ }
181
+ const memories = scanMemoryFiles(memoryDir);
182
+ if (memories.length === 0) {
183
+ pi.sendUserMessage("No memories saved yet. I'll start building memory as we work together.");
184
+ return;
185
+ }
186
+ const lines = memories.map((m) => {
187
+ const age = memoryAge(m.mtimeMs);
188
+ const type = m.type ? `[${m.type}]` : '';
189
+ const desc = m.description ? ` — ${m.description}` : '';
190
+ return `- ${type} **${m.filename}** (${age})${desc}`;
191
+ });
192
+ pi.sendUserMessage(`Here are your saved memories:\n\n${lines.join('\n')}`);
193
+ },
194
+ });
195
+ /**
196
+ * /remember <text> — ask the agent to save a memory immediately.
197
+ */
198
+ pi.registerCommand('remember', {
199
+ description: 'Save a memory immediately',
200
+ handler: async (args) => {
201
+ if (!memoryCwd)
202
+ return;
203
+ const text = args.trim();
204
+ if (!text)
205
+ return;
206
+ pi.sendUserMessage(`Please save this to memory: ${text}`);
207
+ },
208
+ });
209
+ /**
210
+ * /forget <topic> — ask the agent to find and remove a memory.
211
+ */
212
+ pi.registerCommand('forget', {
213
+ description: 'Forget/remove a memory',
214
+ handler: async (args) => {
215
+ if (!memoryCwd)
216
+ return;
217
+ const topic = args.trim();
218
+ if (!topic)
219
+ return;
220
+ pi.sendUserMessage(`Please find and remove any memories about: ${topic}`);
221
+ },
222
+ });
223
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Freshness tracking utilities for memories.
3
+ *
4
+ * Memories are point-in-time observations. These helpers turn an mtime
5
+ * into a human-readable age string and an optional staleness warning
6
+ * that the agent can surface when recalling old memories.
7
+ */
8
+ /**
9
+ * Return the age of a memory in whole days (floored), clamped to ≥ 0.
10
+ *
11
+ * @param mtimeMs - The file's `mtime` in epoch milliseconds.
12
+ */
13
+ export function memoryAgeDays(mtimeMs) {
14
+ return Math.max(0, Math.floor((Date.now() - mtimeMs) / 86_400_000));
15
+ }
16
+ /**
17
+ * Return a short human-readable age label.
18
+ *
19
+ * @param mtimeMs - The file's `mtime` in epoch milliseconds.
20
+ * @returns `"today"`, `"yesterday"`, or `"N days ago"`.
21
+ */
22
+ export function memoryAge(mtimeMs) {
23
+ const d = memoryAgeDays(mtimeMs);
24
+ if (d === 0)
25
+ return 'today';
26
+ if (d === 1)
27
+ return 'yesterday';
28
+ return `${d} days ago`;
29
+ }
30
+ /**
31
+ * Return a freshness caveat string for memories older than one day.
32
+ *
33
+ * When the memory is fresh (≤ 1 day old) an empty string is returned so
34
+ * callers can simply concatenate without branching.
35
+ *
36
+ * @param mtimeMs - The file's `mtime` in epoch milliseconds.
37
+ */
38
+ export function memoryFreshnessNote(mtimeMs) {
39
+ const d = memoryAgeDays(mtimeMs);
40
+ if (d <= 1)
41
+ return '';
42
+ return `This memory is ${d} days old. Memories are point-in-time observations — claims about code or file:line citations may be outdated. Verify against current code before asserting as fact.`;
43
+ }
@@ -0,0 +1,49 @@
1
+ import { mkdirSync } from 'node:fs';
2
+ import * as path from 'node:path';
3
+ import { homedir } from 'node:os';
4
+ import { createHash } from 'node:crypto';
5
+ /**
6
+ * Sanitize a project's absolute cwd into a safe directory name.
7
+ * Returns `<basename>-<8char-sha256-hex>` where non-alphanumeric
8
+ * characters in the basename are replaced with underscores.
9
+ *
10
+ * @example sanitizeProjectPath('/Users/me/projects/my-app') → 'my_app-a1b2c3d4'
11
+ */
12
+ export function sanitizeProjectPath(cwd) {
13
+ const base = path.basename(cwd).replace(/[^a-zA-Z0-9]/g, '_');
14
+ const hash = createHash('sha256').update(cwd).digest('hex').slice(0, 8);
15
+ return `${base}-${hash}`;
16
+ }
17
+ /**
18
+ * Return the global LSD base directory (~/.lsd).
19
+ */
20
+ export function getMemoryBaseDir() {
21
+ return path.join(homedir(), '.lsd');
22
+ }
23
+ /**
24
+ * Return the NFC-normalized memory directory for a given project cwd,
25
+ * with a trailing path separator appended.
26
+ */
27
+ export function getMemoryDir(cwd) {
28
+ const dir = path.join(getMemoryBaseDir(), 'projects', sanitizeProjectPath(cwd), 'memory');
29
+ return (dir + path.sep).normalize('NFC');
30
+ }
31
+ /**
32
+ * Return the path to the project's MEMORY.md entrypoint file.
33
+ */
34
+ export function getMemoryEntrypoint(cwd) {
35
+ return path.join(getMemoryDir(cwd), 'MEMORY.md');
36
+ }
37
+ /**
38
+ * Ensure the memory directory exists on disk.
39
+ * Uses sync I/O because prompt building is synchronous.
40
+ */
41
+ export function ensureMemoryDir(cwd) {
42
+ mkdirSync(getMemoryDir(cwd), { recursive: true });
43
+ }
44
+ /**
45
+ * Check whether an absolute path resides inside the project's memory directory.
46
+ */
47
+ export function isMemoryPath(absolutePath, cwd) {
48
+ return path.normalize(absolutePath).startsWith(getMemoryDir(cwd));
49
+ }
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Memory recall — finds relevant memories for a given user query
3
+ * by asking a fast LLM to select from available memory headers.
4
+ *
5
+ * Supports both Anthropic and OpenAI APIs via direct `fetch()` calls.
6
+ * Gracefully falls back to empty results when no API key is available
7
+ * or when the LLM call fails for any reason.
8
+ */
9
+ import { scanMemoryFiles, formatMemoryManifest } from './memory-scan.js';
10
+ const SELECT_MEMORIES_SYSTEM_PROMPT = `You are selecting memories that will be useful to a coding agent as it processes a user's query. You will be given the user's query and a list of available memory files with their filenames and descriptions.
11
+
12
+ Return a JSON object with a single key "selected_memories" containing an array of filenames (up to 5) that will clearly be useful. Only include memories you are certain will be helpful. If none are relevant, return an empty array.
13
+
14
+ Example response: {"selected_memories": ["user_role.md", "feedback_testing.md"]}`;
15
+ /** Timeout for LLM API calls (ms) */
16
+ const API_TIMEOUT_MS = 15_000;
17
+ /**
18
+ * Find memories relevant to a user query by asking an LLM to select
19
+ * from the available memory headers.
20
+ *
21
+ * @param query - The user's query or message
22
+ * @param memoryDir - Absolute path to the memory directory
23
+ * @param alreadySurfaced - Set of file paths that have already been surfaced (to avoid duplicates)
24
+ * @returns Array of relevant memories with path and modification time
25
+ */
26
+ export async function findRelevantMemories(query, memoryDir, alreadySurfaced) {
27
+ const allMemories = scanMemoryFiles(memoryDir);
28
+ // Filter out already-surfaced paths
29
+ const memories = alreadySurfaced?.size
30
+ ? allMemories.filter((m) => !alreadySurfaced.has(m.filePath))
31
+ : allMemories;
32
+ if (memories.length === 0)
33
+ return [];
34
+ const selectedFilenames = await selectRelevantMemories(query, memories);
35
+ if (selectedFilenames.length === 0)
36
+ return [];
37
+ // Build a lookup from filename → header for O(1) mapping
38
+ const byFilename = new Map();
39
+ for (const m of memories)
40
+ byFilename.set(m.filename, m);
41
+ const results = [];
42
+ for (const filename of selectedFilenames) {
43
+ const header = byFilename.get(filename);
44
+ if (header) {
45
+ results.push({ path: header.filePath, mtimeMs: header.mtimeMs });
46
+ }
47
+ }
48
+ return results;
49
+ }
50
+ /**
51
+ * Ask an LLM to select the most relevant memory filenames for a query.
52
+ * Tries Anthropic first (if ANTHROPIC_API_KEY is set), then OpenAI.
53
+ * Returns an empty array on any failure.
54
+ */
55
+ async function selectRelevantMemories(query, memories) {
56
+ const manifest = formatMemoryManifest(memories);
57
+ const validFilenames = new Set(memories.map((m) => m.filename));
58
+ const apiKey = process.env.ANTHROPIC_API_KEY || process.env.OPENAI_API_KEY;
59
+ if (!apiKey)
60
+ return [];
61
+ const userMessage = `User query:\n${query}\n\nAvailable memories:\n${manifest}`;
62
+ try {
63
+ const isAnthropic = !!process.env.ANTHROPIC_API_KEY;
64
+ const selected = isAnthropic
65
+ ? await callAnthropic(apiKey, userMessage)
66
+ : await callOpenAI(apiKey, userMessage);
67
+ // Only return filenames that actually exist in the scanned memories
68
+ return selected.filter((f) => validFilenames.has(f));
69
+ }
70
+ catch {
71
+ return [];
72
+ }
73
+ }
74
+ /**
75
+ * Call the Anthropic Messages API to select relevant memories.
76
+ */
77
+ async function callAnthropic(apiKey, userMessage) {
78
+ const controller = new AbortController();
79
+ const timer = setTimeout(() => controller.abort(), API_TIMEOUT_MS);
80
+ try {
81
+ const res = await fetch('https://api.anthropic.com/v1/messages', {
82
+ method: 'POST',
83
+ signal: controller.signal,
84
+ headers: {
85
+ 'content-type': 'application/json',
86
+ 'x-api-key': apiKey,
87
+ 'anthropic-version': '2023-06-01',
88
+ },
89
+ body: JSON.stringify({
90
+ model: 'claude-sonnet-4-20250514',
91
+ max_tokens: 256,
92
+ system: SELECT_MEMORIES_SYSTEM_PROMPT,
93
+ messages: [{ role: 'user', content: userMessage }],
94
+ }),
95
+ });
96
+ if (!res.ok)
97
+ return [];
98
+ const body = (await res.json());
99
+ const text = body.content?.find((b) => b.type === 'text')?.text ?? '';
100
+ return parseSelectedMemories(text);
101
+ }
102
+ finally {
103
+ clearTimeout(timer);
104
+ }
105
+ }
106
+ /**
107
+ * Call the OpenAI Chat Completions API to select relevant memories.
108
+ */
109
+ async function callOpenAI(apiKey, userMessage) {
110
+ const controller = new AbortController();
111
+ const timer = setTimeout(() => controller.abort(), API_TIMEOUT_MS);
112
+ try {
113
+ const res = await fetch('https://api.openai.com/v1/chat/completions', {
114
+ method: 'POST',
115
+ signal: controller.signal,
116
+ headers: {
117
+ 'content-type': 'application/json',
118
+ authorization: `Bearer ${apiKey}`,
119
+ },
120
+ body: JSON.stringify({
121
+ model: 'gpt-4o-mini',
122
+ max_tokens: 256,
123
+ messages: [
124
+ { role: 'system', content: SELECT_MEMORIES_SYSTEM_PROMPT },
125
+ { role: 'user', content: userMessage },
126
+ ],
127
+ }),
128
+ });
129
+ if (!res.ok)
130
+ return [];
131
+ const body = (await res.json());
132
+ const text = body.choices?.[0]?.message?.content ?? '';
133
+ return parseSelectedMemories(text);
134
+ }
135
+ finally {
136
+ clearTimeout(timer);
137
+ }
138
+ }
139
+ /**
140
+ * Parse the LLM response text to extract the `selected_memories` array.
141
+ * Handles both clean JSON and JSON wrapped in markdown code fences.
142
+ */
143
+ function parseSelectedMemories(text) {
144
+ // Strip markdown code fences if present
145
+ const cleaned = text.replace(/^```(?:json)?\s*\n?/m, '').replace(/\n?```\s*$/m, '');
146
+ const parsed = JSON.parse(cleaned);
147
+ if (!Array.isArray(parsed.selected_memories))
148
+ return [];
149
+ return parsed.selected_memories.filter((item) => typeof item === 'string');
150
+ }