peaks-loop 4.0.34 → 4.0.35
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 +18 -0
- package/dist/cli/commands/core/memory-command.js +61 -3
- package/dist/cli/commands/dispatch-commands.js +4 -2
- package/dist/cli/commands/memory-commands.d.ts +35 -0
- package/dist/cli/commands/memory-commands.js +119 -10
- package/dist/services/context/context-schema.d.ts +1 -1
- package/dist/services/context/memory-index-reader.d.ts +26 -0
- package/dist/services/context/memory-index-reader.js +62 -30
- package/dist/services/context/memory-preflight-config.d.ts +33 -0
- package/dist/services/context/memory-preflight-config.js +32 -2
- package/dist/services/context/memory-preflight-service.d.ts +20 -1
- package/dist/services/context/memory-preflight-service.js +198 -31
- package/dist/services/doctor/doctor-service/checks/l3-memory-health.d.ts +19 -2
- package/dist/services/doctor/doctor-service/checks/l3-memory-health.js +143 -19
- package/dist/services/job/job-types.d.ts +3 -3
- package/dist/services/memory/memory-ingest-service.d.ts +79 -0
- package/dist/services/memory/memory-ingest-service.js +225 -0
- package/dist/services/memory/memory-rotate-service.d.ts +88 -0
- package/dist/services/memory/memory-rotate-service.js +373 -0
- package/dist/services/memory/project-memory-service/index/ranking.d.ts +9 -1
- package/dist/services/memory/project-memory-service/index/ranking.js +25 -13
- package/dist/services/memory/project-memory-service/index/reindex.d.ts +75 -0
- package/dist/services/memory/project-memory-service/index/reindex.js +207 -0
- package/dist/services/memory/project-memory-service/index/search.js +14 -24
- package/dist/services/memory/project-memory-service/index.d.ts +7 -3
- package/dist/services/memory/project-memory-service/index.js +6 -2
- package/dist/services/memory/project-memory-service/parsers/frontmatter.d.ts +75 -3
- package/dist/services/memory/project-memory-service/parsers/frontmatter.js +113 -24
- package/dist/services/memory/project-memory-service/types.d.ts +31 -1
- package/dist/services/memory/project-memory-service/types.js +76 -1
- package/dist/services/preferences/preferences-types.d.ts +14 -0
- package/dist/services/preferences/preferences-types.js +8 -0
- package/dist/services/share/run-state-contract.d.ts +1 -1
- package/package.json +5 -5
- package/skills/peaks-code/SKILL.md +1 -1
- package/skills/peaks-code/references/runbook.md +6 -0
- package/skills/peaks-code/references/step-11-memory-sediment.md +35 -0
|
@@ -3,25 +3,81 @@
|
|
|
3
3
|
* "Orchestrator Memory Preflight" slice (see
|
|
4
4
|
* docs/superpowers/specs/2026-07-22-orchestrator-memory-preflight-design.md).
|
|
5
5
|
*
|
|
6
|
+
* Slice 2026-09-09-memory-retrieval (this revision) makes the preflight
|
|
7
|
+
* task-relevant and tiered:
|
|
8
|
+
* - selection is ranked against `taskTitle` with the deterministic fuzzy
|
|
9
|
+
* kernel (no network, no embeddings);
|
|
10
|
+
* - hot entries (standing rules / feedback) are ALWAYS eligible, ranked
|
|
11
|
+
* so the most relevant lead; warm entries (project / reference) are
|
|
12
|
+
* gated on relevance and capped well below hot;
|
|
13
|
+
* - three independent budgets are enforced — max items, max bytes, and a
|
|
14
|
+
* soft wall-clock budget for the selection step;
|
|
15
|
+
* - the emitted block stays a compact index (name + path + one-line) so
|
|
16
|
+
* the sub-agent drills down with `Read` on demand instead of receiving
|
|
17
|
+
* memo bodies wholesale (body inlining is opt-in via `includeBodies`).
|
|
18
|
+
*
|
|
6
19
|
* Consumes:
|
|
7
20
|
* - resolveMemoryPreflightConfig (Task 1) — merged defaults + per-project overlay
|
|
8
|
-
* - MemoryIndexReader (Task 3) — reads .peaks/memory/index.json
|
|
9
|
-
* -
|
|
10
|
-
* for memo content (see deviation note below)
|
|
21
|
+
* - MemoryIndexReader (Task 3) — reads .peaks/memory/index.json
|
|
22
|
+
* - fuzzyMatchWithKey — deterministic relevance kernel
|
|
11
23
|
* - truncateToCap — byte-cap truncation of the composed block
|
|
12
24
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* orchestrator dispatch flow only ever has at most a handful of explicit
|
|
16
|
-
* `cacheMemoContent` calls per task (typically 0-3), and ordering / explicit
|
|
17
|
-
* invalidation is preferred over LRU eviction by recency. To keep the
|
|
18
|
-
* semantic obvious and the byte budget check trivial, this implementation
|
|
19
|
-
* uses a plain `Map<path, body>` for memo path -> body content. The
|
|
20
|
-
* `MemoryLruCache` class remains as a separate, reusable LRU primitive
|
|
21
|
-
* (Task 2) and is not consumed by this service.
|
|
25
|
+
* Fail-soft contract: any selection error returns `available: false` with a
|
|
26
|
+
* reason; it never throws and never blocks a dispatch.
|
|
22
27
|
*/
|
|
28
|
+
import { performance } from 'node:perf_hooks';
|
|
29
|
+
import { fuzzyMatchWithKey } from '../fuzzy-matching/fuzzy-match-service.js';
|
|
23
30
|
import { MemoryIndexReader } from './memory-index-reader.js';
|
|
24
31
|
import { resolveMemoryPreflightConfig, } from './memory-preflight-config.js';
|
|
32
|
+
/** Function words carry no selection signal. */
|
|
33
|
+
const STOPWORDS = new Set([
|
|
34
|
+
'the', 'and', 'for', 'with', 'from', 'that', 'this', 'not', 'are', 'was',
|
|
35
|
+
'but', 'you', 'all', 'any', 'can', 'has', 'its', 'our', 'out', 'use', 'via',
|
|
36
|
+
'per', 'into', 'when', 'then', 'than', 'they', 'their', 'should', 'must',
|
|
37
|
+
]);
|
|
38
|
+
/**
|
|
39
|
+
* Tokens shorter than 4 chars are too promiscuous for literal containment
|
|
40
|
+
* ("add" is a substring of "padding"), so they rank but do not gate.
|
|
41
|
+
*/
|
|
42
|
+
const MIN_TOKEN_LENGTH = 4;
|
|
43
|
+
const CJK_RE = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u;
|
|
44
|
+
function queryTokens(query) {
|
|
45
|
+
const seen = new Set();
|
|
46
|
+
const out = [];
|
|
47
|
+
for (const raw of query.toLowerCase().split(/[^\p{L}\p{N}]+/u)) {
|
|
48
|
+
if (raw.length < MIN_TOKEN_LENGTH || STOPWORDS.has(raw) || seen.has(raw))
|
|
49
|
+
continue;
|
|
50
|
+
seen.add(raw);
|
|
51
|
+
out.push(raw);
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Absolute relevance gate: does the token literally occur in the entry?
|
|
57
|
+
*
|
|
58
|
+
* The fuzzy kernel's score is normalized per batch (best match = 1.0), so
|
|
59
|
+
* it cannot distinguish a strong match from the best of a bad lot — an
|
|
60
|
+
* absolute check is required for the warm gate. CJK titles have no word
|
|
61
|
+
* boundaries, so those tokens fall back to 2-char gram overlap.
|
|
62
|
+
*/
|
|
63
|
+
function tokenHits(text, tokens) {
|
|
64
|
+
let hits = 0;
|
|
65
|
+
for (const token of tokens) {
|
|
66
|
+
if (text.includes(token)) {
|
|
67
|
+
hits += 1;
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (!CJK_RE.test(token))
|
|
71
|
+
continue;
|
|
72
|
+
for (let i = 0; i + 2 <= token.length; i += 1) {
|
|
73
|
+
if (text.includes(token.slice(i, i + 2))) {
|
|
74
|
+
hits += 1;
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return hits;
|
|
80
|
+
}
|
|
25
81
|
function truncateToCap(text, capBytes) {
|
|
26
82
|
if (Buffer.byteLength(text, 'utf8') <= capBytes) {
|
|
27
83
|
return { text, truncated: false };
|
|
@@ -48,44 +104,155 @@ export class MemoryPreflightService {
|
|
|
48
104
|
}
|
|
49
105
|
this.cachedMemoContents.set(path, content);
|
|
50
106
|
}
|
|
51
|
-
async fetchBlock(
|
|
107
|
+
async fetchBlock(taskTitle) {
|
|
52
108
|
if (!this.config.enabled)
|
|
53
109
|
return { available: false, reason: 'DISABLED' };
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
return {
|
|
110
|
+
try {
|
|
111
|
+
return this.selectAndCompose(taskTitle ?? '');
|
|
112
|
+
}
|
|
113
|
+
catch (err) {
|
|
114
|
+
// Fail-soft: a selection failure must never block a dispatch.
|
|
115
|
+
return {
|
|
116
|
+
available: false,
|
|
117
|
+
reason: `SELECTION_ERROR: ${err.message ?? String(err)}`,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
selectAndCompose(taskTitle) {
|
|
122
|
+
const startedAt = performance.now();
|
|
123
|
+
const tiered = this.reader.selectTiered();
|
|
124
|
+
if (tiered === null)
|
|
125
|
+
return { available: false, reason: 'MEMORY_INDEX_MISSING' };
|
|
126
|
+
// Rank the union once (one fuzzy pass per token over all candidates),
|
|
127
|
+
// then split by tier — cheaper than ranking each tier separately and
|
|
128
|
+
// order-preserving for both.
|
|
129
|
+
const hotSet = new Set(tiered.hot);
|
|
130
|
+
const tokens = queryTokens(taskTitle);
|
|
131
|
+
const rankedAll = rankByRelevance(taskTitle, [...tiered.hot, ...tiered.warm]);
|
|
132
|
+
// Hot is ALWAYS eligible — ranked so the most task-relevant lead, with
|
|
133
|
+
// unmatched standing rules appended in index order.
|
|
134
|
+
const hotRanked = rankedAll.filter((r) => hotSet.has(r.entry));
|
|
135
|
+
const hotSelected = hotRanked.slice(0, this.config.hotItemCap);
|
|
136
|
+
let timedOut = performance.now() - startedAt >= this.config.selectionTimeBudgetMs;
|
|
137
|
+
const warmSelected = [];
|
|
138
|
+
let warmEligibleCount = 0;
|
|
139
|
+
if (!timedOut && this.config.warmItemCap > 0) {
|
|
140
|
+
const warmRanked = rankedAll
|
|
141
|
+
.filter((r) => !hotSet.has(r.entry))
|
|
142
|
+
.filter((r) => tokenHits(`${r.entry.name} ${r.entry.description}`.toLowerCase(), tokens) >=
|
|
143
|
+
this.config.warmMinTokenHits);
|
|
144
|
+
warmEligibleCount = warmRanked.length;
|
|
145
|
+
for (const candidate of warmRanked) {
|
|
146
|
+
if (warmSelected.length >= this.config.warmItemCap)
|
|
147
|
+
break;
|
|
148
|
+
if (performance.now() - startedAt >=
|
|
149
|
+
this.config.selectionTimeBudgetMs) {
|
|
150
|
+
timedOut = true;
|
|
151
|
+
break;
|
|
152
|
+
}
|
|
153
|
+
warmSelected.push(candidate);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
if (hotSelected.length === 0 && warmSelected.length === 0) {
|
|
157
|
+
return { available: false, reason: 'NO_RELEVANT_MEMORY' };
|
|
158
|
+
}
|
|
159
|
+
const sections = [];
|
|
160
|
+
if (hotSelected.length > 0) {
|
|
161
|
+
sections.push('### Standing memory (always applied)');
|
|
162
|
+
for (const r of hotSelected)
|
|
163
|
+
sections.push(renderEntry(r.entry));
|
|
164
|
+
}
|
|
165
|
+
if (warmSelected.length > 0) {
|
|
166
|
+
sections.push('### Task-relevant memory (drill down with Read)');
|
|
167
|
+
for (const r of warmSelected)
|
|
168
|
+
sections.push(renderEntry(r.entry));
|
|
60
169
|
}
|
|
61
|
-
const listLines = selected
|
|
62
|
-
.map((e) => `- * ${e.name}\n Path: ${e.sourcePath}\n One-line: ${summarize(e.description)}`)
|
|
63
|
-
.join('\n');
|
|
64
170
|
let tail = '\n';
|
|
65
171
|
let cachedCount = 0;
|
|
66
|
-
if (this.cachedMemoContents.size > 0) {
|
|
67
|
-
const
|
|
172
|
+
if (this.config.includeBodies && this.cachedMemoContents.size > 0) {
|
|
173
|
+
const bodies = [];
|
|
68
174
|
for (const [path, body] of this.cachedMemoContents) {
|
|
69
|
-
|
|
175
|
+
bodies.push(`### ${path}\n\n${body}`);
|
|
70
176
|
cachedCount += 1;
|
|
71
177
|
}
|
|
72
|
-
tail = `\n\n## Requested memory details:\n${
|
|
178
|
+
tail = `\n\n## Requested memory details:\n${bodies.join('\n\n')}\n`;
|
|
73
179
|
}
|
|
74
180
|
const header = '## Project memory relevant to this task\n';
|
|
75
|
-
const composed = `${header}${
|
|
76
|
-
const
|
|
77
|
-
const
|
|
78
|
-
const
|
|
181
|
+
const composed = `${header}${sections.join('\n')}\n${tail}`;
|
|
182
|
+
const { text, truncated } = truncateToCap(composed, this.config.maxBytes);
|
|
183
|
+
const selectedCount = hotSelected.length + warmSelected.length;
|
|
184
|
+
const droppedByItemCap = hotRanked.length - hotSelected.length + (warmEligibleCount - warmSelected.length);
|
|
185
|
+
const droppedByBytes = truncated
|
|
186
|
+
? Math.max(0, selectedCount - countItemsInBlock(text))
|
|
187
|
+
: 0;
|
|
188
|
+
const droppedCount = droppedByItemCap + droppedByBytes;
|
|
79
189
|
return {
|
|
80
190
|
available: true,
|
|
81
191
|
block: text,
|
|
82
|
-
feedbackListItems:
|
|
192
|
+
feedbackListItems: selectedCount,
|
|
83
193
|
cachedItemCount: cachedCount,
|
|
194
|
+
hotSelected: hotSelected.length,
|
|
195
|
+
warmSelected: warmSelected.length,
|
|
196
|
+
bytesEmitted: Buffer.byteLength(text, 'utf8'),
|
|
197
|
+
budgetTruncated: truncated || droppedByItemCap > 0 || timedOut,
|
|
198
|
+
timedOut,
|
|
84
199
|
truncated,
|
|
85
200
|
droppedCount: droppedCount > 0 ? droppedCount : undefined,
|
|
86
201
|
};
|
|
87
202
|
}
|
|
88
203
|
}
|
|
204
|
+
/**
|
|
205
|
+
* Slice 2026-09-09-memory-retrieval: the query the dispatch site feeds
|
|
206
|
+
* `fetchBlock`. The bare role ("rd") carries almost no relevance signal,
|
|
207
|
+
* so the first line of the task brief is appended (truncated) to give the
|
|
208
|
+
* fuzzy kernel something to rank against. Pure and fail-soft.
|
|
209
|
+
*/
|
|
210
|
+
export function deriveMemoryQuery(role, taskBody) {
|
|
211
|
+
const firstLine = (taskBody ?? '').split('\n', 1)[0]?.trim() ?? '';
|
|
212
|
+
const trimmed = firstLine.length > 160 ? firstLine.slice(0, 160) : firstLine;
|
|
213
|
+
return trimmed === '' ? role : `${role} ${trimmed}`;
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Rank entries against the task title with the deterministic fuzzy kernel.
|
|
217
|
+
*
|
|
218
|
+
* The kernel is invoked once PER TOKEN rather than once for the whole
|
|
219
|
+
* title: a whole-title query is a single fzf subsequence and realistic
|
|
220
|
+
* task titles match almost nothing that way (measured: 0 warm hits on
|
|
221
|
+
* the real 131-entry warm tier). Per-token accumulation keeps the kernel
|
|
222
|
+
* deterministic and gives a graded score — entries matching more task
|
|
223
|
+
* words, more strongly, rank higher.
|
|
224
|
+
*
|
|
225
|
+
* Every entry is returned: matched entries lead by score desc, unmatched
|
|
226
|
+
* entries follow in index order with score 0 — this is what keeps hot
|
|
227
|
+
* entries "always eligible". An empty/token-less task title degrades to
|
|
228
|
+
* index order.
|
|
229
|
+
*/
|
|
230
|
+
function rankByRelevance(query, entries) {
|
|
231
|
+
if (entries.length === 0)
|
|
232
|
+
return [];
|
|
233
|
+
const tokens = queryTokens(query);
|
|
234
|
+
if (tokens.length === 0) {
|
|
235
|
+
return entries.map((entry) => ({ entry, score: 0 }));
|
|
236
|
+
}
|
|
237
|
+
const accumulated = new Map();
|
|
238
|
+
for (const token of tokens) {
|
|
239
|
+
const matches = fuzzyMatchWithKey(token, entries, {
|
|
240
|
+
keyFn: (e) => `${e.name} ${e.description}`,
|
|
241
|
+
limit: entries.length,
|
|
242
|
+
caseSensitive: false,
|
|
243
|
+
});
|
|
244
|
+
for (const match of matches) {
|
|
245
|
+
accumulated.set(match.item, (accumulated.get(match.item) ?? 0) + match.score);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
// Array#sort is stable (ES2019), so equal scores keep index order.
|
|
249
|
+
return entries
|
|
250
|
+
.map((entry) => ({ entry, score: accumulated.get(entry) ?? 0 }))
|
|
251
|
+
.sort((a, b) => b.score - a.score);
|
|
252
|
+
}
|
|
253
|
+
function renderEntry(entry) {
|
|
254
|
+
return `- * ${entry.name}\n Path: ${entry.sourcePath}\n One-line: ${summarize(entry.description)}`;
|
|
255
|
+
}
|
|
89
256
|
function summarize(description) {
|
|
90
257
|
// Drop the <!-- peaks-feedback-promoted: layer=A --> marker, take the next 1 line.
|
|
91
258
|
const cleaned = description.replace(/<!--[^>]*-->/g, '').trim();
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
* (`L3:l3-memory-health`).
|
|
2
|
+
* Checks for `.peaks/memory/` health (`L3:l3-memory-health` and siblings).
|
|
4
3
|
*
|
|
5
4
|
* Slice 2026-06-13-repair-pre-existing-test-failures: the
|
|
6
5
|
* production MemoryIndex schema (see
|
|
@@ -11,6 +10,24 @@
|
|
|
11
10
|
*
|
|
12
11
|
* When no `.peaks/memory/index.json` exists yet, the check passes
|
|
13
12
|
* (fresh project — no memories have been extracted).
|
|
13
|
+
*
|
|
14
|
+
* Slice 2026-09-09-memory-system-overhaul (D) extends the check with the
|
|
15
|
+
* drift findings the original version could not see. It used to report
|
|
16
|
+
* `ok: true` for "index.json is well-formed JSON; 100 hot + 131 warm" and
|
|
17
|
+
* never looked at coverage, orphans, or unclassified files. It now emits,
|
|
18
|
+
* in addition to the unchanged well-formed-JSON assertion:
|
|
19
|
+
*
|
|
20
|
+
* - `L3:l3-memory-coverage` — disk files vs indexed entries (warning
|
|
21
|
+
* when the gap exceeds a small threshold)
|
|
22
|
+
* - `L3:l3-memory-orphans` — index entries whose `sourcePath` is gone
|
|
23
|
+
* (error) + disk files absent from the
|
|
24
|
+
* index (warning)
|
|
25
|
+
* - `L3:l3-memory-unclassified` — files with no resolvable kind (warning,
|
|
26
|
+
* count + first N names)
|
|
27
|
+
*
|
|
28
|
+
* All three are read-only and fail-soft: an inspection error degrades to a
|
|
29
|
+
* single warning instead of throwing, and none of them change the id or the
|
|
30
|
+
* `ok` semantics of the original `L3:l3-memory-health` assertion.
|
|
14
31
|
*/
|
|
15
32
|
import type { DoctorCheckPlugin } from '../types.js';
|
|
16
33
|
export declare const check: DoctorCheckPlugin;
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
* (`L3:l3-memory-health`).
|
|
2
|
+
* Checks for `.peaks/memory/` health (`L3:l3-memory-health` and siblings).
|
|
4
3
|
*
|
|
5
4
|
* Slice 2026-06-13-repair-pre-existing-test-failures: the
|
|
6
5
|
* production MemoryIndex schema (see
|
|
@@ -11,12 +10,122 @@
|
|
|
11
10
|
*
|
|
12
11
|
* When no `.peaks/memory/index.json` exists yet, the check passes
|
|
13
12
|
* (fresh project — no memories have been extracted).
|
|
13
|
+
*
|
|
14
|
+
* Slice 2026-09-09-memory-system-overhaul (D) extends the check with the
|
|
15
|
+
* drift findings the original version could not see. It used to report
|
|
16
|
+
* `ok: true` for "index.json is well-formed JSON; 100 hot + 131 warm" and
|
|
17
|
+
* never looked at coverage, orphans, or unclassified files. It now emits,
|
|
18
|
+
* in addition to the unchanged well-formed-JSON assertion:
|
|
19
|
+
*
|
|
20
|
+
* - `L3:l3-memory-coverage` — disk files vs indexed entries (warning
|
|
21
|
+
* when the gap exceeds a small threshold)
|
|
22
|
+
* - `L3:l3-memory-orphans` — index entries whose `sourcePath` is gone
|
|
23
|
+
* (error) + disk files absent from the
|
|
24
|
+
* index (warning)
|
|
25
|
+
* - `L3:l3-memory-unclassified` — files with no resolvable kind (warning,
|
|
26
|
+
* count + first N names)
|
|
27
|
+
*
|
|
28
|
+
* All three are read-only and fail-soft: an inspection error degrades to a
|
|
29
|
+
* single warning instead of throwing, and none of them change the id or the
|
|
30
|
+
* `ok` semantics of the original `L3:l3-memory-health` assertion.
|
|
14
31
|
*/
|
|
15
32
|
import { existsSync, readFileSync } from 'node:fs';
|
|
16
|
-
import { join } from 'node:path';
|
|
33
|
+
import { basename, join } from 'node:path';
|
|
17
34
|
import { getErrorMessage } from 'peaks-loop-shared/result';
|
|
35
|
+
import { listMarkdownFiles, MEMORY_MD_FILENAME, parseMemoryFrontmatter } from '../../../memory/project-memory-service/index.js';
|
|
36
|
+
/** Warn when |disk - indexed| exceeds this. Small enough to catch real drift. */
|
|
37
|
+
const COVERAGE_GAP_WARN_THRESHOLD = 2;
|
|
38
|
+
/** How many offending names to inline before truncating the message. */
|
|
39
|
+
const MAX_NAMES_IN_MESSAGE = 5;
|
|
40
|
+
function countEntries(bucket) {
|
|
41
|
+
return Object.values(bucket ?? {}).reduce((sum, arr) => sum + (Array.isArray(arr) ? arr.length : 0), 0);
|
|
42
|
+
}
|
|
43
|
+
function previewNames(names) {
|
|
44
|
+
const shown = names.slice(0, MAX_NAMES_IN_MESSAGE);
|
|
45
|
+
const suffix = names.length > shown.length ? ` (+${names.length - shown.length} more)` : '';
|
|
46
|
+
return shown.join(', ') + suffix;
|
|
47
|
+
}
|
|
48
|
+
function readIndexSourcePaths(indexPath) {
|
|
49
|
+
const parsed = JSON.parse(readFileSync(indexPath, 'utf8'));
|
|
50
|
+
const fromBucket = (bucket) => Object.values(bucket ?? {}).flatMap((arr) => (Array.isArray(arr) ? arr : []));
|
|
51
|
+
return [...fromBucket(parsed.hot), ...fromBucket(parsed.warm), ...(parsed.cold ?? [])];
|
|
52
|
+
}
|
|
53
|
+
function inspectDrift(memoryDir, memoryIndexPath, indexedCount) {
|
|
54
|
+
const checks = [];
|
|
55
|
+
const diskFiles = listMarkdownFiles(memoryDir).filter((filePath) => basename(filePath) !== MEMORY_MD_FILENAME);
|
|
56
|
+
// --- coverage ---------------------------------------------------------
|
|
57
|
+
const gap = diskFiles.length - indexedCount;
|
|
58
|
+
if (Math.abs(gap) > COVERAGE_GAP_WARN_THRESHOLD) {
|
|
59
|
+
const direction = gap > 0
|
|
60
|
+
? `${gap} file(s) on disk are not in the index`
|
|
61
|
+
: `${-gap} index entr(ies) have no matching file`;
|
|
62
|
+
checks.push({
|
|
63
|
+
id: 'L3:l3-memory-coverage',
|
|
64
|
+
ok: false,
|
|
65
|
+
severity: 'warning',
|
|
66
|
+
message: `Memory index coverage gap: ${diskFiles.length} file(s) on disk vs ${indexedCount} indexed — ${direction}. Run \`peaks memory reindex\` for the full drift report.`
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
checks.push({
|
|
71
|
+
id: 'L3:l3-memory-coverage',
|
|
72
|
+
ok: true,
|
|
73
|
+
message: `Memory index coverage: ${diskFiles.length} file(s) on disk, ${indexedCount} indexed (within threshold ${COVERAGE_GAP_WARN_THRESHOLD})`
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
// --- orphans (both directions) ---------------------------------------
|
|
77
|
+
const missingSources = readIndexSourcePaths(memoryIndexPath)
|
|
78
|
+
.filter((entry) => typeof entry.sourcePath !== 'string' || entry.sourcePath.length === 0 || !existsSync(entry.sourcePath))
|
|
79
|
+
.map((entry) => entry.name ?? entry.sourcePath ?? '<unnamed>')
|
|
80
|
+
.sort((left, right) => left.localeCompare(right));
|
|
81
|
+
if (missingSources.length > 0) {
|
|
82
|
+
checks.push({
|
|
83
|
+
id: 'L3:l3-memory-orphans',
|
|
84
|
+
ok: false,
|
|
85
|
+
severity: 'error',
|
|
86
|
+
message: `${missingSources.length} index entr(ies) point at a missing sourcePath: ${previewNames(missingSources)}. Run \`peaks memory reindex\` to rebuild the index.`
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
checks.push({
|
|
91
|
+
id: 'L3:l3-memory-orphans',
|
|
92
|
+
ok: true,
|
|
93
|
+
message: 'No memory index entries point at missing files'
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
// --- unclassified -----------------------------------------------------
|
|
97
|
+
const unclassified = [];
|
|
98
|
+
for (const filePath of diskFiles) {
|
|
99
|
+
try {
|
|
100
|
+
if (parseMemoryFrontmatter(readFileSync(filePath, 'utf8')).kind.kind === null) {
|
|
101
|
+
unclassified.push(basename(filePath, '.md'));
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
unclassified.push(`${basename(filePath, '.md')} (unreadable)`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
unclassified.sort((left, right) => left.localeCompare(right));
|
|
109
|
+
if (unclassified.length > 0) {
|
|
110
|
+
checks.push({
|
|
111
|
+
id: 'L3:l3-memory-unclassified',
|
|
112
|
+
ok: false,
|
|
113
|
+
severity: 'warning',
|
|
114
|
+
message: `${unclassified.length} memory file(s) have no resolvable kind (no metadata.type / kind / type): ${previewNames(unclassified)}. Add \`metadata.type\` then run \`peaks memory reindex\`.`
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
checks.push({
|
|
119
|
+
id: 'L3:l3-memory-unclassified',
|
|
120
|
+
ok: true,
|
|
121
|
+
message: 'Every memory file on disk has a resolvable kind'
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
return checks;
|
|
125
|
+
}
|
|
18
126
|
function run({ resolvedL3Root }) {
|
|
19
|
-
const
|
|
127
|
+
const memoryDir = join(resolvedL3Root, '.peaks/memory');
|
|
128
|
+
const memoryIndexPath = join(memoryDir, 'index.json');
|
|
20
129
|
if (!existsSync(memoryIndexPath)) {
|
|
21
130
|
return [{
|
|
22
131
|
id: 'L3:l3-memory-health',
|
|
@@ -24,32 +133,47 @@ function run({ resolvedL3Root }) {
|
|
|
24
133
|
message: 'No .peaks/memory/index.json yet (no memories extracted)'
|
|
25
134
|
}];
|
|
26
135
|
}
|
|
136
|
+
let parsed;
|
|
27
137
|
try {
|
|
28
138
|
const raw = readFileSync(memoryIndexPath, 'utf8');
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
return [{
|
|
33
|
-
id: 'L3:l3-memory-health',
|
|
34
|
-
ok: false,
|
|
35
|
-
message: '.peaks/memory/index.json missing schema_version / version field'
|
|
36
|
-
}];
|
|
37
|
-
}
|
|
38
|
-
const hotCount = Object.values(parsed.hot ?? {}).reduce((sum, arr) => sum + (Array.isArray(arr) ? arr.length : 0), 0);
|
|
39
|
-
const warmCount = Object.values(parsed.warm ?? {}).reduce((sum, arr) => sum + (Array.isArray(arr) ? arr.length : 0), 0);
|
|
139
|
+
parsed = JSON.parse(raw);
|
|
140
|
+
}
|
|
141
|
+
catch (parseError) {
|
|
40
142
|
return [{
|
|
41
143
|
id: 'L3:l3-memory-health',
|
|
42
|
-
ok:
|
|
43
|
-
message: `.peaks/memory/index.json is
|
|
144
|
+
ok: false,
|
|
145
|
+
message: `.peaks/memory/index.json is not valid JSON: ${getErrorMessage(parseError)}`
|
|
44
146
|
}];
|
|
45
147
|
}
|
|
46
|
-
|
|
148
|
+
const schemaMarker = parsed.schema_version ?? parsed.version;
|
|
149
|
+
if (schemaMarker === undefined) {
|
|
47
150
|
return [{
|
|
48
151
|
id: 'L3:l3-memory-health',
|
|
49
152
|
ok: false,
|
|
50
|
-
message:
|
|
153
|
+
message: '.peaks/memory/index.json missing schema_version / version field'
|
|
51
154
|
}];
|
|
52
155
|
}
|
|
156
|
+
const hotCount = countEntries(parsed.hot);
|
|
157
|
+
const warmCount = countEntries(parsed.warm);
|
|
158
|
+
const checks = [{
|
|
159
|
+
id: 'L3:l3-memory-health',
|
|
160
|
+
ok: true,
|
|
161
|
+
message: `.peaks/memory/index.json is well-formed JSON; version=${schemaMarker}; ${hotCount} hot + ${warmCount} warm memory entries`
|
|
162
|
+
}];
|
|
163
|
+
// Drift inspection is best-effort: a scan failure must not turn a
|
|
164
|
+
// well-formed index into a hard failure.
|
|
165
|
+
try {
|
|
166
|
+
checks.push(...inspectDrift(memoryDir, memoryIndexPath, hotCount + warmCount));
|
|
167
|
+
}
|
|
168
|
+
catch (error) {
|
|
169
|
+
checks.push({
|
|
170
|
+
id: 'L3:l3-memory-coverage',
|
|
171
|
+
ok: true,
|
|
172
|
+
severity: 'warning',
|
|
173
|
+
message: `Memory drift inspection skipped: ${getErrorMessage(error)}`
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
return checks;
|
|
53
177
|
}
|
|
54
178
|
export const check = {
|
|
55
179
|
name: 'l3-memory-health',
|
|
@@ -6,8 +6,8 @@ export declare const SliceStateSchema: z.ZodObject<{
|
|
|
6
6
|
pending: "pending";
|
|
7
7
|
blocked: "blocked";
|
|
8
8
|
failed: "failed";
|
|
9
|
-
skipped: "skipped";
|
|
10
9
|
done: "done";
|
|
10
|
+
skipped: "skipped";
|
|
11
11
|
"in-progress": "in-progress";
|
|
12
12
|
}>;
|
|
13
13
|
commitSha: z.ZodOptional<z.ZodString>;
|
|
@@ -48,8 +48,8 @@ export declare const JobStateSchema: z.ZodObject<{
|
|
|
48
48
|
pending: "pending";
|
|
49
49
|
blocked: "blocked";
|
|
50
50
|
failed: "failed";
|
|
51
|
-
skipped: "skipped";
|
|
52
51
|
done: "done";
|
|
52
|
+
skipped: "skipped";
|
|
53
53
|
"in-progress": "in-progress";
|
|
54
54
|
}>;
|
|
55
55
|
commitSha: z.ZodOptional<z.ZodString>;
|
|
@@ -118,8 +118,8 @@ export declare const JobCheckpointInputSchema: z.ZodObject<{
|
|
|
118
118
|
sliceId: z.ZodString;
|
|
119
119
|
state: z.ZodEnum<{
|
|
120
120
|
failed: "failed";
|
|
121
|
-
skipped: "skipped";
|
|
122
121
|
done: "done";
|
|
122
|
+
skipped: "skipped";
|
|
123
123
|
}>;
|
|
124
124
|
commitSha: z.ZodOptional<z.ZodString>;
|
|
125
125
|
reason: z.ZodOptional<z.ZodString>;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { ProjectMemoryKind } from './project-memory-service/types.js';
|
|
2
|
+
export interface MemoryIngestOptions {
|
|
3
|
+
projectRoot: string;
|
|
4
|
+
/** Override the IDE-side source dir (defaults to `~/.claude/projects/<encoded>/memory`). */
|
|
5
|
+
sourceDir?: string;
|
|
6
|
+
/** Injectable home dir (tests); defaults to `os.homedir()`. */
|
|
7
|
+
homeDir?: string;
|
|
8
|
+
apply?: boolean;
|
|
9
|
+
}
|
|
10
|
+
export interface MemoryIngestImported {
|
|
11
|
+
name: string;
|
|
12
|
+
kind: ProjectMemoryKind;
|
|
13
|
+
sourcePath: string;
|
|
14
|
+
targetPath: string;
|
|
15
|
+
}
|
|
16
|
+
export interface MemoryIngestSkipped {
|
|
17
|
+
name: string;
|
|
18
|
+
sourcePath: string;
|
|
19
|
+
targetPath: string;
|
|
20
|
+
}
|
|
21
|
+
export interface MemoryIngestConflict {
|
|
22
|
+
name: string;
|
|
23
|
+
sourcePath: string;
|
|
24
|
+
targetPath: string;
|
|
25
|
+
reason: string;
|
|
26
|
+
}
|
|
27
|
+
export interface MemoryIngestNeedsClassification {
|
|
28
|
+
name: string;
|
|
29
|
+
sourcePath: string;
|
|
30
|
+
rawKind: string | null;
|
|
31
|
+
reason: string;
|
|
32
|
+
}
|
|
33
|
+
export interface MemoryIngestRefused {
|
|
34
|
+
name: string;
|
|
35
|
+
sourcePath: string;
|
|
36
|
+
reason: string;
|
|
37
|
+
}
|
|
38
|
+
export interface MemoryIngestReport {
|
|
39
|
+
apply: boolean;
|
|
40
|
+
projectRoot: string;
|
|
41
|
+
sourceDir: string;
|
|
42
|
+
sourceExists: boolean;
|
|
43
|
+
memoryDir: string;
|
|
44
|
+
scannedFiles: number;
|
|
45
|
+
imported: MemoryIngestImported[];
|
|
46
|
+
skippedIdentical: MemoryIngestSkipped[];
|
|
47
|
+
conflicts: MemoryIngestConflict[];
|
|
48
|
+
needsClassification: MemoryIngestNeedsClassification[];
|
|
49
|
+
refused: MemoryIngestRefused[];
|
|
50
|
+
writtenFiles: string[];
|
|
51
|
+
warnings: string[];
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Claude Code encodes a project cwd into its `~/.claude/projects/<name>/`
|
|
55
|
+
* directory by replacing every non-alphanumeric character with `-`
|
|
56
|
+
* (`D:\peaks-loop` → `D--peaks-loop`). Separator-agnostic, so the same
|
|
57
|
+
* encoding holds for POSIX paths.
|
|
58
|
+
*/
|
|
59
|
+
export declare function encodeIdeProjectDir(projectRoot: string): string;
|
|
60
|
+
/** Default IDE-side memory dir for a project: `~/.claude/projects/<encoded>/memory`. */
|
|
61
|
+
export declare function defaultIdeMemoryDir(projectRoot: string, homeDir?: string): string;
|
|
62
|
+
/**
|
|
63
|
+
* Rewrite a source file's frontmatter to the peaks contract: `name` pinned to
|
|
64
|
+
* the destination filename stem, and `metadata.type` set to the resolved
|
|
65
|
+
* kind. Non-contract keys (e.g. `originSessionId`, `modified`, `node_type`)
|
|
66
|
+
* are preserved verbatim so provenance survives the import; `type` / `kind`
|
|
67
|
+
* are consumed by the normalization and not duplicated.
|
|
68
|
+
*/
|
|
69
|
+
export declare function renderNormalizedMemory(input: {
|
|
70
|
+
stem: string;
|
|
71
|
+
kind: ProjectMemoryKind;
|
|
72
|
+
frontmatter: string;
|
|
73
|
+
body: string;
|
|
74
|
+
}): string;
|
|
75
|
+
/**
|
|
76
|
+
* Import IDE-side memories into `.peaks/memory/`. Always returns the full
|
|
77
|
+
* envelope; `apply` only controls whether files are actually written.
|
|
78
|
+
*/
|
|
79
|
+
export declare function executeMemoryIngest(options: MemoryIngestOptions): MemoryIngestReport;
|