peaks-loop 4.0.33 → 4.0.35
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +38 -0
- package/dist/cli/commands/code-commands.js +2 -0
- package/dist/cli/commands/code-mode-gate-commands.js +3 -3
- package/dist/cli/commands/code-mode-status-command.d.ts +17 -0
- package/dist/cli/commands/code-mode-status-command.js +51 -0
- package/dist/cli/commands/code-run-command.js +8 -1
- package/dist/cli/commands/code-runtime-commands.js +13 -5
- package/dist/cli/commands/core/memory-command.js +61 -3
- package/dist/cli/commands/core/skill-command.js +1 -1
- package/dist/cli/commands/dispatch-commands.js +4 -2
- package/dist/cli/commands/memory-commands.d.ts +35 -0
- package/dist/cli/commands/memory-commands.js +119 -10
- package/dist/cli/commands/request-commands.js +3 -7
- package/dist/cli/commands/session-24h-mode.js +9 -1
- package/dist/services/24h-mode/auto-engage.d.ts +39 -0
- package/dist/services/24h-mode/auto-engage.js +46 -0
- package/dist/services/24h-mode/decider.d.ts +11 -5
- package/dist/services/24h-mode/decider.js +13 -13
- package/dist/services/audit/enforcers/active-skill-resolver.d.ts +3 -1
- package/dist/services/audit/enforcers/active-skill-resolver.js +3 -5
- package/dist/services/code/auto-compact-lifecycle.d.ts +1 -1
- package/dist/services/code/auto-compact-lifecycle.js +1 -1
- package/dist/services/code/auto-compact-modes.d.ts +1 -1
- package/dist/services/code/auto-compact-modes.js +1 -1
- package/dist/services/code/auto-compact-orchestrator.js +18 -22
- package/dist/services/code/mode-gate.d.ts +6 -2
- package/dist/services/code/mode-gate.js +9 -5
- package/dist/services/code/orchestrator-can-do.js +2 -2
- package/dist/services/config/config-service.d.ts +17 -0
- package/dist/services/config/config-service.js +34 -0
- package/dist/services/context/auto-compact-dispatcher.d.ts +2 -2
- package/dist/services/context/auto-compact-dispatcher.js +4 -4
- package/dist/services/context/auto-compact-reader.js +8 -1
- package/dist/services/context/auto-compact-types.d.ts +13 -1
- package/dist/services/context/build-dispatch-system-prompt.js +2 -2
- package/dist/services/context/memory-index-reader.d.ts +26 -0
- package/dist/services/context/memory-index-reader.js +62 -30
- package/dist/services/context/memory-preflight-config.d.ts +33 -0
- package/dist/services/context/memory-preflight-config.js +32 -2
- package/dist/services/context/memory-preflight-service.d.ts +20 -1
- package/dist/services/context/memory-preflight-service.js +198 -31
- package/dist/services/doctor/doctor-service/checks/l3-memory-health.d.ts +19 -2
- package/dist/services/doctor/doctor-service/checks/l3-memory-health.js +143 -19
- package/dist/services/hooks/auto-compact-hook-install.d.ts +2 -2
- package/dist/services/hooks/auto-compact-hook-install.js +2 -2
- package/dist/services/ide/adapters/claude-code-adapter.d.ts +51 -0
- package/dist/services/ide/adapters/claude-code-adapter.js +84 -14
- package/dist/services/ide/ide-types.d.ts +10 -1
- package/dist/services/job/job-types.d.ts +3 -3
- package/dist/services/memory/memory-ingest-service.d.ts +79 -0
- package/dist/services/memory/memory-ingest-service.js +225 -0
- package/dist/services/memory/memory-rotate-service.d.ts +88 -0
- package/dist/services/memory/memory-rotate-service.js +373 -0
- package/dist/services/memory/project-memory-service/index/ranking.d.ts +9 -1
- package/dist/services/memory/project-memory-service/index/ranking.js +25 -13
- package/dist/services/memory/project-memory-service/index/reindex.d.ts +75 -0
- package/dist/services/memory/project-memory-service/index/reindex.js +207 -0
- package/dist/services/memory/project-memory-service/index/search.js +14 -24
- package/dist/services/memory/project-memory-service/index.d.ts +7 -3
- package/dist/services/memory/project-memory-service/index.js +6 -2
- package/dist/services/memory/project-memory-service/parsers/frontmatter.d.ts +75 -3
- package/dist/services/memory/project-memory-service/parsers/frontmatter.js +113 -24
- package/dist/services/memory/project-memory-service/types.d.ts +31 -1
- package/dist/services/memory/project-memory-service/types.js +76 -1
- package/dist/services/mode/mode-enforcement.d.ts +4 -1
- package/dist/services/mode/mode-enforcement.js +26 -28
- package/dist/services/mode/mode-status-service.d.ts +81 -0
- package/dist/services/mode/mode-status-service.js +108 -0
- 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/dist/services/skills/presence-lease-service.d.ts +21 -0
- package/dist/services/skills/presence-lease-service.js +26 -0
- package/dist/services/skills/skill-presence-service.d.ts +10 -1
- package/dist/services/skills/skill-presence-service.js +32 -4
- package/dist/services/skills/skill-statusline-service.js +6 -3
- package/package.json +5 -5
- package/skills/peaks-code/SKILL.md +10 -10
- package/skills/peaks-code/references/mode-selection.md +7 -3
- package/skills/peaks-code/references/periodic-checkpoint.md +4 -4
- package/skills/peaks-code/references/runbook.md +16 -5
- package/skills/peaks-code/references/session-overload-signal-index.md +2 -2
- package/skills/peaks-code/references/standards-preflight.md +1 -1
- package/skills/peaks-code/references/startup-sequence.md +1 -1
- package/skills/peaks-code/references/step-0-8-gate.md +1 -1
- package/skills/peaks-code/references/step-11-memory-sediment.md +35 -0
- package/skills/peaks-code/references/swarm-dispatch-contract.md +2 -2
- package/skills/peaks-code/references/workflow-gates-and-types.md +1 -1
|
@@ -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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
|
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
|
|
42
|
-
|
|
43
|
-
|
|
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
|
|
47
|
-
|
|
48
|
-
|
|
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
|
|
20
|
-
listCap
|
|
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(
|
|
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
|
|
9
|
-
* -
|
|
10
|
-
* for memo content (see deviation note below)
|
|
21
|
+
* - MemoryIndexReader (Task 3) — reads .peaks/memory/index.json
|
|
22
|
+
* - fuzzyMatchWithKey — deterministic relevance kernel
|
|
11
23
|
* - truncateToCap — byte-cap truncation of the composed block
|
|
12
24
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* orchestrator dispatch flow only ever has at most a handful of explicit
|
|
16
|
-
* `cacheMemoContent` calls per task (typically 0-3), and ordering / explicit
|
|
17
|
-
* invalidation is preferred over LRU eviction by recency. To keep the
|
|
18
|
-
* semantic obvious and the byte budget check trivial, this implementation
|
|
19
|
-
* uses a plain `Map<path, body>` for memo path -> body content. The
|
|
20
|
-
* `MemoryLruCache` class remains as a separate, reusable LRU primitive
|
|
21
|
-
* (Task 2) and is not consumed by this service.
|
|
25
|
+
* Fail-soft contract: any selection error returns `available: false` with a
|
|
26
|
+
* reason; it never throws and never blocks a dispatch.
|
|
22
27
|
*/
|
|
28
|
+
import { performance } from 'node:perf_hooks';
|
|
29
|
+
import { fuzzyMatchWithKey } from '../fuzzy-matching/fuzzy-match-service.js';
|
|
23
30
|
import { MemoryIndexReader } from './memory-index-reader.js';
|
|
24
31
|
import { resolveMemoryPreflightConfig, } from './memory-preflight-config.js';
|
|
32
|
+
/** Function words carry no selection signal. */
|
|
33
|
+
const STOPWORDS = new Set([
|
|
34
|
+
'the', 'and', 'for', 'with', 'from', 'that', 'this', 'not', 'are', 'was',
|
|
35
|
+
'but', 'you', 'all', 'any', 'can', 'has', 'its', 'our', 'out', 'use', 'via',
|
|
36
|
+
'per', 'into', 'when', 'then', 'than', 'they', 'their', 'should', 'must',
|
|
37
|
+
]);
|
|
38
|
+
/**
|
|
39
|
+
* Tokens shorter than 4 chars are too promiscuous for literal containment
|
|
40
|
+
* ("add" is a substring of "padding"), so they rank but do not gate.
|
|
41
|
+
*/
|
|
42
|
+
const MIN_TOKEN_LENGTH = 4;
|
|
43
|
+
const CJK_RE = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u;
|
|
44
|
+
function queryTokens(query) {
|
|
45
|
+
const seen = new Set();
|
|
46
|
+
const out = [];
|
|
47
|
+
for (const raw of query.toLowerCase().split(/[^\p{L}\p{N}]+/u)) {
|
|
48
|
+
if (raw.length < MIN_TOKEN_LENGTH || STOPWORDS.has(raw) || seen.has(raw))
|
|
49
|
+
continue;
|
|
50
|
+
seen.add(raw);
|
|
51
|
+
out.push(raw);
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Absolute relevance gate: does the token literally occur in the entry?
|
|
57
|
+
*
|
|
58
|
+
* The fuzzy kernel's score is normalized per batch (best match = 1.0), so
|
|
59
|
+
* it cannot distinguish a strong match from the best of a bad lot — an
|
|
60
|
+
* absolute check is required for the warm gate. CJK titles have no word
|
|
61
|
+
* boundaries, so those tokens fall back to 2-char gram overlap.
|
|
62
|
+
*/
|
|
63
|
+
function tokenHits(text, tokens) {
|
|
64
|
+
let hits = 0;
|
|
65
|
+
for (const token of tokens) {
|
|
66
|
+
if (text.includes(token)) {
|
|
67
|
+
hits += 1;
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (!CJK_RE.test(token))
|
|
71
|
+
continue;
|
|
72
|
+
for (let i = 0; i + 2 <= token.length; i += 1) {
|
|
73
|
+
if (text.includes(token.slice(i, i + 2))) {
|
|
74
|
+
hits += 1;
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return hits;
|
|
80
|
+
}
|
|
25
81
|
function truncateToCap(text, capBytes) {
|
|
26
82
|
if (Buffer.byteLength(text, 'utf8') <= capBytes) {
|
|
27
83
|
return { text, truncated: false };
|
|
@@ -48,44 +104,155 @@ export class MemoryPreflightService {
|
|
|
48
104
|
}
|
|
49
105
|
this.cachedMemoContents.set(path, content);
|
|
50
106
|
}
|
|
51
|
-
async fetchBlock(
|
|
107
|
+
async fetchBlock(taskTitle) {
|
|
52
108
|
if (!this.config.enabled)
|
|
53
109
|
return { available: false, reason: 'DISABLED' };
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
return {
|
|
110
|
+
try {
|
|
111
|
+
return this.selectAndCompose(taskTitle ?? '');
|
|
112
|
+
}
|
|
113
|
+
catch (err) {
|
|
114
|
+
// Fail-soft: a selection failure must never block a dispatch.
|
|
115
|
+
return {
|
|
116
|
+
available: false,
|
|
117
|
+
reason: `SELECTION_ERROR: ${err.message ?? String(err)}`,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
selectAndCompose(taskTitle) {
|
|
122
|
+
const startedAt = performance.now();
|
|
123
|
+
const tiered = this.reader.selectTiered();
|
|
124
|
+
if (tiered === null)
|
|
125
|
+
return { available: false, reason: 'MEMORY_INDEX_MISSING' };
|
|
126
|
+
// Rank the union once (one fuzzy pass per token over all candidates),
|
|
127
|
+
// then split by tier — cheaper than ranking each tier separately and
|
|
128
|
+
// order-preserving for both.
|
|
129
|
+
const hotSet = new Set(tiered.hot);
|
|
130
|
+
const tokens = queryTokens(taskTitle);
|
|
131
|
+
const rankedAll = rankByRelevance(taskTitle, [...tiered.hot, ...tiered.warm]);
|
|
132
|
+
// Hot is ALWAYS eligible — ranked so the most task-relevant lead, with
|
|
133
|
+
// unmatched standing rules appended in index order.
|
|
134
|
+
const hotRanked = rankedAll.filter((r) => hotSet.has(r.entry));
|
|
135
|
+
const hotSelected = hotRanked.slice(0, this.config.hotItemCap);
|
|
136
|
+
let timedOut = performance.now() - startedAt >= this.config.selectionTimeBudgetMs;
|
|
137
|
+
const warmSelected = [];
|
|
138
|
+
let warmEligibleCount = 0;
|
|
139
|
+
if (!timedOut && this.config.warmItemCap > 0) {
|
|
140
|
+
const warmRanked = rankedAll
|
|
141
|
+
.filter((r) => !hotSet.has(r.entry))
|
|
142
|
+
.filter((r) => tokenHits(`${r.entry.name} ${r.entry.description}`.toLowerCase(), tokens) >=
|
|
143
|
+
this.config.warmMinTokenHits);
|
|
144
|
+
warmEligibleCount = warmRanked.length;
|
|
145
|
+
for (const candidate of warmRanked) {
|
|
146
|
+
if (warmSelected.length >= this.config.warmItemCap)
|
|
147
|
+
break;
|
|
148
|
+
if (performance.now() - startedAt >=
|
|
149
|
+
this.config.selectionTimeBudgetMs) {
|
|
150
|
+
timedOut = true;
|
|
151
|
+
break;
|
|
152
|
+
}
|
|
153
|
+
warmSelected.push(candidate);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
if (hotSelected.length === 0 && warmSelected.length === 0) {
|
|
157
|
+
return { available: false, reason: 'NO_RELEVANT_MEMORY' };
|
|
158
|
+
}
|
|
159
|
+
const sections = [];
|
|
160
|
+
if (hotSelected.length > 0) {
|
|
161
|
+
sections.push('### Standing memory (always applied)');
|
|
162
|
+
for (const r of hotSelected)
|
|
163
|
+
sections.push(renderEntry(r.entry));
|
|
164
|
+
}
|
|
165
|
+
if (warmSelected.length > 0) {
|
|
166
|
+
sections.push('### Task-relevant memory (drill down with Read)');
|
|
167
|
+
for (const r of warmSelected)
|
|
168
|
+
sections.push(renderEntry(r.entry));
|
|
60
169
|
}
|
|
61
|
-
const listLines = selected
|
|
62
|
-
.map((e) => `- * ${e.name}\n Path: ${e.sourcePath}\n One-line: ${summarize(e.description)}`)
|
|
63
|
-
.join('\n');
|
|
64
170
|
let tail = '\n';
|
|
65
171
|
let cachedCount = 0;
|
|
66
|
-
if (this.cachedMemoContents.size > 0) {
|
|
67
|
-
const
|
|
172
|
+
if (this.config.includeBodies && this.cachedMemoContents.size > 0) {
|
|
173
|
+
const bodies = [];
|
|
68
174
|
for (const [path, body] of this.cachedMemoContents) {
|
|
69
|
-
|
|
175
|
+
bodies.push(`### ${path}\n\n${body}`);
|
|
70
176
|
cachedCount += 1;
|
|
71
177
|
}
|
|
72
|
-
tail = `\n\n## Requested memory details:\n${
|
|
178
|
+
tail = `\n\n## Requested memory details:\n${bodies.join('\n\n')}\n`;
|
|
73
179
|
}
|
|
74
180
|
const header = '## Project memory relevant to this task\n';
|
|
75
|
-
const composed = `${header}${
|
|
76
|
-
const
|
|
77
|
-
const
|
|
78
|
-
const
|
|
181
|
+
const composed = `${header}${sections.join('\n')}\n${tail}`;
|
|
182
|
+
const { text, truncated } = truncateToCap(composed, this.config.maxBytes);
|
|
183
|
+
const selectedCount = hotSelected.length + warmSelected.length;
|
|
184
|
+
const droppedByItemCap = hotRanked.length - hotSelected.length + (warmEligibleCount - warmSelected.length);
|
|
185
|
+
const droppedByBytes = truncated
|
|
186
|
+
? Math.max(0, selectedCount - countItemsInBlock(text))
|
|
187
|
+
: 0;
|
|
188
|
+
const droppedCount = droppedByItemCap + droppedByBytes;
|
|
79
189
|
return {
|
|
80
190
|
available: true,
|
|
81
191
|
block: text,
|
|
82
|
-
feedbackListItems:
|
|
192
|
+
feedbackListItems: selectedCount,
|
|
83
193
|
cachedItemCount: cachedCount,
|
|
194
|
+
hotSelected: hotSelected.length,
|
|
195
|
+
warmSelected: warmSelected.length,
|
|
196
|
+
bytesEmitted: Buffer.byteLength(text, 'utf8'),
|
|
197
|
+
budgetTruncated: truncated || droppedByItemCap > 0 || timedOut,
|
|
198
|
+
timedOut,
|
|
84
199
|
truncated,
|
|
85
200
|
droppedCount: droppedCount > 0 ? droppedCount : undefined,
|
|
86
201
|
};
|
|
87
202
|
}
|
|
88
203
|
}
|
|
204
|
+
/**
|
|
205
|
+
* Slice 2026-09-09-memory-retrieval: the query the dispatch site feeds
|
|
206
|
+
* `fetchBlock`. The bare role ("rd") carries almost no relevance signal,
|
|
207
|
+
* so the first line of the task brief is appended (truncated) to give the
|
|
208
|
+
* fuzzy kernel something to rank against. Pure and fail-soft.
|
|
209
|
+
*/
|
|
210
|
+
export function deriveMemoryQuery(role, taskBody) {
|
|
211
|
+
const firstLine = (taskBody ?? '').split('\n', 1)[0]?.trim() ?? '';
|
|
212
|
+
const trimmed = firstLine.length > 160 ? firstLine.slice(0, 160) : firstLine;
|
|
213
|
+
return trimmed === '' ? role : `${role} ${trimmed}`;
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Rank entries against the task title with the deterministic fuzzy kernel.
|
|
217
|
+
*
|
|
218
|
+
* The kernel is invoked once PER TOKEN rather than once for the whole
|
|
219
|
+
* title: a whole-title query is a single fzf subsequence and realistic
|
|
220
|
+
* task titles match almost nothing that way (measured: 0 warm hits on
|
|
221
|
+
* the real 131-entry warm tier). Per-token accumulation keeps the kernel
|
|
222
|
+
* deterministic and gives a graded score — entries matching more task
|
|
223
|
+
* words, more strongly, rank higher.
|
|
224
|
+
*
|
|
225
|
+
* Every entry is returned: matched entries lead by score desc, unmatched
|
|
226
|
+
* entries follow in index order with score 0 — this is what keeps hot
|
|
227
|
+
* entries "always eligible". An empty/token-less task title degrades to
|
|
228
|
+
* index order.
|
|
229
|
+
*/
|
|
230
|
+
function rankByRelevance(query, entries) {
|
|
231
|
+
if (entries.length === 0)
|
|
232
|
+
return [];
|
|
233
|
+
const tokens = queryTokens(query);
|
|
234
|
+
if (tokens.length === 0) {
|
|
235
|
+
return entries.map((entry) => ({ entry, score: 0 }));
|
|
236
|
+
}
|
|
237
|
+
const accumulated = new Map();
|
|
238
|
+
for (const token of tokens) {
|
|
239
|
+
const matches = fuzzyMatchWithKey(token, entries, {
|
|
240
|
+
keyFn: (e) => `${e.name} ${e.description}`,
|
|
241
|
+
limit: entries.length,
|
|
242
|
+
caseSensitive: false,
|
|
243
|
+
});
|
|
244
|
+
for (const match of matches) {
|
|
245
|
+
accumulated.set(match.item, (accumulated.get(match.item) ?? 0) + match.score);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
// Array#sort is stable (ES2019), so equal scores keep index order.
|
|
249
|
+
return entries
|
|
250
|
+
.map((entry) => ({ entry, score: accumulated.get(entry) ?? 0 }))
|
|
251
|
+
.sort((a, b) => b.score - a.score);
|
|
252
|
+
}
|
|
253
|
+
function renderEntry(entry) {
|
|
254
|
+
return `- * ${entry.name}\n Path: ${entry.sourcePath}\n One-line: ${summarize(entry.description)}`;
|
|
255
|
+
}
|
|
89
256
|
function summarize(description) {
|
|
90
257
|
// Drop the <!-- peaks-feedback-promoted: layer=A --> marker, take the next 1 line.
|
|
91
258
|
const cleaned = description.replace(/<!--[^>]*-->/g, '').trim();
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
* (`L3:l3-memory-health`).
|
|
2
|
+
* Checks for `.peaks/memory/` health (`L3:l3-memory-health` and siblings).
|
|
4
3
|
*
|
|
5
4
|
* Slice 2026-06-13-repair-pre-existing-test-failures: the
|
|
6
5
|
* production MemoryIndex schema (see
|
|
@@ -11,6 +10,24 @@
|
|
|
11
10
|
*
|
|
12
11
|
* When no `.peaks/memory/index.json` exists yet, the check passes
|
|
13
12
|
* (fresh project — no memories have been extracted).
|
|
13
|
+
*
|
|
14
|
+
* Slice 2026-09-09-memory-system-overhaul (D) extends the check with the
|
|
15
|
+
* drift findings the original version could not see. It used to report
|
|
16
|
+
* `ok: true` for "index.json is well-formed JSON; 100 hot + 131 warm" and
|
|
17
|
+
* never looked at coverage, orphans, or unclassified files. It now emits,
|
|
18
|
+
* in addition to the unchanged well-formed-JSON assertion:
|
|
19
|
+
*
|
|
20
|
+
* - `L3:l3-memory-coverage` — disk files vs indexed entries (warning
|
|
21
|
+
* when the gap exceeds a small threshold)
|
|
22
|
+
* - `L3:l3-memory-orphans` — index entries whose `sourcePath` is gone
|
|
23
|
+
* (error) + disk files absent from the
|
|
24
|
+
* index (warning)
|
|
25
|
+
* - `L3:l3-memory-unclassified` — files with no resolvable kind (warning,
|
|
26
|
+
* count + first N names)
|
|
27
|
+
*
|
|
28
|
+
* All three are read-only and fail-soft: an inspection error degrades to a
|
|
29
|
+
* single warning instead of throwing, and none of them change the id or the
|
|
30
|
+
* `ok` semantics of the original `L3:l3-memory-health` assertion.
|
|
14
31
|
*/
|
|
15
32
|
import type { DoctorCheckPlugin } from '../types.js';
|
|
16
33
|
export declare const check: DoctorCheckPlugin;
|