@yandy0725/pi-memory 0.3.1 → 1.0.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/README.md +110 -16
- package/README.zh.md +120 -28
- package/index.ts +134 -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
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent-types.ts — Register a single minimal agent type for pi-memory subagents.
|
|
3
|
+
*
|
|
4
|
+
* Writes `memory-agent.md` to ~/.pi/agent/agents/ so pi-subagents
|
|
5
|
+
* picks it up as a custom agent type with restricted tools and replace-mode prompt.
|
|
6
|
+
* Written during extension factory (before pi-subagents initializes its registry).
|
|
7
|
+
*
|
|
8
|
+
* One type serves three tasks:
|
|
9
|
+
* - side-query: thinking=off, maxTurns=1
|
|
10
|
+
* - extractMemories: thinking=high, maxTurns=5
|
|
11
|
+
* - dream: thinking=high (unlimited turns)
|
|
12
|
+
*
|
|
13
|
+
* On first install (before pi restart), pi-subagents auto-falls-back to
|
|
14
|
+
* general-purpose. No manual action needed.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
18
|
+
import { homedir } from "node:os";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
import { CONFIG_DIR_NAME } from "@earendil-works/pi-coding-agent";
|
|
21
|
+
|
|
22
|
+
const AGENTS_DIR = join(homedir(), CONFIG_DIR_NAME, "agent", "agents");
|
|
23
|
+
|
|
24
|
+
const DEFINITION = `---
|
|
25
|
+
description: Memory maintenance agent — file read/write only (no bash/websearch)
|
|
26
|
+
tools: read, write, edit, ls
|
|
27
|
+
prompt_mode: replace
|
|
28
|
+
---
|
|
29
|
+
`;
|
|
30
|
+
|
|
31
|
+
export function ensureAgentTypes(): void {
|
|
32
|
+
try {
|
|
33
|
+
mkdirSync(AGENTS_DIR, { recursive: true });
|
|
34
|
+
} catch {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const path = join(AGENTS_DIR, "memory-agent.md");
|
|
39
|
+
if (!existsSync(path)) {
|
|
40
|
+
try {
|
|
41
|
+
writeFileSync(path, DEFINITION, "utf8");
|
|
42
|
+
} catch {}
|
|
43
|
+
}
|
|
44
|
+
}
|
package/src/config.ts
CHANGED
|
@@ -1,8 +1,22 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
-
import { join } from "node:path";
|
|
3
2
|
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
4
|
import { CONFIG_DIR_NAME, getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
5
5
|
|
|
6
|
+
export interface AutoSurfacingConfig {
|
|
7
|
+
enabled: boolean;
|
|
8
|
+
model: string;
|
|
9
|
+
maxFiles: number;
|
|
10
|
+
maxTopicBytes: number;
|
|
11
|
+
maxInjectionBytes: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface ExtractMemoriesConfig {
|
|
15
|
+
enabled: boolean;
|
|
16
|
+
model: string;
|
|
17
|
+
maxContextTokens: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
6
20
|
export interface MemoryConfig {
|
|
7
21
|
enabled: boolean;
|
|
8
22
|
memoryDir: string;
|
|
@@ -10,6 +24,8 @@ export interface MemoryConfig {
|
|
|
10
24
|
memIndexMaxBytes: number;
|
|
11
25
|
dream: { nudgeAfterSessions: number; nudgeAfterHours: number; model: string };
|
|
12
26
|
sessionSearch: { maxSessions: number; maxMatches: number };
|
|
27
|
+
autoSurfacing: AutoSurfacingConfig;
|
|
28
|
+
extractMemories: ExtractMemoriesConfig;
|
|
13
29
|
}
|
|
14
30
|
|
|
15
31
|
export const DEFAULT_CONFIG: MemoryConfig = {
|
|
@@ -19,6 +35,18 @@ export const DEFAULT_CONFIG: MemoryConfig = {
|
|
|
19
35
|
memIndexMaxBytes: 25600,
|
|
20
36
|
dream: { nudgeAfterSessions: 5, nudgeAfterHours: 24, model: "auto" },
|
|
21
37
|
sessionSearch: { maxSessions: 10, maxMatches: 5 },
|
|
38
|
+
autoSurfacing: {
|
|
39
|
+
enabled: true,
|
|
40
|
+
model: "auto",
|
|
41
|
+
maxFiles: 5,
|
|
42
|
+
maxTopicBytes: 4096,
|
|
43
|
+
maxInjectionBytes: 20480,
|
|
44
|
+
},
|
|
45
|
+
extractMemories: {
|
|
46
|
+
enabled: true,
|
|
47
|
+
model: "auto",
|
|
48
|
+
maxContextTokens: 2000,
|
|
49
|
+
},
|
|
22
50
|
};
|
|
23
51
|
|
|
24
52
|
function expandTilde(p: string): string {
|
|
@@ -28,12 +56,17 @@ function expandTilde(p: string): string {
|
|
|
28
56
|
}
|
|
29
57
|
|
|
30
58
|
function deepMerge<T>(base: T, over: Partial<T>): T {
|
|
59
|
+
// biome-ignore lint/suspicious/noExplicitAny: generic deep merge
|
|
31
60
|
const out: any = { ...base };
|
|
32
61
|
for (const k of Object.keys(over) as (keyof T)[]) {
|
|
62
|
+
// biome-ignore lint/suspicious/noExplicitAny: generic deep merge
|
|
33
63
|
const ov = over[k] as any;
|
|
64
|
+
// biome-ignore lint/suspicious/noExplicitAny: generic deep merge
|
|
34
65
|
if (ov && typeof ov === "object" && !Array.isArray(ov) && typeof (out as any)[k] === "object") {
|
|
66
|
+
// biome-ignore lint/suspicious/noExplicitAny: generic deep merge
|
|
35
67
|
(out as any)[k] = deepMerge((out as any)[k], ov);
|
|
36
68
|
} else if (ov !== undefined) {
|
|
69
|
+
// biome-ignore lint/suspicious/noExplicitAny: generic deep merge
|
|
37
70
|
(out as any)[k] = ov;
|
|
38
71
|
}
|
|
39
72
|
}
|
|
@@ -61,11 +94,11 @@ export async function loadConfig(ctx: LoadConfigContext): Promise<MemoryConfig>
|
|
|
61
94
|
const configDirName = ctx._configDirName ?? CONFIG_DIR_NAME;
|
|
62
95
|
let cfg: MemoryConfig = { ...DEFAULT_CONFIG };
|
|
63
96
|
|
|
64
|
-
const globalFile = join(agentDir, "
|
|
97
|
+
const globalFile = join(agentDir, "memory.json");
|
|
65
98
|
cfg = deepMerge(cfg, readJsonSafe(globalFile));
|
|
66
99
|
|
|
67
100
|
if (ctx.isProjectTrusted()) {
|
|
68
|
-
const projectFile = join(ctx.cwd, configDirName, "
|
|
101
|
+
const projectFile = join(ctx.cwd, configDirName, "memory.json");
|
|
69
102
|
cfg = deepMerge(cfg, readJsonSafe(projectFile));
|
|
70
103
|
}
|
|
71
104
|
|
package/src/dream.ts
CHANGED
|
@@ -1,115 +1,130 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
SessionManager,
|
|
4
|
-
DefaultResourceLoader,
|
|
5
|
-
SettingsManager,
|
|
6
|
-
AuthStorage,
|
|
7
|
-
ModelRegistry,
|
|
8
|
-
} from "@earendil-works/pi-coding-agent";
|
|
9
|
-
import { join } from "node:path";
|
|
10
|
-
import { mkdtemp } from "node:fs/promises";
|
|
11
|
-
import { tmpdir } from "node:os";
|
|
12
|
-
import type { MemoryConfig } from "./config";
|
|
13
|
-
|
|
14
|
-
export const DREAM_SYSTEM_PROMPT = `You are a memory consolidation agent. Your job: read all memory files in the given directory, consolidate entries within each topic (merge duplicates, resolve contradictions, update outdated info), and rebuild the MEMORY.md index to be concise and accurate.
|
|
15
|
-
Rules:
|
|
16
|
-
- Each topic file contains entries as \`## Entry Title\` blocks.
|
|
17
|
-
- Only modify files under the given directory. Never touch anything else.
|
|
18
|
-
- Deduplicate entries: if two entries in the same topic contain the same info, merge them.
|
|
19
|
-
- If entries across different topics overlap, move the content to the more appropriate topic.
|
|
20
|
-
- Rebuild MEMORY.md index: list entries you deem valuable (not necessarily every entry). Each line: - [Entry Title](topic.md). Accuracy matters more than completeness.
|
|
21
|
-
- CRITICAL for entry titles: Only the MEMORY.md index is injected into future coding sessions (topic file content is NOT seen). Every entry title must be self-descriptive and convey enough context to be useful at a glance. Prefer specific, actionable titles like "use uv instead of pip for Python package management" over vague ones like "python tools" or "workflow rules". If an existing title is too vague, rewrite it — keep the original ## heading in the topic file for full context.
|
|
22
|
-
- When done, output a concise summary of changes (merged N, removed N, moved N, updated N).`;
|
|
1
|
+
import { access } from "node:fs/promises";
|
|
2
|
+
import { getSubagentsService, type SubagentsService, type WorkspaceProvider } from "@yandy0725/pi-subagents";
|
|
23
3
|
|
|
4
|
+
/** Build dream consolidation task.
|
|
5
|
+
* Session context strategy: cwd = memoryDir (via WorkspaceProvider).
|
|
6
|
+
* Dream agent sees only the memory files, no parent conversation history.
|
|
7
|
+
* Tool scope: full file read/write/edit (needed for consolidation). */
|
|
24
8
|
export function buildDreamTask(memoryDir: string, maxLines: number): string {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
2. Merge contradictory or overlapping entries into one accurate entry.
|
|
28
|
-
3. Update outdated information.
|
|
29
|
-
4. Move entries to more appropriate topic files when needed.
|
|
30
|
-
5. Rebuild MEMORY.md (max ${maxLines} lines): - [Entry Title](topic.md) per entry you deem valuable (not necessarily every entry). Entries use ## Entry Title format.
|
|
31
|
-
IMPORTANT: Only MEMORY.md index lines are injected into future coding sessions (topic file content is NOT seen by the coding agent). Rewrite every entry title to be self-contained and descriptive — like "always use uv instead of pip for Python" instead of just "python tools". The title alone must tell the model what the entry is about.
|
|
32
|
-
Only edit files under ${memoryDir}. When finished, print a one-line summary of changes.`;
|
|
33
|
-
}
|
|
9
|
+
return `You are a memory consolidation agent. Your job is to read all memory files
|
|
10
|
+
and consolidate them into a clean, deduplicated memory store.
|
|
34
11
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
12
|
+
Phase 1 — Orient:
|
|
13
|
+
- List all files in ${memoryDir}
|
|
14
|
+
- Read MEMORY.md to understand current index
|
|
15
|
+
- Skim each topic file to understand its contents
|
|
16
|
+
|
|
17
|
+
Phase 2 — Gather Signal:
|
|
18
|
+
- Find duplicate entries within and across topic files
|
|
19
|
+
- Identify contradictory information
|
|
20
|
+
- Spot outdated or obsolete entries
|
|
21
|
+
- Note temporal references that should be absolute dates
|
|
22
|
+
|
|
23
|
+
Phase 3 — Consolidate:
|
|
24
|
+
- Merge duplicates into single entries
|
|
25
|
+
- Resolve contradictions — pick the most accurate version
|
|
26
|
+
- Convert relative dates (today, last week) to absolute dates
|
|
27
|
+
- Update outdated information
|
|
28
|
+
- Move entries to more appropriate topic files when needed
|
|
29
|
+
- Delete entries that are no longer relevant
|
|
43
30
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
31
|
+
Phase 4 — Prune & Index:
|
|
32
|
+
- For each topic file, update frontmatter:
|
|
33
|
+
name: meaningful name for the topic
|
|
34
|
+
description: specific summary that helps LLM match queries (be specific!)
|
|
35
|
+
type: one of user, feedback, project, reference
|
|
36
|
+
updated: today's date
|
|
37
|
+
- Generate a compact hook (~150 chars) for each topic summarizing its entries
|
|
38
|
+
- Rebuild MEMORY.md with one line per topic file (max ${maxLines} lines):
|
|
39
|
+
- [Name](file.md) — hook
|
|
40
|
+
- Remove topic files that have no remaining entries
|
|
41
|
+
|
|
42
|
+
CRITICAL for hooks and descriptions:
|
|
43
|
+
- Only the MEMORY.md index is injected into future coding sessions.
|
|
44
|
+
Topic file content is NOT seen by the coding agent unless explicitly
|
|
45
|
+
read or auto-surfaced. The hook and description must be specific
|
|
46
|
+
enough that the LLM can correctly decide relevance.
|
|
47
|
+
- Bad: "Debugging tips"
|
|
48
|
+
- Good: "SSH port 2222 on staging; MySQL 30s timeout; Redis auth fix"
|
|
49
|
+
- Each topic file's \`## Entry Title\` blocks contain the actual memory entries.
|
|
50
|
+
The MEMORY.md line is just a pointer — only ONE line per topic file.
|
|
51
|
+
- When done, output a concise summary of changes (merged N, removed N, moved N, updated N).`;
|
|
54
52
|
}
|
|
55
53
|
|
|
56
|
-
interface RunDreamOpts {
|
|
57
|
-
model:
|
|
54
|
+
export interface RunDreamOpts {
|
|
55
|
+
model: string;
|
|
58
56
|
memoryDir: string;
|
|
59
|
-
cwd: string;
|
|
60
57
|
signal?: AbortSignal;
|
|
61
|
-
|
|
58
|
+
// biome-ignore lint/suspicious/noExplicitAny: pi events API handler
|
|
59
|
+
events?: { on(channel: string, handler: (data: any) => void): () => void };
|
|
60
|
+
service?: SubagentsService;
|
|
62
61
|
}
|
|
63
62
|
|
|
64
63
|
export async function runDream(opts: RunDreamOpts): Promise<string> {
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
// When not provided (production), build the real isolated DefaultResourceLoader + AuthStorage.
|
|
68
|
-
const useReal = !opts.createSession;
|
|
69
|
-
const loader = useReal
|
|
70
|
-
? await (async () => {
|
|
71
|
-
const isolatedAgentDir = await mkdtemp(join(tmpdir(), "pi-dream-"));
|
|
72
|
-
const l = new DefaultResourceLoader({
|
|
73
|
-
cwd: opts.memoryDir,
|
|
74
|
-
agentDir: isolatedAgentDir,
|
|
75
|
-
settingsManager: SettingsManager.inMemory({}),
|
|
76
|
-
systemPromptOverride: () => DREAM_SYSTEM_PROMPT,
|
|
77
|
-
});
|
|
78
|
-
await l.reload();
|
|
79
|
-
return l;
|
|
80
|
-
})()
|
|
81
|
-
: undefined;
|
|
82
|
-
const authStorage = useReal ? AuthStorage.create() : undefined;
|
|
83
|
-
|
|
84
|
-
const { session } = await createSession({
|
|
85
|
-
model: opts.model,
|
|
86
|
-
...(authStorage ? { authStorage } : {}),
|
|
87
|
-
...(useReal ? { modelRegistry: ModelRegistry.create(authStorage!) } : {}),
|
|
88
|
-
tools: ["read", "edit", "write"],
|
|
89
|
-
cwd: opts.memoryDir,
|
|
90
|
-
sessionManager: useReal ? SessionManager.inMemory(opts.memoryDir) : undefined,
|
|
91
|
-
...(loader ? { resourceLoader: loader } : {}),
|
|
92
|
-
});
|
|
64
|
+
const service = opts.service ?? getSubagentsService();
|
|
65
|
+
if (!service) throw new Error("pi-subagents not available — install @yandy0725/pi-subagents");
|
|
93
66
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
});
|
|
67
|
+
const events = opts.events;
|
|
68
|
+
if (!events) throw new Error("events required for dream — pass pi.events");
|
|
69
|
+
|
|
70
|
+
const model = opts.model === "auto" ? undefined : opts.model;
|
|
71
|
+
const task = buildDreamTask(opts.memoryDir, 200);
|
|
100
72
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
73
|
+
// Register workspace provider so the subagent runs in memoryDir
|
|
74
|
+
const provider: WorkspaceProvider = {
|
|
75
|
+
async prepare(_ctx) {
|
|
76
|
+
await access(opts.memoryDir).catch(() => {
|
|
77
|
+
throw new Error(`Memory directory not found: ${opts.memoryDir}`);
|
|
78
|
+
});
|
|
79
|
+
return {
|
|
80
|
+
cwd: opts.memoryDir,
|
|
81
|
+
dispose: () => undefined,
|
|
82
|
+
};
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
const unregister = service.registerWorkspaceProvider(provider);
|
|
86
|
+
|
|
87
|
+
// Spawn the dream subagent
|
|
88
|
+
const agentId = service.spawn(
|
|
89
|
+
"memory-agent",
|
|
90
|
+
task,
|
|
91
|
+
model ? { model, thinkingLevel: "high" } : { thinkingLevel: "high" },
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
// Wait for completion/failure via pi-subagents events (abort-while-queued
|
|
95
|
+
// is handled: pi-subagents guarantees events fire for all terminal states).
|
|
96
|
+
return await new Promise<string>((resolve, reject) => {
|
|
97
|
+
let settled = false;
|
|
98
|
+
|
|
99
|
+
const cleanup = () => {
|
|
100
|
+
if (settled) return;
|
|
101
|
+
settled = true;
|
|
102
|
+
unsubCompleted();
|
|
103
|
+
unsubFailed();
|
|
104
|
+
unregister();
|
|
105
|
+
opts.signal?.removeEventListener("abort", onAbort);
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const onCompleted = (data: { id: string }) => {
|
|
109
|
+
if (data.id !== agentId) return;
|
|
110
|
+
cleanup();
|
|
111
|
+
const record = service.getRecord(agentId);
|
|
112
|
+
resolve(record?.result ?? "Dream completed.");
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const onFailed = (data: { id: string; error?: string }) => {
|
|
116
|
+
if (data.id !== agentId) return;
|
|
117
|
+
cleanup();
|
|
118
|
+
reject(new Error(data.error ?? "Dream agent failed"));
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const onAbort = () => {
|
|
122
|
+
service.abort(agentId);
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const unsubCompleted = events.on("subagents:completed", onCompleted);
|
|
126
|
+
const unsubFailed = events.on("subagents:failed", onFailed);
|
|
127
|
+
|
|
128
|
+
opts.signal?.addEventListener("abort", onAbort, { once: true });
|
|
129
|
+
});
|
|
115
130
|
}
|
package/src/extract.ts
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { access } from "node:fs/promises";
|
|
2
|
+
import { getSubagentsService, type SubagentsService, type WorkspaceProvider } from "@yandy0725/pi-subagents";
|
|
3
|
+
|
|
4
|
+
export interface RunExtractOpts {
|
|
5
|
+
model: string;
|
|
6
|
+
memoryDir: string;
|
|
7
|
+
messages: Array<{ role: string; content: string }>;
|
|
8
|
+
maxContextTokens: number;
|
|
9
|
+
service?: SubagentsService;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Build extraction task prompt.
|
|
13
|
+
* Session context strategy: only current turn (user + assistant messages).
|
|
14
|
+
* No conversation history — extraction is turn-scoped.
|
|
15
|
+
* Tool scope: file read/write only (cwd = memoryDir). */
|
|
16
|
+
export function buildExtractTask(
|
|
17
|
+
memoryDir: string,
|
|
18
|
+
messages: Array<{ role: string; content: string }>,
|
|
19
|
+
maxTokens: number,
|
|
20
|
+
): string {
|
|
21
|
+
// Find user and assistant messages for context
|
|
22
|
+
const fromUser = messages.find((m) => m.role === "user");
|
|
23
|
+
const fromAssistant = messages.findLast((m) => m.role === "assistant");
|
|
24
|
+
const userText = fromUser?.content ?? "";
|
|
25
|
+
const assistantText = fromAssistant?.content ?? "";
|
|
26
|
+
|
|
27
|
+
// Truncate by maxTokens (rough estimate: ~4 chars/token)
|
|
28
|
+
const maxChars = maxTokens * 4;
|
|
29
|
+
const truncatedUser = userText.slice(0, maxChars / 2);
|
|
30
|
+
const truncatedAssistant = assistantText.slice(0, maxChars / 2);
|
|
31
|
+
|
|
32
|
+
return [
|
|
33
|
+
`You are a memory extraction agent. Your cwd is the memory directory at ${memoryDir}.`,
|
|
34
|
+
"",
|
|
35
|
+
"Analyze the conversation snippet below. If you find valuable learnings, write them to topic files in this directory using ONLY file read/write/edit tools. Do NOT use bash, web search, or any other tools.",
|
|
36
|
+
"The memory directory contains topic files with this frontmatter format:",
|
|
37
|
+
"",
|
|
38
|
+
"```yaml",
|
|
39
|
+
"---",
|
|
40
|
+
"name: Topic Name",
|
|
41
|
+
"description: Brief summary for relevance matching",
|
|
42
|
+
"type: feedback # one of: user, feedback, project, reference",
|
|
43
|
+
"updated: 2026-07-13",
|
|
44
|
+
"---",
|
|
45
|
+
"",
|
|
46
|
+
"## Entry Title",
|
|
47
|
+
"Entry content here.",
|
|
48
|
+
"```",
|
|
49
|
+
"",
|
|
50
|
+
"And MEMORY.md index:",
|
|
51
|
+
"- [Name](file.md) — one-line hook summary",
|
|
52
|
+
"",
|
|
53
|
+
"Worth remembering:",
|
|
54
|
+
"- User preferences, coding style choices, tooling preferences",
|
|
55
|
+
"- Project conventions, architecture decisions, naming patterns",
|
|
56
|
+
"- Debugging insights, workarounds, gotchas discovered",
|
|
57
|
+
'- "Always do X" / "Never do Y" rules',
|
|
58
|
+
"- References to external systems or documentation",
|
|
59
|
+
"",
|
|
60
|
+
"NOT worth remembering:",
|
|
61
|
+
"- One-time task instructions or ephemeral details",
|
|
62
|
+
"- Code snippets or file paths derivable from the project",
|
|
63
|
+
"- Information already captured in CLAUDE.md or AGENTS.md",
|
|
64
|
+
"- Git history or recent changes",
|
|
65
|
+
"",
|
|
66
|
+
"When writing memories:",
|
|
67
|
+
"- Use descriptive, self-contained entry titles (only index lines are injected into future sessions)",
|
|
68
|
+
"- Choose the appropriate type: user, feedback, project, reference",
|
|
69
|
+
'- Default type is "feedback"',
|
|
70
|
+
"- Be concise but complete",
|
|
71
|
+
"- If unsure, do NOT write anything",
|
|
72
|
+
"- Use the write/edit tools to directly modify topic files and MEMORY.md",
|
|
73
|
+
"",
|
|
74
|
+
"=== Conversation ===",
|
|
75
|
+
`User: ${truncatedUser}`,
|
|
76
|
+
`Assistant: ${truncatedAssistant}`,
|
|
77
|
+
].join("\n");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function runExtract(opts: RunExtractOpts): Promise<void> {
|
|
81
|
+
if (opts.messages.length === 0) return;
|
|
82
|
+
|
|
83
|
+
const service = opts.service ?? getSubagentsService();
|
|
84
|
+
if (!service) return; // silently skip if no subagent service
|
|
85
|
+
|
|
86
|
+
const model = opts.model === "auto" ? undefined : opts.model;
|
|
87
|
+
const task = buildExtractTask(opts.memoryDir, opts.messages, opts.maxContextTokens);
|
|
88
|
+
|
|
89
|
+
const provider: WorkspaceProvider = {
|
|
90
|
+
async prepare(_ctx) {
|
|
91
|
+
await access(opts.memoryDir).catch(() => {
|
|
92
|
+
throw new Error(`Memory directory not found: ${opts.memoryDir}`);
|
|
93
|
+
});
|
|
94
|
+
return {
|
|
95
|
+
cwd: opts.memoryDir,
|
|
96
|
+
dispose: () => undefined,
|
|
97
|
+
};
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
service.registerWorkspaceProvider(provider);
|
|
101
|
+
|
|
102
|
+
// Fire-and-forget spawn
|
|
103
|
+
service.spawn(
|
|
104
|
+
"memory-agent",
|
|
105
|
+
task,
|
|
106
|
+
model
|
|
107
|
+
? { model, inheritContext: false, maxTurns: 5, thinkingLevel: "high" }
|
|
108
|
+
: { inheritContext: false, maxTurns: 5, thinkingLevel: "high" },
|
|
109
|
+
);
|
|
110
|
+
}
|
package/src/index-file.ts
CHANGED
|
@@ -1,82 +1,91 @@
|
|
|
1
1
|
export interface IndexEntry {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
2
|
+
name: string; // 原名 title,取自 frontmatter name
|
|
3
|
+
topic: string; // 文件名
|
|
4
|
+
hook: string; // 一行描述
|
|
5
|
+
raw: string; // 原始行文本
|
|
5
6
|
}
|
|
7
|
+
|
|
6
8
|
export interface IndexFile {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
+
entries: IndexEntry[];
|
|
10
|
+
raw: string;
|
|
9
11
|
}
|
|
10
12
|
|
|
11
|
-
// Matches: - [
|
|
12
|
-
const LINE_RE = /^-\s+\[([^\]]+)\]\(([^)]+)\)\s
|
|
13
|
+
// Matches: - [Name](topic.md) — hook
|
|
14
|
+
const LINE_RE = /^-\s+\[([^\]]+)\]\(([^)]+)\)\s*—\s*(.*)$/;
|
|
13
15
|
|
|
14
16
|
export function parseIndex(content: string): IndexFile {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
17
|
+
const entries: IndexEntry[] = [];
|
|
18
|
+
for (const line of content.split("\n")) {
|
|
19
|
+
const m = line.match(LINE_RE);
|
|
20
|
+
if (m) {
|
|
21
|
+
entries.push({
|
|
22
|
+
name: m[1].trim(),
|
|
23
|
+
topic: m[2].trim(),
|
|
24
|
+
hook: m[3].trim(),
|
|
25
|
+
raw: line,
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return { entries, raw: content };
|
|
21
30
|
}
|
|
22
31
|
|
|
23
32
|
export function serializeIndex(entries: IndexEntry[]): string {
|
|
24
|
-
|
|
33
|
+
return entries.map((e) => `- [${e.name}](${e.topic}) — ${e.hook}`).join("\n");
|
|
25
34
|
}
|
|
26
35
|
|
|
27
|
-
export function
|
|
28
|
-
|
|
36
|
+
export function upsertEntryByTopic(entries: IndexEntry[], entry: IndexEntry): IndexEntry[] {
|
|
37
|
+
const idx = entries.findIndex((e) => e.topic === entry.topic);
|
|
38
|
+
if (idx === -1) return [...entries, entry];
|
|
39
|
+
const next = [...entries];
|
|
40
|
+
next[idx] = entry;
|
|
41
|
+
return next;
|
|
29
42
|
}
|
|
30
43
|
|
|
31
|
-
export function
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
44
|
+
export function removeEntryByTopic(entries: IndexEntry[], topic: string): IndexEntry[] {
|
|
45
|
+
const idx = entries.findIndex((e) => e.topic === topic);
|
|
46
|
+
if (idx === -1) throw new Error(`Topic "${topic}" not found in index`);
|
|
47
|
+
const next = [...entries];
|
|
48
|
+
next.splice(idx, 1);
|
|
49
|
+
return next;
|
|
37
50
|
}
|
|
38
51
|
|
|
39
|
-
export
|
|
40
|
-
|
|
41
|
-
unique: boolean;
|
|
52
|
+
export function findEntryByTopic(entries: IndexEntry[], topic: string): IndexEntry | null {
|
|
53
|
+
return entries.find((e) => e.topic === topic) ?? null;
|
|
42
54
|
}
|
|
43
55
|
|
|
44
|
-
export function
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
56
|
+
export function updateHook(entries: IndexEntry[], topic: string, hook: string): IndexEntry[] {
|
|
57
|
+
const idx = entries.findIndex((e) => e.topic === topic);
|
|
58
|
+
if (idx === -1) throw new Error(`Topic "${topic}" not found in index`);
|
|
59
|
+
const next = [...entries];
|
|
60
|
+
next[idx] = { ...next[idx], hook, raw: "" };
|
|
61
|
+
return next;
|
|
49
62
|
}
|
|
50
63
|
|
|
51
64
|
export function truncateForInjection(
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
65
|
+
content: string,
|
|
66
|
+
maxLines: number,
|
|
67
|
+
maxBytes: number,
|
|
55
68
|
): { ok: boolean; content: string; truncated: boolean } {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
69
|
+
const lines = content.split("\n");
|
|
70
|
+
let out = content;
|
|
71
|
+
let truncated = false;
|
|
72
|
+
if (lines.length > maxLines) {
|
|
73
|
+
out = lines.slice(0, maxLines).join("\n");
|
|
74
|
+
truncated = true;
|
|
75
|
+
}
|
|
76
|
+
if (Buffer.byteLength(out, "utf8") > maxBytes) {
|
|
77
|
+
let cut = out;
|
|
78
|
+
while (Buffer.byteLength(cut, "utf8") > maxBytes && cut.length > 0) cut = cut.slice(0, -1);
|
|
79
|
+
out = cut;
|
|
80
|
+
truncated = true;
|
|
81
|
+
}
|
|
82
|
+
if (truncated) out += `\n[truncated: memory index exceeds injection limit]`;
|
|
83
|
+
return { ok: !truncated, content: out, truncated };
|
|
71
84
|
}
|
|
72
85
|
|
|
73
|
-
export function checkCapacity(
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
const serialized = serializeIndex(entries);
|
|
79
|
-
if (entries.length > maxLines) return false;
|
|
80
|
-
if (Buffer.byteLength(serialized, "utf8") > maxBytes) return false;
|
|
81
|
-
return true;
|
|
86
|
+
export function checkCapacity(entries: IndexEntry[], maxLines: number, maxBytes: number): boolean {
|
|
87
|
+
const serialized = serializeIndex(entries);
|
|
88
|
+
if (entries.length > maxLines) return false;
|
|
89
|
+
if (Buffer.byteLength(serialized, "utf8") > maxBytes) return false;
|
|
90
|
+
return true;
|
|
82
91
|
}
|