@adhdev/daemon-core 1.0.18-rc.7 → 1.0.18-rc.9
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/dist/commands/windows-atomic-upgrade.d.ts +4 -0
- package/dist/index.js +245 -59
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +245 -59
- package/dist/index.mjs.map +1 -1
- package/dist/mesh/coordinator-prompt.d.ts +57 -13
- package/dist/providers/cli-provider-instance.d.ts +1 -0
- package/package.json +3 -3
- package/src/commands/high-family/mesh-coordinator-launch.ts +12 -2
- package/src/commands/upgrade-helper.ts +28 -14
- package/src/commands/windows-atomic-upgrade.ts +61 -18
- package/src/config/mesh-json-config.ts +5 -1
- package/src/mesh/coordinator-prompt.ts +206 -31
- package/src/mesh/mesh-queue-assignment.ts +126 -1
- package/src/providers/cli-provider-instance.ts +51 -0
|
@@ -82,6 +82,27 @@ export interface CoordinatorOperatingNote {
|
|
|
82
82
|
* (see isNoteExpired) governs expiry; pinned notes never expire regardless.
|
|
83
83
|
*/
|
|
84
84
|
expiresAt?: string;
|
|
85
|
+
/**
|
|
86
|
+
* Phase 2 — the ledger id of this note, when known. Threaded from the ledger
|
|
87
|
+
* entry id at launch so version-supersede can target a specific note and
|
|
88
|
+
* same-class folding can list the subsumed ids. Repo-declared notes may lack
|
|
89
|
+
* one; absent is fine (folding/supersede fall back to the subject key).
|
|
90
|
+
*/
|
|
91
|
+
noteId?: string;
|
|
92
|
+
/**
|
|
93
|
+
* Phase 2 (b) version-supersede — an optional note-id or stable subject-key
|
|
94
|
+
* this note replaces. At injection, any earlier LIVE note whose `noteId` or
|
|
95
|
+
* `subjectKey` matches this value is hidden from the prompt (the ledger entry
|
|
96
|
+
* is retained for audit). Optional and lossless: absent = supersedes nothing.
|
|
97
|
+
*/
|
|
98
|
+
supersedes?: string;
|
|
99
|
+
/**
|
|
100
|
+
* Phase 2 (b)/(c) — an optional stable subject-key grouping notes about the
|
|
101
|
+
* same subject. Drives version-supersede targeting and same-class folding.
|
|
102
|
+
* When absent, folding derives a key from a leading `[tag]` bracket instead,
|
|
103
|
+
* so legacy notes still collapse by their conventional tag prefix.
|
|
104
|
+
*/
|
|
105
|
+
subjectKey?: string;
|
|
85
106
|
}
|
|
86
107
|
export interface CoordinatorPromptContext {
|
|
87
108
|
mesh: LocalMeshEntry;
|
|
@@ -117,21 +138,43 @@ export interface CoordinatorPromptContext {
|
|
|
117
138
|
}
|
|
118
139
|
export declare function buildCoordinatorSystemPrompt(ctx: CoordinatorPromptContext): string;
|
|
119
140
|
/**
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
141
|
+
* Phase 2 (c) — deterministic same-class fold. Given ranked notes (already
|
|
142
|
+
* highest-priority first), collapse runs of UNPINNED notes that share the same
|
|
143
|
+
* category AND subject key into a single injected entry: keep the highest-ranked
|
|
144
|
+
* (first-seen) note and record the note-ids/count it subsumes so the rendered
|
|
145
|
+
* line can say "(+N earlier)". Pinned notes never fold — each pinned note is
|
|
146
|
+
* author-opted-in and shown verbatim. The store is untouched; this is a pure,
|
|
147
|
+
* model-free text fold at injection time.
|
|
148
|
+
*
|
|
149
|
+
* Returns the folded note list (order preserved) with per-entry subsumed info.
|
|
150
|
+
*/
|
|
151
|
+
interface FoldedNote {
|
|
152
|
+
note: CoordinatorOperatingNote;
|
|
153
|
+
/** note-ids of same-class notes this entry subsumes (may be empty). */
|
|
154
|
+
subsumedIds: string[];
|
|
155
|
+
/** count of subsumed notes (== subsumedIds.length, but counts id-less ones too). */
|
|
156
|
+
subsumedCount: number;
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Operating-notes lifecycle selection (read-side). Given the effective notes
|
|
160
|
+
* (oldest-first, ledger order) and the current time, produce the ordered list
|
|
161
|
+
* that rides into the prompt:
|
|
162
|
+
* (i) ALWAYS include pinned notes.
|
|
163
|
+
* (ii) drop expired UNPINNED notes (per category TTL / explicit expiresAt).
|
|
164
|
+
* (iii) Phase 2 (b) drop any note SUPERSEDED by a later live note (by noteId
|
|
165
|
+
* or subjectKey); pinned notes are never dropped by supersede.
|
|
166
|
+
* (iv) rank pinned-first, then durable-category, then recency (newest first).
|
|
167
|
+
* (v) Phase 2 (c) fold same-category/same-subject unpinned runs into one.
|
|
168
|
+
* (vi) Phase 2 (d) fill up to a byte budget AND a count cap; pinned always
|
|
169
|
+
* kept even if they alone exceed the byte budget.
|
|
170
|
+
* Pure + deterministic given `now`. Returns { shown, omittedCount } where `shown`
|
|
171
|
+
* carries per-entry fold info for the renderer.
|
|
128
172
|
*
|
|
129
|
-
* Ranking is stable on the original ledger index so, within a tier,
|
|
130
|
-
*
|
|
131
|
-
* newest notes lead each tier. The cap keeps the leading (highest-priority) N.
|
|
173
|
+
* Ranking is stable on the original ledger index so, within a tier, newest notes
|
|
174
|
+
* lead. The caps keep the leading (highest-priority) entries.
|
|
132
175
|
*/
|
|
133
|
-
export declare function selectOperatingNotesForPrompt(notes: CoordinatorOperatingNote[], now: number, cap?: number): {
|
|
134
|
-
shown:
|
|
176
|
+
export declare function selectOperatingNotesForPrompt(notes: CoordinatorOperatingNote[], now: number, cap?: number, byteBudget?: number): {
|
|
177
|
+
shown: FoldedNote[];
|
|
135
178
|
omittedCount: number;
|
|
136
179
|
};
|
|
137
180
|
/**
|
|
@@ -146,3 +189,4 @@ export declare function selectOperatingNotesForPrompt(notes: CoordinatorOperatin
|
|
|
146
189
|
* That keeps a MAGI-less mesh's prompt byte-identical to before.
|
|
147
190
|
*/
|
|
148
191
|
export declare function buildMagiKindPanelsSection(panels: MagiKindPanelMap | undefined | null): string | null;
|
|
192
|
+
export {};
|
|
@@ -201,6 +201,7 @@ export declare class CliProviderInstance implements ProviderInstance {
|
|
|
201
201
|
private generatingDebounceTimer;
|
|
202
202
|
private generatingDebouncePending;
|
|
203
203
|
private lastApprovalEventFingerprint;
|
|
204
|
+
private lastInteractivePromptEventKey;
|
|
204
205
|
private autoApproveBusy;
|
|
205
206
|
private autoApproveBusyTimer;
|
|
206
207
|
private lastAutoApprovalSignature;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adhdev/daemon-core",
|
|
3
|
-
"version": "1.0.18-rc.
|
|
3
|
+
"version": "1.0.18-rc.9",
|
|
4
4
|
"description": "ADHDev daemon core — CDP, IDE detection, providers, command execution",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -47,8 +47,8 @@
|
|
|
47
47
|
"author": "vilmire",
|
|
48
48
|
"license": "AGPL-3.0-or-later",
|
|
49
49
|
"dependencies": {
|
|
50
|
-
"@adhdev/mesh-shared": "1.0.18-rc.
|
|
51
|
-
"@adhdev/session-host-core": "1.0.18-rc.
|
|
50
|
+
"@adhdev/mesh-shared": "1.0.18-rc.9",
|
|
51
|
+
"@adhdev/session-host-core": "1.0.18-rc.9",
|
|
52
52
|
"@agentclientprotocol/sdk": "^0.16.1",
|
|
53
53
|
"ajv": "^8.20.0",
|
|
54
54
|
"ajv-formats": "^3.0.1",
|
|
@@ -115,8 +115,12 @@ export const meshCoordinatorLaunchHandlers: Record<string, HighFamilyHandler> =
|
|
|
115
115
|
try {
|
|
116
116
|
const { readOperatingNotes } = await import('../../mesh/mesh-ledger.js');
|
|
117
117
|
// readOperatingNotes filters out tombstoned (forgotten) notes so a
|
|
118
|
-
// retracted lesson never rides into the prompt. Newest last
|
|
119
|
-
|
|
118
|
+
// retracted lesson never rides into the prompt. Newest last.
|
|
119
|
+
// Phase 2 (d): the byte-budget/count cap now bounds the injected list
|
|
120
|
+
// (selectOperatingNotesForPrompt), so read a larger candidate tail than
|
|
121
|
+
// the old fixed 20 and let injection-side ranking + budget do the
|
|
122
|
+
// bounding. Keep a sane store-read ceiling to avoid unbounded arrays.
|
|
123
|
+
const noteEntries = readOperatingNotes(id, { tail: 100 });
|
|
120
124
|
const notes = noteEntries
|
|
121
125
|
.map((e) => {
|
|
122
126
|
const p = (e.payload || {}) as Record<string, unknown>;
|
|
@@ -136,6 +140,12 @@ export const meshCoordinatorLaunchHandlers: Record<string, HighFamilyHandler> =
|
|
|
136
140
|
// these → pinned defaults false, expiry governed by category TTL.
|
|
137
141
|
pinned: p.pinned === true,
|
|
138
142
|
...(typeof p.expiresAt === 'string' ? { expiresAt: p.expiresAt } : {}),
|
|
143
|
+
// Phase 2 (b)/(c): thread the ledger id + supersedes/subjectKey
|
|
144
|
+
// so version-supersede targeting and same-class folding work.
|
|
145
|
+
// Legacy notes lack them → no supersede, fold by leading [tag].
|
|
146
|
+
noteId: e.id,
|
|
147
|
+
...(typeof p.supersedes === 'string' ? { supersedes: p.supersedes } : {}),
|
|
148
|
+
...(typeof p.subjectKey === 'string' ? { subjectKey: p.subjectKey } : {}),
|
|
139
149
|
};
|
|
140
150
|
})
|
|
141
151
|
.filter((n): n is NonNullable<typeof n> => n !== null);
|
|
@@ -560,22 +560,36 @@ async function runDaemonUpgradeHelper(payload: DaemonUpgradeHelperPayload): Prom
|
|
|
560
560
|
);
|
|
561
561
|
}
|
|
562
562
|
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
targetVersion: payload.targetVersion,
|
|
567
|
-
portableNode,
|
|
568
|
-
excludePids: upgradePids,
|
|
569
|
-
hooks: createDefaultWindowsAtomicHooks({
|
|
563
|
+
try {
|
|
564
|
+
await performWindowsAtomicUpgrade({
|
|
565
|
+
layout: windowsInstallerLayout,
|
|
570
566
|
packageName: payload.packageName,
|
|
571
567
|
targetVersion: payload.targetVersion,
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
568
|
+
portableNode,
|
|
569
|
+
excludePids: upgradePids,
|
|
570
|
+
hooks: createDefaultWindowsAtomicHooks({
|
|
571
|
+
packageName: payload.packageName,
|
|
572
|
+
targetVersion: payload.targetVersion,
|
|
573
|
+
npmCliPath,
|
|
574
|
+
restartArgv,
|
|
575
|
+
cwd: payload.cwd || process.cwd(),
|
|
576
|
+
env: Object.fromEntries(Object.entries(process.env).filter(([key]) => key !== UPGRADE_HELPER_ENV)),
|
|
577
|
+
log: appendUpgradeLog,
|
|
578
|
+
}),
|
|
579
|
+
});
|
|
580
|
+
} catch (error: any) {
|
|
581
|
+
// performWindowsAtomicUpgrade already rolled the pointer/shims back to the
|
|
582
|
+
// prior version before rethrowing. Without a durable notice that rollback
|
|
583
|
+
// was silent — the daemon simply kept running the old version (the rc.6
|
|
584
|
+
// stuck-upgrade defect). Leave an actionable last-error file naming the
|
|
585
|
+
// target that failed its health/version gate.
|
|
586
|
+
emitUpgradeFailureNotice([
|
|
587
|
+
`adhdev ${payload.packageName}@${payload.targetVersion} upgrade failed and was rolled back: ${error?.message || String(error)}`,
|
|
588
|
+
`Previous version preserved (active prefix: ${windowsInstallerLayout.activePrefix}).`,
|
|
589
|
+
'See daemon-upgrade.log for the full install/health trace. The next daemon start will retry.',
|
|
590
|
+
]);
|
|
591
|
+
throw error;
|
|
592
|
+
}
|
|
579
593
|
try { fs.unlinkSync(getUpgradeFailureNoticePath()); } catch { /* no previous failure notice */ }
|
|
580
594
|
appendUpgradeLog('Installer-managed Windows atomic upgrade completed');
|
|
581
595
|
return;
|
|
@@ -8,6 +8,44 @@ const POINTER_NAME = '.adhdev-current';
|
|
|
8
8
|
const STABLE_FILES = [POINTER_NAME, 'adhdev.cmd', 'adhdev.ps1', 'adhdev'] as const;
|
|
9
9
|
const DEFAULT_HEALTH_PORT = 19222;
|
|
10
10
|
|
|
11
|
+
function fetchLocalJson(port: number, pathname: string): Promise<{ ok: boolean; body: string }> {
|
|
12
|
+
return new Promise((resolve) => {
|
|
13
|
+
const req = http.get(`http://127.0.0.1:${port}${pathname}`, { timeout: 1500 }, (res) => {
|
|
14
|
+
let body = '';
|
|
15
|
+
res.setEncoding('utf8');
|
|
16
|
+
res.on('data', (chunk) => { body += chunk; });
|
|
17
|
+
res.on('end', () => resolve({ ok: res.statusCode === 200, body }));
|
|
18
|
+
});
|
|
19
|
+
req.on('timeout', () => req.destroy());
|
|
20
|
+
req.on('error', () => resolve({ ok: false, body: '' }));
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Liveness + pid identity: GET /health returns {ok, pid, wsPath, port}.
|
|
25
|
+
async function fetchLocalHealth(port: number): Promise<{ ok: boolean; pid?: number }> {
|
|
26
|
+
const { ok, body } = await fetchLocalJson(port, '/health');
|
|
27
|
+
if (!ok) return { ok: false };
|
|
28
|
+
try {
|
|
29
|
+
const pid = Number(JSON.parse(body)?.pid);
|
|
30
|
+
return { ok: true, pid: Number.isFinite(pid) ? pid : undefined };
|
|
31
|
+
} catch {
|
|
32
|
+
return { ok: false };
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Running version: GET /api/v1/status exposes it at payload.status.version.
|
|
37
|
+
// /health carries no version, so the upgrade version gate must read this.
|
|
38
|
+
async function fetchLocalStatusVersion(port: number): Promise<string | undefined> {
|
|
39
|
+
const { ok, body } = await fetchLocalJson(port, '/api/v1/status');
|
|
40
|
+
if (!ok) return undefined;
|
|
41
|
+
try {
|
|
42
|
+
const version = (JSON.parse(body) as { status?: { version?: unknown } })?.status?.version;
|
|
43
|
+
return typeof version === 'string' ? version : undefined;
|
|
44
|
+
} catch {
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
11
49
|
// Markers that identify a Node process as ADHDev-owned when its command line
|
|
12
50
|
// also lives under a versioned install prefix. This deliberately excludes
|
|
13
51
|
// arbitrary user scripts that happen to be located under ~/.adhdev.
|
|
@@ -359,7 +397,13 @@ export function createDefaultWindowsAtomicHooks(options: {
|
|
|
359
397
|
cwd: string;
|
|
360
398
|
env: NodeJS.ProcessEnv;
|
|
361
399
|
log: (message: string) => void;
|
|
400
|
+
/** Loopback IPC port to probe for health/version. Defaults to the daemon's local IPC port. */
|
|
401
|
+
healthPort?: number;
|
|
402
|
+
/** How long to poll for the replacement daemon to report the target version. */
|
|
403
|
+
healthTimeoutMs?: number;
|
|
362
404
|
}): WindowsAtomicUpgradeHooks {
|
|
405
|
+
const healthPort = options.healthPort ?? DEFAULT_HEALTH_PORT;
|
|
406
|
+
const healthTimeoutMs = options.healthTimeoutMs ?? 30000;
|
|
363
407
|
return {
|
|
364
408
|
install: (stagedPrefix, portableNode) => {
|
|
365
409
|
const env: NodeJS.ProcessEnv = {
|
|
@@ -370,7 +414,7 @@ export function createDefaultWindowsAtomicHooks(options: {
|
|
|
370
414
|
};
|
|
371
415
|
const pathKey = Object.keys(env).find((key) => key.toLowerCase() === 'path') || 'Path';
|
|
372
416
|
env[pathKey] = `${path.dirname(portableNode)};${env[pathKey] || ''}`;
|
|
373
|
-
execFileSync(portableNode, [
|
|
417
|
+
const installOutput = String(execFileSync(portableNode, [
|
|
374
418
|
options.npmCliPath,
|
|
375
419
|
'install', '-g', `${options.packageName}@${options.targetVersion}`, '--force', '--prefer-online', '--prefix', stagedPrefix,
|
|
376
420
|
], {
|
|
@@ -379,7 +423,11 @@ export function createDefaultWindowsAtomicHooks(options: {
|
|
|
379
423
|
maxBuffer: 20 * 1024 * 1024,
|
|
380
424
|
windowsHide: true,
|
|
381
425
|
env,
|
|
382
|
-
});
|
|
426
|
+
}));
|
|
427
|
+
// On failure execFileSync throws with stdout attached to the Error; on
|
|
428
|
+
// success it was previously discarded. Surface the npm output either way so
|
|
429
|
+
// a silently-succeeding-then-rolled-back upgrade leaves a diagnosable trail.
|
|
430
|
+
if (installOutput.trim()) options.log(installOutput.trim());
|
|
383
431
|
},
|
|
384
432
|
restart: (portableNode, stagedCliEntry) => {
|
|
385
433
|
const restartArgv = options.restartArgv.map((arg, index) => index === 0 ? stagedCliEntry : arg);
|
|
@@ -406,23 +454,18 @@ export function createDefaultWindowsAtomicHooks(options: {
|
|
|
406
454
|
child.unref();
|
|
407
455
|
},
|
|
408
456
|
waitForHealth: async (pid, targetVersion) => {
|
|
409
|
-
const deadline = Date.now() +
|
|
457
|
+
const deadline = Date.now() + healthTimeoutMs;
|
|
410
458
|
while (Date.now() < deadline) {
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
});
|
|
422
|
-
req.on('timeout', () => req.destroy());
|
|
423
|
-
req.on('error', () => resolve({ ok: false }));
|
|
424
|
-
});
|
|
425
|
-
if (result.ok && result.pid === pid && result.body?.includes(targetVersion)) return true;
|
|
459
|
+
// Liveness + pid identity come from GET /health, whose body is
|
|
460
|
+
// {ok, pid, wsPath, port} — it carries NO version. The running version
|
|
461
|
+
// lives only in GET /api/v1/status → payload.status.version, so the
|
|
462
|
+
// version gate must fetch that endpoint separately. Requiring the raw
|
|
463
|
+
// /health body to include targetVersion is unsatisfiable and silently
|
|
464
|
+
// rolls every upgrade back.
|
|
465
|
+
const liveness = await fetchLocalHealth(healthPort);
|
|
466
|
+
const alive = liveness.ok && liveness.pid === pid;
|
|
467
|
+
const version = alive ? await fetchLocalStatusVersion(healthPort) : undefined;
|
|
468
|
+
if (alive && version === targetVersion) return true;
|
|
426
469
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
427
470
|
}
|
|
428
471
|
return false;
|
|
@@ -161,9 +161,13 @@ function normalizeOperatingNote(value: unknown): CoordinatorOperatingNote | null
|
|
|
161
161
|
...(typeof value.createdAt === 'string' ? { createdAt: value.createdAt } : {}),
|
|
162
162
|
...(typeof value.sourceCoordinator === 'string' ? { sourceCoordinator: value.sourceCoordinator } : {}),
|
|
163
163
|
// Operating-notes lifecycle: a repo-declared note may pin itself or set an
|
|
164
|
-
// explicit expiry, same as a runtime-recorded one.
|
|
164
|
+
// explicit expiry, same as a runtime-recorded one. Phase 2 (b)/(c): it may
|
|
165
|
+
// also declare supersedes/subjectKey to retire an earlier note or group
|
|
166
|
+
// same-subject notes for folding.
|
|
165
167
|
...(value.pinned === true ? { pinned: true } : {}),
|
|
166
168
|
...(typeof value.expiresAt === 'string' ? { expiresAt: value.expiresAt } : {}),
|
|
169
|
+
...(typeof value.supersedes === 'string' ? { supersedes: value.supersedes } : {}),
|
|
170
|
+
...(typeof value.subjectKey === 'string' ? { subjectKey: value.subjectKey } : {}),
|
|
167
171
|
};
|
|
168
172
|
}
|
|
169
173
|
|
|
@@ -99,6 +99,27 @@ export interface CoordinatorOperatingNote {
|
|
|
99
99
|
* (see isNoteExpired) governs expiry; pinned notes never expire regardless.
|
|
100
100
|
*/
|
|
101
101
|
expiresAt?: string;
|
|
102
|
+
/**
|
|
103
|
+
* Phase 2 — the ledger id of this note, when known. Threaded from the ledger
|
|
104
|
+
* entry id at launch so version-supersede can target a specific note and
|
|
105
|
+
* same-class folding can list the subsumed ids. Repo-declared notes may lack
|
|
106
|
+
* one; absent is fine (folding/supersede fall back to the subject key).
|
|
107
|
+
*/
|
|
108
|
+
noteId?: string;
|
|
109
|
+
/**
|
|
110
|
+
* Phase 2 (b) version-supersede — an optional note-id or stable subject-key
|
|
111
|
+
* this note replaces. At injection, any earlier LIVE note whose `noteId` or
|
|
112
|
+
* `subjectKey` matches this value is hidden from the prompt (the ledger entry
|
|
113
|
+
* is retained for audit). Optional and lossless: absent = supersedes nothing.
|
|
114
|
+
*/
|
|
115
|
+
supersedes?: string;
|
|
116
|
+
/**
|
|
117
|
+
* Phase 2 (b)/(c) — an optional stable subject-key grouping notes about the
|
|
118
|
+
* same subject. Drives version-supersede targeting and same-class folding.
|
|
119
|
+
* When absent, folding derives a key from a leading `[tag]` bracket instead,
|
|
120
|
+
* so legacy notes still collapse by their conventional tag prefix.
|
|
121
|
+
*/
|
|
122
|
+
subjectKey?: string;
|
|
102
123
|
}
|
|
103
124
|
|
|
104
125
|
// ─── Prompt Builder ─────────────────────────────
|
|
@@ -597,6 +618,20 @@ function buildRecentActivitySection(activity?: CoordinatorRecentActivity): strin
|
|
|
597
618
|
const OPERATING_NOTES_PROMPT_CAP = 20;
|
|
598
619
|
const OPERATING_NOTE_MAX_CHARS = 300;
|
|
599
620
|
|
|
621
|
+
/**
|
|
622
|
+
* Phase 2 (d) — byte budget for the injected Operating Notes list. The count cap
|
|
623
|
+
* above (20) is still a ceiling, but selection now fills up to this UTF-8 byte
|
|
624
|
+
* budget on the RENDERED note lines, ranked pinned > durable > recency, so a few
|
|
625
|
+
* long notes don't crowd out many short ones and vice-versa. Pinned notes are
|
|
626
|
+
* ALWAYS kept even if they alone exceed the budget (pinned is author-opt-in and
|
|
627
|
+
* bounded by the author); the budget only bounds the unpinned tail.
|
|
628
|
+
*
|
|
629
|
+
* ~6KB ≈ the 20-note cap at typical note length, so on ordinary meshes this
|
|
630
|
+
* changes nothing — it only kicks in when notes run long. It sits well under the
|
|
631
|
+
* whole-prompt PROMPT_SOFT_CAP_BYTES (60KB) so the two caps don't fight.
|
|
632
|
+
*/
|
|
633
|
+
const OPERATING_NOTE_INJECTION_BYTE_BUDGET = 6 * 1024;
|
|
634
|
+
|
|
600
635
|
/**
|
|
601
636
|
* A category is "durable" (survives the cap ahead of recency) when it has no
|
|
602
637
|
* TTL entry — provider_quirk, uncategorized, or any unknown category. This
|
|
@@ -608,35 +643,135 @@ function isDurableCategory(category?: string): boolean {
|
|
|
608
643
|
}
|
|
609
644
|
|
|
610
645
|
/**
|
|
611
|
-
*
|
|
612
|
-
*
|
|
613
|
-
*
|
|
614
|
-
*
|
|
615
|
-
*
|
|
616
|
-
*
|
|
617
|
-
*
|
|
618
|
-
* Pure
|
|
646
|
+
* Phase 2 (b)/(c) — derive a note's subject key for supersede targeting and
|
|
647
|
+
* same-class folding. Precedence:
|
|
648
|
+
* 1. an explicit `subjectKey` (trimmed, lowercased) when present, else
|
|
649
|
+
* 2. a leading `[tag]` bracket, so legacy notes that follow the conventional
|
|
650
|
+
* `[some-tag] …` prefix still group/supersede by that tag, else
|
|
651
|
+
* 3. undefined — the note has no derivable subject and never folds/supersedes
|
|
652
|
+
* by key (it can still be targeted by exact noteId).
|
|
653
|
+
* Pure. Lowercased so `[Foo]` and `[foo]` collapse together.
|
|
654
|
+
*/
|
|
655
|
+
function deriveSubjectKey(note: CoordinatorOperatingNote): string | undefined {
|
|
656
|
+
const explicit = typeof note.subjectKey === 'string' ? note.subjectKey.trim().toLowerCase() : '';
|
|
657
|
+
if (explicit) return explicit;
|
|
658
|
+
const text = typeof note.text === 'string' ? note.text.trimStart() : '';
|
|
659
|
+
const m = /^\[([^\]]{1,80})\]/.exec(text);
|
|
660
|
+
const tag = m ? m[1].trim().toLowerCase() : '';
|
|
661
|
+
return tag || undefined;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/**
|
|
665
|
+
* Phase 2 (c) — deterministic same-class fold. Given ranked notes (already
|
|
666
|
+
* highest-priority first), collapse runs of UNPINNED notes that share the same
|
|
667
|
+
* category AND subject key into a single injected entry: keep the highest-ranked
|
|
668
|
+
* (first-seen) note and record the note-ids/count it subsumes so the rendered
|
|
669
|
+
* line can say "(+N earlier)". Pinned notes never fold — each pinned note is
|
|
670
|
+
* author-opted-in and shown verbatim. The store is untouched; this is a pure,
|
|
671
|
+
* model-free text fold at injection time.
|
|
672
|
+
*
|
|
673
|
+
* Returns the folded note list (order preserved) with per-entry subsumed info.
|
|
674
|
+
*/
|
|
675
|
+
interface FoldedNote {
|
|
676
|
+
note: CoordinatorOperatingNote;
|
|
677
|
+
/** note-ids of same-class notes this entry subsumes (may be empty). */
|
|
678
|
+
subsumedIds: string[];
|
|
679
|
+
/** count of subsumed notes (== subsumedIds.length, but counts id-less ones too). */
|
|
680
|
+
subsumedCount: number;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
function foldSameClassNotes(ranked: CoordinatorOperatingNote[]): FoldedNote[] {
|
|
684
|
+
const out: FoldedNote[] = [];
|
|
685
|
+
// group key → index into `out` of the surviving (highest-ranked) entry.
|
|
686
|
+
const survivorByKey = new Map<string, number>();
|
|
687
|
+
for (const note of ranked) {
|
|
688
|
+
const subject = deriveSubjectKey(note);
|
|
689
|
+
// Only unpinned notes with a category AND a subject key are foldable —
|
|
690
|
+
// without both there is no reliable "same class/subject" signal, so we
|
|
691
|
+
// keep the note standalone (lossless for legacy/uncategorized notes).
|
|
692
|
+
const foldable = !note.pinned && !!note.category && !!subject;
|
|
693
|
+
if (foldable) {
|
|
694
|
+
const key = `${note.category}${subject}`;
|
|
695
|
+
const existing = survivorByKey.get(key);
|
|
696
|
+
if (existing !== undefined) {
|
|
697
|
+
const survivor = out[existing];
|
|
698
|
+
survivor.subsumedCount += 1;
|
|
699
|
+
if (typeof note.noteId === 'string' && note.noteId) survivor.subsumedIds.push(note.noteId);
|
|
700
|
+
continue;
|
|
701
|
+
}
|
|
702
|
+
survivorByKey.set(key, out.length);
|
|
703
|
+
}
|
|
704
|
+
out.push({ note, subsumedIds: [], subsumedCount: 0 });
|
|
705
|
+
}
|
|
706
|
+
return out;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
/** Estimate the UTF-8 byte cost of one rendered operating-note line (Phase 2 d). */
|
|
710
|
+
function renderedNoteBytes(folded: FoldedNote): number {
|
|
711
|
+
return byteLength(renderOperatingNoteLine(folded));
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/**
|
|
715
|
+
* Operating-notes lifecycle selection (read-side). Given the effective notes
|
|
716
|
+
* (oldest-first, ledger order) and the current time, produce the ordered list
|
|
717
|
+
* that rides into the prompt:
|
|
718
|
+
* (i) ALWAYS include pinned notes.
|
|
719
|
+
* (ii) drop expired UNPINNED notes (per category TTL / explicit expiresAt).
|
|
720
|
+
* (iii) Phase 2 (b) drop any note SUPERSEDED by a later live note (by noteId
|
|
721
|
+
* or subjectKey); pinned notes are never dropped by supersede.
|
|
722
|
+
* (iv) rank pinned-first, then durable-category, then recency (newest first).
|
|
723
|
+
* (v) Phase 2 (c) fold same-category/same-subject unpinned runs into one.
|
|
724
|
+
* (vi) Phase 2 (d) fill up to a byte budget AND a count cap; pinned always
|
|
725
|
+
* kept even if they alone exceed the byte budget.
|
|
726
|
+
* Pure + deterministic given `now`. Returns { shown, omittedCount } where `shown`
|
|
727
|
+
* carries per-entry fold info for the renderer.
|
|
619
728
|
*
|
|
620
|
-
* Ranking is stable on the original ledger index so, within a tier,
|
|
621
|
-
*
|
|
622
|
-
* newest notes lead each tier. The cap keeps the leading (highest-priority) N.
|
|
729
|
+
* Ranking is stable on the original ledger index so, within a tier, newest notes
|
|
730
|
+
* lead. The caps keep the leading (highest-priority) entries.
|
|
623
731
|
*/
|
|
624
732
|
export function selectOperatingNotesForPrompt(
|
|
625
733
|
notes: CoordinatorOperatingNote[],
|
|
626
734
|
now: number,
|
|
627
735
|
cap: number = OPERATING_NOTES_PROMPT_CAP,
|
|
628
|
-
|
|
736
|
+
byteBudget: number = OPERATING_NOTE_INJECTION_BYTE_BUDGET,
|
|
737
|
+
): { shown: FoldedNote[]; omittedCount: number } {
|
|
629
738
|
const valid = Array.isArray(notes)
|
|
630
739
|
? notes.filter(n => n && typeof n.text === 'string' && n.text.trim())
|
|
631
740
|
: [];
|
|
632
741
|
|
|
633
742
|
// (i)+(ii): keep pinned always; drop expired unpinned. Retain original index
|
|
634
743
|
// for a stable recency tiebreak (later index == newer, ledger is oldest-first).
|
|
635
|
-
|
|
744
|
+
let kept = valid
|
|
636
745
|
.map((note, index) => ({ note, index }))
|
|
637
746
|
.filter(({ note }) => note.pinned || !isNoteExpired(note as any, now));
|
|
638
747
|
|
|
639
|
-
// (iii)
|
|
748
|
+
// (iii) Phase 2 (b) version-supersede: a LATER live note may name (via
|
|
749
|
+
// `supersedes`) an earlier note's noteId or subjectKey; the earlier one is
|
|
750
|
+
// then hidden. "Later" == higher ledger index. Collect every supersede target
|
|
751
|
+
// paired with the max index that supersedes it, then drop any UNPINNED note
|
|
752
|
+
// whose id/subjectKey is superseded by a strictly-later note. Pinned notes are
|
|
753
|
+
// never dropped by supersede (pinned always wins, mirroring TTL).
|
|
754
|
+
const supersededAtIndex = new Map<string, number>();
|
|
755
|
+
for (const { note, index } of kept) {
|
|
756
|
+
const target = typeof note.supersedes === 'string' ? note.supersedes.trim().toLowerCase() : '';
|
|
757
|
+
if (!target) continue;
|
|
758
|
+
const prev = supersededAtIndex.get(target);
|
|
759
|
+
if (prev === undefined || index > prev) supersededAtIndex.set(target, index);
|
|
760
|
+
}
|
|
761
|
+
if (supersededAtIndex.size > 0) {
|
|
762
|
+
kept = kept.filter(({ note, index }) => {
|
|
763
|
+
if (note.pinned) return true;
|
|
764
|
+
const byId = typeof note.noteId === 'string' ? note.noteId.trim().toLowerCase() : '';
|
|
765
|
+
const bySubject = deriveSubjectKey(note);
|
|
766
|
+
const supIdx = Math.max(
|
|
767
|
+
byId ? (supersededAtIndex.get(byId) ?? -1) : -1,
|
|
768
|
+
bySubject ? (supersededAtIndex.get(bySubject) ?? -1) : -1,
|
|
769
|
+
);
|
|
770
|
+
return !(supIdx > index); // superseded by a strictly-later note → drop
|
|
771
|
+
});
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
// (iv): rank pinned > durable > recency (newest first within a tier).
|
|
640
775
|
const rank = (n: CoordinatorOperatingNote): number =>
|
|
641
776
|
n.pinned ? 0 : isDurableCategory(n.category) ? 1 : 2;
|
|
642
777
|
kept.sort((a, b) => {
|
|
@@ -645,41 +780,81 @@ export function selectOperatingNotesForPrompt(
|
|
|
645
780
|
return b.index - a.index; // newer (higher index) first
|
|
646
781
|
});
|
|
647
782
|
|
|
648
|
-
// (
|
|
649
|
-
const
|
|
650
|
-
|
|
783
|
+
// (v) Phase 2 (c): fold same-class/same-subject unpinned runs into one entry.
|
|
784
|
+
const folded = foldSameClassNotes(kept.map(k => k.note));
|
|
785
|
+
|
|
786
|
+
// (vi) Phase 2 (d): fill up to the byte budget AND the count cap. Pinned
|
|
787
|
+
// entries are always kept (never dropped to fit the budget); the budget/cap
|
|
788
|
+
// bound the unpinned tail. Entries are already highest-priority first, so we
|
|
789
|
+
// walk in order and stop once an UNPINNED entry would break a bound.
|
|
790
|
+
const shown: FoldedNote[] = [];
|
|
791
|
+
let usedBytes = 0;
|
|
792
|
+
let usedCount = 0;
|
|
793
|
+
for (const entry of folded) {
|
|
794
|
+
if (entry.note.pinned) {
|
|
795
|
+
shown.push(entry);
|
|
796
|
+
usedBytes += renderedNoteBytes(entry);
|
|
797
|
+
usedCount += 1;
|
|
798
|
+
continue;
|
|
799
|
+
}
|
|
800
|
+
if (usedCount >= cap) break;
|
|
801
|
+
const bytes = renderedNoteBytes(entry);
|
|
802
|
+
if (usedCount > 0 && usedBytes + bytes > byteBudget) break;
|
|
803
|
+
shown.push(entry);
|
|
804
|
+
usedBytes += bytes;
|
|
805
|
+
usedCount += 1;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
// omittedCount counts eligible (non-expired, non-superseded, post-fold) entries
|
|
809
|
+
// that did not make the cut. Folded-away duplicates are not "omitted" — they are
|
|
810
|
+
// represented by their survivor's "(+N earlier)" marker, so exclude them here.
|
|
811
|
+
const omittedCount = Math.max(0, folded.length - shown.length);
|
|
651
812
|
return { shown, omittedCount };
|
|
652
813
|
}
|
|
653
814
|
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
815
|
+
/**
|
|
816
|
+
* Render one operating-note bullet line (Phase 2 c fold-aware). Shared by the
|
|
817
|
+
* byte-budget estimator and the section renderer so the budget is measured on
|
|
818
|
+
* the exact bytes that ship.
|
|
819
|
+
*/
|
|
820
|
+
function renderOperatingNoteLine(folded: FoldedNote): string {
|
|
821
|
+
const n = folded.note;
|
|
660
822
|
const categoryLabel: Record<string, string> = {
|
|
661
823
|
provider_quirk: 'provider quirk',
|
|
662
824
|
pattern_to_avoid: 'pattern to avoid',
|
|
663
825
|
recovery_lesson: 'recovery lesson',
|
|
664
826
|
};
|
|
827
|
+
const pin = n.pinned ? '📌 ' : '';
|
|
828
|
+
const cat = n.category && categoryLabel[n.category] ? `[${categoryLabel[n.category]}] ` : '';
|
|
829
|
+
const fold = folded.subsumedCount > 0
|
|
830
|
+
? ` _(+${folded.subsumedCount} earlier same-subject note${folded.subsumedCount === 1 ? '' : 's'} folded${folded.subsumedIds.length ? `: ${folded.subsumedIds.join(', ')}` : ''})_`
|
|
831
|
+
: '';
|
|
832
|
+
return `- ${pin}${cat}${truncateNote(n.text.trim())}${fold}`;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
function buildOperatingNotesSection(notes?: CoordinatorOperatingNote[], now: number = Date.now()): string {
|
|
836
|
+
const hasAny = Array.isArray(notes)
|
|
837
|
+
? notes.some(n => n && typeof n.text === 'string' && n.text.trim())
|
|
838
|
+
: false;
|
|
839
|
+
if (!hasAny) return '';
|
|
665
840
|
|
|
666
|
-
// Lifecycle selection: pinned-always + expired-unpinned-dropped +
|
|
667
|
-
// (pinned > durable > recency)
|
|
668
|
-
// highest-priority-first
|
|
841
|
+
// Lifecycle selection: pinned-always + expired-unpinned-dropped +
|
|
842
|
+
// superseded-dropped + rank (pinned > durable > recency) + same-class fold +
|
|
843
|
+
// byte-budget/count cap. `shown` is already highest-priority-first and carries
|
|
844
|
+
// per-entry fold info; renderOperatingNoteLine emits the exact bytes the
|
|
845
|
+
// budget was measured against.
|
|
669
846
|
const { shown, omittedCount } = selectOperatingNotesForPrompt(notes ?? [], now);
|
|
670
847
|
if (shown.length === 0) return '';
|
|
671
848
|
|
|
672
849
|
const lines: string[] = ['## Operating Notes', ''];
|
|
673
850
|
lines.push('Lessons earlier coordinators on this mesh recorded via `mesh_record_note`. Treat them as accumulated operating knowledge — apply them. When you learn a durable lesson (a provider quirk, a pattern to avoid, a recovery lesson), record it with `mesh_record_note` so future coordinators inherit it.');
|
|
674
851
|
lines.push('');
|
|
675
|
-
for (const
|
|
676
|
-
|
|
677
|
-
const cat = n.category && categoryLabel[n.category] ? `[${categoryLabel[n.category]}] ` : '';
|
|
678
|
-
lines.push(`- ${pin}${cat}${truncateNote(n.text.trim())}`);
|
|
852
|
+
for (const entry of shown) {
|
|
853
|
+
lines.push(renderOperatingNoteLine(entry));
|
|
679
854
|
}
|
|
680
855
|
if (omittedCount > 0) {
|
|
681
856
|
lines.push('');
|
|
682
|
-
lines.push(`_${omittedCount} lower-priority note${omittedCount === 1 ? '' : 's'} omitted (kept in ledger; expired
|
|
857
|
+
lines.push(`_${omittedCount} lower-priority note${omittedCount === 1 ? '' : 's'} omitted to fit the injection cap/byte-budget (kept in ledger; expired, superseded, and same-subject-folded notes are also hidden from this list but retained for audit; prune with \`mesh_forget_note\`)._`);
|
|
683
858
|
}
|
|
684
859
|
return lines.join('\n');
|
|
685
860
|
}
|