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.
Files changed (37) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/dist/cli/commands/core/memory-command.js +61 -3
  3. package/dist/cli/commands/dispatch-commands.js +4 -2
  4. package/dist/cli/commands/memory-commands.d.ts +35 -0
  5. package/dist/cli/commands/memory-commands.js +119 -10
  6. package/dist/services/context/context-schema.d.ts +1 -1
  7. package/dist/services/context/memory-index-reader.d.ts +26 -0
  8. package/dist/services/context/memory-index-reader.js +62 -30
  9. package/dist/services/context/memory-preflight-config.d.ts +33 -0
  10. package/dist/services/context/memory-preflight-config.js +32 -2
  11. package/dist/services/context/memory-preflight-service.d.ts +20 -1
  12. package/dist/services/context/memory-preflight-service.js +198 -31
  13. package/dist/services/doctor/doctor-service/checks/l3-memory-health.d.ts +19 -2
  14. package/dist/services/doctor/doctor-service/checks/l3-memory-health.js +143 -19
  15. package/dist/services/job/job-types.d.ts +3 -3
  16. package/dist/services/memory/memory-ingest-service.d.ts +79 -0
  17. package/dist/services/memory/memory-ingest-service.js +225 -0
  18. package/dist/services/memory/memory-rotate-service.d.ts +88 -0
  19. package/dist/services/memory/memory-rotate-service.js +373 -0
  20. package/dist/services/memory/project-memory-service/index/ranking.d.ts +9 -1
  21. package/dist/services/memory/project-memory-service/index/ranking.js +25 -13
  22. package/dist/services/memory/project-memory-service/index/reindex.d.ts +75 -0
  23. package/dist/services/memory/project-memory-service/index/reindex.js +207 -0
  24. package/dist/services/memory/project-memory-service/index/search.js +14 -24
  25. package/dist/services/memory/project-memory-service/index.d.ts +7 -3
  26. package/dist/services/memory/project-memory-service/index.js +6 -2
  27. package/dist/services/memory/project-memory-service/parsers/frontmatter.d.ts +75 -3
  28. package/dist/services/memory/project-memory-service/parsers/frontmatter.js +113 -24
  29. package/dist/services/memory/project-memory-service/types.d.ts +31 -1
  30. package/dist/services/memory/project-memory-service/types.js +76 -1
  31. package/dist/services/preferences/preferences-types.d.ts +14 -0
  32. package/dist/services/preferences/preferences-types.js +8 -0
  33. package/dist/services/share/run-state-contract.d.ts +1 -1
  34. package/package.json +5 -5
  35. package/skills/peaks-code/SKILL.md +1 -1
  36. package/skills/peaks-code/references/runbook.md +6 -0
  37. package/skills/peaks-code/references/step-11-memory-sediment.md +35 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # Changelog
2
2
 
3
+ ## 4.0.35 — 2026-09-10 (记忆系统 overhaul — 写入合一 / 索引重建 / 按任务调取 / 漂移健康检查 / rotate)
4
+
5
+ **Highlights**:
6
+
7
+ 1. **记忆写入单一权威** — `peaks memory ingest` 把 IDE 侧(Claude Code)记忆导入 `.peaks/memory/`,源目录只读、**绝不写 `~/.claude/`**;SKILL.md 明确:工作流内"沉淀记忆"落 `.peaks/memory/`,IDE 侧仅作会话笔记。
8
+
9
+ 2. **索引重建 + 根因修复** — `peaks memory reindex` 全量重扫并重建 `index.json`。**根因**:共享 frontmatter 解析器只认顶层 `type:`,按契约写 `metadata.type` 的文件被**静默丢弃**。现按 `metadata.type → kind → type` 解析,未识别值**输出清单**而非静默跳过。name 解析新增 `name → title → 文件名` 回退;重名冲突上报不覆盖。本仓库实测:索引 **231 → 276**。
10
+
11
+ 3. **调取按任务 + 分层预算** — preflight 此前 `fetchBlock(_taskTitle)` 的 `taskTitle` **完全未使用**,永远注入同一批 feedback+layerA。现按任务排序(per-token fuzzy,无网络/无 embedding),hot 必进、warm 按相关度入选,三重预算(条数/字节/时间)+ 可观测字段。warm 层首次可被注入。顺带修:读取器原来只扁平化 4 种 kind,丢了 decision/rule/convention/module/lesson。
12
+
13
+ 4. **kind 词表 8 → 21** — 纳入语料中实际使用的 13 个 kind(`bug`/`investigation`/`technical-pattern`/`project-rule`/`design`/`handoff`/`session-handoff`/`project-todo`/`publish-closure`/`project-closure`/`slice-closure`/`slice-pilot-findings`/`sediment`),单一常量驱动解析/索引/CLI/doctor。
14
+
15
+ 5. **`peaks memory rotate`(清除机制)** — 实现 2026-07-24 pruning policy 早已规定但从未实现的轮转:A/B 层永不入选(测试断言)、C 层满 **6 个月**且未被钉住 → 归档、D 层 → 报告待删(不删)、每个候选先过 `src/`+`skills/` 引用 grep。默认 dry-run。
16
+
17
+ 6. **doctor 漂移检查** — 新增 `l3-memory-coverage` / `l3-memory-orphans` / `l3-memory-unclassified`;此前只校验"index.json 是合法 JSON",46% 不可见也报 ok。
18
+
19
+ **验证**:build clean、tsc clean、全量 133 files / 1180 passed(1 skipped);本仓库 rotate dry-run `A:22 B:146 C:138 D:12`。
20
+
3
21
  ## 4.0.34 — 2026-09-09 (mode 模型收敛 + auto-compact 死命令 + 上下文窗口覆盖 + 确认门去 TTY)
4
22
 
5
23
  **Highlights**:
@@ -1,6 +1,8 @@
1
- import { executeProjectMemoryBackup, executeProjectMemoryExtract, summarizeProjectMemoryBackupResult, summarizeProjectMemoryExtractResult } from '../../../services/memory/project-memory-service.js';
1
+ import { executeProjectMemoryBackup, executeProjectMemoryExtract, summarizeProjectMemoryBackupResult, summarizeProjectMemoryExtractResult, VALID_PROJECT_MEMORY_KINDS } from '../../../services/memory/project-memory-service.js';
2
2
  import { fail, ok } from 'peaks-loop-shared/result';
3
3
  import { addJsonOption, getErrorMessage, printResult } from '../../cli-helpers.js';
4
+ /** Derived from the canonical kind vocabulary — never hand-maintain a list here. */
5
+ const KIND_HELP = VALID_PROJECT_MEMORY_KINDS.join(', ');
4
6
  export function registerMemoryCommand(program, io) {
5
7
  const memory = program.command('memory').description('Manage project-local Peaks memory');
6
8
  addJsonOption(memory
@@ -48,7 +50,7 @@ export function registerMemoryCommand(program, io) {
48
50
  addJsonOption(memory
49
51
  .command('list')
50
52
  .description('List all memory entries from .peaks/memory/index.json. Pass --pick to spawn fzf for interactive multi-select; the picked subset is written to .peaks/memory/picked.json.')
51
- .option('--kind <kind>', 'filter by memory kind (one of: project, rule, decision, reference, feedback, convention, module, lesson)')
53
+ .option('--kind <kind>', `filter by memory kind (one of: ${KIND_HELP})`)
52
54
  .option('--pick', 'spawn fzf for interactive multi-select (requires fzf >= 0.38); writes picked.json')
53
55
  .option('--fzf-bin <path>', 'override fzf binary path (default: fzf on PATH)', 'fzf')
54
56
  .option('--project <path>', 'target project root (defaults to git root or cwd)')).action((options) => {
@@ -72,10 +74,66 @@ export function registerMemoryCommand(program, io) {
72
74
  process.exitCode = 1;
73
75
  });
74
76
  });
77
+ addJsonOption(memory
78
+ .command('reindex')
79
+ .description('Rebuild .peaks/memory/index.json from every memory file on disk and regenerate MEMORY.md; reports unclassified files and orphans both ways. Dry-run by default; pass --apply to write.')
80
+ .option('--project <path>', 'target project root (defaults to git root or cwd)')
81
+ .option('--dry-run', 'report drift without writing (default)')
82
+ .option('--apply', 'rebuild index.json and regenerate MEMORY.md')).action((options) => {
83
+ void import('../memory-commands.js').then(({ runMemoryReindex }) => {
84
+ void runMemoryReindex(io, {
85
+ ...(options.project !== undefined ? { project: options.project } : {}),
86
+ ...(options.dryRun === true ? { dryRun: true } : {}),
87
+ ...(options.apply === true ? { apply: true } : {}),
88
+ ...(options.json !== undefined ? { json: options.json } : {}),
89
+ });
90
+ }).catch((error) => {
91
+ printResult(io, fail('memory.reindex', 'MEMORY_REINDEX_BOOTSTRAP_FAILED', getErrorMessage(error), {}, []), options.json);
92
+ process.exitCode = 1;
93
+ });
94
+ });
95
+ addJsonOption(memory
96
+ .command('ingest')
97
+ .description('Import memories written by the IDE-side agent (~/.claude/projects/<hash>/memory/*.md) into the peaks-owned .peaks/memory store. The IDE-side dir is read-only. Dry-run by default; pass --apply to write.')
98
+ .option('--project <path>', 'target project root (defaults to git root or cwd)')
99
+ .option('--source-dir <path>', 'override the IDE-side memory directory')
100
+ .option('--dry-run', 'preview imports without writing (default)')
101
+ .option('--apply', 'write normalized memories into .peaks/memory')).action((options) => {
102
+ void import('../memory-commands.js').then(({ runMemoryIngest }) => {
103
+ void runMemoryIngest(io, {
104
+ ...(options.project !== undefined ? { project: options.project } : {}),
105
+ ...(options.sourceDir !== undefined ? { sourceDir: options.sourceDir } : {}),
106
+ ...(options.dryRun === true ? { dryRun: true } : {}),
107
+ ...(options.apply === true ? { apply: true } : {}),
108
+ ...(options.json !== undefined ? { json: options.json } : {}),
109
+ });
110
+ }).catch((error) => {
111
+ printResult(io, fail('memory.ingest', 'MEMORY_INGEST_BOOTSTRAP_FAILED', getErrorMessage(error), {}, []), options.json);
112
+ process.exitCode = 1;
113
+ });
114
+ });
115
+ addJsonOption(memory
116
+ .command('rotate')
117
+ .description('Tier-driven retention for .peaks/memory/ (sediment pruning policy, tier 1: archive only, never delete). Tier assignment: explicit `metadata.tier: A|B|C|D` wins; else files under archived/ are D, files pinned in MEMORY.md are B, kinds rule/convention/project-rule are A, kinds decision/reference/feedback/module/bug/investigation/technical-pattern are B, everything else is C. Tier C older than 6 months (frontmatter updatedAt/updated/modified, else file mtime) and not pinned is archived; tier D is reported as a delete-candidate only. Tier A/B are never selected, every candidate must pass a reference grep against src/ + skills/, and --apply refuses an empty plan or a failed gate. Dry-run by default.')
118
+ .option('--project <path>', 'target project root (defaults to git root or cwd)')
119
+ .option('--dry-run', 'report the rotation plan without moving anything (default)')
120
+ .option('--apply', 'move tier-C candidates into .peaks/memory/archived/')).action((options) => {
121
+ void import('../memory-commands.js').then(({ runMemoryRotate }) => {
122
+ void runMemoryRotate(io, {
123
+ ...(options.project !== undefined ? { project: options.project } : {}),
124
+ ...(options.dryRun === true ? { dryRun: true } : {}),
125
+ ...(options.apply === true ? { apply: true } : {}),
126
+ ...(options.json !== undefined ? { json: options.json } : {}),
127
+ });
128
+ }).catch((error) => {
129
+ printResult(io, fail('memory.rotate', 'MEMORY_ROTATE_BOOTSTRAP_FAILED', getErrorMessage(error), {}, []), options.json);
130
+ process.exitCode = 1;
131
+ });
132
+ });
75
133
  addJsonOption(memory
76
134
  .command('search <query>')
77
135
  .description('Fuzzy-search the memory index (deterministic, local, zero-token). Default --limit 6.')
78
- .option('--kind <kind>', 'filter by memory kind (one of: project, rule, decision, reference, feedback, convention, module, lesson)')
136
+ .option('--kind <kind>', `filter by memory kind (one of: ${KIND_HELP})`)
79
137
  .option('--limit <n>', 'maximum number of matches to return', (value) => Number(value))
80
138
  .option('--project <path>', 'target project root (defaults to git root or cwd)')).action((query, options) => {
81
139
  // Lazy import avoids a top-of-file import cycle (memory-commands.ts
@@ -36,7 +36,7 @@ import { writeLogEntry } from '../../services/log/logger.js';
36
36
  import { PROMPT_LIMIT_BYTES, RECOMMENDED_ROLES, validateRole } from './sub-agent-shared.js';
37
37
  import { runDispatchFromDag } from './dispatch-from-dag.js';
38
38
  import { TEST_TOOL_DETECTION_BLOCK, formatTestToolDetection } from '../../services/dispatch/test-tool-detection.js';
39
- import { MemoryPreflightService } from '../../services/context/memory-preflight-service.js';
39
+ import { MemoryPreflightService, deriveMemoryQuery } from '../../services/context/memory-preflight-service.js';
40
40
  import { buildDispatchSystemPrompt } from '../../services/context/build-dispatch-system-prompt.js';
41
41
  import { computeUiLibraryDispatchBlock } from '../../services/standards/ui-library-dispatch-block.js';
42
42
  import { readFreshContextBlock } from '../../services/fresh-context/fresh-context-block.js';
@@ -373,7 +373,9 @@ export function registerDispatchCommand(parent, io) {
373
373
  // memory preflight block (or silently skip when unavailable) via the
374
374
  // pure-function builder.
375
375
  const preflightService = new MemoryPreflightService(projectRoot, projectPrefs);
376
- const memoryBlock = await preflightService.fetchBlock(role);
376
+ // Slice 2026-09-09-memory-retrieval: rank the injected memory by the
377
+ // task at hand (role + first line of the brief), not the bare role.
378
+ const memoryBlock = await preflightService.fetchBlock(deriveMemoryQuery(role, options.prompt));
377
379
  // Slice 2026-09-03-codegraph-preread (Option A): pre-dispatch
378
380
  // codegraph preflight for RD planning. BEFORE the RD sub-agent's
379
381
  // prompt is composed, ensure the codegraph index exists (init +
@@ -13,9 +13,44 @@ export interface MemoryListCommandOptions {
13
13
  project?: string;
14
14
  json?: boolean;
15
15
  }
16
+ export interface MemoryReindexCommandOptions {
17
+ project?: string;
18
+ dryRun?: boolean;
19
+ apply?: boolean;
20
+ json?: boolean;
21
+ }
22
+ export interface MemoryIngestCommandOptions {
23
+ project?: string;
24
+ sourceDir?: string;
25
+ dryRun?: boolean;
26
+ apply?: boolean;
27
+ json?: boolean;
28
+ }
29
+ export interface MemoryRotateCommandOptions {
30
+ project?: string;
31
+ dryRun?: boolean;
32
+ apply?: boolean;
33
+ json?: boolean;
34
+ }
16
35
  export declare function runMemoryList(io: ProgramIO, options: MemoryListCommandOptions): Promise<void>;
17
36
  /**
18
37
  * Run the memory search subcommand. Extracted so unit tests can
19
38
  * exercise the full envelope without spawning a subprocess.
20
39
  */
21
40
  export declare function runMemorySearch(io: ProgramIO, options: MemorySearchCommandOptions): Promise<void>;
41
+ /**
42
+ * `peaks memory reindex` — rebuild `.peaks/memory/index.json` from disk and
43
+ * regenerate `MEMORY.md`. Dry-run by default; `--apply` writes.
44
+ */
45
+ export declare function runMemoryReindex(io: ProgramIO, options: MemoryReindexCommandOptions): Promise<void>;
46
+ /**
47
+ * `peaks memory rotate` — tier-driven retention for `.peaks/memory/`.
48
+ * Implements the sediment pruning policy (tier 1: archive, never delete).
49
+ * Dry-run by default; `--apply` moves tier-C candidates into `archived/`.
50
+ */
51
+ export declare function runMemoryRotate(io: ProgramIO, options: MemoryRotateCommandOptions): Promise<void>;
52
+ /**
53
+ * `peaks memory ingest` — import memories written by the IDE-side agent into
54
+ * `.peaks/memory/`. The IDE-side source is read-only; dry-run by default.
55
+ */
56
+ export declare function runMemoryIngest(io: ProgramIO, options: MemoryIngestCommandOptions): Promise<void>;
@@ -1,20 +1,19 @@
1
1
  import { findProjectRoot } from '../../services/config/config-safety.js';
2
2
  import { resolveCanonicalProjectRoot } from '../../services/config/config-service.js';
3
3
  import { loadMemoryIndex, searchMemory } from '../../services/memory/memory-search-service.js';
4
+ import { executeMemoryReindex, VALID_PROJECT_MEMORY_KINDS } from '../../services/memory/project-memory-service.js';
5
+ import { executeMemoryIngest } from '../../services/memory/memory-ingest-service.js';
6
+ import { executeMemoryRotate } from '../../services/memory/memory-rotate-service.js';
4
7
  import { pickFromList } from '../../services/fuzzy-matching/fzf-pick-service.js';
5
8
  import { fail, ok } from 'peaks-loop-shared/result';
6
9
  import { getErrorMessage, printResult } from '../cli-helpers.js';
7
10
  import { join } from 'node:path';
8
- const VALID_KINDS = [
9
- 'project',
10
- 'rule',
11
- 'decision',
12
- 'reference',
13
- 'feedback',
14
- 'convention',
15
- 'module',
16
- 'lesson',
17
- ];
11
+ const VALID_KINDS = VALID_PROJECT_MEMORY_KINDS;
12
+ function resolveMemoryProjectRoot(project) {
13
+ return project !== undefined
14
+ ? resolveCanonicalProjectRoot(project)
15
+ : (findProjectRoot(process.cwd()) ?? process.cwd());
16
+ }
18
17
  export async function runMemoryList(io, options) {
19
18
  const projectRoot = options.project !== undefined
20
19
  ? resolveCanonicalProjectRoot(options.project)
@@ -124,3 +123,113 @@ export async function runMemorySearch(io, options) {
124
123
  process.exitCode = 1;
125
124
  }
126
125
  }
126
+ /**
127
+ * `peaks memory reindex` — rebuild `.peaks/memory/index.json` from disk and
128
+ * regenerate `MEMORY.md`. Dry-run by default; `--apply` writes.
129
+ */
130
+ export async function runMemoryReindex(io, options) {
131
+ const projectRoot = resolveMemoryProjectRoot(options.project);
132
+ if (options.dryRun === true && options.apply === true) {
133
+ printResult(io, fail('memory.reindex', 'INVALID_MEMORY_REINDEX_FLAGS', 'Use either --dry-run or --apply, not both', {}, ['Run without --apply to preview the drift report, or pass --apply to rebuild']), options.json);
134
+ process.exitCode = 1;
135
+ return;
136
+ }
137
+ try {
138
+ const report = executeMemoryReindex({ projectRoot, apply: options.apply === true });
139
+ const nextActions = [];
140
+ if (options.apply !== true) {
141
+ nextActions.push('Preview only — re-run with --apply to rebuild index.json and regenerate MEMORY.md.');
142
+ }
143
+ if (report.unclassified.length > 0) {
144
+ nextActions.push(`${report.unclassified.length} file(s) have no resolvable kind; add \`metadata.type\` (or \`kind:\`) to index them.`);
145
+ }
146
+ if (report.orphanIndex.length > 0) {
147
+ nextActions.push(`${report.orphanIndex.length} previous index entry(ies) point at missing files; they are dropped from the rebuilt index.`);
148
+ }
149
+ if (report.nameConflicts.length > 0) {
150
+ nextActions.push(`${report.nameConflicts.length} name collision(s) across files; both entries are kept — rename one file to disambiguate.`);
151
+ }
152
+ printResult(io, ok('memory.reindex', report, [], nextActions), options.json);
153
+ }
154
+ catch (error) {
155
+ const message = getErrorMessage(error);
156
+ const code = error.code ?? 'MEMORY_REINDEX_FAILED';
157
+ printResult(io, fail('memory.reindex', code, message, { projectRoot }, ['Check that the project has a readable .peaks/memory directory']), options.json);
158
+ process.exitCode = 1;
159
+ }
160
+ }
161
+ /**
162
+ * `peaks memory rotate` — tier-driven retention for `.peaks/memory/`.
163
+ * Implements the sediment pruning policy (tier 1: archive, never delete).
164
+ * Dry-run by default; `--apply` moves tier-C candidates into `archived/`.
165
+ */
166
+ export async function runMemoryRotate(io, options) {
167
+ const projectRoot = resolveMemoryProjectRoot(options.project);
168
+ if (options.dryRun === true && options.apply === true) {
169
+ printResult(io, fail('memory.rotate', 'INVALID_MEMORY_ROTATE_FLAGS', 'Use either --dry-run or --apply, not both', {}, ['Run without --apply to preview the rotation plan, or pass --apply to archive tier-C candidates']), options.json);
170
+ process.exitCode = 1;
171
+ return;
172
+ }
173
+ try {
174
+ const report = executeMemoryRotate({ projectRoot, apply: options.apply === true });
175
+ const nextActions = [];
176
+ if (report.refused) {
177
+ nextActions.push(`Refused to apply: ${report.refusalReasons.join('; ')}`);
178
+ }
179
+ else if (options.apply !== true) {
180
+ nextActions.push('Preview only — re-run with --apply to move the tier-C candidates into archived/.');
181
+ }
182
+ if (report.excluded.length > 0) {
183
+ nextActions.push(`${report.excluded.length} candidate(s) excluded by a safety gate (see \`excluded\`).`);
184
+ }
185
+ const deleteCandidates = report.candidates.filter((candidate) => candidate.action === 'delete-candidate');
186
+ if (deleteCandidates.length > 0) {
187
+ nextActions.push(`${deleteCandidates.length} tier-D file(s) are delete-candidates only; peaks never deletes them — remove by hand if you are sure.`);
188
+ }
189
+ printResult(io, ok('memory.rotate', report, report.warnings, nextActions), options.json);
190
+ if (report.refused)
191
+ process.exitCode = 1;
192
+ }
193
+ catch (error) {
194
+ const message = getErrorMessage(error);
195
+ const code = error.code ?? 'MEMORY_ROTATE_FAILED';
196
+ printResult(io, fail('memory.rotate', code, message, { projectRoot }, ['Check that the project has a readable .peaks/memory directory']), options.json);
197
+ process.exitCode = 1;
198
+ }
199
+ }
200
+ /**
201
+ * `peaks memory ingest` — import memories written by the IDE-side agent into
202
+ * `.peaks/memory/`. The IDE-side source is read-only; dry-run by default.
203
+ */
204
+ export async function runMemoryIngest(io, options) {
205
+ const projectRoot = resolveMemoryProjectRoot(options.project);
206
+ if (options.dryRun === true && options.apply === true) {
207
+ printResult(io, fail('memory.ingest', 'INVALID_MEMORY_INGEST_FLAGS', 'Use either --dry-run or --apply, not both', {}, ['Run without --apply to preview imports, or pass --apply to write them into .peaks/memory']), options.json);
208
+ process.exitCode = 1;
209
+ return;
210
+ }
211
+ try {
212
+ const report = executeMemoryIngest({
213
+ projectRoot,
214
+ ...(options.sourceDir !== undefined ? { sourceDir: options.sourceDir } : {}),
215
+ apply: options.apply === true
216
+ });
217
+ const nextActions = [];
218
+ if (options.apply !== true && report.imported.length > 0) {
219
+ nextActions.push('Preview only — re-run with --apply to write these memories into .peaks/memory.');
220
+ }
221
+ if (report.conflicts.length > 0) {
222
+ nextActions.push(`${report.conflicts.length} conflict(s) left both copies in place; resolve them by hand.`);
223
+ }
224
+ if (report.needsClassification.length > 0) {
225
+ nextActions.push(`${report.needsClassification.length} file(s) could not be classified; add \`metadata.type\` to the source before re-running.`);
226
+ }
227
+ printResult(io, ok('memory.ingest', report, report.warnings, nextActions), options.json);
228
+ }
229
+ catch (error) {
230
+ const message = getErrorMessage(error);
231
+ const code = error.code ?? 'MEMORY_INGEST_FAILED';
232
+ printResult(io, fail('memory.ingest', code, message, { projectRoot }, ['Check the --source-dir path and that .peaks/memory is writable']), options.json);
233
+ process.exitCode = 1;
234
+ }
235
+ }
@@ -13,8 +13,8 @@ export declare const ContextJsonSchema: z.ZodObject<{
13
13
  path: z.ZodString;
14
14
  kind: z.ZodEnum<{
15
15
  doc: "doc";
16
- config: "config";
17
16
  source: "source";
17
+ config: "config";
18
18
  test: "test";
19
19
  }>;
20
20
  lines: z.ZodNumber;
@@ -1,8 +1,34 @@
1
1
  import type { MemoryIndexEntry } from '../memory/memory-search-service.js';
2
+ /**
3
+ * Slice 2026-09-09-memory-retrieval: the two tiers the orchestrator
4
+ * preflight ranks over.
5
+ *
6
+ * - `hot` — standing rules / feedback / decisions. Always eligible.
7
+ * - `warm` — project / reference memos. Injected only on task relevance.
8
+ *
9
+ * `cold` is deep storage and is deliberately NOT surfaced here.
10
+ */
11
+ export interface MemoryTieredSelection {
12
+ hot: MemoryIndexEntry[];
13
+ warm: MemoryIndexEntry[];
14
+ }
2
15
  export declare class MemoryIndexReader {
3
16
  private readonly projectRoot;
4
17
  private cache;
5
18
  constructor(projectRoot: string);
19
+ /**
20
+ * Flatten the whole index (all tiers, all buckets). Kept for
21
+ * back-compat with the pre-2026-09-09 callers.
22
+ */
6
23
  loadIfStale(): MemoryIndexEntry[];
24
+ /**
25
+ * Slice 2026-09-09-memory-retrieval: read the index as tiers.
26
+ *
27
+ * Returns `null` when `.peaks/memory/index.json` is absent or
28
+ * unreadable — the caller distinguishes "no index" from "index with
29
+ * zero entries" for its fail-soft reason code. Never throws.
30
+ */
31
+ selectTiered(): MemoryTieredSelection | null;
7
32
  selectFeedbackLayerA(cap: number): MemoryIndexEntry[];
33
+ private loadSnapshot;
8
34
  }
@@ -7,24 +7,25 @@ export class MemoryIndexReader {
7
7
  constructor(projectRoot) {
8
8
  this.projectRoot = projectRoot;
9
9
  }
10
+ /**
11
+ * Flatten the whole index (all tiers, all buckets). Kept for
12
+ * back-compat with the pre-2026-09-09 callers.
13
+ */
10
14
  loadIfStale() {
11
- const indexPath = join(this.projectRoot, '.peaks', 'memory', 'index.json');
12
- if (!existsSync(indexPath))
13
- return [];
14
- const { mtimeMs } = statSync(indexPath);
15
- if (this.cache && this.cache.mtimeMs === mtimeMs)
16
- return this.cache.entries;
17
- let raw;
18
- try {
19
- raw = JSON.parse(readFileSync(indexPath, 'utf8'));
20
- }
21
- catch {
22
- this.cache = null;
23
- return [];
24
- }
25
- const entries = flattenIndex(raw);
26
- this.cache = { mtimeMs, entries };
27
- return entries;
15
+ return this.loadSnapshot()?.entries ?? [];
16
+ }
17
+ /**
18
+ * Slice 2026-09-09-memory-retrieval: read the index as tiers.
19
+ *
20
+ * Returns `null` when `.peaks/memory/index.json` is absent or
21
+ * unreadable — the caller distinguishes "no index" from "index with
22
+ * zero entries" for its fail-soft reason code. Never throws.
23
+ */
24
+ selectTiered() {
25
+ const snapshot = this.loadSnapshot();
26
+ if (snapshot === null)
27
+ return null;
28
+ return snapshot.tiers;
28
29
  }
29
30
  selectFeedbackLayerA(cap) {
30
31
  const all = this.loadIfStale();
@@ -33,24 +34,55 @@ export class MemoryIndexReader {
33
34
  .filter((e) => LAYER_A_RE.test(e.description))
34
35
  .slice(0, Math.max(1, Math.trunc(cap)));
35
36
  }
37
+ loadSnapshot() {
38
+ const indexPath = join(this.projectRoot, '.peaks', 'memory', 'index.json');
39
+ try {
40
+ if (!existsSync(indexPath))
41
+ return null;
42
+ const { mtimeMs } = statSync(indexPath);
43
+ if (this.cache && this.cache.mtimeMs === mtimeMs)
44
+ return this.cache;
45
+ const raw = JSON.parse(readFileSync(indexPath, 'utf8'));
46
+ const tiers = {
47
+ hot: flattenBucket(raw, 'hot'),
48
+ warm: flattenBucket(raw, 'warm'),
49
+ };
50
+ const entries = [
51
+ ...tiers.hot,
52
+ ...tiers.warm,
53
+ ...flattenBucket(raw, 'cold'),
54
+ ];
55
+ this.cache = { mtimeMs, entries, tiers };
56
+ return this.cache;
57
+ }
58
+ catch {
59
+ // Fail-soft: a missing / unreadable / malformed index degrades to
60
+ // "no memory available" rather than throwing into a dispatch.
61
+ this.cache = null;
62
+ return null;
63
+ }
64
+ }
36
65
  }
37
- function flattenIndex(raw) {
66
+ function bucketOf(raw, layer) {
38
67
  if (!raw || typeof raw !== 'object')
68
+ return null;
69
+ const bucket = raw[layer];
70
+ return bucket && typeof bucket === 'object'
71
+ ? bucket
72
+ : null;
73
+ }
74
+ function flattenBucket(raw, layer) {
75
+ const bucket = bucketOf(raw, layer);
76
+ if (!bucket)
39
77
  return [];
40
78
  const out = [];
41
- const obj = raw;
42
- for (const layer of ['hot', 'warm', 'cold']) {
43
- const bucket = obj[layer];
44
- if (!bucket || typeof bucket !== 'object')
79
+ for (const key of Object.keys(bucket)) {
80
+ const list = bucket[key];
81
+ if (!Array.isArray(list))
45
82
  continue;
46
- for (const k of ['feedback', 'project', 'reference', 'user']) {
47
- const list = bucket[k];
48
- if (!Array.isArray(list))
49
- continue;
50
- for (const item of list) {
51
- if (item && typeof item === 'object')
52
- out.push(item);
53
- }
83
+ for (const item of list) {
84
+ if (item && typeof item === 'object')
85
+ out.push(item);
54
86
  }
55
87
  }
56
88
  return out;
@@ -1,9 +1,42 @@
1
1
  import type { ProjectPreferences } from '../preferences/preferences-types.js';
2
2
  export interface MemoryPreflightConfig {
3
3
  readonly enabled: boolean;
4
+ /**
5
+ * Back-compat (slice 2026-07-22): the original single budget knob.
6
+ * Semantics are unchanged — it is the *token* cap, converted to bytes
7
+ * as `maxTokens * 4` when `maxBytes` is not set. Do NOT drop this key:
8
+ * existing `.peaks/preferences.json` files set it.
9
+ */
4
10
  readonly maxTokens: number;
11
+ /** Back-compat: hot-tier item cap fallback when `hotItemCap` is absent. */
5
12
  readonly listCap: number;
6
13
  readonly contentCacheBytes: number;
14
+ /** Hard byte cap on the composed block. Defaults to `maxTokens * 4`. */
15
+ readonly maxBytes: number;
16
+ /** Max hot (standing rule / feedback) items injected. Default 10. */
17
+ readonly hotItemCap: number;
18
+ /** Max warm (project / reference) items injected; 0 disables warm. Default 4. */
19
+ readonly warmItemCap: number;
20
+ /**
21
+ * Warm relevance gate: minimum number of distinct task-title tokens
22
+ * (length >= 4, stopwords removed) that must literally occur in the memo
23
+ * text before it is warm-eligible. 1 (default) means "at least one task
24
+ * word appears in the memo"; raise it to tighten the gate. Hot entries
25
+ * ignore this gate — they are always eligible.
26
+ */
27
+ readonly warmMinTokenHits: number;
28
+ /**
29
+ * Soft wall-clock budget (ms) for the whole selection step. When exceeded
30
+ * the preflight degrades to hot-only (or unranked hot) rather than
31
+ * blocking a dispatch. 0 forces the hot-only path deterministically.
32
+ */
33
+ readonly selectionTimeBudgetMs: number;
34
+ /**
35
+ * Gate for inlining memo *bodies* (via `cacheMemoContent`). Off by
36
+ * default: the preflight emits a compact index and the sub-agent drills
37
+ * down with `Read` against `sourcePath`.
38
+ */
39
+ readonly includeBodies: boolean;
7
40
  }
8
41
  /**
9
42
  * Loose input type for `resolveMemoryPreflightConfig`. Accepts either a
@@ -3,23 +3,53 @@ const DEFAULTS = Object.freeze({
3
3
  maxTokens: 1200,
4
4
  listCap: 12,
5
5
  contentCacheBytes: 6000,
6
+ hotItemCap: 10,
7
+ warmItemCap: 4,
8
+ warmMinTokenHits: 1,
9
+ selectionTimeBudgetMs: 200,
10
+ includeBodies: false,
6
11
  });
7
12
  const LIST_CAP_MIN = 1;
8
13
  const LIST_CAP_MAX = 50;
14
+ const ITEM_CAP_MAX = 100;
9
15
  function asFiniteInt(value, fallback) {
10
16
  return typeof value === 'number' && Number.isFinite(value) && value >= 0
11
17
  ? Math.trunc(value)
12
18
  : fallback;
13
19
  }
20
+ function asFiniteNumber(value, fallback) {
21
+ return typeof value === 'number' && Number.isFinite(value) && value >= 0
22
+ ? value
23
+ : fallback;
24
+ }
25
+ function clamp(value, min, max) {
26
+ return Math.min(max, Math.max(min, value));
27
+ }
14
28
  export function resolveMemoryPreflightConfig(prefs) {
15
29
  const m = prefs.memoryPreflight ?? {};
16
30
  const listCapRaw = asFiniteInt(m.listCap, DEFAULTS.listCap);
31
+ const listCap = clamp(listCapRaw, LIST_CAP_MIN, LIST_CAP_MAX);
32
+ const maxTokens = m.maxTokens && m.maxTokens > 0 ? m.maxTokens : DEFAULTS.maxTokens;
17
33
  return {
18
34
  enabled: m.enabled === false ? false : DEFAULTS.enabled,
19
- maxTokens: m.maxTokens && m.maxTokens > 0 ? m.maxTokens : DEFAULTS.maxTokens,
20
- listCap: Math.min(LIST_CAP_MAX, Math.max(LIST_CAP_MIN, listCapRaw)),
35
+ maxTokens,
36
+ listCap,
21
37
  contentCacheBytes: m.contentCacheBytes && m.contentCacheBytes > 0
22
38
  ? m.contentCacheBytes
23
39
  : DEFAULTS.contentCacheBytes,
40
+ // `maxBytes` wins when explicitly set; otherwise the legacy token cap
41
+ // is preserved verbatim (maxTokens * 4 bytes).
42
+ maxBytes: m.maxBytes && m.maxBytes > 0 ? Math.trunc(m.maxBytes) : maxTokens * 4,
43
+ // `hotItemCap` falls back to the legacy `listCap` only when the caller
44
+ // explicitly set `listCap`; otherwise the tiered default (10) applies.
45
+ hotItemCap: clamp(m.hotItemCap !== undefined
46
+ ? asFiniteInt(m.hotItemCap, DEFAULTS.hotItemCap)
47
+ : m.listCap !== undefined
48
+ ? listCap
49
+ : DEFAULTS.hotItemCap, LIST_CAP_MIN, ITEM_CAP_MAX),
50
+ warmItemCap: clamp(asFiniteInt(m.warmItemCap, DEFAULTS.warmItemCap), 0, ITEM_CAP_MAX),
51
+ warmMinTokenHits: clamp(asFiniteInt(m.warmMinTokenHits, DEFAULTS.warmMinTokenHits), 1, ITEM_CAP_MAX),
52
+ selectionTimeBudgetMs: clamp(asFiniteNumber(m.selectionTimeBudgetMs, DEFAULTS.selectionTimeBudgetMs), 0, 10_000),
53
+ includeBodies: m.includeBodies === true ? true : DEFAULTS.includeBodies,
24
54
  };
25
55
  }
@@ -2,11 +2,22 @@ import { type MemoryPreflightPrefsInput } from './memory-preflight-config.js';
2
2
  export interface MemoryPreflightResult {
3
3
  available: boolean;
4
4
  block?: string;
5
+ /** Back-compat: total items emitted (hot + warm). */
5
6
  feedbackListItems?: number;
6
7
  cachedItemCount?: number;
7
8
  reason?: string;
8
9
  truncated?: boolean | undefined;
9
10
  droppedCount?: number | undefined;
11
+ /** Slice 2026-09-09: hot items emitted. */
12
+ hotSelected?: number | undefined;
13
+ /** Slice 2026-09-09: warm items emitted. */
14
+ warmSelected?: number | undefined;
15
+ /** Slice 2026-09-09: bytes of the emitted block (utf8). */
16
+ bytesEmitted?: number | undefined;
17
+ /** Slice 2026-09-09: true when ANY budget (items / bytes / time) cut content. */
18
+ budgetTruncated?: boolean | undefined;
19
+ /** Slice 2026-09-09: true when the soft selection time budget was hit. */
20
+ timedOut?: boolean | undefined;
10
21
  }
11
22
  export declare class MemoryPreflightService {
12
23
  private readonly reader;
@@ -15,5 +26,13 @@ export declare class MemoryPreflightService {
15
26
  private readonly cachedMemoContents;
16
27
  constructor(projectRoot: string, prefs: MemoryPreflightPrefsInput);
17
28
  cacheMemoContent(path: string, content: string): void;
18
- fetchBlock(_taskTitle: string): Promise<MemoryPreflightResult>;
29
+ fetchBlock(taskTitle: string): Promise<MemoryPreflightResult>;
30
+ private selectAndCompose;
19
31
  }
32
+ /**
33
+ * Slice 2026-09-09-memory-retrieval: the query the dispatch site feeds
34
+ * `fetchBlock`. The bare role ("rd") carries almost no relevance signal,
35
+ * so the first line of the task brief is appended (truncated) to give the
36
+ * fuzzy kernel something to rank against. Pure and fail-soft.
37
+ */
38
+ export declare function deriveMemoryQuery(role: string, taskBody: string | undefined): string;