pi-orchestrator-config 4.23.0 → 4.24.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/CHANGELOG.md +24 -0
- package/extensions/orchestrator/ask-user.ts +90 -22
- package/extensions/orchestrator/dreaming.ts +267 -92
- package/extensions/orchestrator/memory-tools.ts +9 -3
- package/extensions/orchestrator/memory-tree.ts +61 -2
- package/extensions/orchestrator/memory-write.ts +7 -3
- package/extensions/orchestrator/review-learning.ts +44 -1
- package/package.json +1 -1
- package/prompts/pr-review.md +81 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,29 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [4.24.1] - 2026-11-03
|
|
4
|
+
|
|
5
|
+
- Dreaming no longer stores re-derivable changelog: `[done]` takes non-derivable outcomes only (conclusions, measurements, root causes) and reorganizing prunes PR/version/ticket
|
|
6
|
+
history, so `completions` shrinks instead of rotating changelog for changelog. A live run took this project's topic from 18 entries to 2, keeping the cost baseline and the
|
|
7
|
+
self-reload finding.
|
|
8
|
+
- Learned memory now survives maintenance: anything a dream deletes that is pinned, enforced, or review-distilled is restored when it finishes, and a topic file it deleted is
|
|
9
|
+
rebuilt. A deliberate removal is respected because removals are recorded as tombstones rather than inferred from score state — the inference inverted whenever the scheduled
|
|
10
|
+
rebuild ran mid-dream.
|
|
11
|
+
- `completions` is capped at 6,000 characters through per-topic caps, applied by both write paths. The cap is never satisfied by deleting memory: an over-cap topic is reported and
|
|
12
|
+
left intact.
|
|
13
|
+
|
|
14
|
+
## [4.24.0] - 2026-11-03
|
|
15
|
+
|
|
16
|
+
- `/pr-review` asks how to decide after the findings table — `Post all`, `Skip all`, or
|
|
17
|
+
`Decide one by one` — instead of one bulk answer for everything.
|
|
18
|
+
- Deciding one by one gives each finding its own dialog (post, skip, skip with reason, more
|
|
19
|
+
context, stop), captures the skip reason at decision time, keeps author questions on their
|
|
20
|
+
Approve / Edit / Skip path, and stores a reason even for a bare skip.
|
|
21
|
+
- Every `ask_user` prompt offers "💬 Discuss with AI first", which returns a sentinel meaning
|
|
22
|
+
"talk about this decision first" rather than an answer; a caller that passes that row itself
|
|
23
|
+
gets one row, and `allowDiscuss: false` removes it.
|
|
24
|
+
- Docs: the `ask_user` reference covers `allowDiscuss` and the sentinel; the workflow page
|
|
25
|
+
documents the mode choice and the real turn cost.
|
|
26
|
+
|
|
3
27
|
## [4.23.0] - 2026-11-02
|
|
4
28
|
|
|
5
29
|
- Minimum pi version raised to 1.5.0 — the extension floor, the sidecar floor, and every manifest move together.
|
|
@@ -18,6 +18,71 @@ import { createLogger } from "../shared/logger.js";
|
|
|
18
18
|
|
|
19
19
|
const log = createLogger("ask-user");
|
|
20
20
|
|
|
21
|
+
/** Sentinel value returned when the user asks to discuss in plain chat first. */
|
|
22
|
+
export const DISCUSS_VALUE = "__discuss__";
|
|
23
|
+
export const DISCUSS_LABEL = "💬 Discuss with AI first";
|
|
24
|
+
const FREE_INPUT_VALUE = "__free_input__";
|
|
25
|
+
|
|
26
|
+
/** Emoji/punctuation-free, lowercased form so a caller's plain label still matches. */
|
|
27
|
+
const normalizeOption = (s: string): string =>
|
|
28
|
+
s.replace(/[^\p{L}\p{N}]+/gu, " ").trim().toLowerCase();
|
|
29
|
+
|
|
30
|
+
/** A caller option that already is the discuss row: its plain label or the bare sentinel. */
|
|
31
|
+
const isDiscussRow = (option: string): boolean =>
|
|
32
|
+
option.trim().toLowerCase() === DISCUSS_VALUE ||
|
|
33
|
+
normalizeOption(option) === normalizeOption(DISCUSS_LABEL);
|
|
34
|
+
|
|
35
|
+
export function buildAskOptions(
|
|
36
|
+
options: string[] | undefined,
|
|
37
|
+
allowDiscuss: boolean,
|
|
38
|
+
): SelectItem[] {
|
|
39
|
+
// Free-input-only mode: no list, no discuss item.
|
|
40
|
+
if (!options || options.length === 0) return [];
|
|
41
|
+
|
|
42
|
+
let callerSuppliedDiscuss = false;
|
|
43
|
+
const items: SelectItem[] = [];
|
|
44
|
+
for (const opt of options) {
|
|
45
|
+
if (!isDiscussRow(opt)) {
|
|
46
|
+
items.push({ value: opt, label: opt });
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
// Opted out: a caller's discuss row must disappear, not merely go unappended.
|
|
50
|
+
if (!allowDiscuss) continue;
|
|
51
|
+
// Caller's label stays for display; the sentinel makes selecting it pause.
|
|
52
|
+
callerSuppliedDiscuss = true;
|
|
53
|
+
items.push({ value: DISCUSS_VALUE, label: opt, description: "chat first" });
|
|
54
|
+
}
|
|
55
|
+
if (!allowDiscuss) {
|
|
56
|
+
log.debug("ask_user discuss option suppressed", { optionCount: options.length });
|
|
57
|
+
} else if (callerSuppliedDiscuss) {
|
|
58
|
+
log.debug("ask_user discuss option deduped", { optionCount: options.length });
|
|
59
|
+
} else {
|
|
60
|
+
items.push({ value: DISCUSS_VALUE, label: DISCUSS_LABEL, description: "chat first" });
|
|
61
|
+
log.debug("ask_user discuss option offered", { optionCount: options.length });
|
|
62
|
+
}
|
|
63
|
+
items.push({
|
|
64
|
+
value: FREE_INPUT_VALUE,
|
|
65
|
+
label: "✎ Other (type custom answer)",
|
|
66
|
+
description: "free-text",
|
|
67
|
+
});
|
|
68
|
+
return items;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Browser/discord renders options as buttons labelled with the raw string, so the
|
|
73
|
+
* discuss row travels as its label and comes back mapped to the sentinel. The
|
|
74
|
+
* free-input row is a TUI control, not an answer — it is never offered remotely.
|
|
75
|
+
*/
|
|
76
|
+
export function browserAskOptions(items: SelectItem[]): string[] {
|
|
77
|
+
return items
|
|
78
|
+
.filter((item) => item.value !== FREE_INPUT_VALUE)
|
|
79
|
+
.map((item) => (item.value === DISCUSS_VALUE ? DISCUSS_LABEL : item.value));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function answerToValue(answer: string): string {
|
|
83
|
+
return answer === DISCUSS_LABEL ? DISCUSS_VALUE : answer;
|
|
84
|
+
}
|
|
85
|
+
|
|
21
86
|
export function registerAskUser(
|
|
22
87
|
pi: ExtensionAPI,
|
|
23
88
|
terminalNotify: (title: string, body: string) => void,
|
|
@@ -32,6 +97,7 @@ export function registerAskUser(
|
|
|
32
97
|
"Use ask_user when you need user input during a workflow (approvals, selections, confirmations).",
|
|
33
98
|
"Do NOT ask users questions via plain text — always use this tool for structured choices.",
|
|
34
99
|
"Provide clear, concise options. Include a 'no' or 'cancel' option when appropriate.",
|
|
100
|
+
`If ask_user returns exactly "${DISCUSS_VALUE}", the user has NOT answered — they want to talk about this decision first. Open by addressing the decision itself: the trade-offs, the information you are missing, or the alternatives. Never answer with a status report about what you built or verified. Re-call ask_user with the SAME question and options only once the discussion resolves.`,
|
|
35
101
|
],
|
|
36
102
|
parameters: Type.Object({
|
|
37
103
|
question: Type.String({
|
|
@@ -43,6 +109,12 @@ export function registerAskUser(
|
|
|
43
109
|
"List of selectable options. If omitted, only free-text input is shown.",
|
|
44
110
|
}),
|
|
45
111
|
),
|
|
112
|
+
allowDiscuss: Type.Optional(
|
|
113
|
+
Type.Boolean({
|
|
114
|
+
description:
|
|
115
|
+
"Offer a 'Discuss with AI first' option that returns the __discuss__ sentinel without answering (default true).",
|
|
116
|
+
}),
|
|
117
|
+
),
|
|
46
118
|
}),
|
|
47
119
|
|
|
48
120
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
@@ -58,14 +130,16 @@ export function registerAskUser(
|
|
|
58
130
|
|
|
59
131
|
terminalNotify("pi", "Action required");
|
|
60
132
|
|
|
133
|
+
const assembled = buildAskOptions(params.options, params.allowDiscuss !== false);
|
|
134
|
+
|
|
61
135
|
// Emit ask request for pidash browser clients
|
|
62
136
|
const askId = `ask-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
|
|
63
137
|
pi.events.emit("pidash:ui-request", {
|
|
64
138
|
id: askId,
|
|
65
139
|
type: "extension_ui_request",
|
|
66
|
-
method:
|
|
140
|
+
method: assembled.length ? "select" : "input",
|
|
67
141
|
title: params.question,
|
|
68
|
-
options:
|
|
142
|
+
options: assembled.length ? browserAskOptions(assembled) : undefined,
|
|
69
143
|
});
|
|
70
144
|
|
|
71
145
|
// Track if resolved (from TUI or browser)
|
|
@@ -76,20 +150,21 @@ export function registerAskUser(
|
|
|
76
150
|
const resp = data as any;
|
|
77
151
|
if (resp.id === askId && externalDone) {
|
|
78
152
|
if (resp.cancelled) externalDone(null);
|
|
79
|
-
else if (resp.value)
|
|
80
|
-
|
|
153
|
+
else if (resp.value) {
|
|
154
|
+
const value = answerToValue(resp.value);
|
|
155
|
+
log.debug("ask_user browser answer received", { askId, isDiscuss: value === DISCUSS_VALUE });
|
|
156
|
+
externalDone(value);
|
|
157
|
+
} else if (resp.confirmed !== undefined) externalDone(resp.confirmed ? "Yes" : "No");
|
|
81
158
|
}
|
|
82
159
|
});
|
|
83
160
|
|
|
84
|
-
log.debug("ask_user prompt opened with blocked status", { askId, mode:
|
|
161
|
+
log.debug("ask_user prompt opened with blocked status", { askId, mode: assembled.length ? "select" : "input" });
|
|
85
162
|
const result = await ctx.ui.custom<string | null>(
|
|
86
163
|
(tui, theme, _kb, done) => {
|
|
87
164
|
// Allow browser to resolve this dialog
|
|
88
165
|
externalDone = done;
|
|
89
166
|
|
|
90
|
-
let mode: "select" | "input" =
|
|
91
|
-
? "select"
|
|
92
|
-
: "input";
|
|
167
|
+
let mode: "select" | "input" = assembled.length ? "select" : "input";
|
|
93
168
|
let resolved = false;
|
|
94
169
|
|
|
95
170
|
const resolve = (value: string | null) => {
|
|
@@ -141,7 +216,7 @@ export function registerAskUser(
|
|
|
141
216
|
sl.onSelect = (item: SelectItem) => {
|
|
142
217
|
if (item.value === "__no_match__") {
|
|
143
218
|
// Ignore — not a real option
|
|
144
|
-
} else if (item.value ===
|
|
219
|
+
} else if (item.value === FREE_INPUT_VALUE) {
|
|
145
220
|
mode = "input";
|
|
146
221
|
searchInput.setValue("");
|
|
147
222
|
tui.requestRender();
|
|
@@ -153,18 +228,8 @@ export function registerAskUser(
|
|
|
153
228
|
return sl;
|
|
154
229
|
};
|
|
155
230
|
|
|
156
|
-
if (
|
|
157
|
-
allItems =
|
|
158
|
-
...params.options.map((opt: string) => ({
|
|
159
|
-
value: opt,
|
|
160
|
-
label: opt,
|
|
161
|
-
})),
|
|
162
|
-
{
|
|
163
|
-
value: "__free_input__",
|
|
164
|
-
label: "✎ Other (type custom answer)",
|
|
165
|
-
description: "free-text",
|
|
166
|
-
},
|
|
167
|
-
];
|
|
231
|
+
if (assembled.length > 0) {
|
|
232
|
+
allItems = assembled;
|
|
168
233
|
selectList = makeSelectList(allItems);
|
|
169
234
|
}
|
|
170
235
|
|
|
@@ -176,7 +241,7 @@ export function registerAskUser(
|
|
|
176
241
|
if (selected) {
|
|
177
242
|
if (selected.value === "__no_match__") {
|
|
178
243
|
// Ignore
|
|
179
|
-
} else if (selected.value ===
|
|
244
|
+
} else if (selected.value === FREE_INPUT_VALUE) {
|
|
180
245
|
mode = "input";
|
|
181
246
|
searchInput.setValue("");
|
|
182
247
|
} else {
|
|
@@ -288,6 +353,9 @@ export function registerAskUser(
|
|
|
288
353
|
renderResult(result, _options, theme) {
|
|
289
354
|
const text = result.content[0];
|
|
290
355
|
const value = text?.type === "text" ? text.text : "(no response)";
|
|
356
|
+
if (value === DISCUSS_VALUE) {
|
|
357
|
+
return new Text(theme.fg("dim", "💬 Discussion requested"), 0, 0);
|
|
358
|
+
}
|
|
291
359
|
const icon =
|
|
292
360
|
value === "User cancelled"
|
|
293
361
|
? theme.fg("warning", "✗")
|
|
@@ -23,15 +23,248 @@ import { getSetting } from "./project-settings.js";
|
|
|
23
23
|
import { discoverAgents } from "./agents.js";
|
|
24
24
|
import { ICON_DREAM } from "./icons.js";
|
|
25
25
|
import { setSlot } from "./status-bar.js";
|
|
26
|
+
import { distilledLessonTextList } from "./review-learning.js";
|
|
27
|
+
import { invalidateTopicFileCache, removalsSince, topicCharCap } from "./memory-tree.js";
|
|
28
|
+
import { entryHash } from "./memory-scoring.js";
|
|
29
|
+
|
|
30
|
+
/** Topic files memory maintenance rewrites. */
|
|
31
|
+
const MEMORY_TOPIC_FILES = ["lessons", "preferences", "patterns", "decisions", "completions", "mistakes"];
|
|
32
|
+
|
|
33
|
+
/** Line text without its `- [category] ` prefix, lock markers, or `{#id}` suffix. */
|
|
34
|
+
function topicLineText(line: string): string {
|
|
35
|
+
return line
|
|
36
|
+
.replace(/^- \[[a-z]+\] /, "")
|
|
37
|
+
.replace(/\s*\*\((?:pinned|enforced)\)\*/g, "")
|
|
38
|
+
.replace(/\s*\{#[0-9a-f]+\}\s*$/, "")
|
|
39
|
+
.trim();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Entries memory maintenance must never delete, whatever the prompt says:
|
|
44
|
+
* pinned/enforced entries (user-locked, or keyed by text hash for enforcement) and
|
|
45
|
+
* lessons distilled from reviews — their source is the reviews DB, but nothing
|
|
46
|
+
* re-materializes the topic line once a dream removes it (issue #1010).
|
|
47
|
+
*/
|
|
48
|
+
export function isProtectedTopicLine(line: string, reviewLessons: readonly string[]): boolean {
|
|
49
|
+
if (!line.startsWith("- [")) return false;
|
|
50
|
+
if (line.includes("*(pinned)*") || line.includes("*(enforced)*")) return true;
|
|
51
|
+
const text = topicLineText(line);
|
|
52
|
+
return reviewLessons.some((lesson) => {
|
|
53
|
+
const candidate = lesson.trim();
|
|
54
|
+
return candidate.length > 0 && text === candidate;
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Snapshot every memory topic file's lines, so a dream's deletions can be audited. */
|
|
59
|
+
export function snapshotTopicLines(topicsDir: string): Record<string, string[]> {
|
|
60
|
+
const before: Record<string, string[]> = {};
|
|
61
|
+
for (const name of MEMORY_TOPIC_FILES) {
|
|
62
|
+
try {
|
|
63
|
+
before[`${name}.md`] = fs.readFileSync(path.join(topicsDir, `${name}.md`), "utf-8").split("\n");
|
|
64
|
+
} catch {
|
|
65
|
+
// Topic absent — nothing to protect.
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return before;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Restore protected entries a dream removed, at their original index. Returns how
|
|
73
|
+
* many lines were put back; zero means the dream stayed inside its mandate.
|
|
74
|
+
* Any failure is the caller's to log — this never throws.
|
|
75
|
+
*/
|
|
76
|
+
/** Canonical `- [category] text` form used as the memory score key. */
|
|
77
|
+
export function topicLineCanonical(line: string): string {
|
|
78
|
+
const category = /^- \[([a-z]+)\] /.exec(line)?.[1] ?? "";
|
|
79
|
+
return `- [${category}] ${topicLineText(line)}`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export type TopicRestoreReport = {
|
|
83
|
+
restored: number;
|
|
84
|
+
failed: number;
|
|
85
|
+
/** Topics still over their cap after reconcile. Never trimmed here: see below. */
|
|
86
|
+
overCapTopics: string[];
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Restore protected entries a dream removed, at their original index, then keep the
|
|
91
|
+
* file inside its per-topic cap by dropping unprotected additions — never protected
|
|
92
|
+
* ones. Recreates a topic the dream deleted outright. Returns what it did; logging is
|
|
93
|
+
* the caller's, this never throws.
|
|
94
|
+
*/
|
|
95
|
+
export function restoreProtectedTopicLines(
|
|
96
|
+
topicsDir: string,
|
|
97
|
+
before: Record<string, string[]>,
|
|
98
|
+
reviewLessons: readonly string[],
|
|
99
|
+
removedByUser: (line: string) => boolean = () => false,
|
|
100
|
+
): TopicRestoreReport {
|
|
101
|
+
const report: TopicRestoreReport = { restored: 0, failed: 0, overCapTopics: [] };
|
|
102
|
+
for (const [file, beforeLines] of Object.entries(before)) {
|
|
103
|
+
const filePath = path.join(topicsDir, file);
|
|
104
|
+
let current: string[] | null = null;
|
|
105
|
+
try {
|
|
106
|
+
current = fs.readFileSync(filePath, "utf-8").split("\n");
|
|
107
|
+
} catch {
|
|
108
|
+
current = null; // Deleted outright — rebuilt below from the snapshot.
|
|
109
|
+
}
|
|
110
|
+
const base = current ?? beforeLines.filter((line) => !line.startsWith("- ["));
|
|
111
|
+
// A dream that *rewrites* a protected entry keeps it: compare canonical text, not the
|
|
112
|
+
// raw line, or the original returns beside the rewrite as a duplicate.
|
|
113
|
+
const surviving = new Set((current ?? []).map(topicLineCanonical));
|
|
114
|
+
const beforeEntries = new Set(
|
|
115
|
+
beforeLines.filter((line) => line.startsWith("- [")).map(topicLineCanonical),
|
|
116
|
+
);
|
|
117
|
+
const missing = beforeLines
|
|
118
|
+
.map((line, index) => ({ line, index }))
|
|
119
|
+
.filter(({ line }) => {
|
|
120
|
+
if (!line.startsWith("- [") || !isProtectedTopicLine(line, reviewLessons)) return false;
|
|
121
|
+
if (surviving.has(topicLineCanonical(line))) return false;
|
|
122
|
+
// Only an explicit tombstone proves a removal. Score absence does not: the
|
|
123
|
+
// scheduled rebuild prunes scores for lines a dream deleted, which would look
|
|
124
|
+
// exactly like a user removal and strand the entry (issue #1010).
|
|
125
|
+
return !removedByUser(line);
|
|
126
|
+
});
|
|
127
|
+
if (missing.length === 0 && current !== null) continue;
|
|
128
|
+
|
|
129
|
+
const merged = [...base];
|
|
130
|
+
for (const { line, index } of missing) {
|
|
131
|
+
merged.splice(Math.min(index, merged.length), 0, line);
|
|
132
|
+
report.restored += 1;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const cap = topicCharCap(file.replace(/\.md$/, ""));
|
|
136
|
+
// Never trim here. Text cannot distinguish a dream's addition from its rewrite of an
|
|
137
|
+
// existing memory, and deleting the wrong one loses knowledge that the score rebuild
|
|
138
|
+
// cannot recover. The write paths still enforce the cap for normal additions; an
|
|
139
|
+
// over-cap file is reported so the next prune can shrink it with judgment.
|
|
140
|
+
if (merged.join("\n").length > cap) report.overCapTopics.push(file);
|
|
141
|
+
|
|
142
|
+
try {
|
|
143
|
+
fs.writeFileSync(filePath, merged.join("\n"), "utf-8");
|
|
144
|
+
invalidateTopicFileCache(filePath);
|
|
145
|
+
} catch {
|
|
146
|
+
report.failed += 1;
|
|
147
|
+
report.restored -= missing.length;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return report;
|
|
151
|
+
}
|
|
152
|
+
|
|
26
153
|
import { rebuildAndOrganize } from "./situation-report.js";
|
|
27
154
|
import { runPromotionPass } from "./memory-promotion.js";
|
|
28
155
|
import { mergeProvenancePending } from "./memory-provenance.js";
|
|
29
|
-
import { getProjectTmpDir, getProjectMemoryDir, shouldSkipOneshotShutdownDream } from "./utils.js";
|
|
156
|
+
import { getProjectTmpDir, getProjectMemoryDir, getProjectDataDir, shouldSkipOneshotShutdownDream } from "./utils.js";
|
|
30
157
|
import { fileLog } from "../shared/file-logger.js";
|
|
31
158
|
|
|
32
159
|
// Scoring rebuild runs every 30 minutes (cheap, no LLM — just rescores and reorganizes)
|
|
33
160
|
const REBUILD_INTERVAL_MS = 30 * 60 * 1000;
|
|
34
161
|
|
|
162
|
+
export function buildDreamPrompt(
|
|
163
|
+
lastSessionFile: string | undefined,
|
|
164
|
+
topicsDir: string,
|
|
165
|
+
): string {
|
|
166
|
+
return (
|
|
167
|
+
`Memory dreaming — analyze session and maintain topic files.\n` +
|
|
168
|
+
`Session file: ${lastSessionFile || "none"}\n` +
|
|
169
|
+
`Topics directory: ${topicsDir}\n\n` +
|
|
170
|
+
`Topic files are the source of truth for project memory. Each file holds entries for one category.\n\n` +
|
|
171
|
+
`Steps:\n` +
|
|
172
|
+
`1. Read all existing topic files in ${topicsDir}/ (lessons.md, preferences.md, patterns.md, decisions.md, completions.md, mistakes.md).\n` +
|
|
173
|
+
` If the directory doesn't exist, create it.\n` +
|
|
174
|
+
`2. QUALITY GATE: Before extracting from any session, assess its quality:\n` +
|
|
175
|
+
` - Score depth (substantive exchanges > 100 chars? decisions made? corrections?)\n` +
|
|
176
|
+
` - Skip sessions that are only greetings, trivial Q&A, or < 3 exchanges\n` +
|
|
177
|
+
` - Only extract from sessions with real decisions, corrections, or completed work\n` +
|
|
178
|
+
`3. If a session file is provided, read it and extract things worth remembering:\n` +
|
|
179
|
+
` - User corrections → [lesson] → lessons.md\n` +
|
|
180
|
+
` - User preferences → [preference] → preferences.md\n` +
|
|
181
|
+
` - Mistakes or repeated fix attempts → [mistake] → mistakes.md\n` +
|
|
182
|
+
` - Completed work → [done] → completions.md — ONLY non-derivable outcome knowledge:\n` +
|
|
183
|
+
` a conclusion, a measurement, a root cause, "X was fixed by Y because Z".\n` +
|
|
184
|
+
` NEVER store a PR/issue number list, merge or release history, version changelogs,\n` +
|
|
185
|
+
` or closed-ticket roundups — git log, gh, and the tracker already hold them, and a\n` +
|
|
186
|
+
` stored "Open: #772" answers "is it fixed?" wrongly once it ships.\n` +
|
|
187
|
+
` - Patterns or conventions → [pattern] → patterns.md\n` +
|
|
188
|
+
` - Architectural/design decisions → [decision] → decisions.md\n` +
|
|
189
|
+
` QUALITY GATE for extraction: one-off permissions granted in a single session\n` +
|
|
190
|
+
` ("you can merge this PR", "skip tests just this once") AND task-scoped instructions\n` +
|
|
191
|
+
` ("when done, I want an html report", "when bumping to 1.4.0, drop the vendor dir",\n` +
|
|
192
|
+
` config-change notes like "every 30min (changed from 1h)") are session/task\n` +
|
|
193
|
+
` instructions, NOT memories — never extract them. Apply the standing test: would\n` +
|
|
194
|
+
` this apply to a future, unrelated task? No → discard. Only standing, repeated\n` +
|
|
195
|
+
` rules are durable.\n` +
|
|
196
|
+
` Do NOT add duplicates of existing entries.\n` +
|
|
197
|
+
`3b. Review the preference candidate queue: if ${topicsDir}/../preference-candidates.jsonl\n` +
|
|
198
|
+
` exists and has entries, each holds a VERBATIM sentence the regex extractor queued\n` +
|
|
199
|
+
` from user input (issue #961). For each candidate:\n` +
|
|
200
|
+
` - Durable standing preference → DISTILL it into a clean one-line [preference]\n` +
|
|
201
|
+
` (≤100 chars, actionable, no verbatim chatter) and add to preferences.md\n` +
|
|
202
|
+
` - Task-scoped instruction, one-off ask, question, or already covered → discard\n` +
|
|
203
|
+
` After processing: RE-READ preference-candidates.jsonl and rewrite it keeping ONLY\n` +
|
|
204
|
+
` the entries that appeared after your first read — the extractor appends\n` +
|
|
205
|
+
` concurrently, so do NOT truncate the whole file (an entry queued between your\n` +
|
|
206
|
+
` read and your rewrite would be erased before any dream cycle reviewed it).\n` +
|
|
207
|
+
` Keep the file bounded: if it exceeds 200 lines, also drop the oldest entries\n` +
|
|
208
|
+
` beyond that cap.\n` +
|
|
209
|
+
`4. Scan past session files for unprocessed knowledge. Check if ${topicsDir}/../.dream-watermark exists.\n` +
|
|
210
|
+
` If it does, read the timestamp — only process sessions newer than that.\n` +
|
|
211
|
+
` Session directory: find .jsonl files under the pi sessions directory.\n` +
|
|
212
|
+
` For each unprocessed session, extract durable knowledge (same categories as step 3).\n` +
|
|
213
|
+
` Limit: process at most 5 sessions per dream cycle to avoid overload.\n` +
|
|
214
|
+
`5. Reorganize each topic file:\n` +
|
|
215
|
+
` - Remove duplicate or near-duplicate entries\n` +
|
|
216
|
+
` - Remove stale/useless entries\n` +
|
|
217
|
+
` - Remove session-scoped or expired entries — they are transient by design\n` +
|
|
218
|
+
` - Remove task-scoped instruction entries (issue #961): verbatim user utterances\n` +
|
|
219
|
+
` (colloquial chat captured raw), mid-sentence cutoffs, one-off deliverable asks\n` +
|
|
220
|
+
` ("when done I want a report"), version-pinned migration checklists, config-change\n` +
|
|
221
|
+
` notes — these are task/session artifacts, not durable rules. NEVER remove\n` +
|
|
222
|
+
` pinned or enforced entries even when they match these shapes.\n` +
|
|
223
|
+
` - Remove entries that restate git/GitHub/Jira history — merged-PR lists, release and\n` +
|
|
224
|
+
` version notes, closed-ticket roundups. Recoverable by query, therefore not memory.\n` +
|
|
225
|
+
` When an entry mixes history with a non-derivable finding, keep only the finding.\n` +
|
|
226
|
+
` - Keep each file at a reasonable size (aim for under 20 entries per topic)\n` +
|
|
227
|
+
` - NEVER remove or modify entries marked with *(pinned)*\n` +
|
|
228
|
+
` - NEVER remove or modify entries marked with *(enforced)* — these have code-enforced triggers/actions that are keyed by text hash. ANY text change destroys the enforcement binding.\n` +
|
|
229
|
+
`6. Write each updated topic file with this format:\n` +
|
|
230
|
+
` # TopicName\n` +
|
|
231
|
+
` \n` +
|
|
232
|
+
` - [category] summary *(pinned)* (if pinned)\n` +
|
|
233
|
+
` - [category] summary *(enforced)* (if enforced)\n` +
|
|
234
|
+
` - [category] summary *(pinned)* *(enforced)* (if both)\n` +
|
|
235
|
+
` - [category] summary (if neither)\n` +
|
|
236
|
+
`7. Auto-generate skills: if you notice a multi-step workflow pattern across entries,\n` +
|
|
237
|
+
` create a skill file at .pi/skills/<name>/SKILL.md (project-level, NOT global ~/.agents/).\n` +
|
|
238
|
+
` The SKILL.md MUST start with YAML frontmatter:\n` +
|
|
239
|
+
` ---\n` +
|
|
240
|
+
` name: <name>\n` +
|
|
241
|
+
` description: "What this skill does and when to use it"\n` +
|
|
242
|
+
` ---\n` +
|
|
243
|
+
` Only create skills for workflows with 3+ steps that are likely to recur.\n` +
|
|
244
|
+
`8. Write the current timestamp to ${topicsDir}/../.dream-watermark to track progress.\n` +
|
|
245
|
+
`9. Memory rules: one line per entry, max ~100 chars, specific and actionable, no fluff.\n` +
|
|
246
|
+
`10. Promotion destinations: append candidates to ${topicsDir}/../promotions.md using this block format:\n` +
|
|
247
|
+
` ### <12-char-id>\n` +
|
|
248
|
+
` - destination: memory|skill|enforcement|project_rule|discard\n` +
|
|
249
|
+
` - status: proposed\n` +
|
|
250
|
+
` - category: <category>\n` +
|
|
251
|
+
` - text: <exact topic entry text>\n` +
|
|
252
|
+
` - reason: <why this should graduate>\n` +
|
|
253
|
+
` - created: <ISO timestamp>\n` +
|
|
254
|
+
` Optional fields: evidence_count, trigger, action, verifier, skill_name, skill_created.\n` +
|
|
255
|
+
` Rules:\n` +
|
|
256
|
+
` - skill: create .pi/skills/ when confident; set skill_created: true\n` +
|
|
257
|
+
` - enforcement: propose trigger/action when mechanical (never/always + command); do NOT invent run_after\n` +
|
|
258
|
+
` - project_rule: propose only — NEVER write rules/ or .pi/rules/\n` +
|
|
259
|
+
` - discard: stale or superseded noise\n` +
|
|
260
|
+
` - Do not reopen entries already marked applied or rejected in promotions.md\n` +
|
|
261
|
+
`11. Provenance sidecar (optional): for newly extracted entries, write\n` +
|
|
262
|
+
` ${topicsDir}/../provenance-pending.jsonl as a single line of JSON (preferred):\n` +
|
|
263
|
+
` {"entries":[{"category":"lesson","text":"<exact topic text>","sourceSession":"<session basename>","derivedFrom":"<optional>","informs":["optional"]}]}\n` +
|
|
264
|
+
` Do NOT edit memory-scores.jsonl yourself — onComplete merges the sidecar.`
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
|
|
35
268
|
export function registerDreaming(
|
|
36
269
|
pi: ExtensionAPI,
|
|
37
270
|
spawnAsyncAgent: (
|
|
@@ -102,99 +335,13 @@ export function registerDreaming(
|
|
|
102
335
|
currentDreamId = ""; // Reset until we get the ID from spawnAsyncAgent
|
|
103
336
|
const { agents } = discoverAgents(cwd, "user");
|
|
104
337
|
const topicsDir = path.join(getProjectMemoryDir(cwd), "topics");
|
|
338
|
+
// Snapshot before the dream touches anything: whatever it removes that is learned
|
|
339
|
+
// knowledge rather than re-derivable history is put back below (issue #1010).
|
|
340
|
+
const topicsBeforeDream = snapshotTopicLines(topicsDir);
|
|
341
|
+
const dreamStartedAt = Date.now();
|
|
105
342
|
const { id } = spawnAsyncAgent(
|
|
106
343
|
"worker",
|
|
107
|
-
|
|
108
|
-
`Session file: ${lastSessionFile || "none"}\n` +
|
|
109
|
-
`Topics directory: ${topicsDir}\n\n` +
|
|
110
|
-
`Topic files are the source of truth for project memory. Each file holds entries for one category.\n\n` +
|
|
111
|
-
`Steps:\n` +
|
|
112
|
-
`1. Read all existing topic files in ${topicsDir}/ (lessons.md, preferences.md, patterns.md, decisions.md, completions.md, mistakes.md).\n` +
|
|
113
|
-
` If the directory doesn't exist, create it.\n` +
|
|
114
|
-
`2. QUALITY GATE: Before extracting from any session, assess its quality:\n` +
|
|
115
|
-
` - Score depth (substantive exchanges > 100 chars? decisions made? corrections?)\n` +
|
|
116
|
-
` - Skip sessions that are only greetings, trivial Q&A, or < 3 exchanges\n` +
|
|
117
|
-
` - Only extract from sessions with real decisions, corrections, or completed work\n` +
|
|
118
|
-
`3. If a session file is provided, read it and extract things worth remembering:\n` +
|
|
119
|
-
` - User corrections → [lesson] → lessons.md\n` +
|
|
120
|
-
` - User preferences → [preference] → preferences.md\n` +
|
|
121
|
-
` - Mistakes or repeated fix attempts → [mistake] → mistakes.md\n` +
|
|
122
|
-
` - Completed features/PRs merged → [done] → completions.md\n` +
|
|
123
|
-
` - Patterns or conventions → [pattern] → patterns.md\n` +
|
|
124
|
-
` - Architectural/design decisions → [decision] → decisions.md\n` +
|
|
125
|
-
` QUALITY GATE for extraction: one-off permissions granted in a single session\n` +
|
|
126
|
-
` ("you can merge this PR", "skip tests just this once") AND task-scoped instructions\n` +
|
|
127
|
-
` ("when done, I want an html report", "when bumping to 1.4.0, drop the vendor dir",\n` +
|
|
128
|
-
` config-change notes like "every 30min (changed from 1h)") are session/task\n` +
|
|
129
|
-
` instructions, NOT memories — never extract them. Apply the standing test: would\n` +
|
|
130
|
-
` this apply to a future, unrelated task? No → discard. Only standing, repeated\n` +
|
|
131
|
-
` rules are durable.\n` +
|
|
132
|
-
` Do NOT add duplicates of existing entries.\n` +
|
|
133
|
-
`3b. Review the preference candidate queue: if ${topicsDir}/../preference-candidates.jsonl\n` +
|
|
134
|
-
` exists and has entries, each holds a VERBATIM sentence the regex extractor queued\n` +
|
|
135
|
-
` from user input (issue #961). For each candidate:\n` +
|
|
136
|
-
` - Durable standing preference → DISTILL it into a clean one-line [preference]\n` +
|
|
137
|
-
` (≤100 chars, actionable, no verbatim chatter) and add to preferences.md\n` +
|
|
138
|
-
` - Task-scoped instruction, one-off ask, question, or already covered → discard\n` +
|
|
139
|
-
` After processing: RE-READ preference-candidates.jsonl and rewrite it keeping ONLY\n` +
|
|
140
|
-
` the entries that appeared after your first read — the extractor appends\n` +
|
|
141
|
-
` concurrently, so do NOT truncate the whole file (an entry queued between your\n` +
|
|
142
|
-
` read and your rewrite would be erased before any dream cycle reviewed it).\n` +
|
|
143
|
-
` Keep the file bounded: if it exceeds 200 lines, also drop the oldest entries\n` +
|
|
144
|
-
` beyond that cap.\n` +
|
|
145
|
-
`4. Scan past session files for unprocessed knowledge. Check if ${topicsDir}/../.dream-watermark exists.\n` +
|
|
146
|
-
` If it does, read the timestamp — only process sessions newer than that.\n` +
|
|
147
|
-
` Session directory: find .jsonl files under the pi sessions directory.\n` +
|
|
148
|
-
` For each unprocessed session, extract durable knowledge (same categories as step 3).\n` +
|
|
149
|
-
` Limit: process at most 5 sessions per dream cycle to avoid overload.\n` +
|
|
150
|
-
`5. Reorganize each topic file:\n` +
|
|
151
|
-
` - Remove duplicate or near-duplicate entries\n` +
|
|
152
|
-
` - Remove stale/useless entries\n` +
|
|
153
|
-
` - Remove session-scoped or expired entries — they are transient by design\n` +
|
|
154
|
-
` - Remove task-scoped instruction entries (issue #961): verbatim user utterances\n` +
|
|
155
|
-
` (colloquial chat captured raw), mid-sentence cutoffs, one-off deliverable asks\n` +
|
|
156
|
-
` ("when done I want a report"), version-pinned migration checklists, config-change\n` +
|
|
157
|
-
` notes — these are task/session artifacts, not durable rules. NEVER remove\n` +
|
|
158
|
-
` pinned or enforced entries even when they match these shapes.\n` +
|
|
159
|
-
` - Keep each file at a reasonable size (aim for under 20 entries per topic)\n` +
|
|
160
|
-
` - NEVER remove or modify entries marked with *(pinned)*\n` +
|
|
161
|
-
` - NEVER remove or modify entries marked with *(enforced)* — these have code-enforced triggers/actions that are keyed by text hash. ANY text change destroys the enforcement binding.\n` +
|
|
162
|
-
`6. Write each updated topic file with this format:\n` +
|
|
163
|
-
` # TopicName\n` +
|
|
164
|
-
` \n` +
|
|
165
|
-
` - [category] summary *(pinned)* (if pinned)\n` +
|
|
166
|
-
` - [category] summary *(enforced)* (if enforced)\n` +
|
|
167
|
-
` - [category] summary *(pinned)* *(enforced)* (if both)\n` +
|
|
168
|
-
` - [category] summary (if neither)\n` +
|
|
169
|
-
`7. Auto-generate skills: if you notice a multi-step workflow pattern across entries,\n` +
|
|
170
|
-
` create a skill file at .pi/skills/<name>/SKILL.md (project-level, NOT global ~/.agents/).\n` +
|
|
171
|
-
` The SKILL.md MUST start with YAML frontmatter:\n` +
|
|
172
|
-
` ---\n` +
|
|
173
|
-
` name: <name>\n` +
|
|
174
|
-
` description: "What this skill does and when to use it"\n` +
|
|
175
|
-
` ---\n` +
|
|
176
|
-
` Only create skills for workflows with 3+ steps that are likely to recur.\n` +
|
|
177
|
-
`8. Write the current timestamp to ${topicsDir}/../.dream-watermark to track progress.\n` +
|
|
178
|
-
`9. Memory rules: one line per entry, max ~100 chars, specific and actionable, no fluff.\n` +
|
|
179
|
-
`10. Promotion destinations: append candidates to ${topicsDir}/../promotions.md using this block format:\n` +
|
|
180
|
-
` ### <12-char-id>\n` +
|
|
181
|
-
` - destination: memory|skill|enforcement|project_rule|discard\n` +
|
|
182
|
-
` - status: proposed\n` +
|
|
183
|
-
` - category: <category>\n` +
|
|
184
|
-
` - text: <exact topic entry text>\n` +
|
|
185
|
-
` - reason: <why this should graduate>\n` +
|
|
186
|
-
` - created: <ISO timestamp>\n` +
|
|
187
|
-
` Optional fields: evidence_count, trigger, action, verifier, skill_name, skill_created.\n` +
|
|
188
|
-
` Rules:\n` +
|
|
189
|
-
` - skill: create .pi/skills/ when confident; set skill_created: true\n` +
|
|
190
|
-
` - enforcement: propose trigger/action when mechanical (never/always + command); do NOT invent run_after\n` +
|
|
191
|
-
` - project_rule: propose only — NEVER write rules/ or .pi/rules/\n` +
|
|
192
|
-
` - discard: stale or superseded noise\n` +
|
|
193
|
-
` - Do not reopen entries already marked applied or rejected in promotions.md\n` +
|
|
194
|
-
`11. Provenance sidecar (optional): for newly extracted entries, write\n` +
|
|
195
|
-
` ${topicsDir}/../provenance-pending.jsonl as a single line of JSON (preferred):\n` +
|
|
196
|
-
` {"entries":[{"category":"lesson","text":"<exact topic text>","sourceSession":"<session basename>","derivedFrom":"<optional>","informs":["optional"]}]}\n` +
|
|
197
|
-
` Do NOT edit memory-scores.jsonl yourself — onComplete merges the sidecar.`,
|
|
344
|
+
buildDreamPrompt(lastSessionFile, topicsDir),
|
|
198
345
|
cwd,
|
|
199
346
|
agents,
|
|
200
347
|
{
|
|
@@ -206,6 +353,34 @@ export function registerDreaming(
|
|
|
206
353
|
dreamInFlight = false;
|
|
207
354
|
log.info("dream complete", cwd);
|
|
208
355
|
updateDreamStatus();
|
|
356
|
+
// Undo any deletion of learned knowledge before anything reads the topics.
|
|
357
|
+
try {
|
|
358
|
+
const reviewLessons = distilledLessonTextList(getProjectDataDir(cwd));
|
|
359
|
+
const removed = removalsSince(cwd, dreamStartedAt);
|
|
360
|
+
const report = restoreProtectedTopicLines(topicsDir, topicsBeforeDream, reviewLessons, (line) =>
|
|
361
|
+
removed.has(entryHash(topicLineCanonical(line))),
|
|
362
|
+
);
|
|
363
|
+
if (report.restored > 0) {
|
|
364
|
+
log.warn("dream_restored_protected_entries", { restored: report.restored });
|
|
365
|
+
}
|
|
366
|
+
if (report.overCapTopics.length > 0) {
|
|
367
|
+
log.warn("dream_restore_left_topic_over_cap", { topics: report.overCapTopics });
|
|
368
|
+
}
|
|
369
|
+
if (report.failed > 0) {
|
|
370
|
+
log.error("dream_restore_write_failed", { failedTopics: report.failed });
|
|
371
|
+
}
|
|
372
|
+
if (report.restored > 0 || report.failed > 0 || report.overCapTopics.length > 0) {
|
|
373
|
+
fileLog(
|
|
374
|
+
"dreaming",
|
|
375
|
+
report.failed > 0 ? "error" : "warn",
|
|
376
|
+
"dreaming",
|
|
377
|
+
`protected-memory reconcile: restored=${report.restored} over_cap=${report.overCapTopics.join(",") || "-"} failed=${report.failed}`,
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
} catch (err) {
|
|
381
|
+
log.error("dream_restore_failed", { reason: String((err as Error)?.message ?? err).slice(0, 200) });
|
|
382
|
+
fileLog("dreaming", "error", "dreaming", "protected-memory restore failed", err);
|
|
383
|
+
}
|
|
209
384
|
// File log only — console.* leaks into the chat text box.
|
|
210
385
|
try {
|
|
211
386
|
rebuildAndOrganize(cwd);
|
|
@@ -31,9 +31,10 @@ import {
|
|
|
31
31
|
listTopics,
|
|
32
32
|
readAllTopicEntries,
|
|
33
33
|
CATEGORY_TO_TOPIC,
|
|
34
|
-
|
|
34
|
+
topicCharCap,
|
|
35
35
|
findTopicLine,
|
|
36
36
|
invalidateTopicFileCache,
|
|
37
|
+
recordRemoval,
|
|
37
38
|
type TopicInfo,
|
|
38
39
|
} from "./memory-tree.js";
|
|
39
40
|
import { embedEntry, removeEmbedding, vectorSearch, embedMissing } from "./memory-embeddings.js";
|
|
@@ -406,6 +407,9 @@ function registerMemoryRemove(pi: ExtensionAPI): void {
|
|
|
406
407
|
}
|
|
407
408
|
});
|
|
408
409
|
|
|
410
|
+
// Tombstone the removal: the dream guard must be able to tell this from its own damage.
|
|
411
|
+
recordRemoval(cwd, canonicalLine);
|
|
412
|
+
|
|
409
413
|
log.info("memory_remove", category, text.slice(0, 60));
|
|
410
414
|
return {
|
|
411
415
|
content: [{ type: "text", text: `Removed: [${category}] ${text}` }],
|
|
@@ -551,8 +555,10 @@ function registerMemoryEdit(pi: ExtensionAPI): void {
|
|
|
551
555
|
const markers = `${wasPinned ? " *(pinned)*" : ""}${wasEnforced ? " *(enforced)*" : ""}${wasId ? ` {#${wasId}}` : ""}`;
|
|
552
556
|
const replacement = `- [${category}] ${newText}${markers}`;
|
|
553
557
|
const newContent = content.replace(lines[matchIndex]!, replacement);
|
|
554
|
-
|
|
555
|
-
|
|
558
|
+
const cap = topicCharCap(topicName);
|
|
559
|
+
if (newContent.length > cap) {
|
|
560
|
+
log.warn("memory_edit_topic_cap_rejected", { topic: topicName, chars: newContent.length, cap });
|
|
561
|
+
return { content: [{ type: "text", text: `Update would exceed topic size limit (${newContent.length}/${cap} chars).` }], details: undefined };
|
|
556
562
|
}
|
|
557
563
|
// Replace the line
|
|
558
564
|
lines[matchIndex] = replacement;
|
|
@@ -12,8 +12,8 @@
|
|
|
12
12
|
|
|
13
13
|
import { createLogger } from "../shared/logger.js";
|
|
14
14
|
import { getProjectMemoryDir } from "./utils.js";
|
|
15
|
-
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
16
|
-
import { join } from "node:path";
|
|
15
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
|
|
16
|
+
import { dirname, join } from "node:path";
|
|
17
17
|
import {
|
|
18
18
|
type MemoryCategory,
|
|
19
19
|
loadScores,
|
|
@@ -27,6 +27,65 @@ const log = createLogger("memory");
|
|
|
27
27
|
/** Max chars per topic file (~3000 tokens × 4 chars/token) */
|
|
28
28
|
export const MAX_TOPIC_CHARS = 12000;
|
|
29
29
|
|
|
30
|
+
/** Per-topic override of MAX_TOPIC_CHARS. `completions` accretes by default, so it gets
|
|
31
|
+
* a tighter budget: completed-work entries crowd out the knowledge worth keeping. */
|
|
32
|
+
export const TOPIC_CHAR_CAP: Record<string, number> = { completions: 6000 };
|
|
33
|
+
|
|
34
|
+
/** Char cap for a topic file name (without `.md`), falling back to MAX_TOPIC_CHARS. */
|
|
35
|
+
export function topicCharCap(topic: string): number {
|
|
36
|
+
const cap = TOPIC_CHAR_CAP[topic] ?? MAX_TOPIC_CHARS;
|
|
37
|
+
log.debug("topic_char_cap_resolved", { topic, cap, overridden: topic in TOPIC_CHAR_CAP });
|
|
38
|
+
return cap;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Append-only log of deliberate entry removals, so background maintenance can tell a
|
|
42
|
+
* user removal from its own damage. Score absence cannot: the scheduled rebuild prunes
|
|
43
|
+
* scores for lines missing from topic files (issue #1010). */
|
|
44
|
+
const REMOVALS_FILE = "removals.jsonl";
|
|
45
|
+
const MAX_REMOVAL_ROWS = 5000;
|
|
46
|
+
|
|
47
|
+
/** Record that an entry was removed on purpose. Fail-soft: a lost tombstone can only
|
|
48
|
+
* make the dream guard restore a line the user deleted, never delete one. */
|
|
49
|
+
export function recordRemoval(cwd: string, canonicalLine: string): void {
|
|
50
|
+
try {
|
|
51
|
+
const file = join(getProjectMemoryDir(cwd), REMOVALS_FILE);
|
|
52
|
+
const row = `${JSON.stringify({ hash: entryHash(canonicalLine), at: new Date().toISOString() })}\n`;
|
|
53
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
54
|
+
appendFileSync(file, row, "utf-8");
|
|
55
|
+
log.debug("memory_removal_recorded", { hash: entryHash(canonicalLine) });
|
|
56
|
+
const rows = readFileSync(file, "utf-8").split("\n").filter(Boolean);
|
|
57
|
+
if (rows.length > MAX_REMOVAL_ROWS) {
|
|
58
|
+
writeFileSync(file, `${rows.slice(-MAX_REMOVAL_ROWS).join("\n")}\n`, "utf-8");
|
|
59
|
+
}
|
|
60
|
+
} catch (error) {
|
|
61
|
+
log.warn("memory_removal_record_failed", { reason: String((error as Error)?.message ?? error).slice(0, 120) });
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Hashes removed at or after `sinceMs` — the only trustworthy signal that a line
|
|
66
|
+
* missing after a background dream was the user's decision, not the dream's damage. */
|
|
67
|
+
export function removalsSince(cwd: string, sinceMs: number): Set<string> {
|
|
68
|
+
const hashes = new Set<string>();
|
|
69
|
+
try {
|
|
70
|
+
const file = join(getProjectMemoryDir(cwd), REMOVALS_FILE);
|
|
71
|
+
if (!existsSync(file)) return hashes;
|
|
72
|
+
for (const row of readFileSync(file, "utf-8").split("\n")) {
|
|
73
|
+
if (!row.trim()) continue;
|
|
74
|
+
try {
|
|
75
|
+
const parsed = JSON.parse(row) as { hash?: unknown; at?: unknown };
|
|
76
|
+
if (typeof parsed.hash !== "string" || typeof parsed.at !== "string") continue;
|
|
77
|
+
if (Date.parse(parsed.at) >= sinceMs) hashes.add(parsed.hash);
|
|
78
|
+
} catch {
|
|
79
|
+
// Skip a malformed row rather than losing the whole log.
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
log.debug("memory_removals_since", { sinceMs, count: hashes.size });
|
|
83
|
+
} catch (error) {
|
|
84
|
+
log.warn("memory_removals_read_failed", { reason: String((error as Error)?.message ?? error).slice(0, 120) });
|
|
85
|
+
}
|
|
86
|
+
return hashes;
|
|
87
|
+
}
|
|
88
|
+
|
|
30
89
|
/** Topic mapping: which categories go into which topic files */
|
|
31
90
|
export const CATEGORY_TO_TOPIC: Record<MemoryCategory, string> = {
|
|
32
91
|
preference: "preferences",
|
|
@@ -39,7 +39,7 @@ import {
|
|
|
39
39
|
isTaskScopedText,
|
|
40
40
|
hasDurabilityMarker,
|
|
41
41
|
} from "./memory-scoring.js";
|
|
42
|
-
import { CATEGORY_TO_TOPIC,
|
|
42
|
+
import { CATEGORY_TO_TOPIC, topicCharCap, readAllTopicEntries, formatTopicLine, findTopicLine, invalidateTopicFileCache, recordRemoval } from "./memory-tree.js";
|
|
43
43
|
import { embedEntry, removeEmbedding, vectorSearch } from "./memory-embeddings.js";
|
|
44
44
|
import { getSetting } from "./project-settings.js";
|
|
45
45
|
|
|
@@ -2165,6 +2165,8 @@ function supersedeEntry(cwd: string, category: string, text: string): void {
|
|
|
2165
2165
|
removeEmbedding(cwd, text, category);
|
|
2166
2166
|
|
|
2167
2167
|
const canonicalLine = `- [${category}] ${text}`;
|
|
2168
|
+
// The old text is gone on purpose: tombstone it so the dream guard does not undo an edit.
|
|
2169
|
+
recordRemoval(cwd, canonicalLine);
|
|
2168
2170
|
const removedId = match.id;
|
|
2169
2171
|
mutateScores(cwd, (scores) => {
|
|
2170
2172
|
delete scores.entries[entryHash(canonicalLine)];
|
|
@@ -2197,11 +2199,13 @@ function topicCapRejection(cwd: string, category: string, entryLine: string): Ad
|
|
|
2197
2199
|
content = `# ${title}\n`;
|
|
2198
2200
|
}
|
|
2199
2201
|
const newContent = content.trimEnd() + "\n" + entryLine + "\n";
|
|
2200
|
-
|
|
2202
|
+
const cap = topicCharCap(topicName);
|
|
2203
|
+
if (newContent.length > cap) {
|
|
2204
|
+
log.warn("memory_topic_cap_rejected", { topic: topicName, chars: newContent.length, cap });
|
|
2201
2205
|
return {
|
|
2202
2206
|
status: "rejected",
|
|
2203
2207
|
message:
|
|
2204
|
-
`Topic "${topicName}" would exceed size limit (${newContent.length}/${
|
|
2208
|
+
`Topic "${topicName}" would exceed size limit (${newContent.length}/${cap} chars). ` +
|
|
2205
2209
|
`Consolidate or remove entries first using memory_remove.`,
|
|
2206
2210
|
};
|
|
2207
2211
|
}
|
|
@@ -87,6 +87,8 @@ const MAX_LESSON_CHARS = 100;
|
|
|
87
87
|
const STATE_FILE_NAME = "review-learning-state.json";
|
|
88
88
|
/** Cap on tracked review ids — old reviews never re-distill in practice. */
|
|
89
89
|
const MAX_TRACKED_REVIEWS = 200;
|
|
90
|
+
/** Cap on the long-lived protection list that outlives MAX_TRACKED_REVIEWS. */
|
|
91
|
+
const MAX_PROTECTED_LESSON_TEXTS = 5000;
|
|
90
92
|
|
|
91
93
|
// ── Command matching ───────────────────────────────────────────────────────
|
|
92
94
|
|
|
@@ -350,17 +352,58 @@ export function loadReviewLessons(dataDir: string): Map<number, Set<string>> {
|
|
|
350
352
|
return byReview;
|
|
351
353
|
}
|
|
352
354
|
|
|
355
|
+
/** Every distilled review-lesson text recorded in the state file, flattened.
|
|
356
|
+
* Memory maintenance must treat these as learned knowledge, not history: the source
|
|
357
|
+
* (the reviews DB) keeps the finding, but nothing re-materializes the topic line
|
|
358
|
+
* once it is gone, so a dream that deletes one loses it for good (issue #1010).
|
|
359
|
+
* Unions the bounded per-review map with the long-lived protection list so lessons
|
|
360
|
+
* from reviews older than the tracking window stay protected. */
|
|
361
|
+
export function distilledLessonTextList(dataDir: string): string[] {
|
|
362
|
+
const texts = new Set<string>();
|
|
363
|
+
for (const lessons of loadReviewLessons(dataDir).values()) {
|
|
364
|
+
for (const lesson of lessons) if (lesson.trim()) texts.add(lesson.trim());
|
|
365
|
+
}
|
|
366
|
+
for (const lesson of loadProtectedLessonTexts(dataDir)) if (lesson.trim()) texts.add(lesson.trim());
|
|
367
|
+
log.debug("distilled_lesson_texts_loaded", { count: texts.size });
|
|
368
|
+
return [...texts];
|
|
369
|
+
}
|
|
370
|
+
|
|
353
371
|
function writeStateFile(dataDir: string, distilledReviews: number[], reviewLessons: Map<number, Set<string>>): void {
|
|
354
372
|
const lessons: Record<string, string[]> = {};
|
|
355
373
|
const ids = [...reviewLessons.keys()];
|
|
356
374
|
for (const id of ids.slice(-MAX_TRACKED_REVIEWS)) lessons[String(id)] = [...reviewLessons.get(id)!];
|
|
375
|
+
// Protection set for older reviews: `lessons` is bounded by MAX_TRACKED_REVIEWS, so
|
|
376
|
+
// without this union a lesson from review #1 stops being recognizable as reviewed
|
|
377
|
+
// knowledge once 200 newer reviews land, and memory maintenance may delete it (#1010).
|
|
378
|
+
const protectedTexts = new Set(loadProtectedLessonTexts(dataDir));
|
|
379
|
+
for (const set of reviewLessons.values()) for (const text of set) if (text.trim()) protectedTexts.add(text.trim());
|
|
357
380
|
mkdirSync(dataDir, { recursive: true });
|
|
358
381
|
writeFileSync(
|
|
359
382
|
stateFilePath(dataDir),
|
|
360
|
-
`${JSON.stringify(
|
|
383
|
+
`${JSON.stringify(
|
|
384
|
+
{
|
|
385
|
+
distilledReviews: distilledReviews.slice(-MAX_TRACKED_REVIEWS),
|
|
386
|
+
reviewLessons: lessons,
|
|
387
|
+
protectedLessonTexts: [...protectedTexts].slice(-MAX_PROTECTED_LESSON_TEXTS),
|
|
388
|
+
},
|
|
389
|
+
null,
|
|
390
|
+
2,
|
|
391
|
+
)}\n`,
|
|
361
392
|
);
|
|
362
393
|
}
|
|
363
394
|
|
|
395
|
+
/** Lesson texts that must stay recognizable as reviewed knowledge, independent of
|
|
396
|
+
* the bounded per-review map. Empty on a fresh or unreadable state file. */
|
|
397
|
+
export function loadProtectedLessonTexts(dataDir: string): string[] {
|
|
398
|
+
try {
|
|
399
|
+
const raw = JSON.parse(readFileSync(stateFilePath(dataDir), "utf-8"));
|
|
400
|
+
const texts = Array.isArray(raw?.protectedLessonTexts) ? raw.protectedLessonTexts : [];
|
|
401
|
+
return texts.filter((text: unknown): text is string => typeof text === "string" && text.trim().length > 0);
|
|
402
|
+
} catch {
|
|
403
|
+
return [];
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
364
407
|
/** Record a review id as distilled (bounded — oldest entries drop off). Fail-soft. */
|
|
365
408
|
export function markReviewDistilled(dataDir: string, reviewId: number): void {
|
|
366
409
|
try {
|
package/package.json
CHANGED
package/prompts/pr-review.md
CHANGED
|
@@ -44,6 +44,11 @@ If not found, prompt user: "myk-pi-tools is required. Install with: `uv tool ins
|
|
|
44
44
|
- `/pr-review 123` - Review PR #123 in current repo
|
|
45
45
|
- `/pr-review https://github.com/owner/repo/pull/123` - Review from URL
|
|
46
46
|
|
|
47
|
+
Phase 4 asks how you want to decide: `Post all`, `Skip all`, or `Decide one by one`.
|
|
48
|
+
Deciding one by one costs roughly one turn per finding (plus one per discussion round, and one
|
|
49
|
+
more when you skip with a reason or stop early) — choose it when findings need individual
|
|
50
|
+
judgment, not on a 20-finding wall.
|
|
51
|
+
|
|
47
52
|
## Task Plan
|
|
48
53
|
|
|
49
54
|
Before starting any work, create ALL tasks upfront using `TaskCreate`, then set their dependencies
|
|
@@ -522,6 +527,72 @@ This avoids ambiguity — user selections for new findings always refer to the `
|
|
|
522
527
|
- 'none' = Skip posting new findings (previous findings are still auto-posted)
|
|
523
528
|
- Specific numbers = Post only those new findings (numbers refer to the `[NEW]` list)
|
|
524
529
|
|
|
530
|
+
**One ask decides the mode.** After printing the table, call `ask_user` once with question
|
|
531
|
+
`{N} findings — how do you want to decide?` and options `Post all`, `Skip all`, `Decide one by one`.
|
|
532
|
+
Skip this question entirely when there are ZERO `[NEW]` and ZERO `[AUTHOR-QUESTION]` items —
|
|
533
|
+
go straight to Phase 5 with the auto-posted prior findings (see the zero-item rule below).
|
|
534
|
+
Typed answers (`all`, `none`, `3,7,12`) keep working as free text. `ask_user` appends the
|
|
535
|
+
`💬 Discuss with AI first` row itself — never pass it as one of your own options, or the user
|
|
536
|
+
sees it twice.
|
|
537
|
+
|
|
538
|
+
- `Post all` → every `[NEW]` finding is selected. Continue to the refine step in bulk mode.
|
|
539
|
+
- `Skip all` → every `[NEW]` finding is skipped. Collect one blanket reason in bulk mode below.
|
|
540
|
+
- `Decide one by one` → run the per-finding loop below.
|
|
541
|
+
- `💬 Discuss with AI first` (`__discuss__`) → not an answer. Talk about the decision itself —
|
|
542
|
+
trade-offs, what is missing, the alternatives — never a status report on what you just checked —
|
|
543
|
+
then re-ask the same question with the same options once it resolves.
|
|
544
|
+
|
|
545
|
+
**Per-finding loop (chosen by `Decide one by one`):**
|
|
546
|
+
|
|
547
|
+
Everything after this phase — the JSON, the post step, the replies — is unchanged.
|
|
548
|
+
|
|
549
|
+
1. Print the auto-post header block ONCE: `N prior finding(s) will be re-raised (auto-post).`
|
|
550
|
+
`[PREV-*]` findings are never part of the loop.
|
|
551
|
+
2. For each `[AUTHOR-QUESTION]` (Q1..) and then each `[NEW]` finding (1..N), in the stable
|
|
552
|
+
merged order.
|
|
553
|
+
|
|
554
|
+
**`[AUTHOR-QUESTION]` items keep their own flow** — never use the finding template or the
|
|
555
|
+
finding actions for them:
|
|
556
|
+
- Print the original comment (who asked, where, full text) and the AI-generated suggested
|
|
557
|
+
answer from Phase 1c.
|
|
558
|
+
- `ask_user` with question `Question i/N — <one-line summary>` and options `Approve`, `Edit`,
|
|
559
|
+
`Skip`. No `Show more context` (the question body is the context) and no `Skip + reason`
|
|
560
|
+
(the stored reason is `Author question — user chose not to respond`).
|
|
561
|
+
- `Approve` → the suggested answer is posted as-is in Phase 5.
|
|
562
|
+
- `Edit` → print `Answer for Q{i}:` as plain chat text and STOP the turn; the user's next
|
|
563
|
+
message is the revised answer, which Phase 5 posts instead of the suggestion.
|
|
564
|
+
- `Skip` → the question is not answered; it is stored with `skip_reason`
|
|
565
|
+
`Author question — user chose not to respond`.
|
|
566
|
+
|
|
567
|
+
**`[NEW]` findings:**
|
|
568
|
+
a. Print `Finding i/N — <severity> — <file>:<line>`, the source tag, the full description,
|
|
569
|
+
the suggestion block, and which reviewer(s) raised it after dedup.
|
|
570
|
+
b. Call `ask_user` with question `Finding i/N — <one-line summary>` and exactly these options:
|
|
571
|
+
`Post`, `Skip`, `Skip + reason`, `Show more context`, `Stop here`. Do NOT pass
|
|
572
|
+
`Discuss with AI first` — `ask_user` appends that row itself.
|
|
573
|
+
c. Act on the answer:
|
|
574
|
+
- `Post` → finding is selected for posting.
|
|
575
|
+
- `Skip` → finding is skipped. Phase 5b still requires a reason, so store the fixed
|
|
576
|
+
`skip_reason` `Skipped — no reason given`; do not ask the user for one.
|
|
577
|
+
- `Skip + reason` → re-call `ask_user` with question `Reason for finding i/N:` and no
|
|
578
|
+
options, so the answer is free text; use it as the reason and continue with the next
|
|
579
|
+
finding. If the user asks for a multi-line reason, print `Reason for finding i/N:` as
|
|
580
|
+
plain chat text and STOP the turn instead — their next message is the reason.
|
|
581
|
+
- `Show more context` → print the surrounding diff hunk and the affected call sites, then
|
|
582
|
+
re-ask the SAME finding (same index, same options).
|
|
583
|
+
- `Discuss with AI first` → `ask_user` returns exactly `__discuss__`. That is NOT an answer:
|
|
584
|
+
the user wants to talk about this finding. Discuss the finding itself — its trade-offs,
|
|
585
|
+
what you would need to know to judge it, the alternatives — and do not report on your own
|
|
586
|
+
workflow. Re-ask the SAME finding once the discussion resolves. `__discuss__` is a control
|
|
587
|
+
value — never post it, never store it, never write it to `.pi/data/review-guidelines.md`.
|
|
588
|
+
- `Stop here` → every remaining finding is skipped. Ask once for a blanket reason: use
|
|
589
|
+
`ask_user` with no options when one line is enough, otherwise print the prompt as plain
|
|
590
|
+
chat text and STOP the turn. If the user gives no reason, store the fixed
|
|
591
|
+
`skip_reason` `Skipped — no reason given` (Phase 5b rejects an empty one).
|
|
592
|
+
3. The reasons collected here feed the SAME refine → classify → `review-guidelines.md` steps
|
|
593
|
+
below. Only the timing differs: the reason is captured while the user is looking at that
|
|
594
|
+
finding instead of in a later bulk round.
|
|
595
|
+
|
|
525
596
|
If there are ZERO `[NEW]` findings AND zero `[AUTHOR-QUESTION]` findings, skip user
|
|
526
597
|
selection entirely — auto-post the previous findings and proceed directly to Phase 5
|
|
527
598
|
(the auto-post path still generates the comments JSON and posts them — see Phase 5).
|
|
@@ -532,10 +603,16 @@ user approval/edit/skip.
|
|
|
532
603
|
|
|
533
604
|
🚨 **Skip reason collection (MANDATORY — NEVER SKIP THIS STEP):**
|
|
534
605
|
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
606
|
+
In the per-finding loop the reasons were already captured as you went — go straight to
|
|
607
|
+
step 2 (AI refines each reason) and do not prompt again.
|
|
608
|
+
|
|
609
|
+
**Bulk mode only (Post all / Skip all / numbers) — HARD RULE: If the user skipped ANY finding
|
|
610
|
+
(said 'no' or 'Skip all', selected specific numbers that exclude some findings), you MUST ask
|
|
611
|
+
for skip reasons BEFORE proceeding to Phase 5.** Do NOT continue the workflow without collecting
|
|
612
|
+
skip reasons. Skipping this step is a HARD VIOLATION.
|
|
613
|
+
|
|
614
|
+
If the user declines to give any reason, still store one — Phase 5b rejects an empty
|
|
615
|
+
`skip_reason` — using the fixed text `Skipped — no reason given`.
|
|
539
616
|
|
|
540
617
|
After the user selects which findings to post, compute the skipped set (total `[NEW]`
|
|
541
618
|
findings minus user-selected findings). If the skipped set is non-empty:
|