peaks-loop 4.0.34 → 4.0.36
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 +34 -0
- package/dist/cli/commands/code-runtime-commands.d.ts +5 -2
- package/dist/cli/commands/code-runtime-commands.js +57 -2
- package/dist/cli/commands/core/doctor-command.d.ts +8 -0
- package/dist/cli/commands/core/doctor-command.js +44 -2
- package/dist/cli/commands/core/memory-command.js +65 -3
- package/dist/cli/commands/dispatch-commands.js +19 -5
- package/dist/cli/commands/dispatch-from-dag.js +17 -0
- package/dist/cli/commands/memory-commands.d.ts +59 -0
- package/dist/cli/commands/memory-commands.js +195 -19
- package/dist/cli/commands/request-commands.d.ts +8 -0
- package/dist/cli/commands/request-commands.js +23 -2
- package/dist/cli/commands/sub-agent-commands.js +2 -0
- package/dist/cli/commands/wave-plan-commands.d.ts +24 -0
- package/dist/cli/commands/wave-plan-commands.js +93 -0
- package/dist/services/context/build-dispatch-system-prompt.d.ts +66 -9
- package/dist/services/context/build-dispatch-system-prompt.js +132 -17
- package/dist/services/context/context-audit.d.ts +100 -0
- package/dist/services/context/context-audit.js +322 -0
- 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/context/summary-view.d.ts +54 -0
- package/dist/services/context/summary-view.js +114 -0
- package/dist/services/dispatch/file-overlap-wave-planner.d.ts +70 -0
- package/dist/services/dispatch/file-overlap-wave-planner.js +119 -0
- package/dist/services/dispatch/session-capsule.d.ts +23 -0
- package/dist/services/dispatch/session-capsule.js +56 -0
- package/dist/services/dispatch/slice-dag.d.ts +9 -0
- package/dist/services/dispatch/slice-dag.js +9 -1
- package/dist/services/dispatch/test-tool-detection.d.ts +12 -1
- package/dist/services/dispatch/test-tool-detection.js +14 -13
- 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/ide/adapters/claude-code-adapter.d.ts +10 -0
- package/dist/services/ide/adapters/claude-code-adapter.js +20 -1
- package/dist/services/ide/ide-types.d.ts +15 -0
- 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 +80 -3
- package/dist/services/memory/project-memory-service/parsers/frontmatter.js +167 -28
- 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/bee/peaks-qa/SKILL.md +2 -0
- package/skills/bee/peaks-qa/references/qa-sub-agent-dispatch.md +12 -0
- package/skills/bee/peaks-rd/SKILL.md +2 -0
- package/skills/bee/peaks-rd/references/rd-sub-agent-dispatch.md +14 -0
- package/skills/bee/peaks-txt/SKILL.md +2 -0
- package/skills/bee/peaks-ui/SKILL.md +2 -0
- package/skills/peaks-code/SKILL.md +9 -1
- package/skills/peaks-code/references/context-governance.md +29 -0
- package/skills/peaks-code/references/runbook.md +6 -0
- package/skills/peaks-code/references/step-11-memory-sediment.md +35 -0
- package/skills/peaks-doctor/SKILL.md +2 -0
|
@@ -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',
|
|
@@ -1,4 +1,14 @@
|
|
|
1
1
|
import type { IdeAdapter } from '../ide-types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Resolve the absolute path of a Claude Code transcript jsonl by OUTER
|
|
4
|
+
* session id, searching `~/.claude/projects/**` recursively.
|
|
5
|
+
*
|
|
6
|
+
* Exported (slice 2026-09-10-context-audit-and-discipline, Slice A) so
|
|
7
|
+
* `peaks code context-audit` reuses THIS locator instead of re-implementing
|
|
8
|
+
* the recursive find. Returns `null` when the transcript does not exist —
|
|
9
|
+
* callers MUST treat that as "unavailable", never as an error.
|
|
10
|
+
*/
|
|
11
|
+
export declare function resolveClaudeTranscriptPath(outerSessionId: string, projectsDir?: string): string | null;
|
|
2
12
|
/**
|
|
3
13
|
* Resolve the currently-active Claude Code model id from a runtime env map.
|
|
4
14
|
* Returns the first non-empty `CLAUDE_CODE_MODEL_ENV_VARS` value (trimmed), or
|
|
@@ -90,6 +90,20 @@ function findTranscriptJsonl(projectsDir, outerSessionId) {
|
|
|
90
90
|
}
|
|
91
91
|
return null;
|
|
92
92
|
}
|
|
93
|
+
/**
|
|
94
|
+
* Resolve the absolute path of a Claude Code transcript jsonl by OUTER
|
|
95
|
+
* session id, searching `~/.claude/projects/**` recursively.
|
|
96
|
+
*
|
|
97
|
+
* Exported (slice 2026-09-10-context-audit-and-discipline, Slice A) so
|
|
98
|
+
* `peaks code context-audit` reuses THIS locator instead of re-implementing
|
|
99
|
+
* the recursive find. Returns `null` when the transcript does not exist —
|
|
100
|
+
* callers MUST treat that as "unavailable", never as an error.
|
|
101
|
+
*/
|
|
102
|
+
export function resolveClaudeTranscriptPath(outerSessionId, projectsDir = join(homedir(), '.claude', 'projects')) {
|
|
103
|
+
if (typeof outerSessionId !== 'string' || outerSessionId.length === 0)
|
|
104
|
+
return null;
|
|
105
|
+
return findTranscriptJsonl(projectsDir, outerSessionId);
|
|
106
|
+
}
|
|
93
107
|
/** 1M-context window size in tokens (documented single choice: 1,000,000). */
|
|
94
108
|
const ONE_MILLION_CONTEXT_TOKENS = 1_000_000;
|
|
95
109
|
/** Safe-default (non-1M) context window size in tokens. */
|
|
@@ -483,7 +497,12 @@ export const CLAUDE_CODE_ADAPTER = {
|
|
|
483
497
|
compactCommand: 'claude --compact',
|
|
484
498
|
compactPathway: 'ide-native',
|
|
485
499
|
postCompactDetectCommand: 'peaks code auto-compact --json',
|
|
486
|
-
readContextPercentFallback
|
|
500
|
+
readContextPercentFallback,
|
|
501
|
+
// Slice 2026-09-10-context-audit-and-discipline (Slice A): the vendor
|
|
502
|
+
// layout knowledge (`~/.claude/projects/**/<outerSessionId>.jsonl`)
|
|
503
|
+
// stays here; `peaks code context-audit` resolves it through the
|
|
504
|
+
// adapter registry, never by naming this adapter directly.
|
|
505
|
+
resolveTranscriptPath: (outerSessionId) => resolveClaudeTranscriptPath(outerSessionId),
|
|
487
506
|
},
|
|
488
507
|
// Slice #011: standards profile. Claude Code reads its constitution at
|
|
489
508
|
// CLAUDE.md + module-level rules under .claude/rules/**. The values mirror
|
|
@@ -217,6 +217,21 @@ export interface IdeCompactProfile {
|
|
|
217
217
|
* Added in slice 2026-09-02-vendor-neutral-context-probe.
|
|
218
218
|
*/
|
|
219
219
|
readonly readContextPercentFallback?: (input: ContextPercentFallbackInput) => ContextPercentProbe | null;
|
|
220
|
+
/**
|
|
221
|
+
* Optional locator for the IDE's per-session transcript file (jsonl),
|
|
222
|
+
* keyed by the OUTER (harness) session id. `peaks code context-audit`
|
|
223
|
+
* (slice 2026-09-10-context-audit-and-discipline, Slice A) uses it to
|
|
224
|
+
* group the session's tool results by tool + short input key, so the
|
|
225
|
+
* generic audit service never learns any vendor's on-disk layout.
|
|
226
|
+
*
|
|
227
|
+
* Returns the absolute path, or `null` when the transcript does not
|
|
228
|
+
* exist. Adapters MUST NOT throw on a missing file — the audit treats
|
|
229
|
+
* `null` as `available: false` and continues.
|
|
230
|
+
*
|
|
231
|
+
* Adapters that do not opt in simply omit the field; the audit then
|
|
232
|
+
* reports `transcript-locator-unavailable`.
|
|
233
|
+
*/
|
|
234
|
+
readonly resolveTranscriptPath?: (outerSessionId: string) => string | null;
|
|
220
235
|
}
|
|
221
236
|
/**
|
|
222
237
|
* Input the generic `readContextPercent` reader passes to an adapter's
|
|
@@ -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;
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// `peaks memory ingest` — pull memories written by the IDE-side agent into
|
|
3
|
+
// the peaks-owned store.
|
|
4
|
+
//
|
|
5
|
+
// Slice 2026-09-09-memory-system-overhaul (A). Before this, "沉淀记忆" in a
|
|
6
|
+
// peaks-code workflow landed in Claude Code's own per-project memory dir
|
|
7
|
+
// (`~/.claude/projects/<hash>/memory/`) and never reached `.peaks/memory/`
|
|
8
|
+
// — a split-brain write path with no single authority.
|
|
9
|
+
//
|
|
10
|
+
// Authority contract:
|
|
11
|
+
// - `.peaks/memory/` is the authoritative, peaks-owned store.
|
|
12
|
+
// - `~/.claude/**` is READ-ONLY. This module never writes there (same
|
|
13
|
+
// rule the project already applies to `~/.claude/agents/`). The only
|
|
14
|
+
// write targets are inside the project's `.peaks/memory/`.
|
|
15
|
+
// - The IDE-side memory dir is a session note, not the authority.
|
|
16
|
+
//
|
|
17
|
+
// Idempotency: identity is the filename stem. Re-running skips destination
|
|
18
|
+
// files that are byte-identical; when the destination exists but differs it
|
|
19
|
+
// is reported as a conflict and BOTH copies are left untouched (never
|
|
20
|
+
// overwrite user content).
|
|
21
|
+
//
|
|
22
|
+
// Classification: a memory is imported only when its kind resolves through
|
|
23
|
+
// the shared `resolveMemoryKind` rule (`metadata.type` → `kind:` → `type:`).
|
|
24
|
+
// Files with no resolvable kind are reported as needing classification —
|
|
25
|
+
// no type is invented silently.
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
import { existsSync, mkdirSync, readFileSync } from 'node:fs';
|
|
28
|
+
import { homedir } from 'node:os';
|
|
29
|
+
import { basename, join } from 'node:path';
|
|
30
|
+
import { isInsidePath, resolveInputPath, stablePath, stableRealPath } from '../../shared/path-utils.js';
|
|
31
|
+
import { parseMemoryFrontmatter } from './project-memory-service/parsers/frontmatter.js';
|
|
32
|
+
import { summarizeMemoryBody } from './project-memory-service/parsers/markdown-pure.js';
|
|
33
|
+
import { assertSafeProjectMemoryDir, normalizeRoot } from './project-memory-service/store/paths.js';
|
|
34
|
+
import { assertSafeMemoryFileContent, writeNewFile } from './project-memory-service/store/atomic-write.js';
|
|
35
|
+
import { listMarkdownFiles } from './project-memory-service/index/search.js';
|
|
36
|
+
/** `MEMORY.md` is the IDE's own generated index, not a memory source. */
|
|
37
|
+
const IDE_INDEX_FILENAME = 'MEMORY.md';
|
|
38
|
+
const RESERVED_TOP_LEVEL_KEYS = new Set(['name', 'description', 'metadata', 'type', 'kind']);
|
|
39
|
+
const RESERVED_METADATA_KEYS = new Set(['type', 'kind', 'description']);
|
|
40
|
+
/**
|
|
41
|
+
* Claude Code encodes a project cwd into its `~/.claude/projects/<name>/`
|
|
42
|
+
* directory by replacing every non-alphanumeric character with `-`
|
|
43
|
+
* (`D:\peaks-loop` → `D--peaks-loop`). Separator-agnostic, so the same
|
|
44
|
+
* encoding holds for POSIX paths.
|
|
45
|
+
*/
|
|
46
|
+
export function encodeIdeProjectDir(projectRoot) {
|
|
47
|
+
return projectRoot.replace(/[^A-Za-z0-9]/g, '-');
|
|
48
|
+
}
|
|
49
|
+
/** Default IDE-side memory dir for a project: `~/.claude/projects/<encoded>/memory`. */
|
|
50
|
+
export function defaultIdeMemoryDir(projectRoot, homeDir) {
|
|
51
|
+
return join(homeDir ?? homedir(), '.claude', 'projects', encodeIdeProjectDir(projectRoot), 'memory');
|
|
52
|
+
}
|
|
53
|
+
function parseFrontmatterDoc(frontmatter) {
|
|
54
|
+
const top = [];
|
|
55
|
+
const metadata = [];
|
|
56
|
+
let inMetadata = false;
|
|
57
|
+
for (const rawLine of frontmatter.split('\n')) {
|
|
58
|
+
if (rawLine.trim() === '')
|
|
59
|
+
continue;
|
|
60
|
+
const indented = /^\s/.test(rawLine);
|
|
61
|
+
const line = rawLine.trim();
|
|
62
|
+
const separator = line.indexOf(':');
|
|
63
|
+
if (separator < 0)
|
|
64
|
+
continue;
|
|
65
|
+
const key = line.slice(0, separator).trim();
|
|
66
|
+
const value = line.slice(separator + 1).trim();
|
|
67
|
+
if (indented) {
|
|
68
|
+
if (inMetadata)
|
|
69
|
+
metadata.push([key, value]);
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
inMetadata = key === 'metadata';
|
|
73
|
+
if (!inMetadata)
|
|
74
|
+
top.push([key, value]);
|
|
75
|
+
}
|
|
76
|
+
return { top, metadata };
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Rewrite a source file's frontmatter to the peaks contract: `name` pinned to
|
|
80
|
+
* the destination filename stem, and `metadata.type` set to the resolved
|
|
81
|
+
* kind. Non-contract keys (e.g. `originSessionId`, `modified`, `node_type`)
|
|
82
|
+
* are preserved verbatim so provenance survives the import; `type` / `kind`
|
|
83
|
+
* are consumed by the normalization and not duplicated.
|
|
84
|
+
*/
|
|
85
|
+
export function renderNormalizedMemory(input) {
|
|
86
|
+
const doc = parseFrontmatterDoc(input.frontmatter);
|
|
87
|
+
const topMap = new Map(doc.top);
|
|
88
|
+
const metaMap = new Map(doc.metadata);
|
|
89
|
+
const description = topMap.get('description')
|
|
90
|
+
?? metaMap.get('description')
|
|
91
|
+
?? summarizeMemoryBody(input.body);
|
|
92
|
+
const lines = ['---', `name: ${input.stem}`, `description: ${description}`];
|
|
93
|
+
for (const [key, value] of doc.top) {
|
|
94
|
+
if (RESERVED_TOP_LEVEL_KEYS.has(key))
|
|
95
|
+
continue;
|
|
96
|
+
lines.push(`${key}: ${value}`);
|
|
97
|
+
}
|
|
98
|
+
lines.push('metadata:');
|
|
99
|
+
lines.push(` type: ${input.kind}`);
|
|
100
|
+
for (const [key, value] of doc.metadata) {
|
|
101
|
+
if (RESERVED_METADATA_KEYS.has(key))
|
|
102
|
+
continue;
|
|
103
|
+
lines.push(` ${key}: ${value}`);
|
|
104
|
+
}
|
|
105
|
+
lines.push('---', '', input.body, '');
|
|
106
|
+
return lines.join('\n');
|
|
107
|
+
}
|
|
108
|
+
function emptyReport(overrides) {
|
|
109
|
+
return {
|
|
110
|
+
apply: false,
|
|
111
|
+
sourceExists: false,
|
|
112
|
+
scannedFiles: 0,
|
|
113
|
+
imported: [],
|
|
114
|
+
skippedIdentical: [],
|
|
115
|
+
conflicts: [],
|
|
116
|
+
needsClassification: [],
|
|
117
|
+
refused: [],
|
|
118
|
+
writtenFiles: [],
|
|
119
|
+
warnings: [],
|
|
120
|
+
...overrides
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Import IDE-side memories into `.peaks/memory/`. Always returns the full
|
|
125
|
+
* envelope; `apply` only controls whether files are actually written.
|
|
126
|
+
*/
|
|
127
|
+
export function executeMemoryIngest(options) {
|
|
128
|
+
const projectRoot = normalizeRoot(options.projectRoot);
|
|
129
|
+
const memoryDir = assertSafeProjectMemoryDir(projectRoot);
|
|
130
|
+
const apply = options.apply ?? false;
|
|
131
|
+
const sourceDir = options.sourceDir !== undefined
|
|
132
|
+
? resolveInputPath(options.sourceDir)
|
|
133
|
+
: defaultIdeMemoryDir(projectRoot, options.homeDir);
|
|
134
|
+
if (!existsSync(sourceDir)) {
|
|
135
|
+
return emptyReport({
|
|
136
|
+
apply,
|
|
137
|
+
projectRoot,
|
|
138
|
+
sourceDir,
|
|
139
|
+
memoryDir,
|
|
140
|
+
warnings: [`No IDE-side memory directory at ${sourceDir}; nothing to ingest.`]
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
const report = emptyReport({ apply, projectRoot, sourceDir, memoryDir, sourceExists: true });
|
|
144
|
+
const sourceFiles = listMarkdownFiles(sourceDir);
|
|
145
|
+
const indexFiles = sourceFiles.filter((filePath) => basename(filePath) === IDE_INDEX_FILENAME);
|
|
146
|
+
if (indexFiles.length > 0) {
|
|
147
|
+
report.warnings.push(`Skipped ${indexFiles.length} IDE-side index file(s) named ${IDE_INDEX_FILENAME} (generated by the IDE, not a memory).`);
|
|
148
|
+
}
|
|
149
|
+
const memoryFiles = sourceFiles.filter((filePath) => basename(filePath) !== IDE_INDEX_FILENAME);
|
|
150
|
+
report.scannedFiles = memoryFiles.length;
|
|
151
|
+
// Only materialise the destination directory once we know there is at
|
|
152
|
+
// least one candidate and we are actually applying.
|
|
153
|
+
if (apply && memoryFiles.length > 0) {
|
|
154
|
+
mkdirSync(memoryDir, { recursive: true });
|
|
155
|
+
}
|
|
156
|
+
const stableMemoryDir = existsSync(memoryDir) ? stableRealPath(memoryDir) : null;
|
|
157
|
+
for (const sourcePath of memoryFiles) {
|
|
158
|
+
const stem = basename(sourcePath, '.md');
|
|
159
|
+
let content;
|
|
160
|
+
try {
|
|
161
|
+
content = readFileSync(sourcePath, 'utf8');
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
report.refused.push({ name: stem, sourcePath, reason: 'source file could not be read' });
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
const parsed = parseMemoryFrontmatter(content);
|
|
168
|
+
const kind = parsed.kind.kind;
|
|
169
|
+
if (kind === null) {
|
|
170
|
+
report.needsClassification.push({
|
|
171
|
+
name: parsed.name ?? stem,
|
|
172
|
+
sourcePath,
|
|
173
|
+
rawKind: parsed.kind.rawKind,
|
|
174
|
+
reason: parsed.kind.rawKind === null
|
|
175
|
+
? 'no metadata.type / kind / type field in frontmatter'
|
|
176
|
+
: `unrecognized kind value: ${parsed.kind.rawKind}`
|
|
177
|
+
});
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
const normalized = renderNormalizedMemory({ stem, kind, frontmatter: parsed.frontmatter, body: parsed.body });
|
|
181
|
+
// Never import secrets into the peaks store. Fail-soft: report, skip.
|
|
182
|
+
try {
|
|
183
|
+
assertSafeMemoryFileContent(normalized);
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
report.refused.push({ name: stem, sourcePath, reason: 'refused: sensitive content pattern' });
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
const targetPath = join(memoryDir, `${stem}.md`);
|
|
190
|
+
const entry = { name: stem, kind, sourcePath, targetPath };
|
|
191
|
+
if (existsSync(targetPath)) {
|
|
192
|
+
let existing;
|
|
193
|
+
try {
|
|
194
|
+
existing = readFileSync(targetPath, 'utf8');
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
report.conflicts.push({ name: stem, sourcePath, targetPath, reason: 'destination exists but could not be read' });
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
if (existing === normalized) {
|
|
201
|
+
report.skippedIdentical.push({ name: stem, sourcePath, targetPath });
|
|
202
|
+
}
|
|
203
|
+
else {
|
|
204
|
+
report.conflicts.push({ name: stem, sourcePath, targetPath, reason: 'destination exists with different content; both copies left untouched' });
|
|
205
|
+
}
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
if (!apply) {
|
|
209
|
+
report.imported.push(entry);
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
// Defence in depth: the write target must resolve inside the project's
|
|
213
|
+
// `.peaks/memory/`. This is the guard that makes "never write to
|
|
214
|
+
// ~/.claude/**" structural rather than a convention.
|
|
215
|
+
const stableTargetPath = stablePath(resolveInputPath(targetPath));
|
|
216
|
+
if (stableMemoryDir === null || !isInsidePath(stableTargetPath, stableMemoryDir)) {
|
|
217
|
+
report.refused.push({ name: stem, sourcePath, reason: 'refused: target path escapes the project memory directory' });
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
writeNewFile(targetPath, normalized);
|
|
221
|
+
report.imported.push(entry);
|
|
222
|
+
report.writtenFiles.push(targetPath);
|
|
223
|
+
}
|
|
224
|
+
return report;
|
|
225
|
+
}
|