@yandy0725/pi-memory 1.0.1 → 1.2.0
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 +7 -1
- package/README.zh.md +7 -1
- package/index.ts +56 -46
- package/package.json +1 -2
- package/src/agent-config.ts +2 -0
- package/src/agent-runner.ts +121 -0
- package/src/config.ts +10 -6
- package/src/dream.ts +18 -74
- package/src/extract.ts +22 -36
- package/src/inject.ts +46 -117
- package/src/memory-tool.ts +4 -1
- package/src/model-resolver.ts +69 -0
- package/src/agent-types.ts +0 -44
package/README.md
CHANGED
|
@@ -47,7 +47,8 @@ Create `memory.json` in the agent directory (`~/.pi/agent/memory.json`) or the p
|
|
|
47
47
|
"dream": {
|
|
48
48
|
"nudgeAfterSessions": 5,
|
|
49
49
|
"nudgeAfterHours": 24,
|
|
50
|
-
"model": "auto"
|
|
50
|
+
"model": "auto",
|
|
51
|
+
"thinkLevel": "high"
|
|
51
52
|
},
|
|
52
53
|
"sessionSearch": {
|
|
53
54
|
"maxSessions": 10,
|
|
@@ -56,6 +57,7 @@ Create `memory.json` in the agent directory (`~/.pi/agent/memory.json`) or the p
|
|
|
56
57
|
"autoSurfacing": {
|
|
57
58
|
"enabled": true,
|
|
58
59
|
"model": "auto",
|
|
60
|
+
"thinkLevel": "off",
|
|
59
61
|
"maxFiles": 5,
|
|
60
62
|
"maxTopicBytes": 4096,
|
|
61
63
|
"maxInjectionBytes": 20480
|
|
@@ -63,6 +65,7 @@ Create `memory.json` in the agent directory (`~/.pi/agent/memory.json`) or the p
|
|
|
63
65
|
"extractMemories": {
|
|
64
66
|
"enabled": true,
|
|
65
67
|
"model": "auto",
|
|
68
|
+
"thinkLevel": "high",
|
|
66
69
|
"maxContextTokens": 2000
|
|
67
70
|
}
|
|
68
71
|
}
|
|
@@ -77,15 +80,18 @@ Create `memory.json` in the agent directory (`~/.pi/agent/memory.json`) or the p
|
|
|
77
80
|
| `dream.nudgeAfterSessions` | `5` | Sessions since last dream before nudge is shown |
|
|
78
81
|
| `dream.nudgeAfterHours` | `24` | Hours since last dream before nudge is shown |
|
|
79
82
|
| `dream.model` | `"auto"` | Model for dream consolidation (`"auto"` = same as current, or `"provider/id"`) |
|
|
83
|
+
| `dream.thinkLevel` | `"high"` | Thinking effort for dream subagent: `"off"`, `"minimal"`, `"low"`, `"medium"`, `"high"`, `"xhigh"` |
|
|
80
84
|
| `sessionSearch.maxSessions` | `10` | Max sessions to scan when searching history |
|
|
81
85
|
| `sessionSearch.maxMatches` | `5` | Max matches to return from history search |
|
|
82
86
|
| `autoSurfacing.enabled` | `true` | ⭐ Enable per-turn topic file auto-injection |
|
|
83
87
|
| `autoSurfacing.model` | `"auto"` | ⭐ Model for side-query relevance selection |
|
|
88
|
+
| `autoSurfacing.thinkLevel` | `"off"` | ⭐ Thinking effort for side-query (recommended: `"off"` for lightweight selection) |
|
|
84
89
|
| `autoSurfacing.maxFiles` | `5` | ⭐ Max topic files to inject per turn |
|
|
85
90
|
| `autoSurfacing.maxTopicBytes` | `4096` | ⭐ Max bytes per injected topic file (truncated) |
|
|
86
91
|
| `autoSurfacing.maxInjectionBytes` | `20480` | ⭐ Max total bytes of injected content per turn |
|
|
87
92
|
| `extractMemories.enabled` | `true` | ⭐ Enable per-turn memory extraction |
|
|
88
93
|
| `extractMemories.model` | `"auto"` | ⭐ Model for the extraction subagent |
|
|
94
|
+
| `extractMemories.thinkLevel` | `"high"` | ⭐ Thinking effort for extraction: `"off"` / `"minimal"` / `"low"` / `"medium"` / `"high"` / `"xhigh"` |
|
|
89
95
|
| `extractMemories.maxContextTokens` | `2000` | ⭐ Max tokens of conversation to analyze |
|
|
90
96
|
|
|
91
97
|
Project-level config (`.pi/memory.json`) is only loaded when the project is trusted.
|
package/README.zh.md
CHANGED
|
@@ -47,7 +47,8 @@ pi install npm:@yandy0725/pi-memory
|
|
|
47
47
|
"dream": {
|
|
48
48
|
"nudgeAfterSessions": 5,
|
|
49
49
|
"nudgeAfterHours": 24,
|
|
50
|
-
"model": "auto"
|
|
50
|
+
"model": "auto",
|
|
51
|
+
"thinkLevel": "high"
|
|
51
52
|
},
|
|
52
53
|
"sessionSearch": {
|
|
53
54
|
"maxSessions": 10,
|
|
@@ -56,6 +57,7 @@ pi install npm:@yandy0725/pi-memory
|
|
|
56
57
|
"autoSurfacing": {
|
|
57
58
|
"enabled": true,
|
|
58
59
|
"model": "auto",
|
|
60
|
+
"thinkLevel": "off",
|
|
59
61
|
"maxFiles": 5,
|
|
60
62
|
"maxTopicBytes": 4096,
|
|
61
63
|
"maxInjectionBytes": 20480
|
|
@@ -63,6 +65,7 @@ pi install npm:@yandy0725/pi-memory
|
|
|
63
65
|
"extractMemories": {
|
|
64
66
|
"enabled": true,
|
|
65
67
|
"model": "auto",
|
|
68
|
+
"thinkLevel": "high",
|
|
66
69
|
"maxContextTokens": 2000
|
|
67
70
|
}
|
|
68
71
|
}
|
|
@@ -77,15 +80,18 @@ pi install npm:@yandy0725/pi-memory
|
|
|
77
80
|
| `dream.nudgeAfterSessions` | `5` | 触发提醒需经过的会话数 |
|
|
78
81
|
| `dream.nudgeAfterHours` | `24` | 触发提醒需经过的小时数 |
|
|
79
82
|
| `dream.model` | `"auto"` | 整理使用的模型(`"auto"` = 当前模型,或 `"provider/id"`) |
|
|
83
|
+
| `dream.thinkLevel` | `"high"` | 整理子 agent 的思考深度:`"off"` / `"minimal"` / `"low"` / `"medium"` / `"high"` / `"xhigh"` |
|
|
80
84
|
| `sessionSearch.maxSessions` | `10` | 搜索历史时最多扫描的会话数 |
|
|
81
85
|
| `sessionSearch.maxMatches` | `5` | 历史搜索最多返回的匹配数 |
|
|
82
86
|
| `autoSurfacing.enabled` | `true` | ⭐ 启用 per-turn topic 文件自动注入 |
|
|
83
87
|
| `autoSurfacing.model` | `"auto"` | ⭐ side-query 相关性选择模型 |
|
|
88
|
+
| `autoSurfacing.thinkLevel` | `"off"` | ⭐ side-query 思考深度(推荐 `"off"`,轻量选择任务) |
|
|
84
89
|
| `autoSurfacing.maxFiles` | `5` | ⭐ 每轮最多注入的 topic 文件数 |
|
|
85
90
|
| `autoSurfacing.maxTopicBytes` | `4096` | ⭐ 单个注入 topic 文件最大字节数(截断) |
|
|
86
91
|
| `autoSurfacing.maxInjectionBytes` | `20480` | ⭐ 每轮注入内容总字节数上限 |
|
|
87
92
|
| `extractMemories.enabled` | `true` | ⭐ 启用 per-turn 记忆自动提取 |
|
|
88
93
|
| `extractMemories.model` | `"auto"` | ⭐ 提取子 agent 使用的模型 |
|
|
94
|
+
| `extractMemories.thinkLevel` | `"high"` | ⭐ 提取的思考深度:`"off"` / `"minimal"` / `"low"` / `"medium"` / `"high"` / `"xhigh"` |
|
|
89
95
|
| `extractMemories.maxContextTokens` | `2000` | ⭐ 分析对话的最大 token 数 |
|
|
90
96
|
|
|
91
97
|
项目级配置(`.pi/memory.json`)仅在项目受信任时加载。
|
package/index.ts
CHANGED
|
@@ -2,7 +2,6 @@ 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";
|
|
@@ -19,9 +18,6 @@ import { readDreamMeta, shouldNudge, writeDreamMeta } from "./src/nudge";
|
|
|
19
18
|
import { resolveMemoryDir } from "./src/paths";
|
|
20
19
|
import { searchSessions } from "./src/session-search";
|
|
21
20
|
|
|
22
|
-
// Register custom agent type on load (zero setup, auto-fallback on first session).
|
|
23
|
-
ensureAgentTypes();
|
|
24
|
-
|
|
25
21
|
export default function (pi: ExtensionAPI) {
|
|
26
22
|
let memoryDir: string | null = null;
|
|
27
23
|
let config: MemoryConfig | null = null;
|
|
@@ -57,28 +53,30 @@ export default function (pi: ExtensionAPI) {
|
|
|
57
53
|
if (nudge) {
|
|
58
54
|
const ok = await ctx.ui.confirm("Memory Consolidation", `${message}\n\nConsolidate memory files now?`);
|
|
59
55
|
if (ok) {
|
|
60
|
-
// Fire-and-forget:
|
|
61
|
-
//
|
|
62
|
-
// Does not block session_start.
|
|
56
|
+
// Fire-and-forget: does not block session_start. The headless
|
|
57
|
+
// dream agent runs independently; completion notifies the user.
|
|
63
58
|
const dreamModel = config.dream.model;
|
|
59
|
+
const dreamThinkLevel = config.dream.thinkLevel;
|
|
64
60
|
const dir = memoryDir;
|
|
65
61
|
ctx.ui.setStatus("dream", "Consolidating memory...");
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
62
|
+
runDream({
|
|
63
|
+
model: dreamModel,
|
|
64
|
+
thinkLevel: dreamThinkLevel,
|
|
65
|
+
memoryDir: dir,
|
|
66
|
+
modelRegistry: ctx.modelRegistry,
|
|
67
|
+
parentModel: ctx.model,
|
|
68
|
+
})
|
|
69
|
+
.then(async (summary) => {
|
|
73
70
|
await writeDreamMeta(dir, sessions);
|
|
74
71
|
ctx.ui.notify(summary, "info");
|
|
75
|
-
|
|
76
|
-
|
|
72
|
+
})
|
|
73
|
+
// biome-ignore lint/suspicious/noExplicitAny: error catch
|
|
74
|
+
.catch((e: any) => {
|
|
77
75
|
ctx.ui.notify(`Dream failed: ${e.message}`, "error");
|
|
78
|
-
}
|
|
76
|
+
})
|
|
77
|
+
.finally(() => {
|
|
79
78
|
ctx.ui.setStatus("dream", undefined);
|
|
80
|
-
}
|
|
81
|
-
}, 0);
|
|
79
|
+
});
|
|
82
80
|
}
|
|
83
81
|
}
|
|
84
82
|
}
|
|
@@ -87,14 +85,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
87
85
|
pi.on("before_agent_start", async (event, ctx) => {
|
|
88
86
|
if (!config?.enabled || !indexSnapshot || !memoryDir) return;
|
|
89
87
|
|
|
90
|
-
// Auto-surfacing: select relevant topic files via LLM side-query and inject as message.
|
|
91
|
-
// Skip for subagents: pi-subagents strips "subagent" from all children's
|
|
92
|
-
// tool sets, so its absence reliably identifies subagents. Without this guard,
|
|
93
|
-
// runSideQuery's subagent spawn → before_agent_start → re-enter here → OOM.
|
|
94
|
-
const agentTools = event.systemPromptOptions?.selectedTools;
|
|
95
|
-
const isSubagent = agentTools && !agentTools.includes("subagent");
|
|
96
|
-
|
|
97
88
|
const autoSurfacing = config.autoSurfacing;
|
|
89
|
+
// Skip auto-surfacing in subagent sessions: pi-subagents injects an
|
|
90
|
+
// <active_agent name="..."/> tag into every subagent's system prompt.
|
|
91
|
+
// Main sessions (including forks) never have this tag. Our own headless
|
|
92
|
+
// sessions use noExtensions (never bind), so they never reach here.
|
|
93
|
+
const isSubagent = event.systemPrompt?.includes("<active_agent name=\"");
|
|
98
94
|
// biome-ignore lint/suspicious/noExplicitAny: message injection result
|
|
99
95
|
let injectedMessage: any;
|
|
100
96
|
if (autoSurfacing?.enabled && event.prompt && !isSubagent) {
|
|
@@ -103,7 +99,17 @@ export default function (pi: ExtensionAPI) {
|
|
|
103
99
|
const manifest = await scanTopics(memoryDir);
|
|
104
100
|
if (manifest.length > 0) {
|
|
105
101
|
const queryPrompt = buildSurfacingPrompt(manifest, event.prompt.slice(0, 4000), injectedTopics);
|
|
106
|
-
const selected = await runSideQuery(
|
|
102
|
+
const selected = await runSideQuery(
|
|
103
|
+
queryPrompt,
|
|
104
|
+
manifest,
|
|
105
|
+
autoSurfacing.maxFiles,
|
|
106
|
+
autoSurfacing.thinkLevel,
|
|
107
|
+
autoSurfacing.model,
|
|
108
|
+
ctx.modelRegistry,
|
|
109
|
+
ctx.model,
|
|
110
|
+
memoryDir,
|
|
111
|
+
injectedTopics,
|
|
112
|
+
);
|
|
107
113
|
if (selected.length > 0) {
|
|
108
114
|
const content = await injectSurfacedContent(
|
|
109
115
|
memoryDir,
|
|
@@ -112,9 +118,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
112
118
|
autoSurfacing.maxInjectionBytes,
|
|
113
119
|
);
|
|
114
120
|
if (content) {
|
|
115
|
-
// Track injected topics for session-level dedup
|
|
116
121
|
for (const f of selected) injectedTopics.add(f);
|
|
117
|
-
// Inject as a custom message (NOT systemPrompt)
|
|
118
122
|
injectedMessage = { customType: "memory-auto-surfacing", content, display: false };
|
|
119
123
|
}
|
|
120
124
|
}
|
|
@@ -133,15 +137,17 @@ export default function (pi: ExtensionAPI) {
|
|
|
133
137
|
};
|
|
134
138
|
});
|
|
135
139
|
|
|
136
|
-
pi.on("agent_end", async (event) => {
|
|
140
|
+
pi.on("agent_end", async (event, ctx) => {
|
|
137
141
|
if (!config?.enabled || !memoryDir) return;
|
|
138
142
|
const extractConfig = config.extractMemories;
|
|
139
143
|
if (!extractConfig?.enabled) return;
|
|
140
144
|
if (!event.messages || event.messages.length === 0) return;
|
|
141
|
-
// Fire-and-forget: extract memories in background
|
|
142
145
|
runExtract({
|
|
143
146
|
model: extractConfig.model,
|
|
147
|
+
thinkLevel: extractConfig.thinkLevel,
|
|
144
148
|
memoryDir,
|
|
149
|
+
modelRegistry: ctx.modelRegistry,
|
|
150
|
+
parentModel: ctx.model,
|
|
145
151
|
messages: event.messages.map((m) => ({
|
|
146
152
|
// biome-ignore lint/suspicious/noExplicitAny: pi event message union type
|
|
147
153
|
role: String((m as any).role ?? ""),
|
|
@@ -199,23 +205,27 @@ export default function (pi: ExtensionAPI) {
|
|
|
199
205
|
}
|
|
200
206
|
const ok = await ctx.ui.confirm("Dream", "Consolidate all memory files? This rewrites them in-place.");
|
|
201
207
|
if (!ok) return;
|
|
208
|
+
const dir = memoryDir;
|
|
202
209
|
ctx.ui.setStatus("dream", "Consolidating memory...");
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
210
|
+
runDream({
|
|
211
|
+
model: config.dream.model,
|
|
212
|
+
thinkLevel: config.dream.thinkLevel,
|
|
213
|
+
memoryDir,
|
|
214
|
+
modelRegistry: ctx.modelRegistry,
|
|
215
|
+
parentModel: ctx.model,
|
|
216
|
+
})
|
|
217
|
+
.then(async (summary) => {
|
|
218
|
+
const sessions = (await SessionManager.list(ctx.cwd)).length;
|
|
219
|
+
await writeDreamMeta(dir, sessions);
|
|
220
|
+
ctx.ui.notify(summary, "info");
|
|
221
|
+
})
|
|
213
222
|
// biome-ignore lint/suspicious/noExplicitAny: command handler ctx
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
223
|
+
.catch((e: any) => {
|
|
224
|
+
ctx.ui.notify(`Dream failed: ${e.message}`, "error");
|
|
225
|
+
})
|
|
226
|
+
.finally(() => {
|
|
227
|
+
ctx.ui.setStatus("dream", undefined);
|
|
228
|
+
});
|
|
219
229
|
},
|
|
220
230
|
});
|
|
221
231
|
}
|
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"publishConfig": {
|
|
4
4
|
"access": "public"
|
|
5
5
|
},
|
|
6
|
-
"version": "1.0
|
|
6
|
+
"version": "1.2.0",
|
|
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,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
|
@@ -3,9 +3,12 @@ import { homedir } from "node:os";
|
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { CONFIG_DIR_NAME, getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
5
5
|
|
|
6
|
+
export type ThinkLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
|
|
7
|
+
|
|
6
8
|
export interface AutoSurfacingConfig {
|
|
7
9
|
enabled: boolean;
|
|
8
|
-
model
|
|
10
|
+
model?: string;
|
|
11
|
+
thinkLevel: ThinkLevel;
|
|
9
12
|
maxFiles: number;
|
|
10
13
|
maxTopicBytes: number;
|
|
11
14
|
maxInjectionBytes: number;
|
|
@@ -13,7 +16,8 @@ export interface AutoSurfacingConfig {
|
|
|
13
16
|
|
|
14
17
|
export interface ExtractMemoriesConfig {
|
|
15
18
|
enabled: boolean;
|
|
16
|
-
model
|
|
19
|
+
model?: string;
|
|
20
|
+
thinkLevel: ThinkLevel;
|
|
17
21
|
maxContextTokens: number;
|
|
18
22
|
}
|
|
19
23
|
|
|
@@ -22,7 +26,7 @@ export interface MemoryConfig {
|
|
|
22
26
|
memoryDir: string;
|
|
23
27
|
memIndexMaxLines: number;
|
|
24
28
|
memIndexMaxBytes: number;
|
|
25
|
-
dream: { nudgeAfterSessions: number; nudgeAfterHours: number; model
|
|
29
|
+
dream: { nudgeAfterSessions: number; nudgeAfterHours: number; model?: string; thinkLevel: ThinkLevel };
|
|
26
30
|
sessionSearch: { maxSessions: number; maxMatches: number };
|
|
27
31
|
autoSurfacing: AutoSurfacingConfig;
|
|
28
32
|
extractMemories: ExtractMemoriesConfig;
|
|
@@ -33,18 +37,18 @@ export const DEFAULT_CONFIG: MemoryConfig = {
|
|
|
33
37
|
memoryDir: join(homedir(), CONFIG_DIR_NAME, "memory"),
|
|
34
38
|
memIndexMaxLines: 200,
|
|
35
39
|
memIndexMaxBytes: 25600,
|
|
36
|
-
dream: { nudgeAfterSessions: 5, nudgeAfterHours: 24,
|
|
40
|
+
dream: { nudgeAfterSessions: 5, nudgeAfterHours: 24, thinkLevel: "high" },
|
|
37
41
|
sessionSearch: { maxSessions: 10, maxMatches: 5 },
|
|
38
42
|
autoSurfacing: {
|
|
39
43
|
enabled: true,
|
|
40
|
-
|
|
44
|
+
thinkLevel: "off",
|
|
41
45
|
maxFiles: 5,
|
|
42
46
|
maxTopicBytes: 4096,
|
|
43
47
|
maxInjectionBytes: 20480,
|
|
44
48
|
},
|
|
45
49
|
extractMemories: {
|
|
46
50
|
enabled: true,
|
|
47
|
-
|
|
51
|
+
thinkLevel: "high",
|
|
48
52
|
maxContextTokens: 2000,
|
|
49
53
|
},
|
|
50
54
|
};
|
package/src/dream.ts
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import
|
|
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";
|
|
4
|
+
import type { ThinkLevel } from "./config";
|
|
3
5
|
|
|
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). */
|
|
6
|
+
/** Build dream consolidation task. (unchanged) */
|
|
8
7
|
export function buildDreamTask(memoryDir: string, maxLines: number): string {
|
|
9
8
|
return `You are a memory consolidation agent. Your job is to read all memory files
|
|
10
9
|
and consolidate them into a clean, deduplicated memory store.
|
|
@@ -52,79 +51,24 @@ CRITICAL for hooks and descriptions:
|
|
|
52
51
|
}
|
|
53
52
|
|
|
54
53
|
export interface RunDreamOpts {
|
|
55
|
-
model
|
|
54
|
+
model?: string;
|
|
55
|
+
thinkLevel: ThinkLevel;
|
|
56
56
|
memoryDir: string;
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
events?: { on(channel: string, handler: (data: any) => void): () => void };
|
|
60
|
-
service?: SubagentsService;
|
|
57
|
+
modelRegistry: ModelRegistry;
|
|
58
|
+
parentModel?: Model<any>;
|
|
61
59
|
}
|
|
62
60
|
|
|
61
|
+
/** Run dream consolidation via a headless agent. Fire-and-forget (caller decides await). */
|
|
63
62
|
export async function runDream(opts: RunDreamOpts): Promise<string> {
|
|
64
|
-
const service = opts.service ?? getSubagentsService();
|
|
65
|
-
if (!service) throw new Error("pi-subagents not available — install @yandy0725/pi-subagents");
|
|
66
|
-
|
|
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
63
|
const task = buildDreamTask(opts.memoryDir, 200);
|
|
72
|
-
|
|
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",
|
|
64
|
+
return runHeadlessAgent({
|
|
90
65
|
task,
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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 });
|
|
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,
|
|
129
73
|
});
|
|
130
74
|
}
|
package/src/extract.ts
CHANGED
|
@@ -1,30 +1,29 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import
|
|
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";
|
|
4
|
+
import type { ThinkLevel } from "./config";
|
|
3
5
|
|
|
4
6
|
export interface RunExtractOpts {
|
|
5
|
-
model
|
|
7
|
+
model?: string;
|
|
8
|
+
thinkLevel: ThinkLevel;
|
|
6
9
|
memoryDir: string;
|
|
7
10
|
messages: Array<{ role: string; content: string }>;
|
|
8
11
|
maxContextTokens: number;
|
|
9
|
-
|
|
12
|
+
modelRegistry: ModelRegistry;
|
|
13
|
+
parentModel?: Model<any>;
|
|
10
14
|
}
|
|
11
15
|
|
|
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
|
+
/** Build extraction task prompt. (unchanged) */
|
|
16
17
|
export function buildExtractTask(
|
|
17
18
|
memoryDir: string,
|
|
18
19
|
messages: Array<{ role: string; content: string }>,
|
|
19
20
|
maxTokens: number,
|
|
20
21
|
): string {
|
|
21
|
-
// Find user and assistant messages for context
|
|
22
22
|
const fromUser = messages.find((m) => m.role === "user");
|
|
23
23
|
const fromAssistant = messages.findLast((m) => m.role === "assistant");
|
|
24
24
|
const userText = fromUser?.content ?? "";
|
|
25
25
|
const assistantText = fromAssistant?.content ?? "";
|
|
26
26
|
|
|
27
|
-
// Truncate by maxTokens (rough estimate: ~4 chars/token)
|
|
28
27
|
const maxChars = maxTokens * 4;
|
|
29
28
|
const truncatedUser = userText.slice(0, maxChars / 2);
|
|
30
29
|
const truncatedAssistant = assistantText.slice(0, maxChars / 2);
|
|
@@ -77,34 +76,21 @@ export function buildExtractTask(
|
|
|
77
76
|
].join("\n");
|
|
78
77
|
}
|
|
79
78
|
|
|
79
|
+
/** Fire-and-forget memory extraction. Does not await the headless agent. */
|
|
80
80
|
export async function runExtract(opts: RunExtractOpts): Promise<void> {
|
|
81
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
82
|
const task = buildExtractTask(opts.memoryDir, opts.messages, opts.maxContextTokens);
|
|
88
|
-
|
|
89
|
-
|
|
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",
|
|
83
|
+
// fire-and-forget: runner disposes internally via finally
|
|
84
|
+
runHeadlessAgent({
|
|
105
85
|
task,
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
+
});
|
|
110
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 {
|
|
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
|
|
|
@@ -114,27 +117,9 @@ export async function injectSurfacedContent(
|
|
|
114
117
|
return `<relevant_memories>\n${blocks.join("\n\n")}\n</relevant_memories>`;
|
|
115
118
|
}
|
|
116
119
|
|
|
117
|
-
/**
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
export async function runSideQuery(
|
|
121
|
-
prompt: string,
|
|
122
|
-
manifest: TopicManifest[],
|
|
123
|
-
maxFiles: number,
|
|
124
|
-
// biome-ignore lint/suspicious/noExplicitAny: pi events API handler
|
|
125
|
-
events?: { on(channel: string, handler: (data: any) => void): () => void },
|
|
126
|
-
): Promise<string[]> {
|
|
127
|
-
// Filter to uninjected candidates only
|
|
128
|
-
const candidates = manifest.filter((t) => {
|
|
129
|
-
// already-injected topics are marked in the prompt, but we also
|
|
130
|
-
// filter here as a safety net
|
|
131
|
-
return !prompt.includes(`[already injected] ${t.filename}`);
|
|
132
|
-
});
|
|
133
|
-
if (candidates.length === 0) return [];
|
|
134
|
-
|
|
135
|
-
// Build a minimal selection prompt — only current user message + topic manifest.
|
|
136
|
-
// With prompt_mode=replace, this IS the entire system prompt (no inheritance).
|
|
137
|
-
const task = [
|
|
120
|
+
/** Build the side-query task prompt. Extracted for testability. */
|
|
121
|
+
export function buildSideQueryTask(prompt: string, maxFiles: number): string {
|
|
122
|
+
return [
|
|
138
123
|
"Respond with ONLY a JSON object.",
|
|
139
124
|
"",
|
|
140
125
|
"Below is a list of memory topic files and a user query.",
|
|
@@ -145,106 +130,50 @@ export async function runSideQuery(
|
|
|
145
130
|
"",
|
|
146
131
|
'Respond with EXACTLY: {"selected_files": [...]}',
|
|
147
132
|
].join("\n");
|
|
133
|
+
}
|
|
148
134
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
135
|
+
/** Parse selected_files JSON from headless agent response. */
|
|
136
|
+
function parseSelectedFiles(result: string, candidates: TopicManifest[], maxFiles: number): string[] {
|
|
137
|
+
try {
|
|
138
|
+
const jsonMatch = result.match(/\{[^}]*"selected_files"[^}]*\}/s);
|
|
139
|
+
if (!jsonMatch) return [];
|
|
140
|
+
const parsed = JSON.parse(jsonMatch[0]);
|
|
141
|
+
const files: string[] = parsed.selected_files ?? [];
|
|
142
|
+
return files.filter((f: string) => candidates.some((c) => c.filename === f)).slice(0, maxFiles);
|
|
143
|
+
} catch {
|
|
144
|
+
return [];
|
|
153
145
|
}
|
|
146
|
+
}
|
|
154
147
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
148
|
+
/** Run a lightweight headless side-query to select relevant topic files.
|
|
149
|
+
* Returns [] on timeout/failure — no fallback. */
|
|
150
|
+
export async function runSideQuery(
|
|
151
|
+
prompt: string,
|
|
152
|
+
manifest: TopicManifest[],
|
|
153
|
+
maxFiles: number,
|
|
154
|
+
thinkLevel: ThinkLevel,
|
|
155
|
+
model: string | undefined,
|
|
156
|
+
modelRegistry: ModelRegistry,
|
|
157
|
+
parentModel: Model<any> | undefined,
|
|
158
|
+
memoryDir: string,
|
|
159
|
+
injectedTopics: Set<string>,
|
|
160
|
+
): Promise<string[]> {
|
|
161
|
+
const candidates = manifest.filter((t) => !injectedTopics.has(t.filename));
|
|
162
|
+
if (candidates.length === 0) return [];
|
|
163
|
+
const task = buildSideQueryTask(prompt, maxFiles);
|
|
163
164
|
try {
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
let settled = false;
|
|
174
|
-
const cleanup = () => {
|
|
175
|
-
if (settled) return;
|
|
176
|
-
settled = true;
|
|
177
|
-
clearTimeout(timeout);
|
|
178
|
-
unsubCompleted();
|
|
179
|
-
unsubFailed();
|
|
180
|
-
unregister();
|
|
181
|
-
};
|
|
182
|
-
|
|
183
|
-
const onCompleted = (data: { id: string }) => {
|
|
184
|
-
if (data.id !== agentId) return;
|
|
185
|
-
cleanup();
|
|
186
|
-
const record = service.getRecord(agentId);
|
|
187
|
-
const result = record?.result ?? "";
|
|
188
|
-
try {
|
|
189
|
-
const jsonMatch = result.match(/\{[^}]*"selected_files"[^}]*\}/s);
|
|
190
|
-
if (jsonMatch) {
|
|
191
|
-
const parsed = JSON.parse(jsonMatch[0]);
|
|
192
|
-
const files: string[] = parsed.selected_files ?? [];
|
|
193
|
-
const valid = files.filter((f: string) => candidates.some((c) => c.filename === f)).slice(0, maxFiles);
|
|
194
|
-
resolve(valid);
|
|
195
|
-
} else {
|
|
196
|
-
resolve([]);
|
|
197
|
-
}
|
|
198
|
-
} catch {
|
|
199
|
-
resolve([]);
|
|
200
|
-
}
|
|
201
|
-
};
|
|
202
|
-
|
|
203
|
-
const onFailed = (data: { id: string }) => {
|
|
204
|
-
if (data.id !== agentId) return;
|
|
205
|
-
cleanup();
|
|
206
|
-
resolve(keywordMatch(candidates, prompt, maxFiles));
|
|
207
|
-
};
|
|
208
|
-
|
|
209
|
-
const unsubCompleted = events?.on("subagents:completed", onCompleted) ?? (() => {});
|
|
210
|
-
const unsubFailed = events?.on("subagents:failed", onFailed) ?? (() => {});
|
|
211
|
-
|
|
212
|
-
if (!events) {
|
|
213
|
-
cleanup();
|
|
214
|
-
resolve(keywordMatch(candidates, prompt, maxFiles));
|
|
215
|
-
return;
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
// Spawn AFTER subscriptions are active
|
|
219
|
-
const agentId = service.spawn("memory-agent", task, {
|
|
220
|
-
maxTurns: 1,
|
|
221
|
-
inheritContext: false,
|
|
222
|
-
thinkingLevel: "off",
|
|
223
|
-
foreground: true,
|
|
224
|
-
bypassQueue: true,
|
|
225
|
-
});
|
|
165
|
+
const result = await runHeadlessAgent({
|
|
166
|
+
task,
|
|
167
|
+
cwd: memoryDir,
|
|
168
|
+
modelRegistry,
|
|
169
|
+
model,
|
|
170
|
+
parentModel,
|
|
171
|
+
thinkLevel,
|
|
172
|
+
maxTurns: 1,
|
|
173
|
+
timeoutMs: 30_000,
|
|
226
174
|
});
|
|
227
|
-
|
|
228
|
-
return result;
|
|
175
|
+
return parseSelectedFiles(result, candidates, maxFiles);
|
|
229
176
|
} catch {
|
|
230
|
-
|
|
231
|
-
return keywordMatch(candidates, prompt, maxFiles);
|
|
177
|
+
return [];
|
|
232
178
|
}
|
|
233
179
|
}
|
|
234
|
-
|
|
235
|
-
/** Keyword-matching fallback for topic selection (no LLM required). */
|
|
236
|
-
function keywordMatch(manifest: TopicManifest[], userPrompt: string, maxFiles: number): string[] {
|
|
237
|
-
const words = userPrompt
|
|
238
|
-
.toLowerCase()
|
|
239
|
-
.split(/\W+/)
|
|
240
|
-
.filter((w) => w.length > 2);
|
|
241
|
-
const scored = manifest
|
|
242
|
-
.map((t) => {
|
|
243
|
-
const desc = `${t.description} ${t.name}`.toLowerCase();
|
|
244
|
-
const score = words.filter((w) => desc.includes(w)).length;
|
|
245
|
-
return { filename: t.filename, score };
|
|
246
|
-
})
|
|
247
|
-
.filter((t) => t.score > 0)
|
|
248
|
-
.sort((a, b) => b.score - a.score);
|
|
249
|
-
return scored.slice(0, maxFiles).map((t) => t.filename);
|
|
250
|
-
}
|
package/src/memory-tool.ts
CHANGED
|
@@ -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
|
|
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
|
+
}
|
package/src/agent-types.ts
DELETED
|
@@ -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
|
-
}
|