pi-hermes-memory 0.7.21 → 0.7.23
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 +17 -2
- package/package.json +4 -3
- package/src/config.ts +8 -1
- package/src/constants.ts +30 -0
- package/src/handlers/background-review.ts +159 -65
- package/src/handlers/review-memory-ops.ts +481 -0
- package/src/index.ts +4 -1
- package/src/types.ts +4 -0
package/README.md
CHANGED
|
@@ -339,6 +339,19 @@ Background review triggers based on **activity level**, not just turn count:
|
|
|
339
339
|
|
|
340
340
|
Both counters reset after each review.
|
|
341
341
|
|
|
342
|
+
### Background Review Transport
|
|
343
|
+
|
|
344
|
+
By default, background review uses an in-process `completeSimple()` side-channel: a small JSON-only prompt, no child `pi` process, and memory writes applied directly by the extension. This keeps the main session's system prompt, tools, and LLM prefix cache intact.
|
|
345
|
+
|
|
346
|
+
If direct review fails (no model, no auth, provider error, unparseable response), it automatically falls back to the legacy `pi -p --no-session` subprocess path.
|
|
347
|
+
|
|
348
|
+
Set `reviewTransport` in config only when you need to override this:
|
|
349
|
+
|
|
350
|
+
| Value | Behavior |
|
|
351
|
+
|---|---|
|
|
352
|
+
| `direct` (default) | Try in-process `completeSimple()` first; fall back to subprocess on failure |
|
|
353
|
+
| `subprocess` | Always use `pi -p` subprocess (pre-PR #92 behavior) |
|
|
354
|
+
|
|
342
355
|
### Skill Auto-Extraction
|
|
343
356
|
|
|
344
357
|
After a complex task (8+ tool calls using 2+ different tools in a single turn), the extension automatically asks the agent:
|
|
@@ -430,6 +443,7 @@ Create `~/.pi/agent/hermes-memory-config.json`:
|
|
|
430
443
|
"nudgeToolCalls": 15,
|
|
431
444
|
"reviewRecentMessages": 0,
|
|
432
445
|
"reviewEnabled": true,
|
|
446
|
+
"reviewTransport": "direct",
|
|
433
447
|
"memoryOverflowStrategy": "auto-consolidate",
|
|
434
448
|
"autoConsolidate": true,
|
|
435
449
|
"correctionDetection": true,
|
|
@@ -455,12 +469,13 @@ Create `~/.pi/agent/hermes-memory-config.json`:
|
|
|
455
469
|
| `memoryDir` | `~/.pi/agent/pi-hermes-memory` | Custom directory for extension storage files |
|
|
456
470
|
| `projectsMemoryDir` | `projects-memory` | Subdirectory under `~/.pi/agent/` for project-scoped memory |
|
|
457
471
|
| `sessionSearch` | `{ "variant": "legacy" }` | Session search implementation: `legacy` keeps the existing SQLite/FTS snippet search; `anchors` uses the opt-in Markdown request surface and returns compact JSONL line-range anchors from `~/.pi/agent/sessions/` |
|
|
458
|
-
| `llmModelOverride` | unset | Optional model override for
|
|
459
|
-
| `llmThinkingOverride` | unset | Optional thinking override for those
|
|
472
|
+
| `llmModelOverride` | unset | Optional model override for background review (direct and subprocess), correction save, session flush, and consolidation |
|
|
473
|
+
| `llmThinkingOverride` | unset | Optional thinking override for those LLM calls; valid values are `off`, `minimal`, `low`, `medium`, `high`, and `xhigh`. If `llmModelOverride` is set and this is omitted, review/child calls default to `off` |
|
|
460
474
|
| `nudgeInterval` | `10` | Turns between auto-reviews |
|
|
461
475
|
| `nudgeToolCalls` | `15` | Tool calls between auto-reviews (OR with turns) |
|
|
462
476
|
| `reviewRecentMessages` | `0` | Recent messages included in background review (`0` = all) |
|
|
463
477
|
| `reviewEnabled` | `true` | Enable/disable background learning loop |
|
|
478
|
+
| `reviewTransport` | `direct` | Background review LLM transport: `direct` uses in-process `completeSimple()` with subprocess fallback; `subprocess` forces legacy `pi -p` only |
|
|
464
479
|
| `memoryOverflowStrategy` | `auto-consolidate` | Behavior when MEMORY.md, USER.md, failures.md, or project-scoped memory reaches its character limit: `auto-consolidate` runs the existing consolidation flow; `reject` returns an error; `fifo-evict` rotates older entries in file order until the new entry fits |
|
|
465
480
|
| `autoConsolidate` | `true` | Legacy alias for `memoryOverflowStrategy` when `memoryOverflowStrategy` is not set (`true` = `auto-consolidate`, `false` = `reject`) |
|
|
466
481
|
| `consolidationTimeoutMs` | `60000` | Maximum time in milliseconds for auto-consolidation to complete |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-hermes-memory",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.23",
|
|
4
4
|
"description": "🧠 Persistent memory + 🔍 session search + 🛡️ secret scanning for Pi. Token-aware policy-only memory by default, SQLite FTS5 search, auto-consolidation, procedural skills. 368 tests. Ported from Hermes agent.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -45,10 +45,11 @@
|
|
|
45
45
|
"url": "https://github.com/chandra447/pi-hermes-memory"
|
|
46
46
|
},
|
|
47
47
|
"peerDependencies": {
|
|
48
|
+
"@earendil-works/pi-ai": "*",
|
|
48
49
|
"@earendil-works/pi-coding-agent": ">=0.74.0"
|
|
49
50
|
},
|
|
50
51
|
"devDependencies": {
|
|
51
|
-
"@earendil-works/pi-ai": "^0.
|
|
52
|
+
"@earendil-works/pi-ai": "^0.80.2",
|
|
52
53
|
"@earendil-works/pi-coding-agent": "^0.80.2",
|
|
53
54
|
"@types/better-sqlite3": "^7.6.13",
|
|
54
55
|
"tsx": "^4.21.0",
|
|
@@ -56,7 +57,7 @@
|
|
|
56
57
|
"typescript": "^6.0.3"
|
|
57
58
|
},
|
|
58
59
|
"dependencies": {
|
|
59
|
-
"@earendil-works/pi-tui": "^0.
|
|
60
|
+
"@earendil-works/pi-tui": "^0.80.2",
|
|
60
61
|
"better-sqlite3": "^12.9.0"
|
|
61
62
|
}
|
|
62
63
|
}
|
package/src/config.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import * as path from "node:path";
|
|
3
|
-
import type { MemoryConfig, MemoryOverflowStrategy, SessionSearchVariant, ThinkingLevel } from "./types.js";
|
|
3
|
+
import type { MemoryConfig, MemoryOverflowStrategy, ReviewTransport, SessionSearchVariant, ThinkingLevel } from "./types.js";
|
|
4
4
|
import {
|
|
5
5
|
DEFAULT_MEMORY_CHAR_LIMIT,
|
|
6
6
|
DEFAULT_USER_CHAR_LIMIT,
|
|
@@ -19,8 +19,13 @@ import { AGENT_ROOT, normalizeConfiguredMemoryDir, normalizeProjectsMemoryDir }
|
|
|
19
19
|
|
|
20
20
|
const MEMORY_OVERFLOW_STRATEGIES: readonly MemoryOverflowStrategy[] = ["auto-consolidate", "reject", "fifo-evict"];
|
|
21
21
|
const SESSION_SEARCH_VARIANTS: readonly SessionSearchVariant[] = ["legacy", "anchors"];
|
|
22
|
+
const REVIEW_TRANSPORTS: readonly ReviewTransport[] = ["direct", "subprocess"];
|
|
22
23
|
const THINKING_LEVELS: readonly ThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh"];
|
|
23
24
|
|
|
25
|
+
function isReviewTransport(value: unknown): value is ReviewTransport {
|
|
26
|
+
return typeof value === "string" && REVIEW_TRANSPORTS.includes(value as ReviewTransport);
|
|
27
|
+
}
|
|
28
|
+
|
|
24
29
|
function isMemoryOverflowStrategy(value: unknown): value is MemoryOverflowStrategy {
|
|
25
30
|
return typeof value === "string" && MEMORY_OVERFLOW_STRATEGIES.includes(value as MemoryOverflowStrategy);
|
|
26
31
|
}
|
|
@@ -42,6 +47,7 @@ const DEFAULT_CONFIG: MemoryConfig = {
|
|
|
42
47
|
nudgeInterval: DEFAULT_NUDGE_INTERVAL,
|
|
43
48
|
reviewRecentMessages: DEFAULT_REVIEW_RECENT_MESSAGES,
|
|
44
49
|
reviewEnabled: true,
|
|
50
|
+
reviewTransport: "direct",
|
|
45
51
|
flushOnCompact: true,
|
|
46
52
|
flushOnShutdown: true,
|
|
47
53
|
flushMinTurns: DEFAULT_FLUSH_MIN_TURNS,
|
|
@@ -91,6 +97,7 @@ export function loadConfig(configPath = DEFAULT_CONFIG_PATH): MemoryConfig {
|
|
|
91
97
|
if (typeof parsed.nudgeInterval === "number") config.nudgeInterval = parsed.nudgeInterval;
|
|
92
98
|
if (isNonNegativeNumber(parsed.reviewRecentMessages)) config.reviewRecentMessages = parsed.reviewRecentMessages;
|
|
93
99
|
if (typeof parsed.reviewEnabled === "boolean") config.reviewEnabled = parsed.reviewEnabled;
|
|
100
|
+
if (isReviewTransport(parsed.reviewTransport)) config.reviewTransport = parsed.reviewTransport;
|
|
94
101
|
if (typeof parsed.flushOnCompact === "boolean") config.flushOnCompact = parsed.flushOnCompact;
|
|
95
102
|
if (typeof parsed.flushOnShutdown === "boolean") config.flushOnShutdown = parsed.flushOnShutdown;
|
|
96
103
|
if (typeof parsed.flushMinTurns === "number") config.flushMinTurns = parsed.flushMinTurns;
|
package/src/constants.ts
CHANGED
|
@@ -145,6 +145,36 @@ For failures, include: what was tried, why it failed, what error occurred, and w
|
|
|
145
145
|
|
|
146
146
|
Only act if there's something genuinely worth saving. If nothing stands out, just say 'Nothing to save.' and stop.`;
|
|
147
147
|
|
|
148
|
+
// ─── Direct (in-process) background review prompts ───
|
|
149
|
+
export const DIRECT_REVIEW_SYSTEM_PROMPT = `You review coding conversations and extract durable memories worth saving across sessions.
|
|
150
|
+
|
|
151
|
+
Review these aspects:
|
|
152
|
+
- **Memory**: User persona, preferences, expectations about how the agent should behave, work style.
|
|
153
|
+
- **Failures & Corrections**: What failed, user corrections, insights, conventions, tool quirks.
|
|
154
|
+
|
|
155
|
+
Do NOT create or modify skills. Only save genuinely durable facts — not task progress, session outcomes, or temporary state.
|
|
156
|
+
|
|
157
|
+
Respond with JSON only (no markdown fences):
|
|
158
|
+
{
|
|
159
|
+
"operations": [
|
|
160
|
+
{
|
|
161
|
+
"action": "add",
|
|
162
|
+
"target": "memory",
|
|
163
|
+
"content": "entry text"
|
|
164
|
+
}
|
|
165
|
+
]
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
Operation fields:
|
|
169
|
+
- action: "add" | "replace" | "remove"
|
|
170
|
+
- target: "memory" | "user" | "project" | "failure"
|
|
171
|
+
- content: required for add/replace
|
|
172
|
+
- old_text: required for replace/remove (substring match)
|
|
173
|
+
- category: for failure target — failure | correction | insight | convention | tool-quirk | preference
|
|
174
|
+
- failure_reason: optional context for failure entries
|
|
175
|
+
|
|
176
|
+
If nothing is worth saving, return {"operations":[]}.`;
|
|
177
|
+
|
|
148
178
|
// ─── Flush prompt (ported from flush_memories() in run_agent.py ~L7379) ───
|
|
149
179
|
export const FLUSH_PROMPT = `[System: The session is being compressed. Save anything worth remembering — prioritize user preferences, corrections, and recurring patterns over task-specific details.]`;
|
|
150
180
|
|
|
@@ -3,23 +3,128 @@
|
|
|
3
3
|
* Ported from hermes-agent/run_agent.py (_spawn_background_review, _memory_nudge_interval).
|
|
4
4
|
* See PLAN.md → "Hermes Source File Reference Map" for source lines.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
6
|
+
* Default transport: in-process complete() side-channel (preserves parent LLM cache).
|
|
7
|
+
* Fallback: pi.exec("pi", ["-p", ...]) subprocess when direct path is unavailable.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
11
|
-
import { MemoryStore } from "../store/memory-store.js";
|
|
10
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
12
11
|
import { COMBINED_REVIEW_PROMPT } from "../constants.js";
|
|
12
|
+
import { MemoryStore } from "../store/memory-store.js";
|
|
13
|
+
import { DatabaseManager } from "../store/db.js";
|
|
13
14
|
import type { MemoryConfig } from "../types.js";
|
|
14
15
|
import { applyRecentMessageLimit, collectMessageParts } from "./message-parts.js";
|
|
15
16
|
import { execChildPrompt } from "./pi-child-process.js";
|
|
17
|
+
import { runDirectBackgroundReview, type DirectReviewResult } from "./review-memory-ops.js";
|
|
18
|
+
|
|
19
|
+
export interface BackgroundReviewOptions {
|
|
20
|
+
dbManager?: DatabaseManager | null;
|
|
21
|
+
projectName?: string | null;
|
|
22
|
+
deps?: BackgroundReviewDeps;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface BackgroundReviewDeps {
|
|
26
|
+
runDirectReview?: typeof runDirectBackgroundReview;
|
|
27
|
+
execChildPrompt?: typeof execChildPrompt;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ReviewPromptInput {
|
|
31
|
+
parts: string[];
|
|
32
|
+
currentMemory: string;
|
|
33
|
+
currentUser: string;
|
|
34
|
+
currentProject: string | null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function buildSubprocessReviewPrompt(input: ReviewPromptInput): string {
|
|
38
|
+
const reviewPrompt = [
|
|
39
|
+
COMBINED_REVIEW_PROMPT,
|
|
40
|
+
"",
|
|
41
|
+
"--- Current Memory ---",
|
|
42
|
+
input.currentMemory || "(empty)",
|
|
43
|
+
"",
|
|
44
|
+
"--- Current User Profile ---",
|
|
45
|
+
input.currentUser || "(empty)",
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
if (input.currentProject !== null) {
|
|
49
|
+
reviewPrompt.push(
|
|
50
|
+
"",
|
|
51
|
+
"--- Current Project Memory ---",
|
|
52
|
+
input.currentProject || "(empty)",
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
reviewPrompt.push(
|
|
57
|
+
"",
|
|
58
|
+
"--- Conversation to Review ---",
|
|
59
|
+
input.parts.join("\n\n"),
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
return reviewPrompt.join("\n");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function buildDirectReviewUserPrompt(input: ReviewPromptInput): string {
|
|
66
|
+
const sections = [
|
|
67
|
+
"--- Current Memory ---",
|
|
68
|
+
input.currentMemory || "(empty)",
|
|
69
|
+
"",
|
|
70
|
+
"--- Current User Profile ---",
|
|
71
|
+
input.currentUser || "(empty)",
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
if (input.currentProject !== null) {
|
|
75
|
+
sections.push(
|
|
76
|
+
"",
|
|
77
|
+
"--- Current Project Memory ---",
|
|
78
|
+
input.currentProject || "(empty)",
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
sections.push(
|
|
83
|
+
"",
|
|
84
|
+
"--- Conversation to Review ---",
|
|
85
|
+
input.parts.join("\n\n"),
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
return sections.join("\n");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function shouldNotifyDirect(result: DirectReviewResult): boolean {
|
|
92
|
+
return result.ok && result.appliedCount > 0;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function shouldNotifySubprocess(stdout: string | undefined): boolean {
|
|
96
|
+
const output = stdout?.trim();
|
|
97
|
+
return !!output && !output.toLowerCase().includes("nothing to save");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function usesDirectTransport(config: MemoryConfig): boolean {
|
|
101
|
+
return (config.reviewTransport ?? "direct") === "direct";
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function runSubprocessReview(
|
|
105
|
+
pi: ExtensionAPI,
|
|
106
|
+
prompt: string,
|
|
107
|
+
config: MemoryConfig,
|
|
108
|
+
execChild: typeof execChildPrompt,
|
|
109
|
+
): Promise<{ code: number; stdout?: string }> {
|
|
110
|
+
return execChild(pi, prompt, config, {
|
|
111
|
+
signal: undefined,
|
|
112
|
+
timeoutMs: 120000,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
16
115
|
|
|
17
116
|
export function setupBackgroundReview(
|
|
18
117
|
pi: ExtensionAPI,
|
|
19
118
|
store: MemoryStore,
|
|
20
119
|
projectStore: MemoryStore | null,
|
|
21
120
|
config: MemoryConfig,
|
|
121
|
+
options: BackgroundReviewOptions = {},
|
|
22
122
|
): void {
|
|
123
|
+
const dbManager = options.dbManager ?? null;
|
|
124
|
+
const projectName = options.projectName ?? null;
|
|
125
|
+
const runDirectReview = options.deps?.runDirectReview ?? runDirectBackgroundReview;
|
|
126
|
+
const execChild = options.deps?.execChildPrompt ?? execChildPrompt;
|
|
127
|
+
|
|
23
128
|
let turnsSinceReview = 0;
|
|
24
129
|
let toolCallsSinceReview = 0;
|
|
25
130
|
let userTurnCount = 0;
|
|
@@ -37,9 +142,6 @@ export function setupBackgroundReview(
|
|
|
37
142
|
if (!config.reviewEnabled) return;
|
|
38
143
|
if (reviewInProgress) return;
|
|
39
144
|
|
|
40
|
-
// Count tool calls from this turn's message only (not cumulative branch scan —
|
|
41
|
-
// otherwise the counter resets to 0 at review, then immediately re-counts all
|
|
42
|
-
// historical tool calls and re-triggers on every subsequent turn).
|
|
43
145
|
try {
|
|
44
146
|
const msg = event.message;
|
|
45
147
|
if (msg?.role === "assistant") {
|
|
@@ -56,7 +158,6 @@ export function setupBackgroundReview(
|
|
|
56
158
|
// If we can't count tool calls, fall back to turn-based only
|
|
57
159
|
}
|
|
58
160
|
|
|
59
|
-
// Trigger on EITHER turn count OR tool call count
|
|
60
161
|
const turnThresholdMet = turnsSinceReview >= config.nudgeInterval;
|
|
61
162
|
const toolCallThresholdMet = toolCallsSinceReview >= config.nudgeToolCalls;
|
|
62
163
|
|
|
@@ -67,78 +168,71 @@ export function setupBackgroundReview(
|
|
|
67
168
|
toolCallsSinceReview = 0;
|
|
68
169
|
reviewInProgress = true;
|
|
69
170
|
|
|
70
|
-
// Build conversation snapshot from session entries (crash-safe)
|
|
71
171
|
let allParts: string[] = [];
|
|
72
172
|
try {
|
|
73
173
|
const entries = ctx.sessionManager.getBranch();
|
|
74
174
|
allParts = collectMessageParts(entries);
|
|
75
175
|
} catch {
|
|
76
176
|
reviewInProgress = false;
|
|
77
|
-
return;
|
|
177
|
+
return;
|
|
78
178
|
}
|
|
79
179
|
if (allParts.length < 4) {
|
|
80
180
|
reviewInProgress = false;
|
|
81
|
-
return;
|
|
181
|
+
return;
|
|
82
182
|
}
|
|
183
|
+
|
|
83
184
|
const parts = applyRecentMessageLimit(allParts, config.reviewRecentMessages);
|
|
185
|
+
const promptInput: ReviewPromptInput = {
|
|
186
|
+
parts,
|
|
187
|
+
currentMemory: store.getMemoryEntries().join("\n§\n"),
|
|
188
|
+
currentUser: store.getUserEntries().join("\n§\n"),
|
|
189
|
+
currentProject: projectStore ? projectStore.getMemoryEntries().join("\n§\n") : null,
|
|
190
|
+
};
|
|
84
191
|
|
|
85
|
-
const
|
|
86
|
-
const
|
|
87
|
-
const currentProject = projectStore ? projectStore.getMemoryEntries().join("\n§\n") : null;
|
|
192
|
+
const subprocessPrompt = buildSubprocessReviewPrompt(promptInput);
|
|
193
|
+
const directPrompt = buildDirectReviewUserPrompt(promptInput);
|
|
88
194
|
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
"--- Current Memory ---",
|
|
93
|
-
currentMemory || "(empty)",
|
|
94
|
-
"",
|
|
95
|
-
"--- Current User Profile ---",
|
|
96
|
-
currentUser || "(empty)",
|
|
97
|
-
];
|
|
98
|
-
|
|
99
|
-
if (currentProject !== null) {
|
|
100
|
-
reviewPrompt.push(
|
|
101
|
-
"",
|
|
102
|
-
"--- Current Project Memory ---",
|
|
103
|
-
currentProject || "(empty)",
|
|
104
|
-
);
|
|
105
|
-
}
|
|
195
|
+
const finishReview = () => {
|
|
196
|
+
reviewInProgress = false;
|
|
197
|
+
};
|
|
106
198
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
199
|
+
const notifyIfSaved = (saved: boolean) => {
|
|
200
|
+
if (saved) {
|
|
201
|
+
ctx.ui.notify("💾 Memory auto-reviewed and updated", "info");
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
const runReview = async (): Promise<void> => {
|
|
206
|
+
if (usesDirectTransport(config)) {
|
|
207
|
+
const directResult = await runDirectReview(
|
|
208
|
+
ctx as Pick<ExtensionContext, "model" | "modelRegistry">,
|
|
209
|
+
store,
|
|
210
|
+
projectStore,
|
|
211
|
+
{ userPrompt: directPrompt, config, timeoutMs: 120000 },
|
|
212
|
+
dbManager,
|
|
213
|
+
projectName,
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
if (directResult.ok) {
|
|
217
|
+
notifyIfSaved(shouldNotifyDirect(directResult));
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
112
220
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
// Notifications are delivered via .then() once the subprocess completes.
|
|
116
|
-
//
|
|
117
|
-
// We intentionally omit ctx.signal — the signal is tied to the turn
|
|
118
|
-
// lifetime and would abort the subprocess before it finishes now that
|
|
119
|
-
// we're not awaiting. The timeout (120s) provides its own safety net.
|
|
120
|
-
const reviewPromise = execChildPrompt(pi, reviewPrompt.join("\n"), config, {
|
|
121
|
-
signal: undefined,
|
|
122
|
-
timeoutMs: 120000,
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
reviewPromise
|
|
126
|
-
.then((result) => {
|
|
127
|
-
reviewInProgress = false;
|
|
128
|
-
if (result.code === 0 && result.stdout) {
|
|
129
|
-
const output = result.stdout.trim();
|
|
130
|
-
if (output && !output.toLowerCase().includes("nothing to save")) {
|
|
131
|
-
ctx.ui.notify("💾 Memory auto-reviewed and updated", "info");
|
|
132
|
-
}
|
|
221
|
+
if (directResult.fallbackReason === "empty") {
|
|
222
|
+
return;
|
|
133
223
|
}
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const subprocessResult = await runSubprocessReview(pi, subprocessPrompt, config, execChild);
|
|
227
|
+
if (subprocessResult.code === 0) {
|
|
228
|
+
notifyIfSaved(shouldNotifySubprocess(subprocessResult.stdout));
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
runReview()
|
|
138
233
|
.catch(() => {
|
|
139
|
-
// Best-effort
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
});
|
|
234
|
+
// Best-effort only
|
|
235
|
+
})
|
|
236
|
+
.finally(finishReview);
|
|
143
237
|
});
|
|
144
|
-
}
|
|
238
|
+
}
|
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse and apply structured memory operations from direct background review.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { Api, Model } from "@earendil-works/pi-ai";
|
|
6
|
+
import { completeSimple, type Message, type SimpleStreamOptions } from "@earendil-works/pi-ai/compat";
|
|
7
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import { DIRECT_REVIEW_SYSTEM_PROMPT } from "../constants.js";
|
|
9
|
+
import { MemoryStore } from "../store/memory-store.js";
|
|
10
|
+
import { DatabaseManager } from "../store/db.js";
|
|
11
|
+
import {
|
|
12
|
+
formatFailureMemoryContent,
|
|
13
|
+
removeExactSyncedMemories,
|
|
14
|
+
removeSyncedMemories,
|
|
15
|
+
replaceSyncedMemories,
|
|
16
|
+
syncMemoryEntry,
|
|
17
|
+
} from "../store/sqlite-memory-store.js";
|
|
18
|
+
import type { MemoryCategory, MemoryConfig, MemoryResult, ThinkingLevel } from "../types.js";
|
|
19
|
+
|
|
20
|
+
export interface ReviewMemoryOperation {
|
|
21
|
+
action: "add" | "replace" | "remove";
|
|
22
|
+
target: "memory" | "user" | "project" | "failure";
|
|
23
|
+
content?: string;
|
|
24
|
+
old_text?: string;
|
|
25
|
+
category?: MemoryCategory;
|
|
26
|
+
failure_reason?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface ApplyReviewOperationsResult {
|
|
30
|
+
appliedCount: number;
|
|
31
|
+
skippedCount: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface DirectReviewResult {
|
|
35
|
+
ok: boolean;
|
|
36
|
+
appliedCount: number;
|
|
37
|
+
fallbackReason?: "no_model" | "no_auth" | "aborted" | "parse_error" | "provider_error" | "empty";
|
|
38
|
+
error?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface RunDirectBackgroundReviewOptions {
|
|
42
|
+
userPrompt: string;
|
|
43
|
+
config: Pick<MemoryConfig, "llmModelOverride" | "llmThinkingOverride">;
|
|
44
|
+
timeoutMs?: number;
|
|
45
|
+
signal?: AbortSignal;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
type ReviewLlmConfig = Pick<MemoryConfig, "llmModelOverride" | "llmThinkingOverride">;
|
|
49
|
+
|
|
50
|
+
function findExactModelReferenceMatch(modelReference: string, availableModels: Model<Api>[]): Model<Api> | undefined {
|
|
51
|
+
const trimmedReference = modelReference.trim();
|
|
52
|
+
if (!trimmedReference) return undefined;
|
|
53
|
+
|
|
54
|
+
const normalizedReference = trimmedReference.toLowerCase();
|
|
55
|
+
const canonicalMatches = availableModels.filter(
|
|
56
|
+
(model) => `${model.provider}/${model.id}`.toLowerCase() === normalizedReference,
|
|
57
|
+
);
|
|
58
|
+
if (canonicalMatches.length === 1) return canonicalMatches[0];
|
|
59
|
+
if (canonicalMatches.length > 1) return undefined;
|
|
60
|
+
|
|
61
|
+
const slashIndex = trimmedReference.indexOf("/");
|
|
62
|
+
if (slashIndex !== -1) {
|
|
63
|
+
const provider = trimmedReference.substring(0, slashIndex).trim();
|
|
64
|
+
const modelId = trimmedReference.substring(slashIndex + 1).trim();
|
|
65
|
+
if (provider && modelId) {
|
|
66
|
+
const providerMatches = availableModels.filter(
|
|
67
|
+
(model) => model.provider.toLowerCase() === provider.toLowerCase()
|
|
68
|
+
&& model.id.toLowerCase() === modelId.toLowerCase(),
|
|
69
|
+
);
|
|
70
|
+
if (providerMatches.length === 1) return providerMatches[0];
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const idMatches = availableModels.filter((model) => model.id.toLowerCase() === normalizedReference);
|
|
75
|
+
return idMatches.length === 1 ? idMatches[0] : undefined;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function normalizedModelOverride(config: ReviewLlmConfig): string | undefined {
|
|
79
|
+
const trimmed = config.llmModelOverride?.trim();
|
|
80
|
+
return trimmed ? trimmed : undefined;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function effectiveThinkingOverride(config: ReviewLlmConfig): ThinkingLevel | undefined {
|
|
84
|
+
return config.llmThinkingOverride ?? (normalizedModelOverride(config) ? "off" : undefined);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
type ReviewModelRegistry = ExtensionContext["modelRegistry"];
|
|
88
|
+
|
|
89
|
+
export function buildDirectReviewCompletionOptions(
|
|
90
|
+
model: Model<Api>,
|
|
91
|
+
auth: {
|
|
92
|
+
apiKey: string;
|
|
93
|
+
headers?: Record<string, string>;
|
|
94
|
+
env?: Record<string, string>;
|
|
95
|
+
},
|
|
96
|
+
thinking: ThinkingLevel | undefined,
|
|
97
|
+
signal: AbortSignal,
|
|
98
|
+
): SimpleStreamOptions {
|
|
99
|
+
const options: SimpleStreamOptions = {
|
|
100
|
+
apiKey: auth.apiKey,
|
|
101
|
+
headers: auth.headers,
|
|
102
|
+
env: auth.env,
|
|
103
|
+
signal,
|
|
104
|
+
};
|
|
105
|
+
if (model.reasoning && thinking && thinking !== "off") {
|
|
106
|
+
options.reasoning = thinking;
|
|
107
|
+
}
|
|
108
|
+
return options;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function resolveReviewModel(
|
|
112
|
+
ctxModel: Model<Api> | undefined,
|
|
113
|
+
modelRegistry: ReviewModelRegistry,
|
|
114
|
+
config: ReviewLlmConfig,
|
|
115
|
+
): Model<Api> | undefined {
|
|
116
|
+
const override = normalizedModelOverride(config);
|
|
117
|
+
if (override) {
|
|
118
|
+
const matched = findExactModelReferenceMatch(override, modelRegistry.getAll());
|
|
119
|
+
if (matched) return matched;
|
|
120
|
+
}
|
|
121
|
+
return ctxModel;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function extractJsonPayload(text: string): unknown {
|
|
125
|
+
const trimmed = text.trim();
|
|
126
|
+
if (!trimmed) return null;
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
return JSON.parse(trimmed);
|
|
130
|
+
} catch {
|
|
131
|
+
// continue
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const fenced = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
|
135
|
+
if (fenced?.[1]) {
|
|
136
|
+
try {
|
|
137
|
+
return JSON.parse(fenced[1].trim());
|
|
138
|
+
} catch {
|
|
139
|
+
// continue
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const start = trimmed.indexOf("{");
|
|
144
|
+
const end = trimmed.lastIndexOf("}");
|
|
145
|
+
if (start >= 0 && end > start) {
|
|
146
|
+
try {
|
|
147
|
+
return JSON.parse(trimmed.slice(start, end + 1));
|
|
148
|
+
} catch {
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function isMemoryCategory(value: unknown): value is MemoryCategory {
|
|
157
|
+
return value === "failure"
|
|
158
|
+
|| value === "correction"
|
|
159
|
+
|| value === "insight"
|
|
160
|
+
|| value === "preference"
|
|
161
|
+
|| value === "convention"
|
|
162
|
+
|| value === "tool-quirk";
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function isReviewTarget(value: unknown): value is ReviewMemoryOperation["target"] {
|
|
166
|
+
return value === "memory" || value === "user" || value === "project" || value === "failure";
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function isReviewAction(value: unknown): value is ReviewMemoryOperation["action"] {
|
|
170
|
+
return value === "add" || value === "replace" || value === "remove";
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function parseReviewOperations(text: string): ReviewMemoryOperation[] | null {
|
|
174
|
+
if (/nothing to save/i.test(text) && !text.includes("{")) {
|
|
175
|
+
return [];
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const payload = extractJsonPayload(text);
|
|
179
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const operations = (payload as { operations?: unknown }).operations;
|
|
184
|
+
if (!Array.isArray(operations)) return null;
|
|
185
|
+
|
|
186
|
+
const parsed: ReviewMemoryOperation[] = [];
|
|
187
|
+
for (const item of operations) {
|
|
188
|
+
if (!item || typeof item !== "object") continue;
|
|
189
|
+
const op = item as Record<string, unknown>;
|
|
190
|
+
if (!isReviewAction(op.action) || !isReviewTarget(op.target)) continue;
|
|
191
|
+
|
|
192
|
+
const operation: ReviewMemoryOperation = {
|
|
193
|
+
action: op.action,
|
|
194
|
+
target: op.target,
|
|
195
|
+
};
|
|
196
|
+
if (typeof op.content === "string") operation.content = op.content;
|
|
197
|
+
if (typeof op.old_text === "string") operation.old_text = op.old_text;
|
|
198
|
+
if (isMemoryCategory(op.category)) operation.category = op.category;
|
|
199
|
+
if (typeof op.failure_reason === "string") operation.failure_reason = op.failure_reason;
|
|
200
|
+
parsed.push(operation);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return parsed;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function sqliteProjectFor(
|
|
207
|
+
rawTarget: ReviewMemoryOperation["target"],
|
|
208
|
+
projectName?: string | null,
|
|
209
|
+
): string | null | undefined {
|
|
210
|
+
if (rawTarget === "project") return projectName?.trim() || null;
|
|
211
|
+
if (rawTarget === "memory" || rawTarget === "user" || rawTarget === "failure") return null;
|
|
212
|
+
return undefined;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function sqliteTargetFor(rawTarget: ReviewMemoryOperation["target"]): "memory" | "user" | "failure" {
|
|
216
|
+
return rawTarget === "user" ? "user" : rawTarget === "failure" ? "failure" : "memory";
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async function syncAdd(
|
|
220
|
+
rawTarget: ReviewMemoryOperation["target"],
|
|
221
|
+
content: string,
|
|
222
|
+
category: MemoryCategory | undefined,
|
|
223
|
+
failureReason: string | undefined,
|
|
224
|
+
dbManager: DatabaseManager | null,
|
|
225
|
+
projectName?: string | null,
|
|
226
|
+
): Promise<void> {
|
|
227
|
+
if (!dbManager) return;
|
|
228
|
+
|
|
229
|
+
const sqliteTarget = sqliteTargetFor(rawTarget);
|
|
230
|
+
const sqliteProject = sqliteProjectFor(rawTarget, projectName);
|
|
231
|
+
|
|
232
|
+
if (rawTarget === "failure") {
|
|
233
|
+
const failureCategory = category ?? "failure";
|
|
234
|
+
syncMemoryEntry(dbManager, {
|
|
235
|
+
content: formatFailureMemoryContent(content, {
|
|
236
|
+
category: failureCategory,
|
|
237
|
+
failureReason,
|
|
238
|
+
}),
|
|
239
|
+
target: "failure",
|
|
240
|
+
project: sqliteProject ?? null,
|
|
241
|
+
category: failureCategory,
|
|
242
|
+
failureReason,
|
|
243
|
+
});
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
syncMemoryEntry(dbManager, {
|
|
248
|
+
content,
|
|
249
|
+
target: sqliteTarget,
|
|
250
|
+
project: sqliteProject ?? null,
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async function syncReplace(
|
|
255
|
+
rawTarget: ReviewMemoryOperation["target"],
|
|
256
|
+
oldText: string,
|
|
257
|
+
newContent: string,
|
|
258
|
+
dbManager: DatabaseManager | null,
|
|
259
|
+
projectName?: string | null,
|
|
260
|
+
): Promise<void> {
|
|
261
|
+
if (!dbManager) return;
|
|
262
|
+
replaceSyncedMemories(dbManager, oldText, {
|
|
263
|
+
content: newContent,
|
|
264
|
+
target: sqliteTargetFor(rawTarget),
|
|
265
|
+
project: sqliteProjectFor(rawTarget, projectName),
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
async function syncRemove(
|
|
270
|
+
rawTarget: ReviewMemoryOperation["target"],
|
|
271
|
+
oldText: string,
|
|
272
|
+
dbManager: DatabaseManager | null,
|
|
273
|
+
projectName?: string | null,
|
|
274
|
+
): Promise<void> {
|
|
275
|
+
if (!dbManager) return;
|
|
276
|
+
removeSyncedMemories(dbManager, oldText, {
|
|
277
|
+
target: sqliteTargetFor(rawTarget),
|
|
278
|
+
project: sqliteProjectFor(rawTarget, projectName),
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
async function syncEvictions(
|
|
283
|
+
rawTarget: ReviewMemoryOperation["target"],
|
|
284
|
+
evictedEntries: string[] | undefined,
|
|
285
|
+
dbManager: DatabaseManager | null,
|
|
286
|
+
projectName?: string | null,
|
|
287
|
+
): Promise<void> {
|
|
288
|
+
if (!dbManager || !evictedEntries?.length) return;
|
|
289
|
+
for (const entry of evictedEntries) {
|
|
290
|
+
try {
|
|
291
|
+
removeExactSyncedMemories(dbManager, entry, {
|
|
292
|
+
target: sqliteTargetFor(rawTarget),
|
|
293
|
+
project: sqliteProjectFor(rawTarget, projectName),
|
|
294
|
+
});
|
|
295
|
+
} catch {
|
|
296
|
+
// best effort
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
export async function applyReviewOperations(
|
|
302
|
+
store: MemoryStore,
|
|
303
|
+
projectStore: MemoryStore | null,
|
|
304
|
+
operations: ReviewMemoryOperation[],
|
|
305
|
+
dbManager: DatabaseManager | null = null,
|
|
306
|
+
projectName?: string | null,
|
|
307
|
+
): Promise<ApplyReviewOperationsResult> {
|
|
308
|
+
let appliedCount = 0;
|
|
309
|
+
let skippedCount = 0;
|
|
310
|
+
|
|
311
|
+
for (const op of operations) {
|
|
312
|
+
if (op.target === "project" && !projectStore) {
|
|
313
|
+
skippedCount++;
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const rawTarget = op.target;
|
|
318
|
+
const memoryTarget = rawTarget === "project" ? "memory" : rawTarget === "failure" ? "failure" : rawTarget;
|
|
319
|
+
const activeStore = rawTarget === "project" ? projectStore! : store;
|
|
320
|
+
|
|
321
|
+
let result: MemoryResult;
|
|
322
|
+
switch (op.action) {
|
|
323
|
+
case "add": {
|
|
324
|
+
if (!op.content?.trim()) {
|
|
325
|
+
skippedCount++;
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
if (rawTarget === "failure") {
|
|
329
|
+
const category = op.category ?? "failure";
|
|
330
|
+
result = await activeStore.addFailure(op.content, {
|
|
331
|
+
category,
|
|
332
|
+
failureReason: op.failure_reason,
|
|
333
|
+
});
|
|
334
|
+
if (result.success) {
|
|
335
|
+
await syncAdd(rawTarget, op.content, category, op.failure_reason, dbManager, projectName);
|
|
336
|
+
appliedCount++;
|
|
337
|
+
} else {
|
|
338
|
+
skippedCount++;
|
|
339
|
+
}
|
|
340
|
+
} else {
|
|
341
|
+
result = await activeStore.add(memoryTarget, op.content);
|
|
342
|
+
if (result.success) {
|
|
343
|
+
await syncEvictions(rawTarget, result.evicted_entries, dbManager, projectName);
|
|
344
|
+
await syncAdd(rawTarget, op.content, undefined, undefined, dbManager, projectName);
|
|
345
|
+
appliedCount++;
|
|
346
|
+
} else {
|
|
347
|
+
skippedCount++;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
break;
|
|
351
|
+
}
|
|
352
|
+
case "replace": {
|
|
353
|
+
if (!op.old_text || !op.content?.trim()) {
|
|
354
|
+
skippedCount++;
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
result = await activeStore.replace(memoryTarget, op.old_text, op.content);
|
|
358
|
+
if (result.success) {
|
|
359
|
+
await syncReplace(rawTarget, op.old_text, op.content, dbManager, projectName);
|
|
360
|
+
appliedCount++;
|
|
361
|
+
} else {
|
|
362
|
+
skippedCount++;
|
|
363
|
+
}
|
|
364
|
+
break;
|
|
365
|
+
}
|
|
366
|
+
case "remove": {
|
|
367
|
+
if (!op.old_text) {
|
|
368
|
+
skippedCount++;
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
371
|
+
result = await activeStore.remove(memoryTarget, op.old_text);
|
|
372
|
+
if (result.success) {
|
|
373
|
+
await syncRemove(rawTarget, op.old_text, dbManager, projectName);
|
|
374
|
+
appliedCount++;
|
|
375
|
+
} else {
|
|
376
|
+
skippedCount++;
|
|
377
|
+
}
|
|
378
|
+
break;
|
|
379
|
+
}
|
|
380
|
+
default:
|
|
381
|
+
skippedCount++;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
return { appliedCount, skippedCount };
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function responseText(content: unknown): string {
|
|
389
|
+
if (!Array.isArray(content)) return "";
|
|
390
|
+
return content
|
|
391
|
+
.filter((block): block is { type: "text"; text: string } => (
|
|
392
|
+
!!block && typeof block === "object" && (block as { type?: string }).type === "text"
|
|
393
|
+
))
|
|
394
|
+
.map((block) => block.text)
|
|
395
|
+
.join("\n");
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
export async function runDirectBackgroundReview(
|
|
399
|
+
ctx: Pick<ExtensionContext, "model" | "modelRegistry">,
|
|
400
|
+
store: MemoryStore,
|
|
401
|
+
projectStore: MemoryStore | null,
|
|
402
|
+
options: RunDirectBackgroundReviewOptions,
|
|
403
|
+
dbManager: DatabaseManager | null = null,
|
|
404
|
+
projectName?: string | null,
|
|
405
|
+
): Promise<DirectReviewResult> {
|
|
406
|
+
const model = resolveReviewModel(ctx.model, ctx.modelRegistry, options.config);
|
|
407
|
+
if (!model) {
|
|
408
|
+
return { ok: false, appliedCount: 0, fallbackReason: "no_model" };
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
|
|
412
|
+
if (!auth.ok || !auth.apiKey) {
|
|
413
|
+
return {
|
|
414
|
+
ok: false,
|
|
415
|
+
appliedCount: 0,
|
|
416
|
+
fallbackReason: "no_auth",
|
|
417
|
+
error: auth.ok ? `No API key for ${model.provider}` : auth.error,
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const controller = new AbortController();
|
|
422
|
+
const timeoutMs = options.timeoutMs ?? 120000;
|
|
423
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
424
|
+
if (options.signal) {
|
|
425
|
+
options.signal.addEventListener("abort", () => controller.abort(), { once: true });
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const thinking = effectiveThinkingOverride(options.config);
|
|
429
|
+
const userMessage: Message = {
|
|
430
|
+
role: "user",
|
|
431
|
+
content: [{ type: "text", text: options.userPrompt }],
|
|
432
|
+
timestamp: Date.now(),
|
|
433
|
+
};
|
|
434
|
+
|
|
435
|
+
try {
|
|
436
|
+
const response = await completeSimple(
|
|
437
|
+
model,
|
|
438
|
+
{ systemPrompt: DIRECT_REVIEW_SYSTEM_PROMPT, messages: [userMessage] },
|
|
439
|
+
buildDirectReviewCompletionOptions(
|
|
440
|
+
model,
|
|
441
|
+
{ apiKey: auth.apiKey, headers: auth.headers, env: auth.env },
|
|
442
|
+
thinking,
|
|
443
|
+
controller.signal,
|
|
444
|
+
),
|
|
445
|
+
);
|
|
446
|
+
|
|
447
|
+
if (response.stopReason === "aborted") {
|
|
448
|
+
return { ok: false, appliedCount: 0, fallbackReason: "aborted" };
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const text = responseText(response.content);
|
|
452
|
+
const operations = parseReviewOperations(text);
|
|
453
|
+
if (operations === null) {
|
|
454
|
+
return { ok: false, appliedCount: 0, fallbackReason: "parse_error" };
|
|
455
|
+
}
|
|
456
|
+
if (operations.length === 0) {
|
|
457
|
+
return { ok: true, appliedCount: 0, fallbackReason: "empty" };
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const { appliedCount } = await applyReviewOperations(
|
|
461
|
+
store,
|
|
462
|
+
projectStore,
|
|
463
|
+
operations,
|
|
464
|
+
dbManager,
|
|
465
|
+
projectName,
|
|
466
|
+
);
|
|
467
|
+
return { ok: true, appliedCount };
|
|
468
|
+
} catch (err) {
|
|
469
|
+
if (controller.signal.aborted) {
|
|
470
|
+
return { ok: false, appliedCount: 0, fallbackReason: "aborted" };
|
|
471
|
+
}
|
|
472
|
+
return {
|
|
473
|
+
ok: false,
|
|
474
|
+
appliedCount: 0,
|
|
475
|
+
fallbackReason: "provider_error",
|
|
476
|
+
error: err instanceof Error ? err.message : String(err),
|
|
477
|
+
};
|
|
478
|
+
} finally {
|
|
479
|
+
clearTimeout(timeout);
|
|
480
|
+
}
|
|
481
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -188,7 +188,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
188
188
|
registerSkillTool(pi, skillStore);
|
|
189
189
|
|
|
190
190
|
// ── 5. Setup background learning loop (with tool-call-aware nudge) ──
|
|
191
|
-
setupBackgroundReview(pi, store, projectStore, config
|
|
191
|
+
setupBackgroundReview(pi, store, projectStore, config, {
|
|
192
|
+
dbManager,
|
|
193
|
+
projectName: projectName || null,
|
|
194
|
+
});
|
|
192
195
|
|
|
193
196
|
// ── 6. Setup session-end flush ──
|
|
194
197
|
setupSessionFlush(pi, store, projectStore, config);
|
package/src/types.ts
CHANGED
|
@@ -10,6 +10,8 @@ export type SessionSearchVariant = "legacy" | "anchors";
|
|
|
10
10
|
|
|
11
11
|
export type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
|
|
12
12
|
|
|
13
|
+
export type ReviewTransport = "direct" | "subprocess";
|
|
14
|
+
|
|
13
15
|
export interface SessionSearchConfig {
|
|
14
16
|
/** Session search implementation variant. Default: legacy */
|
|
15
17
|
variant: SessionSearchVariant;
|
|
@@ -34,6 +36,8 @@ export interface MemoryConfig {
|
|
|
34
36
|
reviewRecentMessages?: number;
|
|
35
37
|
/** Enable background learning loop. Default: true */
|
|
36
38
|
reviewEnabled: boolean;
|
|
39
|
+
/** How background review invokes the LLM. Default: direct */
|
|
40
|
+
reviewTransport?: ReviewTransport;
|
|
37
41
|
/** Flush memories before compaction. Default: true */
|
|
38
42
|
flushOnCompact: boolean;
|
|
39
43
|
/** Flush memories on session shutdown. Default: true */
|