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.
- package/dist/resources/extensions/memory/auto-extract.js +154 -0
- package/dist/resources/extensions/memory/extension-manifest.json +11 -0
- package/dist/resources/extensions/memory/index.js +223 -0
- package/dist/resources/extensions/memory/memory-age.js +43 -0
- package/dist/resources/extensions/memory/memory-paths.js +49 -0
- package/dist/resources/extensions/memory/memory-recall.js +150 -0
- package/dist/resources/extensions/memory/memory-scan.js +90 -0
- package/dist/resources/extensions/memory/memory-types.js +96 -0
- package/dist/resources/extensions/remote-questions/extension-manifest.json +1 -1
- package/dist/resources/extensions/remote-questions/index.js +53 -0
- package/dist/resources/extensions/remote-questions/telegram-adapter.js +6 -13
- package/dist/resources/extensions/remote-questions/telegram-live-relay.js +447 -0
- package/dist/resources/extensions/remote-questions/telegram-update-stream.js +67 -0
- package/package.json +1 -1
- package/packages/pi-coding-agent/package.json +1 -1
- package/pkg/package.json +1 -1
- package/src/resources/extensions/memory/auto-extract.ts +172 -0
- package/src/resources/extensions/memory/extension-manifest.json +11 -0
- package/src/resources/extensions/memory/index.ts +263 -0
- package/src/resources/extensions/memory/memory-age.ts +43 -0
- package/src/resources/extensions/memory/memory-paths.ts +55 -0
- package/src/resources/extensions/memory/memory-recall.ts +186 -0
- package/src/resources/extensions/memory/memory-scan.ts +118 -0
- package/src/resources/extensions/memory/memory-types.ts +106 -0
- package/src/resources/extensions/memory/tests/auto-extract.test.ts +141 -0
- package/src/resources/extensions/memory/tests/memory-age.test.ts +60 -0
- package/src/resources/extensions/memory/tests/memory-paths.test.ts +89 -0
- package/src/resources/extensions/memory/tests/memory-scan.test.ts +244 -0
- package/src/resources/extensions/remote-questions/extension-manifest.json +1 -1
- package/src/resources/extensions/remote-questions/index.ts +65 -0
- package/src/resources/extensions/remote-questions/telegram-adapter.ts +6 -14
- package/src/resources/extensions/remote-questions/telegram-live-relay.ts +471 -0
- 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
|
+
}
|