@yandy0725/pi-memory 1.1.0 → 1.2.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/index.ts CHANGED
@@ -2,13 +2,11 @@ import { readdir, readFile } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
3
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
4
4
  import { SessionManager } from "@earendil-works/pi-coding-agent";
5
- import { ensureAgentTypes } from "./src/agent-types";
6
5
  import { loadConfig, type MemoryConfig } from "./src/config";
7
6
  import { runDream } from "./src/dream";
8
7
  import { runExtract } from "./src/extract";
9
8
  import {
10
9
  buildInjection,
11
- buildSurfacingPrompt,
12
10
  injectSurfacedContent,
13
11
  loadIndexSnapshot,
14
12
  runSideQuery,
@@ -19,9 +17,6 @@ import { readDreamMeta, shouldNudge, writeDreamMeta } from "./src/nudge";
19
17
  import { resolveMemoryDir } from "./src/paths";
20
18
  import { searchSessions } from "./src/session-search";
21
19
 
22
- // Register custom agent type on load (zero setup, auto-fallback on first session).
23
- ensureAgentTypes();
24
-
25
20
  export default function (pi: ExtensionAPI) {
26
21
  let memoryDir: string | null = null;
27
22
  let config: MemoryConfig | null = null;
@@ -57,30 +52,30 @@ export default function (pi: ExtensionAPI) {
57
52
  if (nudge) {
58
53
  const ok = await ctx.ui.confirm("Memory Consolidation", `${message}\n\nConsolidate memory files now?`);
59
54
  if (ok) {
60
- // Fire-and-forget: defers past the current macrotask so all
61
- // session_start handlers (including pi-subagents') have completed.
62
- // Does not block session_start.
55
+ // Fire-and-forget: does not block session_start. The headless
56
+ // dream agent runs independently; completion notifies the user.
63
57
  const dreamModel = config.dream.model;
64
58
  const dreamThinkLevel = config.dream.thinkLevel;
65
59
  const dir = memoryDir;
66
60
  ctx.ui.setStatus("dream", "Consolidating memory...");
67
- setTimeout(async () => {
68
- try {
69
- const summary = await runDream({
70
- model: dreamModel,
71
- thinkLevel: dreamThinkLevel,
72
- memoryDir: dir,
73
- events: pi.events,
74
- });
61
+ runDream({
62
+ model: dreamModel,
63
+ thinkLevel: dreamThinkLevel,
64
+ memoryDir: dir,
65
+ modelRegistry: ctx.modelRegistry,
66
+ parentModel: ctx.model,
67
+ })
68
+ .then(async (summary) => {
75
69
  await writeDreamMeta(dir, sessions);
76
70
  ctx.ui.notify(summary, "info");
77
- // biome-ignore lint/suspicious/noExplicitAny: error catch
78
- } catch (e: any) {
71
+ })
72
+ // biome-ignore lint/suspicious/noExplicitAny: error catch
73
+ .catch((e: any) => {
79
74
  ctx.ui.notify(`Dream failed: ${e.message}`, "error");
80
- } finally {
75
+ })
76
+ .finally(() => {
81
77
  ctx.ui.setStatus("dream", undefined);
82
- }
83
- }, 0);
78
+ });
84
79
  }
85
80
  }
86
81
  }
@@ -89,14 +84,12 @@ export default function (pi: ExtensionAPI) {
89
84
  pi.on("before_agent_start", async (event, ctx) => {
90
85
  if (!config?.enabled || !indexSnapshot || !memoryDir) return;
91
86
 
92
- // Auto-surfacing: select relevant topic files via LLM side-query and inject as message.
93
- // Skip for subagents: pi-subagents strips "subagent" from all children's
94
- // tool sets, so its absence reliably identifies subagents. Without this guard,
95
- // runSideQuery's subagent spawn → before_agent_start → re-enter here → OOM.
96
- const agentTools = event.systemPromptOptions?.selectedTools;
97
- const isSubagent = agentTools && !agentTools.includes("subagent");
98
-
99
87
  const autoSurfacing = config.autoSurfacing;
88
+ // Skip auto-surfacing in subagent sessions: pi-subagents injects an
89
+ // <active_agent name="..."/> tag into every subagent's system prompt.
90
+ // Main sessions (including forks) never have this tag. Our own headless
91
+ // sessions use noExtensions (never bind), so they never reach here.
92
+ const isSubagent = event.systemPrompt?.includes("<active_agent name=\"");
100
93
  // biome-ignore lint/suspicious/noExplicitAny: message injection result
101
94
  let injectedMessage: any;
102
95
  if (autoSurfacing?.enabled && event.prompt && !isSubagent) {
@@ -104,8 +97,17 @@ export default function (pi: ExtensionAPI) {
104
97
  if (ctx.hasUI) ctx.ui.setStatus("surfacing", "Searching relevant memories…");
105
98
  const manifest = await scanTopics(memoryDir);
106
99
  if (manifest.length > 0) {
107
- const queryPrompt = buildSurfacingPrompt(manifest, event.prompt.slice(0, 4000), injectedTopics);
108
- const selected = await runSideQuery(queryPrompt, manifest, autoSurfacing.maxFiles, autoSurfacing.thinkLevel, pi.events);
100
+ const selected = await runSideQuery(
101
+ manifest,
102
+ event.prompt.slice(0, 4000),
103
+ injectedTopics,
104
+ autoSurfacing.maxFiles,
105
+ autoSurfacing.thinkLevel,
106
+ autoSurfacing.model,
107
+ ctx.modelRegistry,
108
+ ctx.model,
109
+ memoryDir,
110
+ );
109
111
  if (selected.length > 0) {
110
112
  const content = await injectSurfacedContent(
111
113
  memoryDir,
@@ -114,9 +116,7 @@ export default function (pi: ExtensionAPI) {
114
116
  autoSurfacing.maxInjectionBytes,
115
117
  );
116
118
  if (content) {
117
- // Track injected topics for session-level dedup
118
119
  for (const f of selected) injectedTopics.add(f);
119
- // Inject as a custom message (NOT systemPrompt)
120
120
  injectedMessage = { customType: "memory-auto-surfacing", content, display: false };
121
121
  }
122
122
  }
@@ -135,16 +135,17 @@ export default function (pi: ExtensionAPI) {
135
135
  };
136
136
  });
137
137
 
138
- pi.on("agent_end", async (event) => {
138
+ pi.on("agent_end", async (event, ctx) => {
139
139
  if (!config?.enabled || !memoryDir) return;
140
140
  const extractConfig = config.extractMemories;
141
141
  if (!extractConfig?.enabled) return;
142
142
  if (!event.messages || event.messages.length === 0) return;
143
- // Fire-and-forget: extract memories in background
144
143
  runExtract({
145
144
  model: extractConfig.model,
146
145
  thinkLevel: extractConfig.thinkLevel,
147
146
  memoryDir,
147
+ modelRegistry: ctx.modelRegistry,
148
+ parentModel: ctx.model,
148
149
  messages: event.messages.map((m) => ({
149
150
  // biome-ignore lint/suspicious/noExplicitAny: pi event message union type
150
151
  role: String((m as any).role ?? ""),
@@ -202,24 +203,27 @@ export default function (pi: ExtensionAPI) {
202
203
  }
203
204
  const ok = await ctx.ui.confirm("Dream", "Consolidate all memory files? This rewrites them in-place.");
204
205
  if (!ok) return;
206
+ const dir = memoryDir;
205
207
  ctx.ui.setStatus("dream", "Consolidating memory...");
206
- try {
207
- const summary = await runDream({
208
- model: config.dream.model,
209
- thinkLevel: config.dream.thinkLevel,
210
- memoryDir,
211
- signal: ctx.signal,
212
- events: pi.events,
213
- });
214
- const sessions = (await SessionManager.list(ctx.cwd)).length;
215
- await writeDreamMeta(memoryDir, sessions);
216
- ctx.ui.notify(summary, "info");
208
+ runDream({
209
+ model: config.dream.model,
210
+ thinkLevel: config.dream.thinkLevel,
211
+ memoryDir,
212
+ modelRegistry: ctx.modelRegistry,
213
+ parentModel: ctx.model,
214
+ })
215
+ .then(async (summary) => {
216
+ const sessions = (await SessionManager.list(ctx.cwd)).length;
217
+ await writeDreamMeta(dir, sessions);
218
+ ctx.ui.notify(summary, "info");
219
+ })
217
220
  // biome-ignore lint/suspicious/noExplicitAny: command handler ctx
218
- } catch (e: any) {
219
- ctx.ui.notify(`Dream failed: ${e.message}`, "error");
220
- } finally {
221
- ctx.ui.setStatus("dream", undefined);
222
- }
221
+ .catch((e: any) => {
222
+ ctx.ui.notify(`Dream failed: ${e.message}`, "error");
223
+ })
224
+ .finally(() => {
225
+ ctx.ui.setStatus("dream", undefined);
226
+ });
223
227
  },
224
228
  });
225
229
  }
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "1.1.0",
6
+ "version": "1.2.1",
7
7
  "description": "File-system driven persistent memory layer for pi coding agent",
8
8
  "license": "MIT",
9
9
  "repository": {
@@ -35,7 +35,6 @@
35
35
  "@earendil-works/pi-ai": ">=0.80.2",
36
36
  "@earendil-works/pi-coding-agent": ">=0.80.2",
37
37
  "@earendil-works/pi-tui": ">=0.80.2",
38
- "@yandy0725/pi-subagents": "*",
39
38
  "typebox": "*"
40
39
  },
41
40
  "devDependencies": {
@@ -0,0 +1,2 @@
1
+ /** Tools available to the headless memory-agent sub-session (file I/O only). */
2
+ export const MEMORY_AGENT_TOOLS = ["read", "write", "edit", "ls"] as const;
@@ -0,0 +1,121 @@
1
+ import type { Model } from "@earendil-works/pi-ai";
2
+ import {
3
+ type AgentSession,
4
+ type AgentSessionEvent,
5
+ createAgentSession,
6
+ DefaultResourceLoader,
7
+ getAgentDir,
8
+ SessionManager,
9
+ SettingsManager,
10
+ } from "@earendil-works/pi-coding-agent";
11
+ import { MEMORY_AGENT_TOOLS } from "./agent-config";
12
+ import type { ThinkLevel } from "./config";
13
+ import { resolveModel } from "./model-resolver";
14
+
15
+ export interface HeadlessAgentOpts {
16
+ task: string;
17
+ cwd: string;
18
+ modelRegistry: import("@earendil-works/pi-coding-agent").ModelRegistry;
19
+ model?: string;
20
+ parentModel?: Model<any>;
21
+ thinkLevel?: ThinkLevel;
22
+ maxTurns?: number;
23
+ signal?: AbortSignal;
24
+ timeoutMs?: number;
25
+ }
26
+
27
+ const GRACE_TURNS = 1;
28
+
29
+ /**
30
+ * Run a headless memory-agent sub-session: create an in-memory, resource-free
31
+ * session, drive the turn loop, collect the assistant response text, and dispose.
32
+ *
33
+ * Does NOT call bindExtensions — no extension hooks fire in the sub-session,
34
+ * so pi-memory's own before_agent_start cannot recurse.
35
+ */
36
+ export async function runHeadlessAgent(opts: HeadlessAgentOpts): Promise<string> {
37
+ // 1. Resolve model: undefined → parentModel; otherwise fuzzy resolve (fallback parent)
38
+ const resolvedModel = !opts.model
39
+ ? opts.parentModel
40
+ : (resolveModel(opts.model, opts.modelRegistry) ?? opts.parentModel);
41
+
42
+ // 2. Build a pure resource loader (no extensions/skills/context files/etc.)
43
+ const settingsManager = SettingsManager.inMemory();
44
+ const loader = new DefaultResourceLoader({
45
+ cwd: opts.cwd,
46
+ agentDir: getAgentDir(),
47
+ settingsManager,
48
+ noExtensions: true,
49
+ noSkills: true,
50
+ noContextFiles: true,
51
+ noPromptTemplates: true,
52
+ noThemes: true,
53
+ });
54
+ await loader.reload();
55
+
56
+ // 3. Forward abort signal BEFORE createAgentSession (handle early abort)
57
+ let session: AgentSession | undefined;
58
+ const onAbort = (): void => {
59
+ void session?.abort();
60
+ };
61
+ opts.signal?.addEventListener("abort", onAbort, { once: true });
62
+
63
+ // 4. Create the in-memory session (no bindExtensions)
64
+ const created = await createAgentSession({
65
+ cwd: opts.cwd,
66
+ tools: [...MEMORY_AGENT_TOOLS],
67
+ model: resolvedModel as any,
68
+ thinkingLevel: opts.thinkLevel as any,
69
+ modelRegistry: opts.modelRegistry,
70
+ sessionManager: SessionManager.inMemory(opts.cwd),
71
+ settingsManager,
72
+ resourceLoader: loader,
73
+ });
74
+ session = created.session as AgentSession;
75
+
76
+ // 5. Collect response text + enforce turn limits
77
+ let text = "";
78
+ let turnCount = 0;
79
+ let softLimitReached = false;
80
+ const unsubscribe = session.subscribe((event: AgentSessionEvent) => {
81
+ if (event.type === "message_start") {
82
+ text = "";
83
+ } else if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
84
+ text += event.assistantMessageEvent.delta;
85
+ } else if (event.type === "turn_end") {
86
+ turnCount++;
87
+ if (opts.maxTurns != null) {
88
+ if (!softLimitReached && turnCount >= opts.maxTurns) {
89
+ softLimitReached = true;
90
+ void session.steer("You have reached your turn limit. Finish now.");
91
+ } else if (softLimitReached && turnCount >= opts.maxTurns + GRACE_TURNS) {
92
+ void session.abort();
93
+ }
94
+ }
95
+ }
96
+ });
97
+
98
+ try {
99
+ // 6. Drive prompt (with optional timeout)
100
+ const promptPromise = session.prompt(opts.task);
101
+ if (opts.timeoutMs != null) {
102
+ let timeoutId: ReturnType<typeof setTimeout> | undefined;
103
+ const timeoutPromise = new Promise<never>((_, reject) => {
104
+ timeoutId = setTimeout(
105
+ () => reject(new Error(`headless agent timed out after ${opts.timeoutMs}ms`)),
106
+ opts.timeoutMs,
107
+ );
108
+ });
109
+ await Promise.race([promptPromise, timeoutPromise]).finally(() => {
110
+ if (timeoutId) clearTimeout(timeoutId);
111
+ });
112
+ } else {
113
+ await promptPromise;
114
+ }
115
+ return text || (session.getLastAssistantText() ?? "");
116
+ } finally {
117
+ opts.signal?.removeEventListener("abort", onAbort);
118
+ unsubscribe();
119
+ session.dispose?.();
120
+ }
121
+ }
package/src/config.ts CHANGED
@@ -7,7 +7,7 @@ export type ThinkLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh"
7
7
 
8
8
  export interface AutoSurfacingConfig {
9
9
  enabled: boolean;
10
- model: string;
10
+ model?: string;
11
11
  thinkLevel: ThinkLevel;
12
12
  maxFiles: number;
13
13
  maxTopicBytes: number;
@@ -16,7 +16,7 @@ export interface AutoSurfacingConfig {
16
16
 
17
17
  export interface ExtractMemoriesConfig {
18
18
  enabled: boolean;
19
- model: string;
19
+ model?: string;
20
20
  thinkLevel: ThinkLevel;
21
21
  maxContextTokens: number;
22
22
  }
@@ -26,7 +26,7 @@ export interface MemoryConfig {
26
26
  memoryDir: string;
27
27
  memIndexMaxLines: number;
28
28
  memIndexMaxBytes: number;
29
- dream: { nudgeAfterSessions: number; nudgeAfterHours: number; model: string; thinkLevel: ThinkLevel };
29
+ dream: { nudgeAfterSessions: number; nudgeAfterHours: number; model?: string; thinkLevel: ThinkLevel };
30
30
  sessionSearch: { maxSessions: number; maxMatches: number };
31
31
  autoSurfacing: AutoSurfacingConfig;
32
32
  extractMemories: ExtractMemoriesConfig;
@@ -37,11 +37,10 @@ export const DEFAULT_CONFIG: MemoryConfig = {
37
37
  memoryDir: join(homedir(), CONFIG_DIR_NAME, "memory"),
38
38
  memIndexMaxLines: 200,
39
39
  memIndexMaxBytes: 25600,
40
- dream: { nudgeAfterSessions: 5, nudgeAfterHours: 24, model: "auto", thinkLevel: "high" },
40
+ dream: { nudgeAfterSessions: 5, nudgeAfterHours: 24, thinkLevel: "high" },
41
41
  sessionSearch: { maxSessions: 10, maxMatches: 5 },
42
42
  autoSurfacing: {
43
43
  enabled: true,
44
- model: "auto",
45
44
  thinkLevel: "off",
46
45
  maxFiles: 5,
47
46
  maxTopicBytes: 4096,
@@ -49,7 +48,6 @@ export const DEFAULT_CONFIG: MemoryConfig = {
49
48
  },
50
49
  extractMemories: {
51
50
  enabled: true,
52
- model: "auto",
53
51
  thinkLevel: "high",
54
52
  maxContextTokens: 2000,
55
53
  },
package/src/dream.ts CHANGED
@@ -1,11 +1,9 @@
1
- import { access } from "node:fs/promises";
1
+ import type { Model } from "@earendil-works/pi-ai";
2
+ import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
3
+ import { runHeadlessAgent } from "./agent-runner";
2
4
  import type { ThinkLevel } from "./config";
3
- import { getSubagentsService, type SubagentsService, type WorkspaceProvider } from "@yandy0725/pi-subagents";
4
5
 
5
- /** Build dream consolidation task.
6
- * Session context strategy: cwd = memoryDir (via WorkspaceProvider).
7
- * Dream agent sees only the memory files, no parent conversation history.
8
- * Tool scope: full file read/write/edit (needed for consolidation). */
6
+ /** Build dream consolidation task. (unchanged) */
9
7
  export function buildDreamTask(memoryDir: string, maxLines: number): string {
10
8
  return `You are a memory consolidation agent. Your job is to read all memory files
11
9
  and consolidate them into a clean, deduplicated memory store.
@@ -53,80 +51,24 @@ CRITICAL for hooks and descriptions:
53
51
  }
54
52
 
55
53
  export interface RunDreamOpts {
56
- model: string;
54
+ model?: string;
57
55
  thinkLevel: ThinkLevel;
58
56
  memoryDir: string;
59
- signal?: AbortSignal;
60
- // biome-ignore lint/suspicious/noExplicitAny: pi events API handler
61
- events?: { on(channel: string, handler: (data: any) => void): () => void };
62
- service?: SubagentsService;
57
+ modelRegistry: ModelRegistry;
58
+ parentModel?: Model<any>;
63
59
  }
64
60
 
61
+ /** Run dream consolidation via a headless agent. Fire-and-forget (caller decides await). */
65
62
  export async function runDream(opts: RunDreamOpts): Promise<string> {
66
- const service = opts.service ?? getSubagentsService();
67
- if (!service) throw new Error("pi-subagents not available — install @yandy0725/pi-subagents");
68
-
69
- const events = opts.events;
70
- if (!events) throw new Error("events required for dream — pass pi.events");
71
-
72
- const model = opts.model === "auto" ? undefined : opts.model;
73
63
  const task = buildDreamTask(opts.memoryDir, 200);
74
-
75
- // Register workspace provider so the subagent runs in memoryDir
76
- const provider: WorkspaceProvider = {
77
- async prepare(_ctx) {
78
- await access(opts.memoryDir).catch(() => {
79
- throw new Error(`Memory directory not found: ${opts.memoryDir}`);
80
- });
81
- return {
82
- cwd: opts.memoryDir,
83
- dispose: () => undefined,
84
- };
85
- },
86
- };
87
- const unregister = service.registerWorkspaceProvider(provider);
88
-
89
- // Spawn the dream subagent
90
- const agentId = service.spawn(
91
- "memory-agent",
64
+ return runHeadlessAgent({
92
65
  task,
93
- model ? { model, thinkingLevel: opts.thinkLevel } : { thinkingLevel: opts.thinkLevel },
94
- );
95
-
96
- // Wait for completion/failure via pi-subagents events (abort-while-queued
97
- // is handled: pi-subagents guarantees events fire for all terminal states).
98
- return await new Promise<string>((resolve, reject) => {
99
- let settled = false;
100
-
101
- const cleanup = () => {
102
- if (settled) return;
103
- settled = true;
104
- unsubCompleted();
105
- unsubFailed();
106
- unregister();
107
- opts.signal?.removeEventListener("abort", onAbort);
108
- };
109
-
110
- const onCompleted = (data: { id: string }) => {
111
- if (data.id !== agentId) return;
112
- cleanup();
113
- const record = service.getRecord(agentId);
114
- resolve(record?.result ?? "Dream completed.");
115
- };
116
-
117
- const onFailed = (data: { id: string; error?: string }) => {
118
- if (data.id !== agentId) return;
119
- cleanup();
120
- reject(new Error(data.error ?? "Dream agent failed"));
121
- };
122
-
123
- const onAbort = () => {
124
- service.abort(agentId);
125
- };
126
-
127
- const unsubCompleted = events.on("subagents:completed", onCompleted);
128
- const unsubFailed = events.on("subagents:failed", onFailed);
129
-
130
- opts.signal?.addEventListener("abort", onAbort, { once: true });
66
+ cwd: opts.memoryDir,
67
+ modelRegistry: opts.modelRegistry,
68
+ model: opts.model,
69
+ parentModel: opts.parentModel,
70
+ thinkLevel: opts.thinkLevel,
71
+ maxTurns: undefined,
72
+ timeoutMs: 600_000,
131
73
  });
132
74
  }
package/src/extract.ts CHANGED
@@ -1,32 +1,29 @@
1
- import { access } from "node:fs/promises";
1
+ import type { Model } from "@earendil-works/pi-ai";
2
+ import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
3
+ import { runHeadlessAgent } from "./agent-runner";
2
4
  import type { ThinkLevel } from "./config";
3
- import { getSubagentsService, type SubagentsService, type WorkspaceProvider } from "@yandy0725/pi-subagents";
4
5
 
5
6
  export interface RunExtractOpts {
6
- model: string;
7
+ model?: string;
7
8
  thinkLevel: ThinkLevel;
8
9
  memoryDir: string;
9
10
  messages: Array<{ role: string; content: string }>;
10
11
  maxContextTokens: number;
11
- service?: SubagentsService;
12
+ modelRegistry: ModelRegistry;
13
+ parentModel?: Model<any>;
12
14
  }
13
15
 
14
- /** Build extraction task prompt.
15
- * Session context strategy: only current turn (user + assistant messages).
16
- * No conversation history — extraction is turn-scoped.
17
- * Tool scope: file read/write only (cwd = memoryDir). */
16
+ /** Build extraction task prompt. (unchanged) */
18
17
  export function buildExtractTask(
19
18
  memoryDir: string,
20
19
  messages: Array<{ role: string; content: string }>,
21
20
  maxTokens: number,
22
21
  ): string {
23
- // Find user and assistant messages for context
24
22
  const fromUser = messages.find((m) => m.role === "user");
25
23
  const fromAssistant = messages.findLast((m) => m.role === "assistant");
26
24
  const userText = fromUser?.content ?? "";
27
25
  const assistantText = fromAssistant?.content ?? "";
28
26
 
29
- // Truncate by maxTokens (rough estimate: ~4 chars/token)
30
27
  const maxChars = maxTokens * 4;
31
28
  const truncatedUser = userText.slice(0, maxChars / 2);
32
29
  const truncatedAssistant = assistantText.slice(0, maxChars / 2);
@@ -79,35 +76,21 @@ export function buildExtractTask(
79
76
  ].join("\n");
80
77
  }
81
78
 
79
+ /** Fire-and-forget memory extraction. Does not await the headless agent. */
82
80
  export async function runExtract(opts: RunExtractOpts): Promise<void> {
83
81
  if (opts.messages.length === 0) return;
84
-
85
- const service = opts.service ?? getSubagentsService();
86
- if (!service) return; // silently skip if no subagent service
87
-
88
- const model = opts.model === "auto" ? undefined : opts.model;
89
- const thinkLevel = opts.thinkLevel;
90
82
  const task = buildExtractTask(opts.memoryDir, opts.messages, opts.maxContextTokens);
91
-
92
- const provider: WorkspaceProvider = {
93
- async prepare(_ctx) {
94
- await access(opts.memoryDir).catch(() => {
95
- throw new Error(`Memory directory not found: ${opts.memoryDir}`);
96
- });
97
- return {
98
- cwd: opts.memoryDir,
99
- dispose: () => undefined,
100
- };
101
- },
102
- };
103
- service.registerWorkspaceProvider(provider);
104
-
105
- // Fire-and-forget spawn
106
- service.spawn(
107
- "memory-agent",
83
+ // fire-and-forget: runner disposes internally via finally
84
+ runHeadlessAgent({
108
85
  task,
109
- model
110
- ? { model, inheritContext: false, maxTurns: 5, thinkingLevel: thinkLevel }
111
- : { inheritContext: false, maxTurns: 5, thinkingLevel: thinkLevel },
112
- );
86
+ cwd: opts.memoryDir,
87
+ modelRegistry: opts.modelRegistry,
88
+ model: opts.model,
89
+ parentModel: opts.parentModel,
90
+ thinkLevel: opts.thinkLevel,
91
+ maxTurns: 5,
92
+ timeoutMs: 120_000,
93
+ }).catch(() => {
94
+ /* silently ignore extract errors */
95
+ });
113
96
  }
package/src/inject.ts CHANGED
@@ -1,6 +1,9 @@
1
1
  import { readdir, readFile, stat } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
- import { getSubagentsService } from "@yandy0725/pi-subagents";
3
+ import type { Model } from "@earendil-works/pi-ai";
4
+ import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
5
+ import { runHeadlessAgent } from "./agent-runner";
6
+ import type { ThinkLevel } from "./config";
4
7
  import { truncateForInjection } from "./index-file";
5
8
  import { parseFrontmatter } from "./topic-file";
6
9
 
@@ -57,25 +60,15 @@ export async function scanTopics(memoryDir: string): Promise<TopicManifest[]> {
57
60
  return manifests.sort((a, b) => b.mtimeMs - a.mtimeMs);
58
61
  }
59
62
 
60
- export function buildSurfacingPrompt(
61
- manifest: TopicManifest[],
62
- userPrompt: string,
63
- injectedTopics: Set<string>,
64
- ): string {
63
+ function buildSurfacingPrompt(manifest: TopicManifest[], userPrompt: string): string {
65
64
  const lines = manifest.map((t) => {
66
- const marker = injectedTopics.has(t.filename) ? " [already injected]" : "";
67
- return `[${t.type}] ${t.filename} — ${t.description.slice(0, 80)}${marker}`;
65
+ return `[${t.type}] ${t.filename} — ${t.description.slice(0, 80)}`;
68
66
  });
69
- const alreadyInjected =
70
- injectedTopics.size > 0
71
- ? `\n\nNote: ${injectedTopics.size} topic file(s) have already been injected in this session and are marked [already injected]. Prefer selecting uninjected topics.`
72
- : "";
73
67
 
74
68
  return [
75
69
  "You are a memory relevance selector. Below is a list of memory topic files and a user message.",
76
70
  "Select up to N topic files that are relevant to the user's current query.",
77
71
  "Response format: JSON with a 'selected_files' array of filenames.",
78
- alreadyInjected,
79
72
  "",
80
73
  "=== Topic Files ===",
81
74
  ...lines,
@@ -114,30 +107,9 @@ export async function injectSurfacedContent(
114
107
  return `<relevant_memories>\n${blocks.join("\n\n")}\n</relevant_memories>`;
115
108
  }
116
109
 
117
- /** Run a lightweight LLM side-query to select relevant topic files.
118
- * Uses pi-subagents spawn + Promise pattern (same as dream.ts).
119
- * Falls back to keyword matching if subagent service is unavailable. */
120
- import type { ThinkLevel } from "./config";
121
-
122
- export async function runSideQuery(
123
- prompt: string,
124
- manifest: TopicManifest[],
125
- maxFiles: number,
126
- thinkLevel: ThinkLevel,
127
- // biome-ignore lint/suspicious/noExplicitAny: pi events API handler
128
- events?: { on(channel: string, handler: (data: any) => void): () => void },
129
- ): Promise<string[]> {
130
- // Filter to uninjected candidates only
131
- const candidates = manifest.filter((t) => {
132
- // already-injected topics are marked in the prompt, but we also
133
- // filter here as a safety net
134
- return !prompt.includes(`[already injected] ${t.filename}`);
135
- });
136
- if (candidates.length === 0) return [];
137
-
138
- // Build a minimal selection prompt — only current user message + topic manifest.
139
- // With prompt_mode=replace, this IS the entire system prompt (no inheritance).
140
- const task = [
110
+ /** Build the side-query task prompt. Extracted for testability. */
111
+ export function buildSideQueryTask(prompt: string, maxFiles: number): string {
112
+ return [
141
113
  "Respond with ONLY a JSON object.",
142
114
  "",
143
115
  "Below is a list of memory topic files and a user query.",
@@ -148,106 +120,51 @@ export async function runSideQuery(
148
120
  "",
149
121
  'Respond with EXACTLY: {"selected_files": [...]}',
150
122
  ].join("\n");
123
+ }
151
124
 
152
- const service = getSubagentsService();
153
- if (!service) {
154
- // Fallback: keyword matching when subagent service unavailable
155
- return keywordMatch(candidates, prompt, maxFiles);
125
+ /** Parse selected_files JSON from headless agent response. */
126
+ function parseSelectedFiles(result: string, candidates: TopicManifest[], maxFiles: number): string[] {
127
+ try {
128
+ const jsonMatch = result.match(/\{[^}]*"selected_files"[^}]*\}/s);
129
+ if (!jsonMatch) return [];
130
+ const parsed = JSON.parse(jsonMatch[0]);
131
+ const files: string[] = parsed.selected_files ?? [];
132
+ return files.filter((f: string) => candidates.some((c) => c.filename === f)).slice(0, maxFiles);
133
+ } catch {
134
+ return [];
156
135
  }
136
+ }
157
137
 
158
- const provider = {
159
- // biome-ignore lint/suspicious/noExplicitAny: WorkspaceProvider prepare ctx
160
- async prepare(_ctx: any) {
161
- return { cwd: process.cwd(), dispose: () => undefined };
162
- },
163
- };
164
- const unregister = service.registerWorkspaceProvider(provider);
165
-
138
+ /** Run a lightweight headless side-query to select relevant topic files.
139
+ * Returns [] on timeout/failure — no fallback. */
140
+ export async function runSideQuery(
141
+ manifest: TopicManifest[],
142
+ userPrompt: string,
143
+ injectedTopics: Set<string>,
144
+ maxFiles: number,
145
+ thinkLevel: ThinkLevel,
146
+ model: string | undefined,
147
+ modelRegistry: ModelRegistry,
148
+ parentModel: Model<any> | undefined,
149
+ memoryDir: string,
150
+ ): Promise<string[]> {
151
+ const candidates = manifest.filter((t) => !injectedTopics.has(t.filename));
152
+ if (candidates.length === 0) return [];
153
+ const surfacingPrompt = buildSurfacingPrompt(candidates, userPrompt);
154
+ const task = buildSideQueryTask(surfacingPrompt, maxFiles);
166
155
  try {
167
- // Subscribe to events BEFORE spawn to prevent race: if the subagent
168
- // completes between spawn and subscribe, the event is lost and
169
- // the Promise never resolves except by timeout.
170
- const result = await new Promise<string[]>((resolve) => {
171
- const timeout = setTimeout(() => {
172
- cleanup();
173
- resolve(keywordMatch(candidates, prompt, maxFiles));
174
- }, 30_000);
175
-
176
- let settled = false;
177
- const cleanup = () => {
178
- if (settled) return;
179
- settled = true;
180
- clearTimeout(timeout);
181
- unsubCompleted();
182
- unsubFailed();
183
- unregister();
184
- };
185
-
186
- const onCompleted = (data: { id: string }) => {
187
- if (data.id !== agentId) return;
188
- cleanup();
189
- const record = service.getRecord(agentId);
190
- const result = record?.result ?? "";
191
- try {
192
- const jsonMatch = result.match(/\{[^}]*"selected_files"[^}]*\}/s);
193
- if (jsonMatch) {
194
- const parsed = JSON.parse(jsonMatch[0]);
195
- const files: string[] = parsed.selected_files ?? [];
196
- const valid = files.filter((f: string) => candidates.some((c) => c.filename === f)).slice(0, maxFiles);
197
- resolve(valid);
198
- } else {
199
- resolve([]);
200
- }
201
- } catch {
202
- resolve([]);
203
- }
204
- };
205
-
206
- const onFailed = (data: { id: string }) => {
207
- if (data.id !== agentId) return;
208
- cleanup();
209
- resolve(keywordMatch(candidates, prompt, maxFiles));
210
- };
211
-
212
- const unsubCompleted = events?.on("subagents:completed", onCompleted) ?? (() => {});
213
- const unsubFailed = events?.on("subagents:failed", onFailed) ?? (() => {});
214
-
215
- if (!events) {
216
- cleanup();
217
- resolve(keywordMatch(candidates, prompt, maxFiles));
218
- return;
219
- }
220
-
221
- // Spawn AFTER subscriptions are active
222
- const agentId = service.spawn("memory-agent", task, {
223
- maxTurns: 1,
224
- inheritContext: false,
225
- thinkingLevel: thinkLevel,
226
- foreground: true,
227
- bypassQueue: true,
228
- });
156
+ const result = await runHeadlessAgent({
157
+ task,
158
+ cwd: memoryDir,
159
+ modelRegistry,
160
+ model,
161
+ parentModel,
162
+ thinkLevel,
163
+ maxTurns: 1,
164
+ timeoutMs: 30_000,
229
165
  });
230
-
231
- return result;
166
+ return parseSelectedFiles(result, candidates, maxFiles);
232
167
  } catch {
233
- unregister();
234
- return keywordMatch(candidates, prompt, maxFiles);
168
+ return [];
235
169
  }
236
170
  }
237
-
238
- /** Keyword-matching fallback for topic selection (no LLM required). */
239
- function keywordMatch(manifest: TopicManifest[], userPrompt: string, maxFiles: number): string[] {
240
- const words = userPrompt
241
- .toLowerCase()
242
- .split(/\W+/)
243
- .filter((w) => w.length > 2);
244
- const scored = manifest
245
- .map((t) => {
246
- const desc = `${t.description} ${t.name}`.toLowerCase();
247
- const score = words.filter((w) => desc.includes(w)).length;
248
- return { filename: t.filename, score };
249
- })
250
- .filter((t) => t.score > 0)
251
- .sort((a, b) => b.score - a.score);
252
- return scored.slice(0, maxFiles).map((t) => t.filename);
253
- }
@@ -110,7 +110,10 @@ export async function doAdd(memoryDir: string, p: AddParams): Promise<ActionResu
110
110
 
111
111
  // Build hook from all entries (comma-separated titles, trimmed to ~150 chars)
112
112
  const allEntries = parseEntries(topicContent);
113
- const hook = allEntries.map((e) => e.title).join("; ").slice(0, 150);
113
+ const hook = allEntries
114
+ .map((e) => e.title)
115
+ .join("; ")
116
+ .slice(0, 150);
114
117
  next = updateHook(entries, topic, hook);
115
118
  if (!checkCapacity(next, p.maxLines, p.maxBytes)) {
116
119
  return {
@@ -0,0 +1,69 @@
1
+ import type { Model } from "@earendil-works/pi-ai";
2
+ import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
3
+
4
+ interface ModelEntry {
5
+ id: string;
6
+ name: string;
7
+ provider: string;
8
+ }
9
+
10
+ /**
11
+ * Resolve a model string to a Model instance.
12
+ * Tries exact "provider/modelId" match (only available models), then fuzzy match.
13
+ * Returns the Model on success, or undefined on failure (caller falls back to parent model).
14
+ */
15
+ export function resolveModel(input: string, registry: ModelRegistry): Model<any> | undefined {
16
+ if (!input) return undefined;
17
+ const all = (registry.getAvailable?.() ?? registry.getAll()) as ModelEntry[];
18
+ if (all.length === 0) return undefined;
19
+ const availableSet = new Set(all.map((m) => `${m.provider}/${m.id}`.toLowerCase()));
20
+
21
+ // 1. Exact match: "provider/modelId" — only if available (has auth)
22
+ const slashIdx = input.indexOf("/");
23
+ if (slashIdx !== -1) {
24
+ const provider = input.slice(0, slashIdx);
25
+ const modelId = input.slice(slashIdx + 1);
26
+ if (availableSet.has(input.toLowerCase())) {
27
+ const found = registry.find(provider, modelId);
28
+ if (found) return found;
29
+ }
30
+ }
31
+
32
+ // 2. Fuzzy match against available models
33
+ const query = input.toLowerCase();
34
+ let bestMatch: ModelEntry | undefined;
35
+ let bestScore = 0;
36
+
37
+ for (const m of all) {
38
+ const id = m.id.toLowerCase();
39
+ const name = m.name.toLowerCase();
40
+ const full = `${m.provider}/${m.id}`.toLowerCase();
41
+
42
+ let score = 0;
43
+ if (id === query || full === query) {
44
+ score = 100;
45
+ } else if (id.includes(query) || full.includes(query)) {
46
+ score = 60 + (query.length / id.length) * 30;
47
+ } else if (name.includes(query)) {
48
+ score = 40 + (query.length / name.length) * 20;
49
+ } else if (
50
+ query
51
+ .split(/[\s\-/]+/)
52
+ .every((part) => id.includes(part) || name.includes(part) || m.provider.toLowerCase().includes(part))
53
+ ) {
54
+ score = 20;
55
+ }
56
+
57
+ if (score > bestScore) {
58
+ bestScore = score;
59
+ bestMatch = m;
60
+ }
61
+ }
62
+
63
+ if (bestMatch && bestScore >= 20) {
64
+ const found = registry.find(bestMatch.provider, bestMatch.id);
65
+ if (found) return found;
66
+ }
67
+
68
+ return undefined;
69
+ }
@@ -1,44 +0,0 @@
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
- }