@animalabs/connectome-host 0.7.4 → 0.8.0
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/.env.example +12 -5
- package/.github/PULL_REQUEST_TEMPLATE.md +3 -2
- package/.github/workflows/changelog.yml +9 -4
- package/.github/workflows/ci.yml +5 -3
- package/.github/workflows/publish.yml +12 -6
- package/CHANGELOG.md +245 -0
- package/CONTRIBUTING.md +47 -19
- package/README.md +27 -0
- package/bun.lock +27 -31
- package/changelog.d/README.md +28 -0
- package/package.json +5 -5
- package/recipes/SETUP.md +11 -5
- package/recipes/TRIUMVIRATE-SETUP.md +68 -14
- package/recipes/knowledge-miner.json +0 -30
- package/recipes/mock-test.json +19 -0
- package/recipes/triumvirate.json +6 -1
- package/scripts/release-changelog.ts +210 -21
- package/src/cache-keepalive-log.ts +41 -0
- package/src/commands.ts +96 -0
- package/src/framework-strategy.ts +37 -0
- package/src/gate-telemetry.ts +106 -0
- package/src/headless.ts +10 -0
- package/src/index.ts +167 -55
- package/src/mcpl-config.ts +99 -1
- package/src/modules/identity-module.ts +310 -2
- package/src/modules/instructions-module.ts +265 -0
- package/src/modules/mcpl-admin-module.ts +58 -11
- package/src/modules/subagent-module.ts +18 -0
- package/src/recipe.ts +732 -25
- package/src/web/panel-data.ts +19 -0
- package/src/workspace-mounts.ts +73 -0
- package/test/audit-module-optins.test.ts +10 -3
- package/test/cache-keepalive-log.test.ts +83 -0
- package/test/conversations-recipe.test.ts +142 -0
- package/test/framework-fkm-composition.test.ts +35 -3
- package/test/framework-strategy-defaults.test.ts +19 -0
- package/test/gate-telemetry-adapter.test.ts +84 -0
- package/test/gate-telemetry.test.ts +91 -0
- package/test/identity-and-surfaces.test.ts +212 -1
- package/test/instructions-module.test.ts +258 -0
- package/test/mcpl-admin-module.test.ts +41 -0
- package/test/mcpl-agent-overlay.test.ts +51 -3
- package/test/mcpl-child-env.test.ts +64 -0
- package/test/nudge-command.test.ts +47 -0
- package/test/recipe-cache-keepalive.test.ts +59 -0
- package/test/recipe-compression-fallback.test.ts +19 -0
- package/test/recipe-hybrid-prose-routing.test.ts +12 -0
- package/test/recipe-instructions.test.ts +176 -0
- package/test/recipe-kv-unified.test.ts +87 -0
- package/test/recipe-mcp-source.test.ts +54 -0
- package/test/recipe-openai-compatible.test.ts +54 -0
- package/test/recipe-path-resolution.test.ts +19 -8
- package/test/recipe-provider.test.ts +14 -0
- package/test/recipe-save-unresolved.test.ts +244 -0
- package/test/recipe-source-only.test.ts +38 -0
- package/test/release-changelog.test.ts +202 -0
- package/test/subagent-prose-routing.test.ts +109 -0
- package/test/workspace-mounts.test.ts +68 -0
- package/web/src/App.tsx +1 -0
- package/web/src/Health.tsx +61 -1
|
@@ -1,32 +1,221 @@
|
|
|
1
1
|
// Runs as npm's `version` lifecycle hook (see package.json): at that point
|
|
2
2
|
// package.json already carries the new version, and files staged here are
|
|
3
3
|
// included in the release commit that `npm version` then creates and tags.
|
|
4
|
-
|
|
4
|
+
//
|
|
5
|
+
// Folds the pending fragments in changelog.d/ (one file per change,
|
|
6
|
+
// `<slug>.<breaking|added|changed|fixed>.md`) together with anything filed
|
|
7
|
+
// directly under the standing `## Unreleased` section into a new
|
|
8
|
+
// `## X.Y.Z — YYYY-MM-DD` section, deletes the consumed fragments, and
|
|
9
|
+
// leaves a fresh empty `## Unreleased` above it. Refuses to release when
|
|
10
|
+
// there is nothing to release, or when the input's shape is ambiguous.
|
|
11
|
+
import {
|
|
12
|
+
existsSync,
|
|
13
|
+
readdirSync,
|
|
14
|
+
readFileSync,
|
|
15
|
+
unlinkSync,
|
|
16
|
+
writeFileSync,
|
|
17
|
+
} from "node:fs";
|
|
18
|
+
import { join } from "node:path";
|
|
5
19
|
|
|
6
|
-
const
|
|
7
|
-
const
|
|
8
|
-
|
|
20
|
+
const CHANGELOG = "CHANGELOG.md";
|
|
21
|
+
const FRAGMENT_DIR = "changelog.d";
|
|
22
|
+
// Canonical subsection order; a fragment's category must be one of these.
|
|
23
|
+
const CATEGORIES = ["breaking", "added", "changed", "fixed"] as const;
|
|
24
|
+
type Category = (typeof CATEGORIES)[number];
|
|
25
|
+
const HEADINGS: Record<Category, string> = {
|
|
26
|
+
breaking: "Breaking",
|
|
27
|
+
added: "Added",
|
|
28
|
+
changed: "Changed",
|
|
29
|
+
fixed: "Fixed",
|
|
30
|
+
};
|
|
9
31
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
32
|
+
// Refusals throw and are reported at the entry point, which then lets the
|
|
33
|
+
// process end on its own with exitCode 1. process.exit() would race the
|
|
34
|
+
// stderr write when stderr is a pipe (asynchronous on macOS), leaving the
|
|
35
|
+
// caller a bare failure status with no reason attached.
|
|
36
|
+
class ReleaseError extends Error {}
|
|
37
|
+
const fail = (msg: string): never => {
|
|
38
|
+
throw new ReleaseError(msg);
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
interface Fragment {
|
|
42
|
+
name: string;
|
|
43
|
+
category: Category;
|
|
44
|
+
body: string;
|
|
14
45
|
}
|
|
15
46
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
47
|
+
// Fragment grammar: every non-blank line starts a bullet or continues one
|
|
48
|
+
// (indented two or more spaces; nested bullets included). Headings and
|
|
49
|
+
// thematic breaks are refused wherever they appear — at top level a '## '
|
|
50
|
+
// line would splice a fake section boundary into the released changelog,
|
|
51
|
+
// and as item content ('- ## x', ' ## x') they still render as headings.
|
|
52
|
+
const isBulletStart = (l: string): boolean => /^[-*] /.test(l);
|
|
53
|
+
const isContinuation = (l: string): boolean => /^ {2,}\S/.test(l);
|
|
54
|
+
const itemContent = (l: string): string => l.replace(/^\s*(?:[-*]\s+)?/, "");
|
|
55
|
+
const isHeading = (s: string): boolean => /^#{1,6}(\s|$)/.test(s);
|
|
56
|
+
// Thematic breaks may carry interior whitespace ('- - -', '* * *').
|
|
57
|
+
const isRule = (s: string): boolean => /^([-*_=])(\s*\1){2,}\s*$/.test(s);
|
|
58
|
+
const isBlockConstruct = (l: string): boolean =>
|
|
59
|
+
isRule(l.trim()) || isHeading(itemContent(l));
|
|
60
|
+
|
|
61
|
+
// The directory is scanned fail-closed: anything that is not README.md or
|
|
62
|
+
// a well-formed fragment file aborts the release, so an entry can never be
|
|
63
|
+
// silently left out (e.g. a fragment created under a 'fix/' subdirectory
|
|
64
|
+
// because the slug was taken verbatim from a branch name).
|
|
65
|
+
function collectFragments(): Fragment[] {
|
|
66
|
+
const fragments: Fragment[] = [];
|
|
67
|
+
if (!existsSync(FRAGMENT_DIR)) return fragments;
|
|
68
|
+
const entries = readdirSync(FRAGMENT_DIR, { withFileTypes: true }).sort(
|
|
69
|
+
(a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0),
|
|
70
|
+
);
|
|
71
|
+
for (const entry of entries) {
|
|
72
|
+
const { name } = entry;
|
|
73
|
+
if (name === "README.md") continue;
|
|
74
|
+
if (!entry.isFile()) {
|
|
75
|
+
fail(
|
|
76
|
+
`${FRAGMENT_DIR}/${name}: not a file. Fragments are flat files directly ` +
|
|
77
|
+
`in ${FRAGMENT_DIR}/ — a slug cannot contain '/'; use the PR number, ` +
|
|
78
|
+
"or the branch name with '/' replaced by '-'.",
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
const m = name.match(/\.(breaking|added|changed|fixed)\.md$/);
|
|
82
|
+
if (!m) {
|
|
83
|
+
fail(
|
|
84
|
+
`${FRAGMENT_DIR}/${name}: unrecognized file — name fragments ` +
|
|
85
|
+
`'<slug>.<${CATEGORIES.join("|")}>.md' so the entry is not silently stranded.`,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
const body = readFileSync(join(FRAGMENT_DIR, name), "utf8").trim();
|
|
89
|
+
if (!body) fail(`${FRAGMENT_DIR}/${name}: empty fragment.`);
|
|
90
|
+
const offending = body
|
|
91
|
+
.split("\n")
|
|
92
|
+
.find(
|
|
93
|
+
(l) =>
|
|
94
|
+
l.trim() !== "" &&
|
|
95
|
+
(!(isBulletStart(l) || isContinuation(l)) || isBlockConstruct(l)),
|
|
96
|
+
);
|
|
97
|
+
if (offending !== undefined) {
|
|
98
|
+
fail(
|
|
99
|
+
`${FRAGMENT_DIR}/${name}: a fragment is one or more markdown bullets ` +
|
|
100
|
+
"('- …'; continuation lines indented two spaces; no headings or " +
|
|
101
|
+
`rules) — offending line: '${offending}'.`,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
fragments.push({ name, category: m[1] as Category, body });
|
|
105
|
+
}
|
|
106
|
+
return fragments;
|
|
20
107
|
}
|
|
21
108
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
109
|
+
// Merge fragment bullets into the Unreleased body's subsection structure.
|
|
110
|
+
// Directly-filed entries are kept; a fragment joins the first subsection
|
|
111
|
+
// whose title starts with its category (so audience-qualified headings like
|
|
112
|
+
// '### Breaking (recipe authors only)' still attract 'breaking' fragments),
|
|
113
|
+
// or a new canonical subsection. Output is emitted in canonical order.
|
|
114
|
+
function mergeFragments(body: string, frags: Fragment[]): string {
|
|
115
|
+
interface Part {
|
|
116
|
+
title: string;
|
|
117
|
+
lines: string[];
|
|
118
|
+
}
|
|
119
|
+
const preamble: string[] = [];
|
|
120
|
+
const parts: Part[] = [];
|
|
121
|
+
let current: Part | null = null;
|
|
122
|
+
for (const line of body.split("\n")) {
|
|
123
|
+
const h = line.match(/^###\s+(.*)$/);
|
|
124
|
+
if (h) {
|
|
125
|
+
current = { title: (h[1] ?? "").trim(), lines: [] };
|
|
126
|
+
parts.push(current);
|
|
127
|
+
} else if (current) {
|
|
128
|
+
current.lines.push(line);
|
|
129
|
+
} else {
|
|
130
|
+
preamble.push(line);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
for (const f of frags) {
|
|
134
|
+
let part = parts.find((p) => p.title.toLowerCase().startsWith(f.category));
|
|
135
|
+
if (!part) {
|
|
136
|
+
part = { title: HEADINGS[f.category], lines: [] };
|
|
137
|
+
parts.push(part);
|
|
138
|
+
}
|
|
139
|
+
part.lines.push("", ...f.body.split("\n"));
|
|
140
|
+
}
|
|
141
|
+
const rank = (t: string): number => {
|
|
142
|
+
const i = CATEGORIES.findIndex((c) => t.toLowerCase().startsWith(c));
|
|
143
|
+
return i === -1 ? CATEGORIES.length : i;
|
|
144
|
+
};
|
|
145
|
+
const chunks: string[] = [];
|
|
146
|
+
const pre = preamble.join("\n").trim();
|
|
147
|
+
if (pre) chunks.push(pre);
|
|
148
|
+
for (const p of [...parts].sort((a, b) => rank(a.title) - rank(b.title))) {
|
|
149
|
+
const content = p.lines.join("\n").replace(/\n{3,}/g, "\n\n").trim();
|
|
150
|
+
chunks.push(content ? `### ${p.title}\n\n${content}` : `### ${p.title}`);
|
|
151
|
+
}
|
|
152
|
+
return chunks.join("\n\n");
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function main(): void {
|
|
156
|
+
const { version } = JSON.parse(readFileSync("package.json", "utf8")) as {
|
|
157
|
+
version: string;
|
|
158
|
+
};
|
|
159
|
+
const text = readFileSync(CHANGELOG, "utf8");
|
|
160
|
+
const fragments = collectFragments();
|
|
161
|
+
|
|
162
|
+
// Exactly one Unreleased heading. A second one silently strands entries:
|
|
163
|
+
// only the first is ever cut, so anything filed under a later heading is
|
|
164
|
+
// never released and never reaches the GitHub release notes.
|
|
165
|
+
const headings = [...text.matchAll(/^## Unreleased[ \t]*$/gm)];
|
|
166
|
+
if (headings.length === 0) {
|
|
167
|
+
fail(`no '## Unreleased' section in ${CHANGELOG} — add one before releasing.`);
|
|
168
|
+
}
|
|
169
|
+
if (headings.length > 1) {
|
|
170
|
+
const lines = headings.map((m) => text.slice(0, m.index ?? 0).split("\n").length);
|
|
171
|
+
fail(
|
|
172
|
+
`${headings.length} '## Unreleased' headings (lines ${lines.join(", ")}). ` +
|
|
173
|
+
"Only the first is released; fold them into one before releasing.",
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
const header = headings[0]!;
|
|
177
|
+
const headerIndex = header.index ?? 0;
|
|
178
|
+
|
|
179
|
+
const escaped = version.replace(/[.]/g, "\\.");
|
|
180
|
+
if (new RegExp(`^## ${escaped}([^0-9]|$)`, "m").test(text)) {
|
|
181
|
+
fail(`a '## ${version}' section already exists.`);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const afterHeader = text.slice(headerIndex + header[0].length);
|
|
185
|
+
const nextSection = afterHeader.search(/^## /m);
|
|
186
|
+
const oldBody = nextSection === -1 ? afterHeader : afterHeader.slice(0, nextSection);
|
|
187
|
+
const rest = nextSection === -1 ? "" : afterHeader.slice(nextSection);
|
|
188
|
+
|
|
189
|
+
const merged = mergeFragments(oldBody, fragments);
|
|
190
|
+
if (!/^[ \t]*[-*] /m.test(merged)) {
|
|
191
|
+
fail(
|
|
192
|
+
`nothing to release as ${version} — no fragments in ${FRAGMENT_DIR}/ ` +
|
|
193
|
+
"and no entries under '## Unreleased'.",
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Spliced by index rather than string-replaced: `text.replace("## Unreleased", …)`
|
|
198
|
+
// would hit the first *substring* occurrence, which is not necessarily the
|
|
199
|
+
// heading the regex matched (an inline mention of `## Unreleased` in prose
|
|
200
|
+
// comes first) and would inject the version heading into the wrong place.
|
|
201
|
+
const date = new Date().toISOString().slice(0, 10);
|
|
202
|
+
writeFileSync(
|
|
203
|
+
CHANGELOG,
|
|
204
|
+
text.slice(0, headerIndex) +
|
|
205
|
+
`## Unreleased\n\n## ${version} — ${date}\n\n${merged}\n\n` +
|
|
206
|
+
rest,
|
|
207
|
+
);
|
|
208
|
+
for (const f of fragments) unlinkSync(join(FRAGMENT_DIR, f.name));
|
|
209
|
+
console.log(
|
|
210
|
+
`${CHANGELOG}: released '## ${version} — ${date}' from ${fragments.length} ` +
|
|
211
|
+
"fragment(s) plus the Unreleased section.",
|
|
212
|
+
);
|
|
28
213
|
}
|
|
29
214
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
215
|
+
try {
|
|
216
|
+
main();
|
|
217
|
+
} catch (e) {
|
|
218
|
+
if (!(e instanceof ReleaseError)) throw e;
|
|
219
|
+
console.error(`release-changelog: ${e.message}`);
|
|
220
|
+
process.exitCode = 1;
|
|
221
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Where prompt-cache keepalive events get written.
|
|
2
|
+
//
|
|
3
|
+
// Every event goes to STDERR, deliberately and without exception.
|
|
4
|
+
//
|
|
5
|
+
// The host's systemd unit routes `StandardError` to
|
|
6
|
+
// `<data>/service-stderr.log` and leaves stdout on the journal. That file is
|
|
7
|
+
// where an operator actually looks — it is where `[inference-refusal]` and the
|
|
8
|
+
// `[autobiographical]` lines live. Splitting keepalive events across two sinks
|
|
9
|
+
// by severity means the routine ones land somewhere nobody greps.
|
|
10
|
+
//
|
|
11
|
+
// This is not hypothetical. On 2026-08-23 the keepalive ran correctly on
|
|
12
|
+
// fable-cm for three hours — three clean refreshes, 523,102 tokens read each,
|
|
13
|
+
// zero writes — while a monitor tailing service-stderr.log reported
|
|
14
|
+
// `keepalive=0` the entire time, because `refreshed` was going to stdout. An
|
|
15
|
+
// operator would have concluded the feature was dead. A background spender
|
|
16
|
+
// that cannot be found in the log an operator reads is indistinguishable from
|
|
17
|
+
// one that never ran.
|
|
18
|
+
//
|
|
19
|
+
// Volume does not justify splitting them: one refresh per lineage per ~50
|
|
20
|
+
// minutes is nothing next to the compression chatter already in that file.
|
|
21
|
+
|
|
22
|
+
import type { KeepaliveEvent } from '@animalabs/membrane';
|
|
23
|
+
|
|
24
|
+
export const KEEPALIVE_LOG_PREFIX = '[cache-keepalive]';
|
|
25
|
+
|
|
26
|
+
/** Render one event as a single log line. */
|
|
27
|
+
export function formatKeepaliveEvent(event: KeepaliveEvent): string {
|
|
28
|
+
return `${KEEPALIVE_LOG_PREFIX} ${JSON.stringify(event)}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Write one keepalive event to the operator-visible log.
|
|
33
|
+
*
|
|
34
|
+
* `sink` exists for tests; production always uses stderr via the default.
|
|
35
|
+
*/
|
|
36
|
+
export function logKeepaliveEvent(
|
|
37
|
+
event: KeepaliveEvent,
|
|
38
|
+
sink: (line: string) => void = (line) => console.error(line),
|
|
39
|
+
): void {
|
|
40
|
+
sink(formatKeepaliveEvent(event));
|
|
41
|
+
}
|
package/src/commands.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* Commands:
|
|
5
5
|
* /undo — Revert to state before last agent turn
|
|
6
6
|
* /redo — Re-apply last undone action
|
|
7
|
+
* /nudge [agent] — Run inference on current context (no new events)
|
|
7
8
|
* /checkpoint N — Save current state as named checkpoint
|
|
8
9
|
* /restore N — Branch from checkpoint, switch to it
|
|
9
10
|
* /branches — List all Chronicle branches
|
|
@@ -35,6 +36,10 @@ import { type FleetModule, formatChildRow } from './modules/fleet-module.js';
|
|
|
35
36
|
/** Imported lazily to avoid circular deps — index.ts re-exports the type. */
|
|
36
37
|
interface AppContext {
|
|
37
38
|
framework: AgentFramework;
|
|
39
|
+
/** Resolved main-agent name (see index.ts resolveAgentName). Optional
|
|
40
|
+
* because some callers (tui/webui refs) don't thread it; /puppet falls
|
|
41
|
+
* back to the first registered agent, same as getAgentCM. */
|
|
42
|
+
agentName?: string;
|
|
38
43
|
sessionManager: import('./session-manager.js').SessionManager;
|
|
39
44
|
recipe: Recipe;
|
|
40
45
|
branchState: BranchState;
|
|
@@ -147,6 +152,8 @@ export function handleCommand(command: string, app: AppContext): CommandResult {
|
|
|
147
152
|
{ text: ' /export Export lessons to ./output/ (JSON + markdown)', style: 'system' },
|
|
148
153
|
{ text: ' /undo Revert last agent turn', style: 'system' },
|
|
149
154
|
{ text: ' /redo Re-apply undone action', style: 'system' },
|
|
155
|
+
{ text: ' /nudge [agent] Run inference on current context (no new events)', style: 'system' },
|
|
156
|
+
{ text: ' /puppet <tool> [json] Admin: execute a tool AS the agent, store the pair', style: 'system' },
|
|
150
157
|
{ text: ' /checkpoint <name> Save current state', style: 'system' },
|
|
151
158
|
{ text: ' /restore <name> Restore to checkpoint', style: 'system' },
|
|
152
159
|
{ text: ' /branches List Chronicle branches', style: 'system' },
|
|
@@ -189,6 +196,12 @@ export function handleCommand(command: string, app: AppContext): CommandResult {
|
|
|
189
196
|
case 'undo':
|
|
190
197
|
return handleUndo(app);
|
|
191
198
|
|
|
199
|
+
case 'nudge':
|
|
200
|
+
return handleNudge(app, args[0]);
|
|
201
|
+
|
|
202
|
+
case 'puppet':
|
|
203
|
+
return handlePuppet(app, args);
|
|
204
|
+
|
|
192
205
|
case 'redo':
|
|
193
206
|
return handleRedo(app);
|
|
194
207
|
|
|
@@ -651,6 +664,89 @@ export function handleExport(app: AppContext): CommandResult {
|
|
|
651
664
|
};
|
|
652
665
|
}
|
|
653
666
|
|
|
667
|
+
/**
|
|
668
|
+
* /nudge [agent] — admin-level: queue an inference turn on the agent's
|
|
669
|
+
* CURRENT context without adding any message or event (framework
|
|
670
|
+
* `nudgeAgent`). The zero-pollution complement to /undo: rewind, then nudge,
|
|
671
|
+
* and the agent takes another swing at exactly what it already sees.
|
|
672
|
+
*/
|
|
673
|
+
/**
|
|
674
|
+
* /puppet <toolName> [json-input] — admin: execute one tool AS the main
|
|
675
|
+
* agent and store the tool_use + tool_result pair in its window, exactly as
|
|
676
|
+
* a model-initiated call (Framework.puppetToolCall). The call runs for real.
|
|
677
|
+
* Refused unless the agent is idle and the tool is on its surface. Does not
|
|
678
|
+
* wake the agent. Born from the princess exemplar surgery (2026-08-23):
|
|
679
|
+
* one first-person pair restores a capacity the model can't find on its own
|
|
680
|
+
* — older models especially. Disclosure to the resident is the operator's
|
|
681
|
+
* call; the precedent was disclosed first.
|
|
682
|
+
*/
|
|
683
|
+
function handlePuppet(app: AppContext, args: string[]): CommandResult {
|
|
684
|
+
const toolName = args[0];
|
|
685
|
+
if (!toolName) {
|
|
686
|
+
return {
|
|
687
|
+
lines: [
|
|
688
|
+
{ text: 'Usage: /puppet <toolName> [json-input]', style: 'system' },
|
|
689
|
+
{ text: ' Executes the tool AS the agent (for real) and stores the', style: 'system' },
|
|
690
|
+
{ text: ' tool_use + tool_result pair in its window. Requires idle.', style: 'system' },
|
|
691
|
+
],
|
|
692
|
+
};
|
|
693
|
+
}
|
|
694
|
+
const rawInput = args.slice(1).join(' ').trim();
|
|
695
|
+
let input: Record<string, unknown> = {};
|
|
696
|
+
if (rawInput) {
|
|
697
|
+
try {
|
|
698
|
+
const parsed = JSON.parse(rawInput);
|
|
699
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
700
|
+
return { lines: [{ text: 'puppet: input must be a JSON object', style: 'system' }] };
|
|
701
|
+
}
|
|
702
|
+
input = parsed;
|
|
703
|
+
} catch (e) {
|
|
704
|
+
return { lines: [{ text: `puppet: bad JSON input: ${e instanceof Error ? e.message : e}`, style: 'system' }] };
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
const agentName = app.agentName ?? app.framework.getAllAgents()[0]?.name;
|
|
708
|
+
if (!agentName) {
|
|
709
|
+
return { lines: [{ text: 'puppet: no registered agent', style: 'system' }] };
|
|
710
|
+
}
|
|
711
|
+
const asyncWork = (async (): Promise<CommandResult> => {
|
|
712
|
+
try {
|
|
713
|
+
const { toolUseId, result } = await app.framework.puppetToolCall(agentName, toolName, input);
|
|
714
|
+
const preview = result.isError
|
|
715
|
+
? `ERROR: ${result.error ?? 'unknown'}`
|
|
716
|
+
: String(typeof result.data === 'string' ? result.data : JSON.stringify(result.data) ?? '').slice(0, 300);
|
|
717
|
+
return {
|
|
718
|
+
lines: [
|
|
719
|
+
{ text: `puppet ${agentName}: ${toolName} → ${result.isError ? 'error' : 'ok'} (${toolUseId})`, style: 'system' },
|
|
720
|
+
{ text: ` stored tool_use + tool_result in ${agentName}'s window (no wake).`, style: 'system' },
|
|
721
|
+
{ text: ` result: ${preview.replace(/\n/g, ' ')}`, style: 'system' },
|
|
722
|
+
],
|
|
723
|
+
};
|
|
724
|
+
} catch (e) {
|
|
725
|
+
return { lines: [{ text: `puppet failed: ${e instanceof Error ? e.message : e}`, style: 'system' }] };
|
|
726
|
+
}
|
|
727
|
+
})();
|
|
728
|
+
return {
|
|
729
|
+
lines: [{ text: `puppet: executing ${toolName} as ${agentName}...`, style: 'system' }],
|
|
730
|
+
asyncWork,
|
|
731
|
+
};
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
function handleNudge(app: AppContext, agentName?: string): CommandResult {
|
|
735
|
+
const r = app.framework.nudgeAgent(agentName, 'host-console');
|
|
736
|
+
if (!r.ok) {
|
|
737
|
+
return { lines: [{ text: `Nudge failed: ${r.error}`, style: 'system' }] };
|
|
738
|
+
}
|
|
739
|
+
const when = r.agentStatus === 'idle'
|
|
740
|
+
? 'running now'
|
|
741
|
+
: `queued — runs when current turn settles (agent is ${r.agentStatus})`;
|
|
742
|
+
return {
|
|
743
|
+
lines: [{
|
|
744
|
+
text: `Nudged ${r.agentName}: inference on current context, no new events (${when}).`,
|
|
745
|
+
style: 'system',
|
|
746
|
+
}],
|
|
747
|
+
};
|
|
748
|
+
}
|
|
749
|
+
|
|
654
750
|
function handleUndo(app: AppContext): CommandResult {
|
|
655
751
|
const { framework, branchState: bs } = app;
|
|
656
752
|
const cm = getAgentCM(framework);
|
|
@@ -2,6 +2,7 @@ import {
|
|
|
2
2
|
AutobiographicalStrategy,
|
|
3
3
|
PassthroughStrategy,
|
|
4
4
|
type ContextStrategy,
|
|
5
|
+
type ConversationRouterConfig,
|
|
5
6
|
} from '@animalabs/agent-framework';
|
|
6
7
|
import type { Recipe, RecipeStrategy } from './recipe.js';
|
|
7
8
|
import { FrontdeskStrategy } from './strategies/frontdesk-strategy.js';
|
|
@@ -12,11 +13,17 @@ const PASSTHROUGH_KEYS: ReadonlyArray<keyof RecipeStrategy> = [
|
|
|
12
13
|
'maxSpeculativeL1s',
|
|
13
14
|
'compressionRefusalCurveFallbacks',
|
|
14
15
|
'compressionContextBudgetTokens',
|
|
16
|
+
'compressionSourceOnly',
|
|
17
|
+
'compressionSourceOnlyFallback',
|
|
18
|
+
'compressionMergeSourceOnly',
|
|
19
|
+
'compressionMergeSourceOnlyFallback',
|
|
20
|
+
'compressionRecallBudgetTokens',
|
|
15
21
|
'positionedRecallPairs',
|
|
16
22
|
'recallHeaderTemplate',
|
|
17
23
|
'targetChunkTokens',
|
|
18
24
|
'mergeThreshold',
|
|
19
25
|
'summaryTargetTokens',
|
|
26
|
+
'productionBudgetTokens',
|
|
20
27
|
'l1BudgetTokens',
|
|
21
28
|
'l2BudgetTokens',
|
|
22
29
|
'l3BudgetTokens',
|
|
@@ -28,6 +35,7 @@ const PASSTHROUGH_KEYS: ReadonlyArray<keyof RecipeStrategy> = [
|
|
|
28
35
|
'compressionSlackRatio',
|
|
29
36
|
'overBudgetGraceRatio',
|
|
30
37
|
'foldingStrategy',
|
|
38
|
+
'kvUnified',
|
|
31
39
|
'speculativeProduction',
|
|
32
40
|
'l1HoldbackChunks',
|
|
33
41
|
'summaryParticipant',
|
|
@@ -124,3 +132,32 @@ export function buildFrameworkStrategy(
|
|
|
124
132
|
? new FrontdeskStrategy(autobiographicalOpts)
|
|
125
133
|
: new AutobiographicalStrategy(autobiographicalOpts);
|
|
126
134
|
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Map a recipe's `conversations` block to the framework's
|
|
138
|
+
* ConversationRouterConfig. The host supplies the two fields a recipe
|
|
139
|
+
* cannot: `templateAgent` is the recipe's own (sole) agent, and
|
|
140
|
+
* `strategyFactory` builds a FRESH instance of the recipe's configured
|
|
141
|
+
* strategy per fork — strategy instances are stateful and must never be
|
|
142
|
+
* shared between ContextManagers (without a factory the framework would
|
|
143
|
+
* silently give forks passthrough, i.e. no compression).
|
|
144
|
+
*/
|
|
145
|
+
export function buildConversationsConfig(
|
|
146
|
+
recipe: Recipe,
|
|
147
|
+
agentName: string,
|
|
148
|
+
model: string,
|
|
149
|
+
timeZone: string,
|
|
150
|
+
extensions?: ExtensionRegistry,
|
|
151
|
+
): ConversationRouterConfig | undefined {
|
|
152
|
+
const conv = recipe.conversations;
|
|
153
|
+
if (!conv) return undefined;
|
|
154
|
+
return {
|
|
155
|
+
templateAgent: agentName,
|
|
156
|
+
...(conv.bind !== undefined ? { bind: conv.bind } : {}),
|
|
157
|
+
...(conv.trigger !== undefined ? { trigger: conv.trigger } : {}),
|
|
158
|
+
...(conv.idleTtlMs !== undefined ? { idleTtlMs: conv.idleTtlMs } : {}),
|
|
159
|
+
...(conv.closurePrompt !== undefined ? { closurePrompt: conv.closurePrompt } : {}),
|
|
160
|
+
...(conv.agentPrefix !== undefined ? { agentPrefix: conv.agentPrefix } : {}),
|
|
161
|
+
strategyFactory: () => buildFrameworkStrategy(recipe, model, timeZone, extensions),
|
|
162
|
+
};
|
|
163
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Household-gateway telemetry headers (x-gate-* stamps).
|
|
3
|
+
*
|
|
4
|
+
* The data boundary these headers exist under: a household inference gateway
|
|
5
|
+
* records them into its ledger and STRIPS them before the vendor — the vendor
|
|
6
|
+
* must never see them. That boundary is only real if the host refuses to
|
|
7
|
+
* attach the stamps anywhere else, so attachment is double-gated:
|
|
8
|
+
*
|
|
9
|
+
* 1. `GATE_TELEMETRY=1` — the operator's explicit declaration that the
|
|
10
|
+
* configured base URL is such a gateway. Absent/false ⇒ never attach.
|
|
11
|
+
* 2. `ANTHROPIC_BASE_URL` actually set — the flag alone must not stamp
|
|
12
|
+
* traffic that would go to the vendor's default endpoint.
|
|
13
|
+
*
|
|
14
|
+
* Fail-closed on both (review finding on the first wiring: the stamp was
|
|
15
|
+
* attached unconditionally, so with no base URL configured the value went
|
|
16
|
+
* straight to the vendor).
|
|
17
|
+
*
|
|
18
|
+
* Two stamps ride the same hook:
|
|
19
|
+
*
|
|
20
|
+
* x-gate-debt-chunks compression debt at request build (every lane — the
|
|
21
|
+
* aux lane is where the debt series is most telling)
|
|
22
|
+
* x-gate-origin WHY the turn fired: heartbeat | event | mail |
|
|
23
|
+
* operator | <raw reason> — stream lane ONLY
|
|
24
|
+
* x-gate-channel where (adapter-namespaced id) — stream lane ONLY
|
|
25
|
+
* x-gate-counterparty who woke the agent (namespaced id) — stream lane ONLY
|
|
26
|
+
*
|
|
27
|
+
* The origin trio describes the agent's turn; a compression call running in
|
|
28
|
+
* the background is not the turn, so on the 'complete' lane those three are
|
|
29
|
+
* withheld (an older membrane that passes no lane gets them on every call —
|
|
30
|
+
* documented, and the ledger's `streamed` flag lets a reader tell the lanes
|
|
31
|
+
* apart regardless). Values are ids and short class words: never content,
|
|
32
|
+
* never display names.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/** Truthy env-flag parse: unset/''/'0'/'false' (any case) are off. */
|
|
36
|
+
function envFlag(value: string | undefined): boolean {
|
|
37
|
+
return value !== undefined && value !== '' && value !== '0' && value.toLowerCase() !== 'false';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** What the framework knows about the turn in progress (agent-framework InferenceRequest). */
|
|
41
|
+
export interface TurnTrigger {
|
|
42
|
+
reason: string;
|
|
43
|
+
source: string;
|
|
44
|
+
channelId?: string;
|
|
45
|
+
counterparty?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface DynamicHeadersContext {
|
|
49
|
+
lane?: 'stream' | 'complete';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Collapse the framework's free-form reason/source pair into the ledger's
|
|
54
|
+
* origin classes. The raw reason survives when no class fits, clipped and
|
|
55
|
+
* sanitized (ids and words only), so a new event kind shows up as itself
|
|
56
|
+
* instead of vanishing into 'event'.
|
|
57
|
+
*/
|
|
58
|
+
export function originClass(trigger: TurnTrigger): string {
|
|
59
|
+
const r = trigger.reason.toLowerCase();
|
|
60
|
+
const s = trigger.source.toLowerCase();
|
|
61
|
+
if (r.includes('heartbeat') || s.includes('heartbeat')) return 'heartbeat';
|
|
62
|
+
if (r.includes('mail') || s.includes('mail')) return 'mail';
|
|
63
|
+
if (r === 'mcpl:channel-incoming' || r === 'mcpl:push-event' || r.startsWith('discord')) return 'event';
|
|
64
|
+
// a person typing at the host itself: headless IPC, CLI, TUI, web UI, API
|
|
65
|
+
if (r === 'external-message' || ['headless', 'cli', 'tui', 'webui', 'api'].includes(s)) return 'operator';
|
|
66
|
+
if (r.includes('admin') || r.includes('nudge') || r.includes('unstick') || r.includes('operator')) return 'operator';
|
|
67
|
+
return r.replace(/[^a-z0-9:_-]/g, '').slice(0, 40) || 'event';
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Header-safe attribute. HTTP header values are ByteStrings: a single
|
|
72
|
+
* non-ASCII code point (an emoji in a channel name, say) makes Fetch throw
|
|
73
|
+
* and would turn telemetry into a failed model request. So the rule is
|
|
74
|
+
* fail-closed on the WHOLE value — visible ASCII (0x20..0x7e) only, clipped
|
|
75
|
+
* to 120 — never character-stripping, which would mint a different id and
|
|
76
|
+
* collide provenance. An unsendable id is simply not sent (null → dropped).
|
|
77
|
+
*/
|
|
78
|
+
function attr(v: string | undefined): string | null {
|
|
79
|
+
if (typeof v !== 'string') return null;
|
|
80
|
+
const t = v.trim();
|
|
81
|
+
if (!t) return null;
|
|
82
|
+
for (let i = 0; i < t.length; i++) {
|
|
83
|
+
const code = t.charCodeAt(i);
|
|
84
|
+
if (code < 0x20 || code > 0x7e) return null;
|
|
85
|
+
}
|
|
86
|
+
return t.slice(0, 120);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function gateTelemetryHeaders(
|
|
90
|
+
env: Record<string, string | undefined>,
|
|
91
|
+
pendingDebtChunks: () => number | null,
|
|
92
|
+
activeTrigger: () => TurnTrigger | null = () => null,
|
|
93
|
+
): ((ctx?: DynamicHeadersContext) => Record<string, string | number | null>) | undefined {
|
|
94
|
+
if (!envFlag(env.GATE_TELEMETRY)) return undefined;
|
|
95
|
+
if (!env.ANTHROPIC_BASE_URL) return undefined;
|
|
96
|
+
return (ctx?: DynamicHeadersContext) => {
|
|
97
|
+
const out: Record<string, string | number | null> = { 'x-gate-debt-chunks': pendingDebtChunks() };
|
|
98
|
+
if (ctx?.lane === 'complete') return out;
|
|
99
|
+
const t = activeTrigger();
|
|
100
|
+
if (!t) return out;
|
|
101
|
+
out['x-gate-origin'] = originClass(t);
|
|
102
|
+
out['x-gate-channel'] = attr(t.channelId);
|
|
103
|
+
out['x-gate-counterparty'] = attr(t.counterparty);
|
|
104
|
+
return out;
|
|
105
|
+
};
|
|
106
|
+
}
|
package/src/headless.ts
CHANGED
|
@@ -205,6 +205,16 @@ export async function runHeadless(app: AppContext, argv: string[] = []): Promise
|
|
|
205
205
|
for (const line of result.lines) {
|
|
206
206
|
emit({ type: 'command-output', text: line.text, style: line.style ?? null });
|
|
207
207
|
}
|
|
208
|
+
// Commands with async follow-up (fleet kill/restart, puppet) put
|
|
209
|
+
// their real outcome in asyncWork; without this await the IPC
|
|
210
|
+
// caller only ever saw the "...starting" line and the result was
|
|
211
|
+
// silently dropped.
|
|
212
|
+
if (result.asyncWork) {
|
|
213
|
+
const followUp = await result.asyncWork;
|
|
214
|
+
for (const line of followUp.lines) {
|
|
215
|
+
emit({ type: 'command-output', text: line.text, style: line.style ?? null });
|
|
216
|
+
}
|
|
217
|
+
}
|
|
208
218
|
if (result.switchToSessionId) {
|
|
209
219
|
await app.switchSession(result.switchToSessionId);
|
|
210
220
|
emit({ type: 'command-output', text: 'Session switched.', style: 'system' });
|