@agfpd/iapeer 0.2.33 → 0.2.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/package.json +1 -1
- package/src/cli/cli.test.ts +23 -0
- package/src/cli/index.ts +85 -6
- package/src/daemon/index.ts +12 -1
- package/src/daemon/main.test.ts +63 -0
- package/src/daemon/main.ts +36 -0
- package/src/enable/enable.test.ts +15 -12
- package/src/enable/index.ts +21 -13
- package/src/identity/index.ts +9 -1
- package/src/identity/profileStandard.test.ts +116 -0
- package/src/identity/profileStandard.ts +218 -0
- package/src/launch/composeSystemPrompt.layers.test.ts +49 -16
- package/src/launch/composeSystemPrompt.test.ts +80 -33
- package/src/launch/composeSystemPrompt.ts +99 -61
- package/src/launch/types.ts +13 -2
- package/src/lifecycle/index.ts +9 -3
- package/src/registry/index.ts +9 -6
- package/src/transport/index.ts +25 -0
- package/src/transport/routesend-wake.test.ts +69 -0
|
@@ -55,10 +55,33 @@ function jqJson(value: string): string {
|
|
|
55
55
|
.replace(new RegExp(String.fromCharCode(0x00), 'g'), '\\u0000')
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
/** A per-file section: a path marker line, then the file content. The marker is an
|
|
59
|
+
* HTML comment — inert in rendered markdown yet plainly visible in the raw system-
|
|
60
|
+
* prompt the model reads (verified: the model's context surfaces HTML comments) —
|
|
61
|
+
* so a reader (human or agent) can tell, per file, exactly which path each chunk of
|
|
62
|
+
* the assembled prompt came from. No marker when `path` is absent (a pure-renderer
|
|
63
|
+
* caller, or the synthesized Layer-1 YAML / Layer-3 registry which are not files). */
|
|
64
|
+
function markedSection(path: string | undefined, content: string): string {
|
|
65
|
+
return path ? `<!-- ${path} -->\n${content}` : content
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Trailing-newline-trimmed length test: a 0-byte or newline-only file is "empty"
|
|
69
|
+
* and contributes no section (and so no dangling marker). */
|
|
70
|
+
function hasContent(s: string | undefined): s is string {
|
|
71
|
+
return s !== undefined && s.replace(/\n+$/, '').length > 0
|
|
72
|
+
}
|
|
73
|
+
|
|
58
74
|
/**
|
|
59
|
-
* Compose the merged system prompt
|
|
60
|
-
*
|
|
61
|
-
*
|
|
75
|
+
* Compose the merged system prompt: the YAML identity block (Layer 1) followed by
|
|
76
|
+
* every file-sourced layer rendered PER FILE — each injected `.md` prefixed by a
|
|
77
|
+
* path marker (`<!-- <path> -->`). Layers, general → specific:
|
|
78
|
+
* 1. YAML identity block (synthesized, no marker) — byte-for-byte the jq layout.
|
|
79
|
+
* 2. IAPEER.md doctrine: global then local, each its own marked section.
|
|
80
|
+
* 3. Normalized peer registry (generated, no marker).
|
|
81
|
+
* 4. Other `<DOMAIN>.md` at the .iapeer root — each half (global, local) marked.
|
|
82
|
+
* 5. fragments/ `*.md` — each half marked. Most-volatile, sits LAST.
|
|
83
|
+
* Empty files contribute nothing (no section, no marker). Sections are joined by a
|
|
84
|
+
* single blank line and the output ends with exactly one newline.
|
|
62
85
|
*/
|
|
63
86
|
export function composeSystemPrompt(input: ComposePromptInput): string {
|
|
64
87
|
// The eight identity lines, in the bash's exact key order. Keys verbatim:
|
|
@@ -77,38 +100,37 @@ export function composeSystemPrompt(input: ComposePromptInput): string {
|
|
|
77
100
|
// jq's final "" array element → the bare blank line after the closing '---'.
|
|
78
101
|
'\n'
|
|
79
102
|
|
|
80
|
-
//
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
|
|
103
|
+
// The YAML identity block is the only Layer-1 section (synthesized, no marker).
|
|
104
|
+
const sections: string[] = [yaml]
|
|
105
|
+
|
|
106
|
+
// Layer 2 — IAPEER.md doctrine, GLOBAL then LOCAL (general → specific), each its
|
|
107
|
+
// OWN marked file-section. The bash existence-gate (present-but-empty global → a
|
|
108
|
+
// bare "\n") is dropped deliberately: an empty doctrine file now contributes no
|
|
109
|
+
// section (and no dangling marker), like every other empty file. IAPEER.md carries
|
|
110
|
+
// the only FIXED semantics in the assembly (host = general peer rules, local = this
|
|
111
|
+
// peer's rules — the CLAUDE.md analogue); its meaning is conveyed by the well-known
|
|
112
|
+
// filename + path, so the marker stays a bare path like every other file's.
|
|
113
|
+
if (hasContent(input.globalDoctrine)) {
|
|
114
|
+
sections.push(markedSection(input.globalDoctrinePath, input.globalDoctrine))
|
|
115
|
+
}
|
|
116
|
+
if (hasContent(input.peerDoctrine)) {
|
|
117
|
+
sections.push(markedSection(input.peerDoctrinePath, input.peerDoctrine))
|
|
118
|
+
}
|
|
90
119
|
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
|
|
94
|
-
|
|
120
|
+
// Layer 3 — normalized peer registry (generated from the index, not a file → no
|
|
121
|
+
// marker). Empty list → nothing.
|
|
122
|
+
const registry = renderRegistry(input.peers ?? [])
|
|
123
|
+
if (registry.length > 0) sections.push(registry)
|
|
95
124
|
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
const layer3 = renderRegistry(input.peers ?? [])
|
|
101
|
-
const layer4 = renderMergedBlocks(input.pluginDomains ?? [])
|
|
102
|
-
const layer5 = renderMergedBlocks(input.promptFragments ?? [])
|
|
103
|
-
const sections = [layer12, layer3, layer4, layer5].filter(section => section.length > 0)
|
|
125
|
+
// Layers 4 (other <DOMAIN>.md) then 5 (fragments/, most-volatile, LAST) — each
|
|
126
|
+
// file half (global, local) is its own marked section. Empty halves drop out.
|
|
127
|
+
pushMarkedBlocks(sections, input.pluginDomains ?? [])
|
|
128
|
+
pushMarkedBlocks(sections, input.promptFragments ?? [])
|
|
104
129
|
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
// trailing
|
|
108
|
-
//
|
|
109
|
-
// with a single '\n'. This makes the output robust to whether the source files
|
|
110
|
-
// carried trailing newlines (a present-but-newline-only tail never widens a gap).
|
|
111
|
-
if (sections.length === 1) return sections[0]
|
|
130
|
+
// Normalize every seam to exactly one blank line: strip each section's trailing
|
|
131
|
+
// newlines, join with '\n\n', end with a single '\n'. Robust to whether source
|
|
132
|
+
// files carried trailing newlines. A bare-doctrine peer is now [YAML, local
|
|
133
|
+
// IAPEER.md] → two sections, so it always carries its doctrine's path marker.
|
|
112
134
|
return sections.map(section => section.replace(/\n+$/, '')).join('\n\n') + '\n'
|
|
113
135
|
}
|
|
114
136
|
|
|
@@ -132,32 +154,23 @@ function renderRegistry(peers: readonly PublicPeerSummary[]): string {
|
|
|
132
154
|
}
|
|
133
155
|
|
|
134
156
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
135
|
-
// Layers 4 & 5 —
|
|
157
|
+
// Layers 4 & 5 — per-file marked sections:
|
|
136
158
|
// • Layer 4: every non-IAPEER `<DOMAIN>.md` pair at the .iapeer/ root (operator /
|
|
137
159
|
// plugin user-settings; SPAWNER_INSTRUCTIONS.md, … flow in organically).
|
|
138
160
|
// • Layer 5: every `<STEM>.md` pair in .iapeer/fragments/ (primitive-owned,
|
|
139
161
|
// machine-regenerated doctrine fragments — iapeer-memory's guide + note index).
|
|
140
|
-
//
|
|
141
|
-
//
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
//
|
|
162
|
+
// Each block contributes its GLOBAL half then its LOCAL half (general → specific),
|
|
163
|
+
// EACH as its own marked file-section (a 0-byte / newline-only file → no section,
|
|
164
|
+
// no dangling marker). Blocks are appended in the order given (the gatherer sorts
|
|
165
|
+
// by filename for determinism); the stem (`domain`) is used only for that ordering,
|
|
166
|
+
// never emitted.
|
|
145
167
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
146
168
|
|
|
147
|
-
function
|
|
148
|
-
const out: string[] = []
|
|
169
|
+
function pushMarkedBlocks(sections: string[], blocks: readonly PromptDomainBlock[]): void {
|
|
149
170
|
for (const b of blocks) {
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
// the spacing is uniform. Halves of one stem are kept tight (single '\n');
|
|
153
|
-
// distinct stems are separated by a blank line below.
|
|
154
|
-
const halves = [b.global, b.local]
|
|
155
|
-
.filter((s): s is string => s !== undefined)
|
|
156
|
-
.map(s => s.replace(/\n+$/, ''))
|
|
157
|
-
.filter(s => s.length > 0)
|
|
158
|
-
if (halves.length > 0) out.push(halves.join('\n'))
|
|
171
|
+
if (hasContent(b.global)) sections.push(markedSection(b.globalPath, b.global))
|
|
172
|
+
if (hasContent(b.local)) sections.push(markedSection(b.localPath, b.local))
|
|
159
173
|
}
|
|
160
|
-
return out.join('\n\n')
|
|
161
174
|
}
|
|
162
175
|
|
|
163
176
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -218,16 +231,28 @@ function listDomainFiles(root: string): string[] {
|
|
|
218
231
|
return listMdFiles(root).filter(name => name.toLowerCase() !== doctrineLower)
|
|
219
232
|
}
|
|
220
233
|
|
|
234
|
+
/** Display path for a marker: an absolute path with a leading HOME abbreviated to
|
|
235
|
+
* `~` (the form a human scanning the assembled prompt expects). HOME unset / path
|
|
236
|
+
* outside HOME → the absolute path verbatim. */
|
|
237
|
+
function displayPath(absPath: string, env: NodeJS.ProcessEnv): string {
|
|
238
|
+
const home = env.HOME?.trim()
|
|
239
|
+
if (home && (absPath === home || absPath.startsWith(home + '/'))) {
|
|
240
|
+
return '~' + absPath.slice(home.length)
|
|
241
|
+
}
|
|
242
|
+
return absPath
|
|
243
|
+
}
|
|
244
|
+
|
|
221
245
|
/** Build a list of merged global/local blocks (Layer 4 OR Layer 5) from the
|
|
222
246
|
* union of `*.md` stems present in the global and/or local dir, sorted by
|
|
223
247
|
* filename (plain codepoint order) for a deterministic prompt. `lister` selects
|
|
224
248
|
* the candidate `.md` files — Layer 4 excludes the IAPEER.md doctrine, Layer 5
|
|
225
|
-
* takes every fragment.
|
|
226
|
-
*
|
|
227
|
-
* dropped by the renderer. */
|
|
249
|
+
* takes every fragment. Each present half carries its display path for the marker.
|
|
250
|
+
* A read that races a rotation (file removed between listdir and read) → that half
|
|
251
|
+
* is undefined and drops out; a 0-byte file → '' and is dropped by the renderer. */
|
|
228
252
|
function gatherMergedBlocks(
|
|
229
253
|
globalDir: string,
|
|
230
254
|
localDir: string,
|
|
255
|
+
env: NodeJS.ProcessEnv,
|
|
231
256
|
lister: (dir: string) => string[] = listMdFiles,
|
|
232
257
|
): PromptDomainBlock[] {
|
|
233
258
|
const names = new Set<string>([...lister(globalDir), ...lister(localDir)])
|
|
@@ -235,8 +260,14 @@ function gatherMergedBlocks(
|
|
|
235
260
|
const block: PromptDomainBlock = { domain: name.slice(0, -3) } // strip ".md"
|
|
236
261
|
const global = readFileIfPresent(join(globalDir, name))
|
|
237
262
|
const local = readFileIfPresent(join(localDir, name))
|
|
238
|
-
if (global !== undefined)
|
|
239
|
-
|
|
263
|
+
if (global !== undefined) {
|
|
264
|
+
block.global = global
|
|
265
|
+
block.globalPath = displayPath(join(globalDir, name), env)
|
|
266
|
+
}
|
|
267
|
+
if (local !== undefined) {
|
|
268
|
+
block.local = local
|
|
269
|
+
block.localPath = displayPath(join(localDir, name), env)
|
|
270
|
+
}
|
|
240
271
|
return block
|
|
241
272
|
})
|
|
242
273
|
}
|
|
@@ -246,16 +277,19 @@ export function gatherPromptInput(opts: GatherPromptOptions): ComposePromptInput
|
|
|
246
277
|
const globalRoot = opts.globalRoot ?? resolveGlobalRoot(env)
|
|
247
278
|
const localRoot = join(opts.cwd, IAPEER_DIR)
|
|
248
279
|
|
|
249
|
-
// Layer 2 — IAPEER.md merge.
|
|
250
|
-
// '' when present-but-empty
|
|
251
|
-
|
|
252
|
-
const
|
|
280
|
+
// Layer 2 — IAPEER.md merge. Each half carries its display path for the marker.
|
|
281
|
+
// Existence-gated: undefined when absent, '' when present-but-empty (an empty
|
|
282
|
+
// doctrine now contributes no section — no bare-"\n" legacy quirk).
|
|
283
|
+
const globalDoctrineFile = join(globalRoot, IAPEER_DOCTRINE_FILE)
|
|
284
|
+
const peerDoctrineFile = join(localRoot, IAPEER_DOCTRINE_FILE)
|
|
285
|
+
const globalDoctrine = readFileIfPresent(globalDoctrineFile)
|
|
286
|
+
const peerDoctrine = readFileIfPresent(peerDoctrineFile) ?? ''
|
|
253
287
|
|
|
254
288
|
// Layer 4 — union of every non-IAPEER domain present globally and/or locally,
|
|
255
289
|
// sorted by filename for a deterministic prompt. Empty (0-byte) files stay as
|
|
256
290
|
// presence markers — readFileIfPresent returns '', and the renderer drops empty
|
|
257
291
|
// halves, so a marker contributes no text/separator.
|
|
258
|
-
const pluginDomains = gatherMergedBlocks(globalRoot, localRoot, listDomainFiles)
|
|
292
|
+
const pluginDomains = gatherMergedBlocks(globalRoot, localRoot, env, listDomainFiles)
|
|
259
293
|
|
|
260
294
|
// Layer 5 — doctrine fragments: every `*.md` in the fragments/ subdir of both
|
|
261
295
|
// roots, merged per stem (global guide → per-peer specifics). Primitive-owned and
|
|
@@ -264,6 +298,7 @@ export function gatherPromptInput(opts: GatherPromptOptions): ComposePromptInput
|
|
|
264
298
|
const promptFragments = gatherMergedBlocks(
|
|
265
299
|
join(globalRoot, FRAGMENTS_DIR),
|
|
266
300
|
join(localRoot, FRAGMENTS_DIR),
|
|
301
|
+
env,
|
|
267
302
|
)
|
|
268
303
|
|
|
269
304
|
// Layer 3 — peers projected through the shared normalizer, sorted by personality
|
|
@@ -285,7 +320,10 @@ export function gatherPromptInput(opts: GatherPromptOptions): ComposePromptInput
|
|
|
285
320
|
hostname: opts.hostname,
|
|
286
321
|
today: opts.today,
|
|
287
322
|
peerDoctrine,
|
|
288
|
-
|
|
323
|
+
peerDoctrinePath: displayPath(peerDoctrineFile, env),
|
|
324
|
+
...(globalDoctrine !== undefined
|
|
325
|
+
? { globalDoctrine, globalDoctrinePath: displayPath(globalDoctrineFile, env) }
|
|
326
|
+
: {}),
|
|
289
327
|
peers,
|
|
290
328
|
pluginDomains,
|
|
291
329
|
promptFragments,
|
package/src/launch/types.ts
CHANGED
|
@@ -42,14 +42,21 @@ export type { PublicPeerSummary } from '../registry/index.ts'
|
|
|
42
42
|
/** One merged block: the global + local halves of a single stem (either may be
|
|
43
43
|
* absent), used for BOTH Layer 4 (`<DOMAIN>.md` at the .iapeer/ root) and Layer 5
|
|
44
44
|
* (`<STEM>.md` in .iapeer/fragments/). `domain` is the filename stem, used only
|
|
45
|
-
* for stable ordering — it is NOT emitted
|
|
46
|
-
*
|
|
45
|
+
* for stable ordering — it is NOT emitted. Within a block global precedes local
|
|
46
|
+
* (general → specific), and each half is rendered as its OWN per-file section
|
|
47
|
+
* prefixed by a path marker (`globalPath` / `localPath`) — the assembly is now
|
|
48
|
+
* per-file, not an organic stem-merge. */
|
|
47
49
|
export interface PromptDomainBlock {
|
|
48
50
|
domain: string
|
|
49
51
|
/** Global half content (general): ~/.iapeer/<DOMAIN>.md or ~/.iapeer/fragments/<STEM>.md. */
|
|
50
52
|
global?: string
|
|
51
53
|
/** Local half content (specific — overrides global): <cwd>/.iapeer/… counterpart. */
|
|
52
54
|
local?: string
|
|
55
|
+
/** Display path for the global half's marker (HOME abbreviated to ~). Absent →
|
|
56
|
+
* no marker (pure-renderer callers). */
|
|
57
|
+
globalPath?: string
|
|
58
|
+
/** Display path for the local half's marker. */
|
|
59
|
+
localPath?: string
|
|
53
60
|
}
|
|
54
61
|
|
|
55
62
|
export interface ComposePromptInput {
|
|
@@ -64,10 +71,14 @@ export interface ComposePromptInput {
|
|
|
64
71
|
today: string
|
|
65
72
|
/** Layer 2 local: <cwd>/.iapeer/IAPEER.md content. '' when absent. */
|
|
66
73
|
peerDoctrine: string
|
|
74
|
+
/** Display path for the local IAPEER.md marker (HOME abbreviated to ~). */
|
|
75
|
+
peerDoctrinePath?: string
|
|
67
76
|
/** Layer 2 global, OPTIONAL: ~/.iapeer/IAPEER.md content (sits between the YAML
|
|
68
77
|
* block and the per-peer doctrine so per-peer overrides global). Existence-
|
|
69
78
|
* gated: a present-but-empty file → '', an absent file → undefined. */
|
|
70
79
|
globalDoctrine?: string
|
|
80
|
+
/** Display path for the global IAPEER.md marker. */
|
|
81
|
+
globalDoctrinePath?: string
|
|
71
82
|
/** Layer 3: the normalized peer registry. Empty/omitted → the layer emits
|
|
72
83
|
* nothing (and the output stays byte-identical to the legacy YAML+doctrine). */
|
|
73
84
|
peers?: PublicPeerSummary[]
|
package/src/lifecycle/index.ts
CHANGED
|
@@ -223,8 +223,12 @@ export function clearStopped(cfg: LifecycleConfig, identity: string): void {
|
|
|
223
223
|
// Presence on a dead session = the daemon EAGERLY relaunches FRESH (does NOT
|
|
224
224
|
// wait for a message) and injects initial_prompt. Consumed on the relaunch.
|
|
225
225
|
// .deaths : crash-loop guard — a small JSON ring of recent death epoch-ms.
|
|
226
|
-
// .topic : the topic
|
|
227
|
-
// resume discriminator).
|
|
226
|
+
// .topic : the topic the current/last session last WORKED ON (executor
|
|
227
|
+
// fresh-vs-resume discriminator). Written at wake (the boot topic) AND on
|
|
228
|
+
// every live delivery carrying a different non-empty topic (the daemon/CLI
|
|
229
|
+
// compositions wire transport's noteLiveTopic seam to writeTopic — defect
|
|
230
|
+
// 11.06: a wake-only marker goes stale over a long session and false-freshes
|
|
231
|
+
// the stop→start resume the clean park promised).
|
|
228
232
|
// .ephemeral-armed : wake_policy:ephemeral M2 — set by the DAEMON when it routes
|
|
229
233
|
// an OUTBOUND send from an ephemeral worker (its single final reply, ADR-006:
|
|
230
234
|
// workers send no intermediate messages, so outbound ⇒ the task is answered).
|
|
@@ -381,7 +385,9 @@ export function readTopic(cfg: LifecycleConfig, identity: string): string {
|
|
|
381
385
|
}
|
|
382
386
|
}
|
|
383
387
|
|
|
384
|
-
/** Store the
|
|
388
|
+
/** Store the session's working topic (raw string, best-effort). Two writers: the
|
|
389
|
+
* wake (boot topic, wakeOrSpawn) and live deliveries via the noteLiveTopic seam
|
|
390
|
+
* (makeNoteLiveTopic) — so the marker follows the work, not just the boot. */
|
|
385
391
|
export function writeTopic(cfg: LifecycleConfig, identity: string, topic: string): void {
|
|
386
392
|
mkdirSync(cfg.stateDir, { recursive: true, mode: 0o700 })
|
|
387
393
|
try {
|
package/src/registry/index.ts
CHANGED
|
@@ -158,14 +158,17 @@ function normalizePeer(raw: unknown): PeerRecord {
|
|
|
158
158
|
)}"`,
|
|
159
159
|
)
|
|
160
160
|
}
|
|
161
|
-
|
|
161
|
+
// default_runtime is the contract field; `runtime` is legacy. Dual-read accepts
|
|
162
|
+
// both during the staged migration (write still emits `runtime` until Phase 2).
|
|
163
|
+
const defaultRuntime = obj.default_runtime ?? obj.runtime
|
|
164
|
+
if (!isRuntime(defaultRuntime)) {
|
|
162
165
|
throw new IapError(
|
|
163
|
-
`peers-profiles.json corrupted: peer "${obj.personality}"
|
|
166
|
+
`peers-profiles.json corrupted: peer "${obj.personality}" default_runtime must match /^[a-z][a-z0-9]{0,31}$/`,
|
|
164
167
|
)
|
|
165
168
|
}
|
|
166
169
|
const runtimes = Array.isArray(obj.runtimes)
|
|
167
|
-
? ensureRuntimes(obj.runtimes.filter(item => typeof item === 'string') as string[],
|
|
168
|
-
: [
|
|
170
|
+
? ensureRuntimes(obj.runtimes.filter(item => typeof item === 'string') as string[], defaultRuntime)
|
|
171
|
+
: [defaultRuntime]
|
|
169
172
|
const { description } = clampDescription(typeof obj.description === 'string' ? obj.description : '')
|
|
170
173
|
if (typeof obj.cwd !== 'string' || !obj.cwd.trim()) {
|
|
171
174
|
throw new IapError(`peers-profiles.json corrupted: peer "${obj.personality}" cwd is required`)
|
|
@@ -177,7 +180,7 @@ function normalizePeer(raw: unknown): PeerRecord {
|
|
|
177
180
|
let intelligence: Intelligence
|
|
178
181
|
let intelligenceRaw: string | undefined
|
|
179
182
|
if (obj.intelligence === undefined || obj.intelligence === null || obj.intelligence === '') {
|
|
180
|
-
intelligence = defaultIntelligenceForRuntime(
|
|
183
|
+
intelligence = defaultIntelligenceForRuntime(defaultRuntime)
|
|
181
184
|
// no raw on disk → a healed default will be persisted
|
|
182
185
|
} else {
|
|
183
186
|
const normalized = normalizeIntelligenceValue(obj.intelligence)
|
|
@@ -192,7 +195,7 @@ function normalizePeer(raw: unknown): PeerRecord {
|
|
|
192
195
|
const interfaces = normalizeInterfaces(obj.interfaces, obj.personality)
|
|
193
196
|
return {
|
|
194
197
|
personality: obj.personality,
|
|
195
|
-
runtime:
|
|
198
|
+
runtime: defaultRuntime,
|
|
196
199
|
runtimes,
|
|
197
200
|
description,
|
|
198
201
|
intelligence,
|
package/src/transport/index.ts
CHANGED
|
@@ -440,6 +440,29 @@ export interface RouteDeps {
|
|
|
440
440
|
wake?: WakeFn
|
|
441
441
|
/** Ephemeral-target serial-queue delivery (M3); absent → normal routing. */
|
|
442
442
|
ephemeral?: EphemeralRouteDeps
|
|
443
|
+
/** Fired after a successful LIVE delivery whose envelope carried a non-empty
|
|
444
|
+
* topic — the hit path AND the post-wake fast-path redelivery, NOT the boot
|
|
445
|
+
* first-message (the wake itself records its topic). The fresh-vs-resume seam:
|
|
446
|
+
* lifecycle keeps a per-identity `.topic` marker the wake resolver compares
|
|
447
|
+
* the incoming topic against; before this hook the marker was written ONLY at
|
|
448
|
+
* wake, so it held «the topic the session woke with», while the resolver's
|
|
449
|
+
* semantics want «the topic the session last worked on». A long-lived executor
|
|
450
|
+
* that took live-delivered messages of topic B all day then false-FRESHED on
|
|
451
|
+
* stop→start→wake with topic B (live incident 11.06, claude-iapeer: a day of
|
|
452
|
+
* context lost to cause=idle-reaped-new-topic). Injected so transport never
|
|
453
|
+
* imports lifecycle (layering — main.ts is where the two meet); call sites
|
|
454
|
+
* swallow hook errors (a hook must never fail a delivered message). */
|
|
455
|
+
noteLiveTopic?: (identity: string, topic: string) => void
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/** Best-effort noteLiveTopic invocation — only on a non-empty topic, never throws. */
|
|
459
|
+
function noteTopic(deps: RouteDeps, identity: string, topic: string | undefined): void {
|
|
460
|
+
if (!topic) return
|
|
461
|
+
try {
|
|
462
|
+
deps.noteLiveTopic?.(identity, topic)
|
|
463
|
+
} catch {
|
|
464
|
+
/* a post-delivery hook must never fail the delivery */
|
|
465
|
+
}
|
|
443
466
|
}
|
|
444
467
|
|
|
445
468
|
function truncateTopic(raw: string | undefined): string | undefined {
|
|
@@ -534,6 +557,7 @@ export async function routeSend(
|
|
|
534
557
|
// peer.cwd enables the Ф-B transcript-mtime liveness probe (busy-session case).
|
|
535
558
|
const delivered = deliverViaTmux(target.value, envelope, peer.cwd)
|
|
536
559
|
if (!delivered.ok) return delivered
|
|
560
|
+
noteTopic(deps, target.value.address, topic)
|
|
537
561
|
return ok({
|
|
538
562
|
ok: true,
|
|
539
563
|
delivered_to: { personality: target.value.personality, runtime: target.value.runtime },
|
|
@@ -567,6 +591,7 @@ export async function routeSend(
|
|
|
567
591
|
if (woke.taskDelivered === false) {
|
|
568
592
|
const delivered = deliverViaTmux(live.value, envelope, peer.cwd)
|
|
569
593
|
if (!delivered.ok) return delivered
|
|
594
|
+
noteTopic(deps, live.value.address, topic)
|
|
570
595
|
return ok({
|
|
571
596
|
ok: true,
|
|
572
597
|
delivered_to: { personality: live.value.personality, runtime: live.value.runtime },
|
|
@@ -137,3 +137,72 @@ describe('routeSend wake fast-path (concurrent-sender envelope loss)', () => {
|
|
|
137
137
|
expect(r.error.message).toMatch(/verify-before-act|not live/)
|
|
138
138
|
})
|
|
139
139
|
})
|
|
140
|
+
|
|
141
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
142
|
+
// noteLiveTopic seam — a LIVE delivery carrying a topic must surface it to the
|
|
143
|
+
// injected hook (lifecycle's .topic marker write), so the fresh-vs-resume
|
|
144
|
+
// discriminator compares against the topic the session last WORKED ON, not the
|
|
145
|
+
// one it woke with (defect 11.06: stale wake-time marker → false fresh).
|
|
146
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
describe('routeSend noteLiveTopic seam (live-delivered topic)', () => {
|
|
149
|
+
test('live hit with a topic → hook fired ONCE with the TARGET identity + topic', async () => {
|
|
150
|
+
startTargetSession()
|
|
151
|
+
const notes: Array<[string, string]> = []
|
|
152
|
+
const r = await routeSend(
|
|
153
|
+
caller,
|
|
154
|
+
{ personality: TARGET, message: 'm', topic: 'fix-resume' },
|
|
155
|
+
{ noteLiveTopic: (id, t) => notes.push([id, t]) },
|
|
156
|
+
)
|
|
157
|
+
expect(r.ok).toBe(true)
|
|
158
|
+
expect(notes).toEqual([[`telegram-${TARGET}`, 'fix-resume']])
|
|
159
|
+
killTargetSession()
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
test('no topic → hook NOT fired; a throwing hook never fails the delivered message', async () => {
|
|
163
|
+
startTargetSession()
|
|
164
|
+
const notes: string[] = []
|
|
165
|
+
const r1 = await routeSend(caller, { personality: TARGET, message: 'm1' }, { noteLiveTopic: () => { notes.push('x') } })
|
|
166
|
+
expect(r1.ok).toBe(true)
|
|
167
|
+
expect(notes).toEqual([])
|
|
168
|
+
const r2 = await routeSend(
|
|
169
|
+
caller,
|
|
170
|
+
{ personality: TARGET, message: 'm2', topic: 't' },
|
|
171
|
+
{ noteLiveTopic: () => { throw new Error('boom') } },
|
|
172
|
+
)
|
|
173
|
+
expect(r2.ok).toBe(true)
|
|
174
|
+
killTargetSession()
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
test('post-wake fast-path redelivery (taskDelivered:false) also notes the topic — it is a LIVE delivery', async () => {
|
|
178
|
+
const notes: Array<[string, string]> = []
|
|
179
|
+
const wake: WakeFn = async () => {
|
|
180
|
+
startTargetSession()
|
|
181
|
+
return { status: 'READY', woke: false, runtime: 'telegram', taskDelivered: false }
|
|
182
|
+
}
|
|
183
|
+
const r = await routeSend(
|
|
184
|
+
caller,
|
|
185
|
+
{ personality: TARGET, message: 'm', topic: 'tt' },
|
|
186
|
+
{ wake, noteLiveTopic: (id, t) => notes.push([id, t]) },
|
|
187
|
+
)
|
|
188
|
+
expect(r.ok).toBe(true)
|
|
189
|
+
expect(notes).toEqual([[`telegram-${TARGET}`, 'tt']])
|
|
190
|
+
killTargetSession()
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
test('boot first-message delivery (woke:true) does NOT fire the hook — the wake records its own topic', async () => {
|
|
194
|
+
const notes: string[] = []
|
|
195
|
+
const wake: WakeFn = async () => {
|
|
196
|
+
startTargetSession()
|
|
197
|
+
return { status: 'READY', woke: true, runtime: 'telegram', taskDelivered: true }
|
|
198
|
+
}
|
|
199
|
+
const r = await routeSend(
|
|
200
|
+
caller,
|
|
201
|
+
{ personality: TARGET, message: 'm', topic: 'tb' },
|
|
202
|
+
{ wake, noteLiveTopic: () => { notes.push('x') } },
|
|
203
|
+
)
|
|
204
|
+
expect(r.ok).toBe(true)
|
|
205
|
+
expect(notes).toEqual([])
|
|
206
|
+
killTargetSession()
|
|
207
|
+
})
|
|
208
|
+
})
|