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.
Files changed (72) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/dist/cli/commands/code-runtime-commands.d.ts +5 -2
  3. package/dist/cli/commands/code-runtime-commands.js +57 -2
  4. package/dist/cli/commands/core/doctor-command.d.ts +8 -0
  5. package/dist/cli/commands/core/doctor-command.js +44 -2
  6. package/dist/cli/commands/core/memory-command.js +65 -3
  7. package/dist/cli/commands/dispatch-commands.js +19 -5
  8. package/dist/cli/commands/dispatch-from-dag.js +17 -0
  9. package/dist/cli/commands/memory-commands.d.ts +59 -0
  10. package/dist/cli/commands/memory-commands.js +195 -19
  11. package/dist/cli/commands/request-commands.d.ts +8 -0
  12. package/dist/cli/commands/request-commands.js +23 -2
  13. package/dist/cli/commands/sub-agent-commands.js +2 -0
  14. package/dist/cli/commands/wave-plan-commands.d.ts +24 -0
  15. package/dist/cli/commands/wave-plan-commands.js +93 -0
  16. package/dist/services/context/build-dispatch-system-prompt.d.ts +66 -9
  17. package/dist/services/context/build-dispatch-system-prompt.js +132 -17
  18. package/dist/services/context/context-audit.d.ts +100 -0
  19. package/dist/services/context/context-audit.js +322 -0
  20. package/dist/services/context/context-schema.d.ts +1 -1
  21. package/dist/services/context/memory-index-reader.d.ts +26 -0
  22. package/dist/services/context/memory-index-reader.js +62 -30
  23. package/dist/services/context/memory-preflight-config.d.ts +33 -0
  24. package/dist/services/context/memory-preflight-config.js +32 -2
  25. package/dist/services/context/memory-preflight-service.d.ts +20 -1
  26. package/dist/services/context/memory-preflight-service.js +198 -31
  27. package/dist/services/context/summary-view.d.ts +54 -0
  28. package/dist/services/context/summary-view.js +114 -0
  29. package/dist/services/dispatch/file-overlap-wave-planner.d.ts +70 -0
  30. package/dist/services/dispatch/file-overlap-wave-planner.js +119 -0
  31. package/dist/services/dispatch/session-capsule.d.ts +23 -0
  32. package/dist/services/dispatch/session-capsule.js +56 -0
  33. package/dist/services/dispatch/slice-dag.d.ts +9 -0
  34. package/dist/services/dispatch/slice-dag.js +9 -1
  35. package/dist/services/dispatch/test-tool-detection.d.ts +12 -1
  36. package/dist/services/dispatch/test-tool-detection.js +14 -13
  37. package/dist/services/doctor/doctor-service/checks/l3-memory-health.d.ts +19 -2
  38. package/dist/services/doctor/doctor-service/checks/l3-memory-health.js +143 -19
  39. package/dist/services/ide/adapters/claude-code-adapter.d.ts +10 -0
  40. package/dist/services/ide/adapters/claude-code-adapter.js +20 -1
  41. package/dist/services/ide/ide-types.d.ts +15 -0
  42. package/dist/services/job/job-types.d.ts +3 -3
  43. package/dist/services/memory/memory-ingest-service.d.ts +79 -0
  44. package/dist/services/memory/memory-ingest-service.js +225 -0
  45. package/dist/services/memory/memory-rotate-service.d.ts +88 -0
  46. package/dist/services/memory/memory-rotate-service.js +373 -0
  47. package/dist/services/memory/project-memory-service/index/ranking.d.ts +9 -1
  48. package/dist/services/memory/project-memory-service/index/ranking.js +25 -13
  49. package/dist/services/memory/project-memory-service/index/reindex.d.ts +75 -0
  50. package/dist/services/memory/project-memory-service/index/reindex.js +207 -0
  51. package/dist/services/memory/project-memory-service/index/search.js +14 -24
  52. package/dist/services/memory/project-memory-service/index.d.ts +7 -3
  53. package/dist/services/memory/project-memory-service/index.js +6 -2
  54. package/dist/services/memory/project-memory-service/parsers/frontmatter.d.ts +80 -3
  55. package/dist/services/memory/project-memory-service/parsers/frontmatter.js +167 -28
  56. package/dist/services/memory/project-memory-service/types.d.ts +31 -1
  57. package/dist/services/memory/project-memory-service/types.js +76 -1
  58. package/dist/services/preferences/preferences-types.d.ts +14 -0
  59. package/dist/services/preferences/preferences-types.js +8 -0
  60. package/dist/services/share/run-state-contract.d.ts +1 -1
  61. package/package.json +5 -5
  62. package/skills/bee/peaks-qa/SKILL.md +2 -0
  63. package/skills/bee/peaks-qa/references/qa-sub-agent-dispatch.md +12 -0
  64. package/skills/bee/peaks-rd/SKILL.md +2 -0
  65. package/skills/bee/peaks-rd/references/rd-sub-agent-dispatch.md +14 -0
  66. package/skills/bee/peaks-txt/SKILL.md +2 -0
  67. package/skills/bee/peaks-ui/SKILL.md +2 -0
  68. package/skills/peaks-code/SKILL.md +9 -1
  69. package/skills/peaks-code/references/context-governance.md +29 -0
  70. package/skills/peaks-code/references/runbook.md +6 -0
  71. package/skills/peaks-code/references/step-11-memory-sediment.md +35 -0
  72. package/skills/peaks-doctor/SKILL.md +2 -0
@@ -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;
@@ -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 + layer=A filter
9
- * - MemoryLruCache (Task 2) — constructed for capacity parity, but NOT used
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
- * Deviation from brief — controller-accepted (pre-task 4):
14
- * The brief mandated use of `MemoryLruCache` for memo content caching. The
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(_taskTitle) {
107
+ async fetchBlock(taskTitle) {
52
108
  if (!this.config.enabled)
53
109
  return { available: false, reason: 'DISABLED' };
54
- const selected = this.reader.selectFeedbackLayerA(this.config.listCap);
55
- if (selected.length === 0) {
56
- const any = this.reader.loadIfStale();
57
- if (any.length === 0)
58
- return { available: false, reason: 'MEMORY_INDEX_MISSING' };
59
- return { available: false, reason: 'NO_FEEDBACK_LAYER_A' };
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 sections = [];
172
+ if (this.config.includeBodies && this.cachedMemoContents.size > 0) {
173
+ const bodies = [];
68
174
  for (const [path, body] of this.cachedMemoContents) {
69
- sections.push(`### ${path}\n\n${body}`);
175
+ bodies.push(`### ${path}\n\n${body}`);
70
176
  cachedCount += 1;
71
177
  }
72
- tail = `\n\n## Requested memory details:\n${sections.join('\n\n')}\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}${listLines}${tail}`;
76
- const capBytes = Math.max(64, this.config.maxTokens * 4);
77
- const { text, truncated } = truncateToCap(composed, capBytes);
78
- const droppedCount = truncated ? selected.length - countItemsInBlock(text) : 0;
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: selected.length,
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();
@@ -0,0 +1,54 @@
1
+ /**
2
+ * `--summary` — bounded, additive views of large CLI envelopes.
3
+ *
4
+ * Slice 2026-09-10-context-audit-and-discipline (Slice B, part 1).
5
+ *
6
+ * Rationale (measured, session 2026-09-07-session-245530): dumping a full
7
+ * `peaks memory reindex --json` array four times cost ≈ 160 KB ≈ 40K tokens
8
+ * of orchestrator context — roughly 4% of a 1M window for ONE command
9
+ * repeated. The default envelopes stay exactly as they are (back-compat);
10
+ * `--summary` is an OPT-IN view that keeps counts + names-of-first-N and
11
+ * drops the per-entry bodies, which remain on disk and are re-readable with
12
+ * the full command. No information is destroyed — only the in-context copy
13
+ * shrinks.
14
+ *
15
+ * Byte bound: every summary object is passed through `fitSummaryToBytes`,
16
+ * which shrinks string arrays (longest first) until the JSON serialization is
17
+ * ≤ `SUMMARY_DATA_MAX_BYTES` (2 KB minus a small envelope reserve, so the
18
+ * PRINTED envelope stays ≤ `SUMMARY_MAX_BYTES`). Scalars are never touched,
19
+ * so counts and paths stay exact.
20
+ */
21
+ /** Hard ceiling for the PRINTED `--summary` envelope, in UTF-8 bytes. */
22
+ export declare const SUMMARY_MAX_BYTES = 2048;
23
+ /**
24
+ * Reserve for the envelope wrapper (`ok`/`command`/`warnings`/`nextActions`/
25
+ * error fields) that the CLI adds around `data`. The builders cap `data` at
26
+ * `SUMMARY_DATA_MAX_BYTES` so the whole printed envelope stays ≤ 2 KB.
27
+ */
28
+ export declare const SUMMARY_ENVELOPE_RESERVE_BYTES = 512;
29
+ /** Cap applied to the summary `data` object itself (pretty-printed size). */
30
+ export declare const SUMMARY_DATA_MAX_BYTES: number;
31
+ /** Per-name character cap — a name is a label, not a document. */
32
+ export declare const SUMMARY_NAME_MAX_CHARS = 120;
33
+ /** How many names each command asks for before the byte fitter trims. */
34
+ export declare const SUMMARY_INITIAL_NAMES = 40;
35
+ /** A bounded `{count, names}` view: `count` is the true total, `names` a prefix. */
36
+ export interface BoundedNames {
37
+ readonly count: number;
38
+ readonly names: readonly string[];
39
+ }
40
+ /**
41
+ * Build a `{count, names}` view. `count` is always the full length; `names`
42
+ * carries the first `SUMMARY_INITIAL_NAMES` (clipped) entries — the byte
43
+ * fitter may trim further.
44
+ */
45
+ export declare function boundedNames(names: readonly string[]): BoundedNames;
46
+ /**
47
+ * Shrink `data` (in place on a clone) until its JSON form fits `maxBytes`.
48
+ *
49
+ * Algorithm: repeatedly find the LONGEST nested array and drop its last
50
+ * element. Scalars are never modified, so `count` fields stay truthful; the
51
+ * `names` arrays simply show fewer names. Returns the input unchanged when it
52
+ * already fits or when there is no array left to trim.
53
+ */
54
+ export declare function fitSummaryToBytes<T extends Record<string, unknown>>(data: T, maxBytes?: number): T;