@openparachute/agent 0.2.3-rc.11 → 0.2.3-rc.12
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/backends/programmatic.ts +14 -1
- package/src/backends/registry.ts +47 -0
- package/src/backends/types.ts +9 -0
- package/src/daemon.ts +22 -2
- package/src/jobs.ts +9 -0
- package/src/sandbox/types.ts +38 -0
- package/src/transports/vault.ts +56 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openparachute/agent",
|
|
3
|
-
"version": "0.2.3-rc.
|
|
3
|
+
"version": "0.2.3-rc.12",
|
|
4
4
|
"description": "Vault-native agents for Claude Code — a #agent/definition note + an inbound message becomes a sandboxed claude turn; the reply is written back as a note. Messaging gateway on :1941.",
|
|
5
5
|
"license": "AGPL-3.0",
|
|
6
6
|
"type": "module",
|
|
@@ -442,6 +442,7 @@ export class ProgrammaticBackend implements AgentBackend {
|
|
|
442
442
|
onInterim?: InterimSink,
|
|
443
443
|
attachments?: InboundAttachment[],
|
|
444
444
|
runContext?: RunContext,
|
|
445
|
+
subjectDossier?: string,
|
|
445
446
|
): Promise<DeliverResult> {
|
|
446
447
|
const spec = handle.spec;
|
|
447
448
|
if (!spec) {
|
|
@@ -587,10 +588,22 @@ export class ProgrammaticBackend implements AgentBackend {
|
|
|
587
588
|
// Unset → no flag, no file (today's behavior unchanged). The `-file` form is
|
|
588
589
|
// robust to long/multiline prompts and keeps the prompt visible-on-disk. Its
|
|
589
590
|
// lifecycle is tied to the workspace (like .mcp.json) — it disappears with it.
|
|
591
|
+
//
|
|
592
|
+
// COMPOSED PROMPT (roles×threads NOW slice): when a `subjectDossier` is handed in,
|
|
593
|
+
// the agent's stable ROLE (the spec's systemPrompt) is composed with the per-thread
|
|
594
|
+
// subject context as `roleBody + "\n\n---\n\n" + dossier`. The role stays constant
|
|
595
|
+
// across a role's threads; the subject-specific dossier layers on top. Absent/empty
|
|
596
|
+
// dossier → the role body is written VERBATIM (byte-identical to HEAD — the
|
|
597
|
+
// null-subject invariant; the run-context preamble stays on the MESSAGE, not here).
|
|
590
598
|
let systemPromptFile: string | undefined;
|
|
591
599
|
if (typeof spec.systemPrompt === "string" && spec.systemPrompt.length > 0) {
|
|
600
|
+
const roleBody = spec.systemPrompt;
|
|
601
|
+
const composed =
|
|
602
|
+
typeof subjectDossier === "string" && subjectDossier.length > 0
|
|
603
|
+
? `${roleBody}\n\n---\n\n${subjectDossier}`
|
|
604
|
+
: roleBody;
|
|
592
605
|
systemPromptFile = join(workspace, "system-prompt.txt");
|
|
593
|
-
writeFileSync(systemPromptFile,
|
|
606
|
+
writeFileSync(systemPromptFile, composed, { mode: 0o600 });
|
|
594
607
|
}
|
|
595
608
|
|
|
596
609
|
// The agent's WORKING dir (design 2026-06-16-agent-filesystem-and-sharing.md):
|
package/src/backends/registry.ts
CHANGED
|
@@ -393,6 +393,16 @@ export interface QueuedMessage {
|
|
|
393
393
|
* something else. Absent → the run context omits `fired-by`. Carries no routing meaning.
|
|
394
394
|
*/
|
|
395
395
|
sender?: string;
|
|
396
|
+
/**
|
|
397
|
+
* The THREAD SUBJECT (roles×threads NOW slice) — rides from the inbound note's
|
|
398
|
+
* `metadata.subject`, through `contextFor.emit` (daemon.ts), onto this queue item.
|
|
399
|
+
* In the NOW slice it carries NO routing meaning — the drain stays per-CHANNEL serial,
|
|
400
|
+
* NOT per-subject — but it is threaded through so the composed prompt can fold in a
|
|
401
|
+
* subject dossier and the NEXT slice (#120 thread routing + per-thread session
|
|
402
|
+
* continuity) can key off it without re-plumbing. Absent → no subject (today's behavior;
|
|
403
|
+
* the weave path is untouched). Carries no routing meaning in NOW.
|
|
404
|
+
*/
|
|
405
|
+
subject?: string;
|
|
396
406
|
}
|
|
397
407
|
|
|
398
408
|
/** A registered programmatic agent's live status (surfaced in /health + the list). */
|
|
@@ -478,6 +488,16 @@ export class ProgrammaticAgentRegistry {
|
|
|
478
488
|
* Unwired → reset is a clean no-op beyond returning that the agent exists.
|
|
479
489
|
*/
|
|
480
490
|
private readonly clearSession?: (channel: string, name: string) => Promise<void>;
|
|
491
|
+
/**
|
|
492
|
+
* Optional pre-turn SUBJECT-DOSSIER read (roles×threads NOW slice) — per-thread
|
|
493
|
+
* context for the current subject, folded into the composed system prompt (the
|
|
494
|
+
* programmatic backend's `roleBody + "\n\n---\n\n" + dossier`). Read in {@link drain}
|
|
495
|
+
* keyed on the inbound message's subject. UNWIRED in the NOW slice (the default registry
|
|
496
|
+
* does NOT set it — there's no dossier-note convention yet), so every turn composes the
|
|
497
|
+
* role body verbatim and the null-subject path is byte-identical to HEAD. The seam is
|
|
498
|
+
* here so a future dossier source threads in without re-plumbing `deliver`.
|
|
499
|
+
*/
|
|
500
|
+
private readonly readSubjectDossier?: (channel: string, subject: string) => Promise<string | undefined>;
|
|
481
501
|
/** Base backoff (ms) between outbound retries (FIX 1). Injectable so tests run fast. */
|
|
482
502
|
private readonly outboundRetryBaseMs: number;
|
|
483
503
|
|
|
@@ -491,6 +511,11 @@ export class ProgrammaticAgentRegistry {
|
|
|
491
511
|
readSession?: (channel: string, name: string) => Promise<string | undefined>;
|
|
492
512
|
/** Clear the persisted thread-note session (the per-agent restart / reset). */
|
|
493
513
|
clearSession?: (channel: string, name: string) => Promise<void>;
|
|
514
|
+
/**
|
|
515
|
+
* Read the per-thread subject dossier (roles×threads NOW slice). Optional + UNWIRED
|
|
516
|
+
* by default — the composed prompt is the role body verbatim until a dossier source exists.
|
|
517
|
+
*/
|
|
518
|
+
readSubjectDossier?: (channel: string, subject: string) => Promise<string | undefined>;
|
|
494
519
|
/** Override the outbound-retry backoff base (ms). Default {@link OUTBOUND_RETRY_BASE_MS}. */
|
|
495
520
|
outboundRetryBaseMs?: number;
|
|
496
521
|
}) {
|
|
@@ -501,6 +526,7 @@ export class ProgrammaticAgentRegistry {
|
|
|
501
526
|
if (deps.onTurnEvent) this.onTurnEvent = deps.onTurnEvent;
|
|
502
527
|
if (deps.readSession) this.readSession = deps.readSession;
|
|
503
528
|
if (deps.clearSession) this.clearSession = deps.clearSession;
|
|
529
|
+
if (deps.readSubjectDossier) this.readSubjectDossier = deps.readSubjectDossier;
|
|
504
530
|
this.outboundRetryBaseMs = deps.outboundRetryBaseMs ?? OUTBOUND_RETRY_BASE_MS;
|
|
505
531
|
}
|
|
506
532
|
|
|
@@ -891,6 +917,24 @@ export class ProgrammaticAgentRegistry {
|
|
|
891
917
|
...(firedBy ? { firedBy } : {}),
|
|
892
918
|
};
|
|
893
919
|
|
|
920
|
+
// SUBJECT DOSSIER (roles×threads NOW slice): when the inbound carries a subject AND a
|
|
921
|
+
// dossier source is wired, resolve the per-thread context the backend folds into the
|
|
922
|
+
// composed system prompt. UNWIRED by default (no dossier-note convention yet) and
|
|
923
|
+
// no-subject always → `undefined`, so the system prompt is the role body verbatim and
|
|
924
|
+
// the null-subject path is byte-identical to HEAD. Best-effort: a dossier read failure
|
|
925
|
+
// logs + the turn still runs (role-only), never strands the queue.
|
|
926
|
+
let subjectDossier: string | undefined;
|
|
927
|
+
if (msg.subject && this.readSubjectDossier) {
|
|
928
|
+
try {
|
|
929
|
+
subjectDossier = await this.readSubjectDossier(handle.channel, msg.subject);
|
|
930
|
+
} catch (err) {
|
|
931
|
+
console.error(
|
|
932
|
+
`parachute-agent: subject-dossier read for channel "${channel}" ` +
|
|
933
|
+
`subject "${msg.subject}" failed (turn runs role-only): ${(err as Error).message}`,
|
|
934
|
+
);
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
|
|
894
938
|
let result;
|
|
895
939
|
try {
|
|
896
940
|
// Forward each interim event to the streaming-view sink (keyed by channel)
|
|
@@ -906,6 +950,9 @@ export class ProgrammaticAgentRegistry {
|
|
|
906
950
|
msg.attachments,
|
|
907
951
|
// agent#162: the daemon-known runtime context (real clock, new/resumed, fired-by).
|
|
908
952
|
runContext,
|
|
953
|
+
// roles×threads NOW slice: the per-thread subject dossier, folded into the composed
|
|
954
|
+
// system prompt. Undefined (no subject / unwired source) → role body verbatim.
|
|
955
|
+
subjectDossier,
|
|
909
956
|
);
|
|
910
957
|
} catch (err) {
|
|
911
958
|
// The backend contract is failure-as-VALUE, never a throw — but defend so a
|
package/src/backends/types.ts
CHANGED
|
@@ -244,6 +244,14 @@ export interface AgentBackend {
|
|
|
244
244
|
* The programmatic backend prepends it as a concise, clearly-labeled preamble to the turn
|
|
245
245
|
* message so the agent stamps ACCURATE times instead of fabricating them. ADDITIVE — omitted
|
|
246
246
|
* → the turn message is exactly as before.
|
|
247
|
+
*
|
|
248
|
+
* `subjectDossier` (optional, roles×threads NOW slice) is per-THREAD context for the
|
|
249
|
+
* agent's CURRENT subject — folded into the SYSTEM prompt (NOT the message): the programmatic
|
|
250
|
+
* backend writes `roleBody + "\n\n---\n\n" + dossier` to the per-turn `system-prompt.txt` when
|
|
251
|
+
* present, so the agent's ROLE (the spec's systemPrompt) stays stable across threads while the
|
|
252
|
+
* subject-specific context layers on top. The run-context preamble is unaffected (it stays on
|
|
253
|
+
* the MESSAGE). ADDITIVE — omitted/empty → the system prompt is the role body verbatim
|
|
254
|
+
* (byte-identical to HEAD; the null-subject invariant).
|
|
247
255
|
*/
|
|
248
256
|
deliver(
|
|
249
257
|
handle: AgentHandle,
|
|
@@ -252,6 +260,7 @@ export interface AgentBackend {
|
|
|
252
260
|
onInterim?: InterimSink,
|
|
253
261
|
attachments?: InboundAttachment[],
|
|
254
262
|
runContext?: RunContext,
|
|
263
|
+
subjectDossier?: string,
|
|
255
264
|
): Promise<DeliverResult>;
|
|
256
265
|
|
|
257
266
|
/**
|
package/src/daemon.ts
CHANGED
|
@@ -345,6 +345,10 @@ export function contextFor(
|
|
|
345
345
|
// agent#162: carry the inbound sender so the drain can derive the run-context
|
|
346
346
|
// `fired-by` (a scheduled `runner:<jobId>` fire vs an interactive/delegated message).
|
|
347
347
|
...(msg.meta?.sender ? { sender: msg.meta.sender } : {}),
|
|
348
|
+
// roles×threads NOW slice: carry the thread subject through to the drain so the
|
|
349
|
+
// composed prompt can fold in a subject dossier (and the NEXT slice can route by
|
|
350
|
+
// it). No routing meaning yet. Absent → unchanged (the weave path is untouched).
|
|
351
|
+
...(msg.meta?.subject ? { subject: msg.meta.subject } : {}),
|
|
348
352
|
});
|
|
349
353
|
return;
|
|
350
354
|
}
|
|
@@ -372,6 +376,10 @@ export function contextFor(
|
|
|
372
376
|
// agent#162: carry the sender through the pending buffer too, so a turn that runs on
|
|
373
377
|
// register() still derives the right run-context `fired-by`.
|
|
374
378
|
...(msg.meta?.sender ? { sender: msg.meta.sender } : {}),
|
|
379
|
+
// roles×threads NOW slice: carry the thread subject through the pending buffer too,
|
|
380
|
+
// so a turn that runs on register() still folds in its subject dossier. No routing
|
|
381
|
+
// meaning yet. Absent → unchanged.
|
|
382
|
+
...(msg.meta?.subject ? { subject: msg.meta.subject } : {}),
|
|
375
383
|
});
|
|
376
384
|
if (outcome === "queued") return;
|
|
377
385
|
// outcome === "unknown" — not an expected programmatic channel. It may still be a
|
|
@@ -1815,7 +1823,12 @@ export function createFetchHandler(
|
|
|
1815
1823
|
if (!transport) {
|
|
1816
1824
|
return json({ error: `job "${id}" targets a non-vault channel "${job.channel}"` }, 400);
|
|
1817
1825
|
}
|
|
1818
|
-
|
|
1826
|
+
// roles×threads NOW slice: thread the job's subject (absent → no subject).
|
|
1827
|
+
await transport.injectInbound({
|
|
1828
|
+
content: job.message,
|
|
1829
|
+
sender: `runner:${job.id}`,
|
|
1830
|
+
...(job.subject ? { subject: job.subject } : {}),
|
|
1831
|
+
});
|
|
1819
1832
|
return json({ ok: true, id, status: "ok" });
|
|
1820
1833
|
} catch (err) {
|
|
1821
1834
|
return json({ error: `failed to run job: ${(err as Error).message}` }, 502);
|
|
@@ -3381,7 +3394,14 @@ function main(): void {
|
|
|
3381
3394
|
if (!transport) {
|
|
3382
3395
|
throw new Error(`channel "${job.channel}" is not a live vault channel`);
|
|
3383
3396
|
}
|
|
3384
|
-
|
|
3397
|
+
// roles×threads NOW slice: thread the job's subject onto the inbound note so the
|
|
3398
|
+
// turn's composed prompt can fold in its dossier. Absent → no subject (the weave
|
|
3399
|
+
// job carries none → byte-identical to HEAD).
|
|
3400
|
+
await transport.injectInbound({
|
|
3401
|
+
content: job.message,
|
|
3402
|
+
sender: `runner:${job.id}`,
|
|
3403
|
+
...(job.subject ? { subject: job.subject } : {}),
|
|
3404
|
+
});
|
|
3385
3405
|
},
|
|
3386
3406
|
// Persist bookkeeping (lastRunAt/lastStatus) back onto the job note (addressed
|
|
3387
3407
|
// by its vault note id). A job loaded from the store always carries `noteId`.
|
package/src/jobs.ts
CHANGED
|
@@ -65,6 +65,13 @@ export interface Job {
|
|
|
65
65
|
lastStatus?: string;
|
|
66
66
|
/** ISO timestamp of the next scheduled fire — COMPUTED IN MEMORY, never persisted. */
|
|
67
67
|
nextRunAt?: string;
|
|
68
|
+
/**
|
|
69
|
+
* The THREAD SUBJECT a fire of this job carries (roles×threads NOW slice). When set,
|
|
70
|
+
* the runner stamps it onto the inbound note (`metadata.subject`) so the turn's composed
|
|
71
|
+
* prompt + (NEXT) thread routing can read it. Optional; absent → today's behavior (the
|
|
72
|
+
* weave job carries none — its fire is byte-identical to HEAD).
|
|
73
|
+
*/
|
|
74
|
+
subject?: string;
|
|
68
75
|
}
|
|
69
76
|
|
|
70
77
|
/** A slug: alphanumeric, dash, underscore (same shape as channel names). */
|
|
@@ -202,6 +209,7 @@ export class VaultJobStore {
|
|
|
202
209
|
createdAt: n.createdAt ?? "",
|
|
203
210
|
...(n.lastRunAt ? { lastRunAt: n.lastRunAt } : {}),
|
|
204
211
|
...(n.lastStatus ? { lastStatus: n.lastStatus } : {}),
|
|
212
|
+
...(n.subject ? { subject: n.subject } : {}),
|
|
205
213
|
});
|
|
206
214
|
}
|
|
207
215
|
}
|
|
@@ -229,6 +237,7 @@ export class VaultJobStore {
|
|
|
229
237
|
createdAt: job.createdAt,
|
|
230
238
|
...(job.lastRunAt ? { lastRunAt: job.lastRunAt } : {}),
|
|
231
239
|
...(job.lastStatus ? { lastStatus: job.lastStatus } : {}),
|
|
240
|
+
...(job.subject ? { subject: job.subject } : {}),
|
|
232
241
|
});
|
|
233
242
|
return { ...job, noteId }; // id stays the slug; noteId addresses the persisted note.
|
|
234
243
|
}
|
package/src/sandbox/types.ts
CHANGED
|
@@ -380,3 +380,41 @@ export function normalizeChannel(ch: AgentChannel): { name: string; access: "rea
|
|
|
380
380
|
if (typeof ch === "string") return { name: ch, access: "write" };
|
|
381
381
|
return { name: ch.name, access: ch.access ?? "write" };
|
|
382
382
|
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Collapse a string to a flat, path-safe slug — every char outside
|
|
386
|
+
* `[a-zA-Z0-9_-]` becomes `-`. This MIRRORS the channel/name sanitizer used on
|
|
387
|
+
* the vault note paths (`vault.ts:746`, `:822-823`) so a value sanitized here
|
|
388
|
+
* can be used as a path leaf or a `--session-id` segment without re-escaping.
|
|
389
|
+
* Pure + idempotent. Exported so the NEXT slice (per-thread workspace paths,
|
|
390
|
+
* spec §G) reuses this exact sanitizer rather than re-implementing it (drift risk).
|
|
391
|
+
*/
|
|
392
|
+
export function slug(raw: string): string {
|
|
393
|
+
return raw.replace(/[^a-zA-Z0-9_-]/g, "-");
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* The THREAD identity key for one (agent, subject) pair — the roles×threads NOW
|
|
398
|
+
* slice (design team-vault `Strategy/2026-06-29-agents-roles-threads`).
|
|
399
|
+
*
|
|
400
|
+
* - No subject (absent / empty / whitespace-only) → the BARE `specName`. This is
|
|
401
|
+
* the back-compat path: every agent today (incl. the weave) carries no subject,
|
|
402
|
+
* so `threadKey` returns exactly the spec name and every downstream
|
|
403
|
+
* path/key/workspace is byte-identical to HEAD.
|
|
404
|
+
* - A non-empty subject → `${specName}--${slug(subject)}`. The `--` separator and
|
|
405
|
+
* the `slug()` sanitize make the key safe to use as a vault path leaf or a
|
|
406
|
+
* Claude `--session-id` segment.
|
|
407
|
+
*
|
|
408
|
+
* SECURITY: `subject` is UNTRUSTED input that later becomes a path leaf, so it is
|
|
409
|
+
* run through {@link slug} here — `../`, spaces, slashes, and other path-dangerous
|
|
410
|
+
* chars collapse to `-`, so a subject can never traverse the path hierarchy.
|
|
411
|
+
* `specName` is NOT sanitized here (it's already a sanitized agent/channel name
|
|
412
|
+
* upstream); only the untrusted subject is.
|
|
413
|
+
*
|
|
414
|
+
* NOTE: subject is PER-THREAD — it rides on the inbound message, NOT on the
|
|
415
|
+
* {@link AgentSpec} (which is per-AGENT). Keep it off the spec.
|
|
416
|
+
*/
|
|
417
|
+
export function threadKey(specName: string, subject?: string): string {
|
|
418
|
+
const trimmed = subject?.trim();
|
|
419
|
+
return trimmed ? `${specName}--${slug(trimmed)}` : specName;
|
|
420
|
+
}
|
package/src/transports/vault.ts
CHANGED
|
@@ -161,6 +161,11 @@ export interface JobNote {
|
|
|
161
161
|
lastRunAt?: string;
|
|
162
162
|
/** "ok" / "error: …" from the most recent fire. */
|
|
163
163
|
lastStatus?: string;
|
|
164
|
+
/**
|
|
165
|
+
* The THREAD SUBJECT a fire carries (roles×threads NOW slice) — read back from
|
|
166
|
+
* `metadata.subject`. Optional; absent → today's behavior (no subject).
|
|
167
|
+
*/
|
|
168
|
+
subject?: string;
|
|
164
169
|
}
|
|
165
170
|
|
|
166
171
|
/** The metadata payload written for a job note (all string-typed, per the vault). */
|
|
@@ -176,6 +181,13 @@ export interface JobNoteMetadata {
|
|
|
176
181
|
createdAt: string;
|
|
177
182
|
lastRunAt?: string;
|
|
178
183
|
lastStatus?: string;
|
|
184
|
+
/**
|
|
185
|
+
* The THREAD SUBJECT a fire of this job carries (roles×threads NOW slice) — stamped
|
|
186
|
+
* onto the inbound note the runner injects, so the turn's composed prompt + (NEXT)
|
|
187
|
+
* thread routing can read it. Optional; absent → today's behavior (the weave job
|
|
188
|
+
* carries none).
|
|
189
|
+
*/
|
|
190
|
+
subject?: string;
|
|
179
191
|
}
|
|
180
192
|
|
|
181
193
|
/**
|
|
@@ -1319,8 +1331,21 @@ export class VaultTransport implements Transport {
|
|
|
1319
1331
|
* thin wrapper over `writeInbound` so the inbound write path has ONE
|
|
1320
1332
|
* implementation; only the default sender differs.
|
|
1321
1333
|
*/
|
|
1322
|
-
async injectInbound(opts: {
|
|
1323
|
-
|
|
1334
|
+
async injectInbound(opts: {
|
|
1335
|
+
content: string;
|
|
1336
|
+
sender?: string;
|
|
1337
|
+
/**
|
|
1338
|
+
* SUBJECT (roles×threads NOW slice) — the thread axis for a runner-fired turn.
|
|
1339
|
+
* When a job carries a subject, the runner threads it here; it's stamped onto the
|
|
1340
|
+
* inbound note's `metadata.subject` (via {@link writeInbound}'s `extraMeta`) so the
|
|
1341
|
+
* turn's composed prompt + (NEXT) thread routing can read it. Absent/empty → NO
|
|
1342
|
+
* `subject` field on the note (today's behavior exactly — the weave job is unaffected).
|
|
1343
|
+
*/
|
|
1344
|
+
subject?: string;
|
|
1345
|
+
}): Promise<{ id: string }> {
|
|
1346
|
+
const subject = opts.subject?.trim();
|
|
1347
|
+
const extraMeta = subject ? { subject } : undefined;
|
|
1348
|
+
return this.writeInbound(opts.content, opts.sender ?? "runner", extraMeta);
|
|
1324
1349
|
}
|
|
1325
1350
|
|
|
1326
1351
|
/**
|
|
@@ -1579,6 +1604,10 @@ export class VaultTransport implements Transport {
|
|
|
1579
1604
|
if (typeof m.createdAt === "string") job.createdAt = m.createdAt;
|
|
1580
1605
|
if (typeof m.lastRunAt === "string") job.lastRunAt = m.lastRunAt;
|
|
1581
1606
|
if (typeof m.lastStatus === "string") job.lastStatus = m.lastStatus;
|
|
1607
|
+
// roles×threads NOW slice: read the thread subject back (absent/blank → undefined).
|
|
1608
|
+
// Trim-guarded symmetrically with the write side (upsertJobNote) so a whitespace-only
|
|
1609
|
+
// value that somehow landed in the vault can't propagate downstream as a "subject".
|
|
1610
|
+
if (typeof m.subject === "string" && m.subject.trim()) job.subject = m.subject.trim();
|
|
1582
1611
|
jobs.push(job);
|
|
1583
1612
|
}
|
|
1584
1613
|
return jobs;
|
|
@@ -1600,6 +1629,8 @@ export class VaultTransport implements Transport {
|
|
|
1600
1629
|
createdAt: string;
|
|
1601
1630
|
lastRunAt?: string;
|
|
1602
1631
|
lastStatus?: string;
|
|
1632
|
+
/** The thread subject a fire carries (roles×threads NOW slice); absent → no field. */
|
|
1633
|
+
subject?: string;
|
|
1603
1634
|
}): Promise<{ id: string }> {
|
|
1604
1635
|
const safeId = job.id.replace(/[^a-zA-Z0-9_-]/g, "-");
|
|
1605
1636
|
const safeChannel = job.channel.replace(/[^a-zA-Z0-9_-]/g, "-");
|
|
@@ -1615,6 +1646,11 @@ export class VaultTransport implements Transport {
|
|
|
1615
1646
|
if (job.tz) metadata.tz = job.tz;
|
|
1616
1647
|
if (job.lastRunAt) metadata.lastRunAt = job.lastRunAt;
|
|
1617
1648
|
if (job.lastStatus) metadata.lastStatus = job.lastStatus;
|
|
1649
|
+
// roles×threads NOW slice: persist a non-empty subject; absent → no field (the weave
|
|
1650
|
+
// job writes none, so its note is byte-identical to HEAD).
|
|
1651
|
+
if (typeof job.subject === "string" && job.subject.trim().length > 0) {
|
|
1652
|
+
metadata.subject = job.subject.trim();
|
|
1653
|
+
}
|
|
1618
1654
|
|
|
1619
1655
|
const res = await fetch(`${this.vaultUrl}/vault/${this.vault}/api/notes`, {
|
|
1620
1656
|
method: "POST",
|
|
@@ -1772,9 +1808,13 @@ export class VaultTransport implements Transport {
|
|
|
1772
1808
|
}
|
|
1773
1809
|
// Flatten the note's metadata into the inbound meta (string-valued), then
|
|
1774
1810
|
// stamp our own provenance fields. `source`/`note_id`/`direction` are set
|
|
1775
|
-
// explicitly so they win over anything in the note's metadata.
|
|
1811
|
+
// explicitly so they win over anything in the note's metadata. `subject` is
|
|
1812
|
+
// SKIPPED here and handled explicitly below (normalized to non-empty-or-absent),
|
|
1813
|
+
// so a blank `subject: " "` can't slip through the raw spread — absent and
|
|
1814
|
+
// whitespace-only must be indistinguishable downstream (the null-subject invariant).
|
|
1776
1815
|
const flatMeta: Record<string, string> = {};
|
|
1777
1816
|
for (const [k, v] of Object.entries(meta)) {
|
|
1817
|
+
if (k === "subject") continue;
|
|
1778
1818
|
flatMeta[k] = typeof v === "string" ? v : String(v);
|
|
1779
1819
|
}
|
|
1780
1820
|
|
|
@@ -1789,6 +1829,18 @@ export class VaultTransport implements Transport {
|
|
|
1789
1829
|
attachments = await this.fetchInboundAttachments(note.id);
|
|
1790
1830
|
}
|
|
1791
1831
|
|
|
1832
|
+
// SUBJECT (roles×threads NOW slice). The thread axis: when the inbound note
|
|
1833
|
+
// carries a non-empty string `metadata.subject`, surface it onto the emitted
|
|
1834
|
+
// event meta so it can flow through the queue → composed prompt → (NEXT) thread
|
|
1835
|
+
// routing. ABSENT/empty → no `subject` field on the emitted meta, so the emit is
|
|
1836
|
+
// BYTE-IDENTICAL to HEAD (the null-subject invariant — the weave path is untouched).
|
|
1837
|
+
// `subject` is deliberately SKIPPED in the `flatMeta` spread above and re-added here
|
|
1838
|
+
// ONLY when non-empty, so an empty/whitespace-only value can never leak through —
|
|
1839
|
+
// absent and blank are indistinguishable downstream.
|
|
1840
|
+
const rawSubject = meta.subject;
|
|
1841
|
+
const subject =
|
|
1842
|
+
typeof rawSubject === "string" && rawSubject.trim().length > 0 ? rawSubject : undefined;
|
|
1843
|
+
|
|
1792
1844
|
this.ctx.emit({
|
|
1793
1845
|
// `channel` here is the in-memory InboundMessage.channel TS field (NOT serialized
|
|
1794
1846
|
// note metadata) — left as the channel name. The routing key rides in `meta.agent`.
|
|
@@ -1803,6 +1855,7 @@ export class VaultTransport implements Transport {
|
|
|
1803
1855
|
note_id: note.id,
|
|
1804
1856
|
sender: typeof meta.sender === "string" ? meta.sender : "",
|
|
1805
1857
|
direction: "inbound",
|
|
1858
|
+
...(subject ? { subject } : {}),
|
|
1806
1859
|
},
|
|
1807
1860
|
source: "vault",
|
|
1808
1861
|
...(attachments.length > 0 ? { attachments } : {}),
|