peaks-loop 4.0.0-beta.24 → 4.0.0-beta.25
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 +12 -0
- package/README-en.md +1 -1
- package/README.md +1 -1
- package/dist/cli/commands/dispatch-commands.js +17 -2
- package/dist/services/context/build-dispatch-system-prompt.d.ts +36 -0
- package/dist/services/context/build-dispatch-system-prompt.js +22 -0
- package/dist/services/context/memory-index-reader.d.ts +8 -0
- package/dist/services/context/memory-index-reader.js +57 -0
- package/dist/services/context/memory-lru-cache.d.ts +12 -0
- package/dist/services/context/memory-lru-cache.js +60 -0
- package/dist/services/context/memory-preflight-config.d.ts +19 -0
- package/dist/services/context/memory-preflight-config.js +25 -0
- package/dist/services/context/memory-preflight-service.d.ts +19 -0
- package/dist/services/context/memory-preflight-service.js +120 -0
- package/dist/services/preferences/preferences-service.js +10 -0
- package/dist/services/preferences/preferences-types.d.ts +13 -0
- package/dist/services/preferences/preferences-types.js +11 -0
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -11,6 +11,18 @@
|
|
|
11
11
|
- peaks-loop-doctor@0.0.12
|
|
12
12
|
- peaks-loop-final-review@0.0.12
|
|
13
13
|
|
|
14
|
+
## 4.1.0 (Unreleased)
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
|
|
18
|
+
- **Sub-agent memory preflight**: peaks-code orchestrator now
|
|
19
|
+
automatically injects a token-bounded `feedback / layer A` memory
|
|
20
|
+
block from `.peaks/memory/index.json` into every sub-agent's
|
|
21
|
+
system prompt. Defaults to 1.2k token cap (configurable via
|
|
22
|
+
`.peaks/preferences.json::memoryPreflight.maxTokens`); silent
|
|
23
|
+
degradation when the index is missing. No new CLI surface.
|
|
24
|
+
See `docs/superpowers/specs/2026-07-22-orchestrator-memory-preflight-design.md`.
|
|
25
|
+
|
|
14
26
|
## 4.0.0
|
|
15
27
|
|
|
16
28
|
### Patch Changes
|
package/README-en.md
CHANGED
|
@@ -123,7 +123,7 @@ Every lane opens with **one slash command**.
|
|
|
123
123
|
|
|
124
124
|
| | |
|
|
125
125
|
| --- | --- |
|
|
126
|
-
| **Latest** | [`4.0.0-beta.
|
|
126
|
+
| **Latest** | [`4.0.0-beta.25`](https://github.com/SquabbyZ/peaks-loop/releases) — 4.x GA is in the works |
|
|
127
127
|
| **Domains** | Code (`peaks-code`) · Content (`peaks-content`) · Project health (`peaks-doctor`) · Issue sweep (`peaks-issue-fix-orchestrator`) · Custom SOP (`peaks-sop`) · Cross-domain primitives (`peaks-solo` dispatcher · `peaks-resume` · `peaks-status` · `peaks-test`) |
|
|
128
128
|
| **Sediment pool** | `~/.peaks/` local pool · twice-clean runs auto-promote to a bee · broken runs come back for you to redefine · the bee grows with your taste |
|
|
129
129
|
| **Test suite** | 5,439 passed · 19 skipped · 0 failed |
|
package/README.md
CHANGED
|
@@ -123,7 +123,7 @@ npm i -g peaks-loop
|
|
|
123
123
|
|
|
124
124
|
| | |
|
|
125
125
|
| --- | --- |
|
|
126
|
-
| **最新版本** | [`4.0.0-beta.
|
|
126
|
+
| **最新版本** | [`4.0.0-beta.25`](https://github.com/SquabbyZ/peaks-loop/releases) — 4.x 正式版筹备中 |
|
|
127
127
|
| **覆盖域** | 代码(`peaks-code`) · 内容(`peaks-content`) · 项目健康(`peaks-doctor`) · 批量修 issue(`peaks-issue-fix-orchestrator`) · 自定义 SOP(`peaks-sop`) · 通用原语(`peaks-solo` 分诊 / `peaks-resume` 续 / `peaks-status` 看 / `peaks-test` 测) |
|
|
128
128
|
| **沉淀池** | `~/.peaks/` 本地池 · 跑两次自动晋升成 bee · 跑翻车让你重定义 · bee 跟着你的口味长 |
|
|
129
129
|
| **测试套件** | 5,439 passed · 19 skipped · 0 failed |
|
|
@@ -36,6 +36,8 @@ import { writeLogEntry } from '../../services/log/logger.js';
|
|
|
36
36
|
import { HEADROOM_MODES, 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';
|
|
40
|
+
import { buildDispatchSystemPrompt } from '../../services/context/build-dispatch-system-prompt.js';
|
|
39
41
|
export function registerDispatchCommand(parent, io) {
|
|
40
42
|
addJsonOption(parent
|
|
41
43
|
.command('dispatch')
|
|
@@ -152,9 +154,11 @@ export function registerDispatchCommand(parent, io) {
|
|
|
152
154
|
// Preferences hard-block when headroom.enabled=false (returns HEADROOM_DISABLED_BY_PREFERENCE).
|
|
153
155
|
// loadPreferences can throw on schema mismatch; we fall back to defaults to avoid
|
|
154
156
|
// breaking the dispatch on a stale preferences.json file.
|
|
157
|
+
let projectPrefs = DEFAULT_PREFERENCES;
|
|
155
158
|
let headroomPrefs = DEFAULT_PREFERENCES.headroom;
|
|
156
159
|
try {
|
|
157
|
-
|
|
160
|
+
projectPrefs = loadPreferences(projectRoot);
|
|
161
|
+
headroomPrefs = projectPrefs.headroom;
|
|
158
162
|
}
|
|
159
163
|
catch { // TODO(g2): legacy silent catch — grace: 1 minor release (v2.14.0)
|
|
160
164
|
// Keep default preferences; the user can re-run with explicit --headroom-mode
|
|
@@ -187,7 +191,18 @@ export function registerDispatchCommand(parent, io) {
|
|
|
187
191
|
}
|
|
188
192
|
// G7.7 headroom compress (opt-in). If headroom fails or is unavailable,
|
|
189
193
|
// fall back to the original prompt + emit warning.
|
|
190
|
-
|
|
194
|
+
// Slice 2026-07-22-orchestrator-memory-preflight (Task 5): prepend the
|
|
195
|
+
// memory preflight block (or silently skip when unavailable) via the
|
|
196
|
+
// pure-function builder, BEFORE the headroom-ai compress step so the
|
|
197
|
+
// compressor sees the augmented payload.
|
|
198
|
+
const preflightService = new MemoryPreflightService(projectRoot, projectPrefs);
|
|
199
|
+
const memoryBlock = await preflightService.fetchBlock(role);
|
|
200
|
+
const memoryAugmentedBody = buildDispatchSystemPrompt({
|
|
201
|
+
taskTitle: role,
|
|
202
|
+
taskBody: options.prompt,
|
|
203
|
+
memoryBlock,
|
|
204
|
+
});
|
|
205
|
+
let effectivePrompt = `${formatTestToolDetection()}\n\n${memoryAugmentedBody}`;
|
|
191
206
|
let headroomCompressed = false;
|
|
192
207
|
let headroomResult = null;
|
|
193
208
|
const warnings = [...decision.warnings];
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* buildDispatchSystemPrompt — pure-function prompt composer for sub-agent dispatch.
|
|
3
|
+
*
|
|
4
|
+
* Slice 2026-07-22-orchestrator-memory-preflight (Task 5). The orchestrator
|
|
5
|
+
* dispatch flow (`src/cli/commands/dispatch-commands.ts`) calls
|
|
6
|
+
* `MemoryPreflightService.fetchBlock` and feeds the result into this builder
|
|
7
|
+
* so the memory block, when available, is prepended BEFORE the task brief.
|
|
8
|
+
*
|
|
9
|
+
* Keeping the builder a pure function (no IO) makes the three acceptance
|
|
10
|
+
* cases easy to test in isolation:
|
|
11
|
+
* 1. returns the original prompt (sans memory block) when unavailable
|
|
12
|
+
* 2. prepends the memory block when available
|
|
13
|
+
* 3. never pushes the memory block below the task brief
|
|
14
|
+
*/
|
|
15
|
+
import type { MemoryPreflightResult } from './memory-preflight-service.js';
|
|
16
|
+
export interface DispatchPromptInput {
|
|
17
|
+
taskTitle: string;
|
|
18
|
+
taskBody: string;
|
|
19
|
+
memoryBlock: MemoryPreflightResult;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Compose the system-prompt body that the dispatch site prepends to
|
|
23
|
+
* `formatTestToolDetection()\n\n`.
|
|
24
|
+
*
|
|
25
|
+
* Byte-identical degradation contract (slice 2026-07-22-orchestrator-memory-preflight
|
|
26
|
+
* controller brief): when the memory block is unavailable, the caller does
|
|
27
|
+
* `formatTestToolDetection()\n\n${taskBody}` — i.e. the final prompt is exactly
|
|
28
|
+
* `${formatTestToolDetection()}\n\n${taskBody}`. Today's pre-change behavior
|
|
29
|
+
* produced the same string from `src/cli/commands/dispatch-commands.ts:220`,
|
|
30
|
+
* so the unavailable branch MUST return `taskBody` (NOT a `# title\n\n` wrap).
|
|
31
|
+
*
|
|
32
|
+
* Available branch prepends the memory block before the `## Task` heading so
|
|
33
|
+
* `## Project memory …` always sits above the task brief (never pushed below
|
|
34
|
+
* it).
|
|
35
|
+
*/
|
|
36
|
+
export declare function buildDispatchSystemPrompt(input: DispatchPromptInput): string;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compose the system-prompt body that the dispatch site prepends to
|
|
3
|
+
* `formatTestToolDetection()\n\n`.
|
|
4
|
+
*
|
|
5
|
+
* Byte-identical degradation contract (slice 2026-07-22-orchestrator-memory-preflight
|
|
6
|
+
* controller brief): when the memory block is unavailable, the caller does
|
|
7
|
+
* `formatTestToolDetection()\n\n${taskBody}` — i.e. the final prompt is exactly
|
|
8
|
+
* `${formatTestToolDetection()}\n\n${taskBody}`. Today's pre-change behavior
|
|
9
|
+
* produced the same string from `src/cli/commands/dispatch-commands.ts:220`,
|
|
10
|
+
* so the unavailable branch MUST return `taskBody` (NOT a `# title\n\n` wrap).
|
|
11
|
+
*
|
|
12
|
+
* Available branch prepends the memory block before the `## Task` heading so
|
|
13
|
+
* `## Project memory …` always sits above the task brief (never pushed below
|
|
14
|
+
* it).
|
|
15
|
+
*/
|
|
16
|
+
export function buildDispatchSystemPrompt(input) {
|
|
17
|
+
const { taskBody, memoryBlock } = input;
|
|
18
|
+
if (memoryBlock.available === true && typeof memoryBlock.block === 'string') {
|
|
19
|
+
return `${memoryBlock.block}\n## Task\n${taskBody}\n`;
|
|
20
|
+
}
|
|
21
|
+
return taskBody;
|
|
22
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { MemoryIndexEntry } from '../memory/memory-search-service.js';
|
|
2
|
+
export declare class MemoryIndexReader {
|
|
3
|
+
private readonly projectRoot;
|
|
4
|
+
private cache;
|
|
5
|
+
constructor(projectRoot: string);
|
|
6
|
+
loadIfStale(): MemoryIndexEntry[];
|
|
7
|
+
selectFeedbackLayerA(cap: number): MemoryIndexEntry[];
|
|
8
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
const LAYER_A_RE = /peaks-feedback-promoted:\s*layer=A\b/;
|
|
4
|
+
export class MemoryIndexReader {
|
|
5
|
+
projectRoot;
|
|
6
|
+
cache = null;
|
|
7
|
+
constructor(projectRoot) {
|
|
8
|
+
this.projectRoot = projectRoot;
|
|
9
|
+
}
|
|
10
|
+
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;
|
|
28
|
+
}
|
|
29
|
+
selectFeedbackLayerA(cap) {
|
|
30
|
+
const all = this.loadIfStale();
|
|
31
|
+
return all
|
|
32
|
+
.filter((e) => e.kind === 'feedback')
|
|
33
|
+
.filter((e) => LAYER_A_RE.test(e.description))
|
|
34
|
+
.slice(0, Math.max(1, Math.trunc(cap)));
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function flattenIndex(raw) {
|
|
38
|
+
if (!raw || typeof raw !== 'object')
|
|
39
|
+
return [];
|
|
40
|
+
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')
|
|
45
|
+
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
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export declare class MemoryLruCache {
|
|
2
|
+
private readonly budgetBytes;
|
|
3
|
+
private readonly store;
|
|
4
|
+
private currentBytes;
|
|
5
|
+
constructor(budgetBytes: number);
|
|
6
|
+
set(key: string, body: string): void;
|
|
7
|
+
get(key: string): string | undefined;
|
|
8
|
+
delete(key: string): void;
|
|
9
|
+
size(): number;
|
|
10
|
+
private evictIfOver;
|
|
11
|
+
private evictAll;
|
|
12
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
export class MemoryLruCache {
|
|
2
|
+
budgetBytes;
|
|
3
|
+
store = new Map();
|
|
4
|
+
currentBytes = 0;
|
|
5
|
+
constructor(budgetBytes) {
|
|
6
|
+
this.budgetBytes = budgetBytes;
|
|
7
|
+
}
|
|
8
|
+
set(key, body) {
|
|
9
|
+
if (this.store.has(key))
|
|
10
|
+
this.delete(key);
|
|
11
|
+
const bytes = Buffer.byteLength(body, 'utf8');
|
|
12
|
+
if (bytes > this.budgetBytes) {
|
|
13
|
+
// Oversized insert: evict ALL existing entries (LRU order) so only
|
|
14
|
+
// the newly inserted key remains in the cache. This prevents the
|
|
15
|
+
// unbounded growth that a naive "single oversized exception" would
|
|
16
|
+
// allow when oversized values are inserted consecutively.
|
|
17
|
+
this.evictAll();
|
|
18
|
+
this.store.set(key, { body, bytes });
|
|
19
|
+
this.currentBytes += bytes;
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
this.store.set(key, { body, bytes });
|
|
23
|
+
this.currentBytes += bytes;
|
|
24
|
+
this.evictIfOver();
|
|
25
|
+
}
|
|
26
|
+
get(key) {
|
|
27
|
+
const entry = this.store.get(key);
|
|
28
|
+
if (!entry)
|
|
29
|
+
return undefined;
|
|
30
|
+
// refresh recency
|
|
31
|
+
this.store.delete(key);
|
|
32
|
+
this.store.set(key, entry);
|
|
33
|
+
return entry.body;
|
|
34
|
+
}
|
|
35
|
+
delete(key) {
|
|
36
|
+
const entry = this.store.get(key);
|
|
37
|
+
if (!entry)
|
|
38
|
+
return;
|
|
39
|
+
this.store.delete(key);
|
|
40
|
+
this.currentBytes -= entry.bytes;
|
|
41
|
+
}
|
|
42
|
+
size() {
|
|
43
|
+
return this.currentBytes;
|
|
44
|
+
}
|
|
45
|
+
evictIfOver() {
|
|
46
|
+
while (this.currentBytes > this.budgetBytes && this.store.size > 0) {
|
|
47
|
+
// Map preserves insertion order — first key is least-recently-touched
|
|
48
|
+
const oldestKey = this.store.keys().next().value;
|
|
49
|
+
if (oldestKey === undefined)
|
|
50
|
+
break;
|
|
51
|
+
this.delete(oldestKey);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
evictAll() {
|
|
55
|
+
// Empty the store; called before an oversized insert so only the new
|
|
56
|
+
// entry remains, per the eviction contract documented above.
|
|
57
|
+
this.store.clear();
|
|
58
|
+
this.currentBytes = 0;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { ProjectPreferences } from '../preferences/preferences-types.js';
|
|
2
|
+
export interface MemoryPreflightConfig {
|
|
3
|
+
readonly enabled: boolean;
|
|
4
|
+
readonly maxTokens: number;
|
|
5
|
+
readonly listCap: number;
|
|
6
|
+
readonly contentCacheBytes: number;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Loose input type for `resolveMemoryPreflightConfig`. Accepts either a
|
|
10
|
+
* full `ProjectPreferences` (e.g. the output of `loadPreferences()`) or
|
|
11
|
+
* any object that at minimum carries `memoryPreflight`. Lets callers
|
|
12
|
+
* pass a partial overlay literal — e.g. `{}` or `{ memoryPreflight: {...} }`
|
|
13
|
+
* — without having to construct a fully-populated preferences fixture.
|
|
14
|
+
*
|
|
15
|
+
* Downstream tasks (orchestrator service, dispatch hook) should import
|
|
16
|
+
* this alias rather than re-deriving the structural type.
|
|
17
|
+
*/
|
|
18
|
+
export type MemoryPreflightPrefsInput = ProjectPreferences | Pick<ProjectPreferences, 'memoryPreflight'>;
|
|
19
|
+
export declare function resolveMemoryPreflightConfig(prefs: MemoryPreflightPrefsInput): MemoryPreflightConfig;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
const DEFAULTS = Object.freeze({
|
|
2
|
+
enabled: true,
|
|
3
|
+
maxTokens: 1200,
|
|
4
|
+
listCap: 12,
|
|
5
|
+
contentCacheBytes: 6000,
|
|
6
|
+
});
|
|
7
|
+
const LIST_CAP_MIN = 1;
|
|
8
|
+
const LIST_CAP_MAX = 50;
|
|
9
|
+
function asFiniteInt(value, fallback) {
|
|
10
|
+
return typeof value === 'number' && Number.isFinite(value) && value >= 0
|
|
11
|
+
? Math.trunc(value)
|
|
12
|
+
: fallback;
|
|
13
|
+
}
|
|
14
|
+
export function resolveMemoryPreflightConfig(prefs) {
|
|
15
|
+
const m = prefs.memoryPreflight ?? {};
|
|
16
|
+
const listCapRaw = asFiniteInt(m.listCap, DEFAULTS.listCap);
|
|
17
|
+
return {
|
|
18
|
+
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)),
|
|
21
|
+
contentCacheBytes: m.contentCacheBytes && m.contentCacheBytes > 0
|
|
22
|
+
? m.contentCacheBytes
|
|
23
|
+
: DEFAULTS.contentCacheBytes,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { type MemoryPreflightPrefsInput } from './memory-preflight-config.js';
|
|
2
|
+
export interface MemoryPreflightResult {
|
|
3
|
+
available: boolean;
|
|
4
|
+
block?: string;
|
|
5
|
+
feedbackListItems?: number;
|
|
6
|
+
cachedItemCount?: number;
|
|
7
|
+
reason?: string;
|
|
8
|
+
truncated?: boolean | undefined;
|
|
9
|
+
droppedCount?: number | undefined;
|
|
10
|
+
}
|
|
11
|
+
export declare class MemoryPreflightService {
|
|
12
|
+
private readonly reader;
|
|
13
|
+
private readonly config;
|
|
14
|
+
/** path -> body; sub-agent-requested memo contents (Task 4 deviation, see module header). */
|
|
15
|
+
private readonly cachedMemoContents;
|
|
16
|
+
constructor(projectRoot: string, prefs: MemoryPreflightPrefsInput);
|
|
17
|
+
cacheMemoContent(path: string, content: string): void;
|
|
18
|
+
fetchBlock(_taskTitle: string): Promise<MemoryPreflightResult>;
|
|
19
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MemoryPreflightService — orchestrator-facing API for the
|
|
3
|
+
* "Orchestrator Memory Preflight" slice (see
|
|
4
|
+
* docs/superpowers/specs/2026-07-22-orchestrator-memory-preflight-design.md).
|
|
5
|
+
*
|
|
6
|
+
* Consumes:
|
|
7
|
+
* - 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)
|
|
11
|
+
* - compressPrompt (existing) — headroom-ai wrapper for hard-cap compression
|
|
12
|
+
*
|
|
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.
|
|
22
|
+
*/
|
|
23
|
+
import { compressPrompt } from './headroom-client.js';
|
|
24
|
+
import { MemoryIndexReader } from './memory-index-reader.js';
|
|
25
|
+
import { resolveMemoryPreflightConfig, } from './memory-preflight-config.js';
|
|
26
|
+
async function compressToCap(text, capBytes, mode) {
|
|
27
|
+
try {
|
|
28
|
+
const result = await compressPrompt(text, mode);
|
|
29
|
+
if (result.warning !== null || result.compressedPrompt === null) {
|
|
30
|
+
return { text, truncated: false };
|
|
31
|
+
}
|
|
32
|
+
const compressed = result.compressedPrompt;
|
|
33
|
+
if (Buffer.byteLength(compressed, 'utf8') > capBytes) {
|
|
34
|
+
const sliced = compressed.slice(0, Math.max(0, capBytes - 64)) + '\n…[truncated]';
|
|
35
|
+
return { text: sliced, truncated: true };
|
|
36
|
+
}
|
|
37
|
+
return { text: compressed, truncated: false };
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return { text, truncated: false };
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
export class MemoryPreflightService {
|
|
44
|
+
reader;
|
|
45
|
+
config;
|
|
46
|
+
/** path -> body; sub-agent-requested memo contents (Task 4 deviation, see module header). */
|
|
47
|
+
cachedMemoContents = new Map();
|
|
48
|
+
constructor(projectRoot, prefs) {
|
|
49
|
+
this.config = resolveMemoryPreflightConfig(prefs);
|
|
50
|
+
this.reader = new MemoryIndexReader(projectRoot);
|
|
51
|
+
}
|
|
52
|
+
cacheMemoContent(path, content) {
|
|
53
|
+
if (!this.config.enabled)
|
|
54
|
+
return;
|
|
55
|
+
const bytes = Buffer.byteLength(content, 'utf8');
|
|
56
|
+
if (bytes > this.config.contentCacheBytes) {
|
|
57
|
+
// too big to ever fit; do not cache.
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
this.cachedMemoContents.set(path, content);
|
|
61
|
+
}
|
|
62
|
+
async fetchBlock(_taskTitle) {
|
|
63
|
+
if (!this.config.enabled)
|
|
64
|
+
return { available: false, reason: 'DISABLED' };
|
|
65
|
+
const selected = this.reader.selectFeedbackLayerA(this.config.listCap);
|
|
66
|
+
if (selected.length === 0) {
|
|
67
|
+
const any = this.reader.loadIfStale();
|
|
68
|
+
if (any.length === 0)
|
|
69
|
+
return { available: false, reason: 'MEMORY_INDEX_MISSING' };
|
|
70
|
+
return { available: false, reason: 'NO_FEEDBACK_LAYER_A' };
|
|
71
|
+
}
|
|
72
|
+
const listLines = selected
|
|
73
|
+
.map((e) => `- * ${e.name}\n Path: ${e.sourcePath}\n One-line: ${summarize(e.description)}`)
|
|
74
|
+
.join('\n');
|
|
75
|
+
let tail = '\n';
|
|
76
|
+
let cachedCount = 0;
|
|
77
|
+
if (this.cachedMemoContents.size > 0) {
|
|
78
|
+
const sections = [];
|
|
79
|
+
for (const [path, body] of this.cachedMemoContents) {
|
|
80
|
+
sections.push(`### ${path}\n\n${body}`);
|
|
81
|
+
cachedCount += 1;
|
|
82
|
+
}
|
|
83
|
+
tail = `\n\n## Requested memory details:\n${sections.join('\n\n')}\n`;
|
|
84
|
+
}
|
|
85
|
+
const header = '## Project memory relevant to this task\n';
|
|
86
|
+
const composed = `${header}${listLines}${tail}`;
|
|
87
|
+
const capBytes = Math.max(64, this.config.maxTokens * 4);
|
|
88
|
+
const { text, truncated } = await compressToCap(composed, capBytes, 'balanced');
|
|
89
|
+
const droppedCount = truncated ? selected.length - countItemsInBlock(text) : 0;
|
|
90
|
+
return {
|
|
91
|
+
available: true,
|
|
92
|
+
block: text,
|
|
93
|
+
feedbackListItems: selected.length,
|
|
94
|
+
cachedItemCount: cachedCount,
|
|
95
|
+
truncated,
|
|
96
|
+
droppedCount: droppedCount > 0 ? droppedCount : undefined,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
function summarize(description) {
|
|
101
|
+
// Drop the <!-- peaks-feedback-promoted: layer=A --> marker, take the next 1 line.
|
|
102
|
+
const cleaned = description.replace(/<!--[^>]*-->/g, '').trim();
|
|
103
|
+
return cleaned.split('\n')[0] ?? cleaned;
|
|
104
|
+
}
|
|
105
|
+
function countItemsInBlock(text) {
|
|
106
|
+
// Count `- * ` markers ONLY in the list portion (between the
|
|
107
|
+
// `## Project memory relevant to this task` header and the
|
|
108
|
+
// `## Requested memory details:` sub-section, if present). Memo
|
|
109
|
+
// bodies appended under `## Requested memory details:` may
|
|
110
|
+
// legitimately contain their own `- * ` markdown bullets, which
|
|
111
|
+
// would otherwise inflate the count and produce a wrong
|
|
112
|
+
// `droppedCount` in the truncated case.
|
|
113
|
+
const headerEnd = text.indexOf('\n## ');
|
|
114
|
+
if (headerEnd === -1) {
|
|
115
|
+
return (text.match(/- \* /g) ?? []).length;
|
|
116
|
+
}
|
|
117
|
+
const tailStart = text.indexOf('\n## Requested memory details:', headerEnd + 1);
|
|
118
|
+
const listEnd = tailStart === -1 ? text.length : tailStart;
|
|
119
|
+
return (text.slice(0, listEnd).match(/- \* /g) ?? []).length;
|
|
120
|
+
}
|
|
@@ -9,6 +9,16 @@ export function preferencesPath(projectRoot) {
|
|
|
9
9
|
export function loadPreferences(projectRoot) {
|
|
10
10
|
const filePath = preferencesPath(projectRoot);
|
|
11
11
|
if (!existsSync(filePath)) {
|
|
12
|
+
// Slice 2026-07-22-orchestrator-memory-preflight: when
|
|
13
|
+
// .peaks/preferences.json is missing, this returns a clone of
|
|
14
|
+
// DEFAULT_PREFERENCES, which now carries the `memoryPreflight`
|
|
15
|
+
// block (see preferences-types.ts). Partial overlays fall through
|
|
16
|
+
// to mergePreferences below, where the new `memoryPreflight` block
|
|
17
|
+
// shallow-replaces the default block — `mergePreferences` does not
|
|
18
|
+
// deep-merge `memoryPreflight` (only `fanout` is deep-merged), so a
|
|
19
|
+
// partial overlay will replace the whole block. Resolution still
|
|
20
|
+
// works because `resolveMemoryPreflightConfig` applies field-level
|
|
21
|
+
// defaults over the partial input (see memory-preflight-config.ts).
|
|
12
22
|
return structuredClone(DEFAULT_PREFERENCES);
|
|
13
23
|
}
|
|
14
24
|
let raw;
|
|
@@ -131,6 +131,19 @@ export interface ProjectPreferences {
|
|
|
131
131
|
* saved file is rejected at load time.
|
|
132
132
|
*/
|
|
133
133
|
readonly fanout: FanoutPreference;
|
|
134
|
+
/**
|
|
135
|
+
* Slice 2026-07-22-orchestrator-memory-preflight: orchestrator-side memory
|
|
136
|
+
* preflight knobs. Optional — `resolveMemoryPreflightConfig` (see
|
|
137
|
+
* src/services/context/memory-preflight-config.ts) merges over a hard-coded
|
|
138
|
+
* default block when this key is absent, so legacy preferences.json files
|
|
139
|
+
* keep working unchanged.
|
|
140
|
+
*/
|
|
141
|
+
readonly memoryPreflight?: {
|
|
142
|
+
readonly enabled?: boolean;
|
|
143
|
+
readonly maxTokens?: number;
|
|
144
|
+
readonly listCap?: number;
|
|
145
|
+
readonly contentCacheBytes?: number;
|
|
146
|
+
};
|
|
134
147
|
}
|
|
135
148
|
export type FanoutMode = 'fan-out';
|
|
136
149
|
export declare const FANOUT_MODES: readonly FanoutMode[];
|
|
@@ -50,4 +50,15 @@ export const DEFAULT_PREFERENCES = {
|
|
|
50
50
|
fanout: {
|
|
51
51
|
defaultMode: 'fan-out'
|
|
52
52
|
},
|
|
53
|
+
// Slice 2026-07-22-orchestrator-memory-preflight: defaults aligned with
|
|
54
|
+
// memory-preflight-config.ts::DEFAULTS. Kept in sync manually because
|
|
55
|
+
// loadPreferences() returns DEFAULT_PREFERENCES verbatim when the on-disk
|
|
56
|
+
// file is absent, and partial overlays rely on memoryPreflight being
|
|
57
|
+
// defined here.
|
|
58
|
+
memoryPreflight: {
|
|
59
|
+
enabled: true,
|
|
60
|
+
maxTokens: 1200,
|
|
61
|
+
listCap: 12,
|
|
62
|
+
contentCacheBytes: 6000,
|
|
63
|
+
},
|
|
53
64
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "peaks-loop",
|
|
3
|
-
"version": "4.0.0-beta.
|
|
3
|
+
"version": "4.0.0-beta.25",
|
|
4
4
|
"description": "Loop Engineering CLI — workflow primitive / loop guards / evaluators / slice orchestration",
|
|
5
5
|
"author": "SquabbyZ",
|
|
6
6
|
"license": "MIT",
|
|
@@ -54,9 +54,9 @@
|
|
|
54
54
|
"headroom-ai": "0.22.4",
|
|
55
55
|
"yaml": "^2.9.0",
|
|
56
56
|
"zod": "^3.25.76",
|
|
57
|
+
"peaks-loop-audit-independent": "0.0.11",
|
|
57
58
|
"peaks-loop-crystallization": "0.0.12",
|
|
58
59
|
"peaks-loop-doctor": "0.0.11",
|
|
59
|
-
"peaks-loop-audit-independent": "0.0.11",
|
|
60
60
|
"peaks-loop-mut": "0.1.0",
|
|
61
61
|
"peaks-loop-final-review": "0.0.11",
|
|
62
62
|
"peaks-loop-job-snapshot": "0.0.5",
|