@animalabs/connectome-host 0.7.4 → 0.8.1
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 +320 -0
- package/CONTRIBUTING.md +47 -19
- package/README.md +27 -0
- package/bun.lock +26 -32
- package/changelog.d/README.md +28 -0
- package/package.json +6 -6
- 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 +221 -32
- package/src/framework-agent-config.ts +3 -0
- package/src/framework-strategy.ts +42 -0
- package/src/gate-telemetry.ts +134 -0
- package/src/headless.ts +10 -0
- package/src/index.ts +194 -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/modules/web-ui-module.ts +32 -4
- package/src/recipe.ts +821 -25
- package/src/web/panel-data.ts +44 -1
- 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/commands-qa-family.test.ts +239 -0
- package/test/conversations-recipe.test.ts +142 -0
- package/test/count-tokens-model.test.ts +31 -0
- package/test/framework-fkm-composition.test.ts +35 -3
- package/test/framework-strategy-defaults.test.ts +60 -0
- package/test/gate-telemetry-adapter.test.ts +84 -0
- package/test/gate-telemetry.test.ts +124 -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/subconscious-recipe.test.ts +86 -0
- package/test/tool-wrapper-prose-guard-recipe.test.ts +37 -0
- package/test/web-ui-module.test.ts +41 -0
- package/test/workspace-mounts.test.ts +68 -0
- package/web/src/App.tsx +10 -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
|
+
}
|