@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.
@@ -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, "pi-memory.json");
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, "pi-memory.json");
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
- createAgentSession,
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
- return `Consolidate the memory files under ${memoryDir}. Read every .md file (including MEMORY.md), then:
26
- 1. Deduplicate entries within each topic that say the same thing.
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
- export function resolveDreamModel(config: MemoryConfig, ctx: { model: any; modelRegistry: any }): any | null {
36
- if (config.dream.model === "auto") return ctx.model;
37
- const slash = config.dream.model.indexOf("/");
38
- if (slash === -1) return null;
39
- const provider = config.dream.model.slice(0, slash);
40
- const id = config.dream.model.slice(slash + 1);
41
- return ctx.modelRegistry?.find(provider, id) ?? null;
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
- export function extractSummary(messages: any[]): string {
45
- for (let i = messages.length - 1; i >= 0; i--) {
46
- const m = messages[i];
47
- if (m.role === "assistant") {
48
- if (typeof m.content === "string") return m.content;
49
- const t = (m.content || []).find((b: any) => b.type === "text");
50
- if (t) return t.text;
51
- }
52
- }
53
- return "Dream completed.";
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: any;
54
+ export interface RunDreamOpts {
55
+ model: string;
58
56
  memoryDir: string;
59
- cwd: string;
60
57
  signal?: AbortSignal;
61
- createSession?: typeof createAgentSession;
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 createSession = opts.createSession ?? createAgentSession;
66
- // DI: when opts.createSession is provided (test path), skip real loader/auth construction.
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
- let summary = "Dream completed.";
95
- const unsub = session.subscribe((e: any) => {
96
- if (e.type === "agent_end") {
97
- summary = extractSummary(session.messages);
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
- const onAbort = () => session.abort?.();
102
- opts.signal?.addEventListener("abort", onAbort);
103
- try {
104
- await session.prompt(buildDreamTask(opts.memoryDir, 200));
105
- // Extract from messages as fallback when agent_end didn't fire (test path / edge case).
106
- if (summary === "Dream completed." && session.messages?.length) {
107
- summary = extractSummary(session.messages);
108
- }
109
- return summary;
110
- } finally {
111
- opts.signal?.removeEventListener("abort", onAbort);
112
- unsub();
113
- session.dispose();
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
- title: string;
3
- topic: string;
4
- raw: string;
2
+ name: string; // 原名 title,取自 frontmatter name
3
+ topic: string; // 文件名
4
+ hook: string; // 一行描述
5
+ raw: string; // 原始行文本
5
6
  }
7
+
6
8
  export interface IndexFile {
7
- entries: IndexEntry[];
8
- raw: string;
9
+ entries: IndexEntry[];
10
+ raw: string;
9
11
  }
10
12
 
11
- // Matches: - [Title](topic.md)
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
- const entries: IndexEntry[] = [];
16
- for (const line of content.split("\n")) {
17
- const m = line.match(LINE_RE);
18
- if (m) entries.push({ title: m[1].trim(), topic: m[2].trim(), raw: line });
19
- }
20
- return { entries, raw: content };
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
- return entries.map((e) => `- [${e.title}](${e.topic})`).join("\n");
33
+ return entries.map((e) => `- [${e.name}](${e.topic}) — ${e.hook}`).join("\n");
25
34
  }
26
35
 
27
- export function addEntry(entries: IndexEntry[], entry: IndexEntry): IndexEntry[] {
28
- return [...entries, entry];
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 removeEntryByTitle(entries: IndexEntry[], title: string): IndexEntry[] {
32
- const idx = entries.findIndex((e) => e.title === title);
33
- if (idx === -1) throw new Error(`Entry "${title}" not found in index`);
34
- const next = [...entries];
35
- next.splice(idx, 1);
36
- return next;
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 interface MatchResult {
40
- entry: IndexEntry | null;
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 matchEntryByTitle(entries: IndexEntry[], title: string): MatchResult {
45
- const matches = entries.filter((e) => e.title === title);
46
- if (matches.length === 0) return { entry: null, unique: false };
47
- if (matches.length === 1) return { entry: matches[0], unique: true };
48
- return { entry: matches[0], unique: false };
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
- content: string,
53
- maxLines: number,
54
- maxBytes: number,
65
+ content: string,
66
+ maxLines: number,
67
+ maxBytes: number,
55
68
  ): { ok: boolean; content: string; truncated: boolean } {
56
- const lines = content.split("\n");
57
- let out = content;
58
- let truncated = false;
59
- if (lines.length > maxLines) {
60
- out = lines.slice(0, maxLines).join("\n");
61
- truncated = true;
62
- }
63
- if (Buffer.byteLength(out, "utf8") > maxBytes) {
64
- let cut = out;
65
- while (Buffer.byteLength(cut, "utf8") > maxBytes && cut.length > 0) cut = cut.slice(0, -1);
66
- out = cut;
67
- truncated = true;
68
- }
69
- if (truncated) out += `\n[truncated: memory index exceeds injection limit]`;
70
- return { ok: !truncated, content: out, truncated };
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
- entries: IndexEntry[],
75
- maxLines: number,
76
- maxBytes: number,
77
- ): boolean {
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
  }