@mmerterden/multi-agent-pipeline 20.29.0 → 20.30.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.
Files changed (146) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +5 -5
  3. package/README.tr.md +5 -5
  4. package/docs/architecture.md +3 -3
  5. package/docs/ecosystem.md +5 -5
  6. package/docs/facts.json +7 -7
  7. package/install/catalog-history.json +1 -1
  8. package/install/claude.mjs +10 -1
  9. package/install/codex.mjs +2 -0
  10. package/install/copilot.mjs +13 -0
  11. package/manifest.json +149 -48
  12. package/package.json +2 -1
  13. package/pipeline/commands/multi-agent/autopilot-on/SKILL.md +13 -15
  14. package/pipeline/commands/multi-agent/office-off/SKILL.md +27 -0
  15. package/pipeline/commands/multi-agent/office-on/SKILL.md +50 -0
  16. package/pipeline/commands/multi-agent/office-status/SKILL.md +29 -0
  17. package/pipeline/commands/multi-agent/serve/SKILL.md +4 -2
  18. package/pipeline/commands/multi-agent/stack/SKILL.md +15 -8
  19. package/pipeline/commands/multi-agent/steer/SKILL.md +10 -9
  20. package/pipeline/commands/multi-agent/sync/SKILL.md +3 -3
  21. package/pipeline/contract/CHANGELOG.md +62 -0
  22. package/pipeline/contract/README.md +52 -6
  23. package/pipeline/contract/fixtures/error-invalid-request.json +1 -1
  24. package/pipeline/contract/fixtures/error-unauthorized.json +1 -1
  25. package/pipeline/contract/fixtures/error-unsigned.json +1 -1
  26. package/pipeline/contract/manifest.json +367 -22
  27. package/pipeline/contract/types/index.d.ts +530 -3
  28. package/pipeline/multi-agent-refs/cross-cli-contract.md +5 -4
  29. package/pipeline/multi-agent-refs/features/office-threat-model.md +91 -0
  30. package/pipeline/multi-agent-refs/help/en.md +3 -0
  31. package/pipeline/multi-agent-refs/help/tr.md +3 -0
  32. package/pipeline/office/NOTICE.md +42 -0
  33. package/pipeline/office/assets/BIN.png +0 -0
  34. package/pipeline/office/assets/BOOKSHELF.png +0 -0
  35. package/pipeline/office/assets/CACTUS.png +0 -0
  36. package/pipeline/office/assets/CLOCK.png +0 -0
  37. package/pipeline/office/assets/COFFEE.png +0 -0
  38. package/pipeline/office/assets/COFFEE_TABLE.png +0 -0
  39. package/pipeline/office/assets/CUSHIONED_BENCH.png +0 -0
  40. package/pipeline/office/assets/CUSHIONED_CHAIR_BACK.png +0 -0
  41. package/pipeline/office/assets/CUSHIONED_CHAIR_FRONT.png +0 -0
  42. package/pipeline/office/assets/CUSHIONED_CHAIR_SIDE.png +0 -0
  43. package/pipeline/office/assets/DESK_FRONT.png +0 -0
  44. package/pipeline/office/assets/DESK_SIDE.png +0 -0
  45. package/pipeline/office/assets/DOUBLE_BOOKSHELF.png +0 -0
  46. package/pipeline/office/assets/HANGING_PLANT.png +0 -0
  47. package/pipeline/office/assets/LARGE_PAINTING.png +0 -0
  48. package/pipeline/office/assets/LARGE_PLANT.png +0 -0
  49. package/pipeline/office/assets/PC_BACK.png +0 -0
  50. package/pipeline/office/assets/PC_FRONT_OFF.png +0 -0
  51. package/pipeline/office/assets/PC_FRONT_ON_1.png +0 -0
  52. package/pipeline/office/assets/PC_FRONT_ON_2.png +0 -0
  53. package/pipeline/office/assets/PC_FRONT_ON_3.png +0 -0
  54. package/pipeline/office/assets/PC_SIDE.png +0 -0
  55. package/pipeline/office/assets/PLANT.png +0 -0
  56. package/pipeline/office/assets/PLANT_2.png +0 -0
  57. package/pipeline/office/assets/POT.png +0 -0
  58. package/pipeline/office/assets/SMALL_PAINTING.png +0 -0
  59. package/pipeline/office/assets/SMALL_PAINTING_2.png +0 -0
  60. package/pipeline/office/assets/SMALL_TABLE_FRONT.png +0 -0
  61. package/pipeline/office/assets/SMALL_TABLE_SIDE.png +0 -0
  62. package/pipeline/office/assets/SOFA_BACK.png +0 -0
  63. package/pipeline/office/assets/SOFA_FRONT.png +0 -0
  64. package/pipeline/office/assets/SOFA_SIDE.png +0 -0
  65. package/pipeline/office/assets/TABLE_FRONT.png +0 -0
  66. package/pipeline/office/assets/WHITEBOARD.png +0 -0
  67. package/pipeline/office/assets/WOODEN_BENCH.png +0 -0
  68. package/pipeline/office/assets/WOODEN_CHAIR_BACK.png +0 -0
  69. package/pipeline/office/assets/WOODEN_CHAIR_FRONT.png +0 -0
  70. package/pipeline/office/assets/WOODEN_CHAIR_SIDE.png +0 -0
  71. package/pipeline/office/assets/char_0.png +0 -0
  72. package/pipeline/office/assets/char_1.png +0 -0
  73. package/pipeline/office/assets/char_2.png +0 -0
  74. package/pipeline/office/assets/char_3.png +0 -0
  75. package/pipeline/office/assets/char_4.png +0 -0
  76. package/pipeline/office/assets/char_5.png +0 -0
  77. package/pipeline/office/assets/floor_0.png +0 -0
  78. package/pipeline/office/assets/floor_1.png +0 -0
  79. package/pipeline/office/assets/floor_2.png +0 -0
  80. package/pipeline/office/assets/floor_3.png +0 -0
  81. package/pipeline/office/assets/floor_4.png +0 -0
  82. package/pipeline/office/assets/floor_5.png +0 -0
  83. package/pipeline/office/assets/floor_6.png +0 -0
  84. package/pipeline/office/assets/floor_7.png +0 -0
  85. package/pipeline/office/assets/floor_8.png +0 -0
  86. package/pipeline/office/assets/wall_0.png +0 -0
  87. package/pipeline/office/index.html +5319 -0
  88. package/pipeline/schemas/activity-chat.schema.json +78 -0
  89. package/pipeline/schemas/activity-roles.json +48 -0
  90. package/pipeline/schemas/activity-roles.schema.json +49 -0
  91. package/pipeline/schemas/activity.schema.json +429 -0
  92. package/pipeline/schemas/autopilot-on-request.schema.json +33 -0
  93. package/pipeline/schemas/autopilot-on.schema.json +111 -0
  94. package/pipeline/schemas/contract-error.schema.json +33 -3
  95. package/pipeline/schemas/issue-maturity.schema.json +68 -0
  96. package/pipeline/schemas/ledger.schema.json +202 -0
  97. package/pipeline/schemas/permissions.schema.json +47 -0
  98. package/pipeline/schemas/prefs.schema.json +19 -0
  99. package/pipeline/schemas/prs.schema.json +73 -0
  100. package/pipeline/schemas/review-verdict-request.schema.json +20 -0
  101. package/pipeline/schemas/review-verdict.schema.json +62 -0
  102. package/pipeline/schemas/routine-delete.schema.json +59 -0
  103. package/pipeline/schemas/routines.schema.json +38 -0
  104. package/pipeline/schemas/run-evidence.schema.json +29 -0
  105. package/pipeline/schemas/run-results.schema.json +218 -0
  106. package/pipeline/schemas/service-read.schema.json +58 -0
  107. package/pipeline/schemas/stack-change.schema.json +82 -0
  108. package/pipeline/schemas/stack-request.schema.json +33 -0
  109. package/pipeline/schemas/stack.schema.json +59 -0
  110. package/pipeline/schemas/steer-request.schema.json +25 -0
  111. package/pipeline/schemas/steer.schema.json +59 -0
  112. package/pipeline/schemas/tool-call-request.schema.json +19 -0
  113. package/pipeline/schemas/tool-call.schema.json +47 -0
  114. package/pipeline/schemas/tools.schema.json +60 -0
  115. package/pipeline/scripts/_cost.mjs +66 -1
  116. package/pipeline/scripts/_office-smoke-lib.sh +115 -0
  117. package/pipeline/scripts/_spawn-async.mjs +73 -0
  118. package/pipeline/scripts/_transcript-tail.mjs +274 -0
  119. package/pipeline/scripts/activity-snapshot.mjs +1027 -0
  120. package/pipeline/scripts/autopilot-control.mjs +214 -9
  121. package/pipeline/scripts/contract-server.mjs +1178 -48
  122. package/pipeline/scripts/cost-analyze.mjs +2 -53
  123. package/pipeline/scripts/cost-table.json +1 -0
  124. package/pipeline/scripts/office-build.mjs +89 -0
  125. package/pipeline/scripts/office-control.mjs +383 -0
  126. package/pipeline/scripts/office-ledger.mjs +409 -0
  127. package/pipeline/scripts/routine-registry.mjs +42 -6
  128. package/pipeline/scripts/run-results.mjs +373 -0
  129. package/pipeline/scripts/service-read.mjs +244 -0
  130. package/pipeline/scripts/stack-toggle.mjs +285 -0
  131. package/pipeline/scripts/steer-run.mjs +97 -0
  132. package/pipeline/scripts/toolkit-tools.mjs +599 -0
  133. package/pipeline/scripts/uninstall.mjs +3 -0
  134. package/pipeline/scripts/work-items.mjs +304 -0
  135. package/pipeline/skills/.skill-manifest.json +19 -7
  136. package/pipeline/skills/.skills-index.json +34 -1
  137. package/pipeline/skills/shared/README.md +6 -3
  138. package/pipeline/skills/shared/core/multi-agent-autopilot-on/SKILL.md +13 -15
  139. package/pipeline/skills/shared/core/multi-agent-office-off/SKILL.md +16 -0
  140. package/pipeline/skills/shared/core/multi-agent-office-on/SKILL.md +22 -0
  141. package/pipeline/skills/shared/core/multi-agent-office-status/SKILL.md +17 -0
  142. package/pipeline/skills/shared/core/multi-agent-serve/SKILL.md +4 -2
  143. package/pipeline/skills/shared/core/multi-agent-stack/SKILL.md +14 -6
  144. package/pipeline/skills/shared/core/multi-agent-steer/SKILL.md +10 -9
  145. package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +3 -3
  146. package/pipeline/skills/skills-index.md +4 -1
@@ -0,0 +1,19 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://github.com/mmerterden/multi-agent-pipeline/pipeline/schemas/tool-call-request.schema.json",
4
+ "title": "Tool call request",
5
+ "description": "The body of POST /v1/tools/{name}/call: the tool's arguments, which must satisfy its inputSchema. At most 64 KiB.",
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "required": ["contractVersion"],
9
+ "properties": {
10
+ "contractVersion": {
11
+ "type": "string",
12
+ "pattern": "^1\\.[0-9]+\\.[0-9]+$",
13
+ "x-sensitivity": "none"
14
+ },
15
+ "arguments": {
16
+ "type": "object"
17
+ }
18
+ }
19
+ }
@@ -0,0 +1,47 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://github.com/mmerterden/multi-agent-pipeline/pipeline/schemas/tool-call.schema.json",
4
+ "title": "Tool call result",
5
+ "description": "What POST /v1/tools/{name}/call returns (`toolkit-tools.mjs call`): the tool's result as text, JSON (as text) and images (base64, image types only). Every text is redacted.",
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "required": ["contractVersion", "tool", "risk", "isError", "content"],
9
+ "properties": {
10
+ "contractVersion": {
11
+ "type": "string",
12
+ "pattern": "^1\\.[0-9]+\\.[0-9]+$",
13
+ "x-sensitivity": "none"
14
+ },
15
+ "tool": {
16
+ "type": "string"
17
+ },
18
+ "risk": {
19
+ "enum": ["read", "low", "high"]
20
+ },
21
+ "isError": {
22
+ "type": "boolean"
23
+ },
24
+ "content": {
25
+ "type": "array",
26
+ "items": {
27
+ "type": "object",
28
+ "additionalProperties": false,
29
+ "required": ["kind"],
30
+ "properties": {
31
+ "kind": {
32
+ "enum": ["text", "json", "image"]
33
+ },
34
+ "text": {
35
+ "type": "string"
36
+ },
37
+ "mime": {
38
+ "type": "string"
39
+ },
40
+ "data": {
41
+ "type": "string"
42
+ }
43
+ }
44
+ }
45
+ }
46
+ }
47
+ }
@@ -0,0 +1,60 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://github.com/mmerterden/multi-agent-pipeline/pipeline/schemas/tools.schema.json",
4
+ "title": "Toolkit tools",
5
+ "description": "What GET /v1/tools returns (`toolkit-tools.mjs list`): every tool the registered multi-agent-toolkit MCP server serves, with its category (the name's prefix), risk from its own annotations (readOnlyHint read, destructiveHint high, else low) and its inputSchema, from which a client builds the form. Calls are denied by default; allowed says which tools can be called and reason why the others cannot.",
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "required": ["contractVersion", "tools"],
9
+ "properties": {
10
+ "contractVersion": {
11
+ "type": "string",
12
+ "pattern": "^1\\.[0-9]+\\.[0-9]+$",
13
+ "x-sensitivity": "none"
14
+ },
15
+ "tools": {
16
+ "type": "array",
17
+ "items": {
18
+ "type": "object",
19
+ "additionalProperties": false,
20
+ "required": [
21
+ "name",
22
+ "title",
23
+ "category",
24
+ "description",
25
+ "risk",
26
+ "inputSchema",
27
+ "allowed",
28
+ "reason"
29
+ ],
30
+ "properties": {
31
+ "name": {
32
+ "type": "string"
33
+ },
34
+ "title": {
35
+ "type": ["string", "null"]
36
+ },
37
+ "category": {
38
+ "type": "string"
39
+ },
40
+ "description": {
41
+ "type": "string"
42
+ },
43
+ "risk": {
44
+ "enum": ["read", "low", "high"]
45
+ },
46
+ "inputSchema": {
47
+ "type": "object"
48
+ },
49
+ "allowed": {
50
+ "type": "boolean",
51
+ "description": "Whether POST /v1/tools/{name}/call accepts this tool: only a readOnlyHint tool whose inputSchema takes no location (path, URL, file, directory, output, project, root) and that is not in the web or context family."
52
+ },
53
+ "reason": {
54
+ "type": ["string", "null"]
55
+ }
56
+ }
57
+ }
58
+ }
59
+ }
60
+ }
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * @file _cost.mjs - shared token-cost pricing math for the .mjs cost scripts
3
- * (cost-budget-check.mjs, run-aggregator.mjs).
3
+ * (cost-budget-check.mjs, run-aggregator.mjs, cost-analyze.mjs,
4
+ * office-ledger.mjs).
4
5
  *
5
6
  * The single source of the pricing formula for Node callers. Rate DATA still
6
7
  * lives in cost-table.json; this module owns only the arithmetic so the formula
@@ -73,3 +74,67 @@ export function attemptSpend(rows) {
73
74
  }
74
75
  return { usd, unknown };
75
76
  }
77
+
78
+ /**
79
+ * Wire ID -> rate. Transcripts carry the wire value ("claude-opus-5-5"); the
80
+ * price table is keyed by rung, so each rung's `modelId` and `copilotModelId`
81
+ * map to its rate, `legacy` adds previous-generation IDs at their own rates,
82
+ * and `models` overrides the rung rate for a dispatched ID whose model is not
83
+ * the rung's (Copilot CLI's older models bill at their own rate). Matching on
84
+ * the ID rather than the rung name keeps a generation move a data edit, which
85
+ * is the contract cost-table.json states for itself. A rung name ("opus")
86
+ * prices as its rung, which is what a tracker records.
87
+ *
88
+ * @param {object} table cost-table.json
89
+ * @returns {(modelId: string|null|undefined) => object|null}
90
+ */
91
+ export function modelRateLookup(table) {
92
+ const byId = new Map([
93
+ ...Object.entries(table.legacy || {}),
94
+ ...Object.values(table.prices || {}).flatMap((p) =>
95
+ [p.modelId, p.copilotModelId].filter(Boolean).map((id) => [id, p]),
96
+ ),
97
+ ...Object.entries(table.models || {}),
98
+ ]);
99
+ const longestFirst = [...byId.keys()].sort((a, b) => b.length - a.length);
100
+ return (modelId) => {
101
+ if (!modelId) return null;
102
+ const exact = byId.get(modelId) ?? table.prices?.[modelId];
103
+ if (exact) return exact;
104
+ // A dated snapshot id (a known id, a hyphen, a release date) prices as that
105
+ // id; the longest known id wins, so a current id is never read as a shorter
106
+ // legacy one. Written as a shape rather than as a literal, because
107
+ // smoke-model-fallback.sh reads every model id in this tree and requires it
108
+ // to exist in cost-table.json. An id nobody knows prices as nothing and is
109
+ // COUNTED as unpriced rather than folded in at zero.
110
+ const id = longestFirst.find(
111
+ (known) => modelId.startsWith(`${known}-`) || modelId.startsWith(`${known}[`),
112
+ );
113
+ return id ? byId.get(id) : null;
114
+ };
115
+ }
116
+
117
+ /**
118
+ * Cache WRITES are not a discount, they are a surcharge: Anthropic prices a
119
+ * 1h cache write above fresh input. cost-table.json carries cacheReadPerMtok
120
+ * only, so the write rate is derived at the published 1.25x of input - an
121
+ * unpriced component silently dropped would make every long-context session
122
+ * look cheap, which is the direction that hurts.
123
+ */
124
+ export const CACHE_WRITE_MULTIPLIER = 1.25;
125
+
126
+ /**
127
+ * USD for one transcript usage record, or null without a rate.
128
+ *
129
+ * @param {object|null} rate
130
+ * @param {{input_tokens?:number, output_tokens?:number, cache_read_input_tokens?:number, cache_creation_input_tokens?:number}} u
131
+ */
132
+ export function priceUsage(rate, u) {
133
+ const fresh = Number(u.input_tokens || 0);
134
+ const out = Number(u.output_tokens || 0);
135
+ const cacheRead = Number(u.cache_read_input_tokens || 0);
136
+ const cacheWrite = Number(u.cache_creation_input_tokens || 0);
137
+ const base = costUsd(rate, fresh, out, cacheRead);
138
+ if (base === null) return null;
139
+ return base + (cacheWrite / 1_000_000) * (rate.inPerMtok || 0) * CACHE_WRITE_MULTIPLIER;
140
+ }
@@ -0,0 +1,115 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # _office-smoke-lib.sh - shared by the office route smokes: a synthetic
4
+ # office seeded into a temporary directory, one contract-server started on it,
5
+ # and a request helper that can send any Host, Origin or Authorization.
6
+ #
7
+ # Repo layout only: the seed lives under test/fixtures, which an install does
8
+ # not carry, so a smoke sourcing this skips itself when that file is absent.
9
+ #
10
+ # Usage (from a smoke in pipeline/scripts/):
11
+ # . "$(dirname "${BASH_SOURCE[0]}")/_office-smoke-lib.sh"
12
+ # office_start # sets OFFICE_PORT, OFFICE_TOKEN, OFFICE_SECRET, ...
13
+ # office_req GET /v1/activity [header...] # body in $OFFICE_BODY, status echoed
14
+ # office_stop # also run by the EXIT trap
15
+
16
+ OFFICE_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
17
+ OFFICE_ROOT="$(cd "$OFFICE_LIB_DIR/../.." && pwd)"
18
+ OFFICE_SEED="$OFFICE_ROOT/test/fixtures/office-seed.mjs"
19
+ OFFICE_SERVER="$OFFICE_LIB_DIR/contract-server.mjs"
20
+ OFFICE_TMP=""
21
+ OFFICE_PID=""
22
+
23
+ office_available() { [ -f "$OFFICE_SEED" ] && command -v curl >/dev/null 2>&1; }
24
+
25
+ office_stop() {
26
+ if [ -n "$OFFICE_PID" ]; then
27
+ kill -TERM "$OFFICE_PID" 2>/dev/null
28
+ wait "$OFFICE_PID" 2>/dev/null
29
+ OFFICE_PID=""
30
+ fi
31
+ if [ -n "$OFFICE_TMP" ] && [ -d "$OFFICE_TMP" ]; then
32
+ find "$OFFICE_TMP" -depth -mindepth 1 -delete 2>/dev/null
33
+ rmdir "$OFFICE_TMP" 2>/dev/null
34
+ fi
35
+ }
36
+
37
+ # office_json <file> <expression on o> - one value out of a JSON file.
38
+ office_json() {
39
+ node -e 'const o=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8"));const v=(new Function("o","return "+process.argv[2]))(o);process.stdout.write(typeof v==="string"?v:JSON.stringify(v))' "$1" "$2"
40
+ }
41
+
42
+ office_start() {
43
+ OFFICE_TMP="$(mktemp -d "${TMPDIR:-/tmp}/office-smoke.XXXXXX")"
44
+ trap office_stop EXIT
45
+ node "$OFFICE_SEED" "$OFFICE_TMP/office" > "$OFFICE_TMP/seed.json" || return 1
46
+ OFFICE_SECRET="$(office_json "$OFFICE_TMP/seed.json" o.secret)"
47
+ OFFICE_BLOB="$(office_json "$OFFICE_TMP/seed.json" o.blob)"
48
+ OFFICE_THINKING="$(office_json "$OFFICE_TMP/seed.json" o.thinking)"
49
+ OFFICE_SESSION="$(office_json "$OFFICE_TMP/seed.json" o.session)"
50
+ node -e '
51
+ const s = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8"));
52
+ for (const [k, v] of Object.entries(s.env)) console.log(`export ${k}=${JSON.stringify(v)}`);
53
+ ' "$OFFICE_TMP/seed.json" > "$OFFICE_TMP/env.sh"
54
+ # shellcheck disable=SC1091
55
+ . "$OFFICE_TMP/env.sh"
56
+ node "$OFFICE_SERVER" --port 0 --print-token --allow-origin "http://127.0.0.1:5173" \
57
+ > "$OFFICE_TMP/ready" 2> "$OFFICE_TMP/server.err" &
58
+ OFFICE_PID=$!
59
+ local i=0
60
+ while [ ! -s "$OFFICE_TMP/ready" ] && [ "$i" -lt 100 ]; do
61
+ sleep 0.1
62
+ i=$((i + 1))
63
+ done
64
+ [ -s "$OFFICE_TMP/ready" ] || return 1
65
+ OFFICE_PORT="$(office_json "$OFFICE_TMP/ready" o.port)"
66
+ OFFICE_TOKEN="$(office_json "$OFFICE_TMP/ready" o.token)"
67
+ return 0
68
+ }
69
+
70
+ # office_req METHOD PATH [header...]
71
+ # Sends Host 127.0.0.1:<port> and the bearer token unless a header overrides
72
+ # them (pass "Host: x" or "Authorization: none" to change them). Prints the
73
+ # status; the body is left in $OFFICE_TMP/body. The URL and every header go
74
+ # to curl through a 0600 config file, never through argv, so the token (and
75
+ # the token-shaped values the attack checks send) stay off the process list.
76
+ office_req() {
77
+ local method="$1" path="$2"
78
+ shift 2
79
+ local host="Host: 127.0.0.1:$OFFICE_PORT" auth="Authorization: Bearer $OFFICE_TOKEN"
80
+ local cfg="$OFFICE_TMP/curl.cfg"
81
+ local h
82
+ : > "$cfg"
83
+ chmod 600 "$cfg"
84
+ for h in "$@"; do
85
+ case "$h" in
86
+ Host:*) host="$h" ;;
87
+ "Authorization: none") auth="" ;;
88
+ Authorization:*) auth="$h" ;;
89
+ *) office_cfg_line header "$h" >> "$cfg" ;;
90
+ esac
91
+ done
92
+ office_cfg_line header "$host" >> "$cfg"
93
+ [ -n "$auth" ] && office_cfg_line header "$auth" >> "$cfg"
94
+ if [ -n "${OFFICE_DATA:-}" ]; then
95
+ office_cfg_line header "Content-Type: application/json" >> "$cfg"
96
+ printf '%s' "$OFFICE_DATA" > "$OFFICE_TMP/data.json"
97
+ office_cfg_line data-binary "@$OFFICE_TMP/data.json" >> "$cfg"
98
+ fi
99
+ office_cfg_line url "http://127.0.0.1:$OFFICE_PORT$path" >> "$cfg"
100
+ curl -s -m "${OFFICE_MAX_TIME:-10}" -o "$OFFICE_TMP/body" -w '%{http_code}' -X "$method" -K "$cfg"
101
+ }
102
+
103
+ # office_cfg_line KEY VALUE - one curl config line, the value quoted.
104
+ office_cfg_line() {
105
+ local v="${2//\\/\\\\}"
106
+ v="${v//\"/\\\"}"
107
+ printf '%s = "%s"\n' "$1" "$v"
108
+ }
109
+
110
+ # office_call METHOD PATH [headers...] - office_req without a subshell:
111
+ # sets OFFICE_STATUS and OFFICE_BODY in the caller.
112
+ office_call() {
113
+ OFFICE_STATUS="$(office_req "$@")"
114
+ OFFICE_BODY="$(cat "$OFFICE_TMP/body" 2>/dev/null)"
115
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * @file _spawn-async.mjs - run a child process without blocking the event
3
+ * loop, for the scripts contract-server.mjs calls per request: a slow service
4
+ * client must not stall every other route while it waits.
5
+ *
6
+ * Output is collected up to a cap; past it the child is killed and the result
7
+ * says `truncated`. A timeout kills the child too and resolves with
8
+ * `timedOut`. Never rejects: the caller reads `status`.
9
+ *
10
+ * @module pipeline/scripts/_spawn-async
11
+ */
12
+
13
+ import { spawn } from "node:child_process";
14
+ import { clearTimeout, setTimeout } from "node:timers";
15
+
16
+ /**
17
+ * @param {string} cmd
18
+ * @param {string[]} args
19
+ * @param {{env?: object, timeoutMs?: number, maxBytes?: number}} [opts]
20
+ * @returns {Promise<{status: number|null, stdout: string, timedOut: boolean, truncated: boolean, error: string|null}>}
21
+ */
22
+ export function spawnAsync(
23
+ cmd,
24
+ args,
25
+ { env = process.env, timeoutMs = 60_000, maxBytes = 32 * 1024 * 1024 } = {},
26
+ ) {
27
+ return new Promise((resolve) => {
28
+ let child;
29
+ try {
30
+ child = spawn(cmd, args, { env, stdio: ["ignore", "pipe", "ignore"] });
31
+ } catch (e) {
32
+ resolve({
33
+ status: null,
34
+ stdout: "",
35
+ timedOut: false,
36
+ truncated: false,
37
+ error: e.code ?? e.message,
38
+ });
39
+ return;
40
+ }
41
+ const chunks = [];
42
+ let size = 0;
43
+ let timedOut = false;
44
+ let truncated = false;
45
+ let error = null;
46
+ const timer = setTimeout(() => {
47
+ timedOut = true;
48
+ child.kill("SIGKILL");
49
+ }, timeoutMs);
50
+ child.stdout.on("data", (d) => {
51
+ size += d.length;
52
+ if (size > maxBytes) {
53
+ truncated = true;
54
+ child.kill("SIGKILL");
55
+ return;
56
+ }
57
+ chunks.push(d);
58
+ });
59
+ child.on("error", (e) => {
60
+ error = e.code ?? e.message;
61
+ });
62
+ child.on("close", (status) => {
63
+ clearTimeout(timer);
64
+ resolve({
65
+ status: timedOut || truncated ? null : status,
66
+ stdout: Buffer.concat(chunks).toString("utf8"),
67
+ timedOut,
68
+ truncated,
69
+ error,
70
+ });
71
+ });
72
+ });
73
+ }
@@ -0,0 +1,274 @@
1
+ /**
2
+ * @file _transcript-tail.mjs - read the end of a session transcript, and
3
+ * whatever was appended to it since the last read, without reading the file
4
+ * whole.
5
+ *
6
+ * The office view (activity-snapshot.mjs) asks two questions of a transcript
7
+ * that may run to hundreds of megabytes: "what was appended since I last
8
+ * looked" (createFollower, one byte offset per file, a half-written last line
9
+ * left for the next read) and "show me the last N messages, then the N before
10
+ * those" (readTailPage, reading backwards in chunks from an offset the caller
11
+ * sends back).
12
+ *
13
+ * Privacy is decided here, before anything is parsed:
14
+ *
15
+ * - api-request, api-request-blob and api-request-shape records are never
16
+ * parsed. They carry whole request bodies (every file the session read),
17
+ * and their top-level `"type":"api-request..."` is recognised on the raw
18
+ * line, which can only appear unescaped as a real key;
19
+ * - a line over MAX_LINE_BYTES is skipped unparsed;
20
+ * - a record whose type is not in the caller's allow-list is dropped;
21
+ * - thinking and redacted_thinking blocks are removed from every record
22
+ * returned, so no caller can emit one.
23
+ *
24
+ * Redaction of what remains is the caller's job (transcript-index.mjs
25
+ * createRedactor), because only the caller knows which fields it shows.
26
+ *
27
+ * Synchronous reads with bounded buffers: a poll touches a handful of files
28
+ * and each read is capped, so there is no stream to manage or leak.
29
+ *
30
+ * @module pipeline/scripts/_transcript-tail
31
+ */
32
+
33
+ import { closeSync, fstatSync, openSync, readSync, statSync } from "node:fs";
34
+
35
+ export const NEVER_READ_TYPES = ["api-request", "api-request-blob", "api-request-shape"];
36
+ const NEVER_READ = /"type"\s*:\s*"api-request(?:-blob|-shape)?"/;
37
+ const THINKING = new Set(["thinking", "redacted_thinking"]);
38
+
39
+ /** A single line longer than this is skipped without being parsed. */
40
+ export const MAX_LINE_BYTES = 2 * 1024 * 1024;
41
+ /** How far back from the end a follower starts on a file it has not seen. */
42
+ export const DEFAULT_INITIAL_BYTES = 256 * 1024;
43
+ /** The most one follower read takes in; a larger jump starts over near the end. */
44
+ const MAX_FOLLOW_BYTES = 8 * 1024 * 1024;
45
+ const CHUNK = 64 * 1024;
46
+ /** How far one page may scan backwards before it returns what it found. */
47
+ const MAX_SCAN_BYTES = 32 * 1024 * 1024;
48
+
49
+ /** The record types the chat drawer shows. */
50
+ export const CHAT_TYPES = new Set(["user", "assistant"]);
51
+ /** The record types the office view reduces to a desk's state. */
52
+ export const ACTIVITY_TYPES = new Set(["user", "assistant", "system", "ai-title"]);
53
+
54
+ /**
55
+ * One line, parsed, or null when it is not to be read: an api-request record,
56
+ * an oversized line, not JSON, not an object, or a type outside `allow`
57
+ * (null allows every type except the never-read ones).
58
+ *
59
+ * @param {string} text
60
+ * @param {Set<string>|null} [allow]
61
+ */
62
+ export function parseLine(text, allow = null) {
63
+ if (!text || Buffer.byteLength(text) > MAX_LINE_BYTES) return null;
64
+ if (NEVER_READ.test(text)) return null;
65
+ let rec;
66
+ try {
67
+ rec = JSON.parse(text);
68
+ } catch {
69
+ return null;
70
+ }
71
+ if (!rec || typeof rec !== "object" || Array.isArray(rec)) return null;
72
+ if (NEVER_READ_TYPES.includes(rec.type)) return null;
73
+ if (allow && !allow.has(rec.type)) return null;
74
+ const content = rec.message?.content;
75
+ if (Array.isArray(content)) {
76
+ rec.message.content = content.filter((b) => !(b && THINKING.has(b.type)));
77
+ }
78
+ return rec;
79
+ }
80
+
81
+ function readRange(fd, start, end) {
82
+ const len = Math.max(0, end - start);
83
+ const buf = Buffer.alloc(len);
84
+ let got = 0;
85
+ while (got < len) {
86
+ const n = readSync(fd, buf, got, len - got, start + got);
87
+ if (n <= 0) break;
88
+ got += n;
89
+ }
90
+ return got === len ? buf : buf.subarray(0, got);
91
+ }
92
+
93
+ /**
94
+ * Remembers one byte offset per file and returns what was appended since.
95
+ *
96
+ * The first read of a file starts `maxInitialBytes` before its end (at the
97
+ * next line boundary), so a long session costs one bounded read. A file that
98
+ * shrank or was replaced (a different inode) starts over the same way, and the
99
+ * result says `reset`. A last line without its newline is left unread and
100
+ * returned once it is complete.
101
+ *
102
+ * @param {{maxInitialBytes?: number, allow?: Set<string>|null}} [opts]
103
+ */
104
+ export function createFollower({ maxInitialBytes = DEFAULT_INITIAL_BYTES, allow = null } = {}) {
105
+ const files = new Map();
106
+ return {
107
+ /**
108
+ * @param {string} path
109
+ * @returns {{records: {offset:number, record:object}[], reset: boolean, size: number}}
110
+ */
111
+ read(path) {
112
+ let fd;
113
+ try {
114
+ fd = openSync(path, "r");
115
+ } catch {
116
+ files.delete(path);
117
+ return { records: [], reset: false, size: 0 };
118
+ }
119
+ try {
120
+ const st = fstatSync(fd);
121
+ if (!st.isFile()) return { records: [], reset: false, size: 0 };
122
+ const prev = files.get(path);
123
+ let start;
124
+ let skipFirst = false;
125
+ let reset = false;
126
+ if (prev && prev.ino === st.ino && prev.offset <= st.size) {
127
+ start = prev.offset;
128
+ if (st.size - start > MAX_FOLLOW_BYTES) {
129
+ start = st.size - maxInitialBytes;
130
+ skipFirst = true;
131
+ reset = true;
132
+ }
133
+ } else {
134
+ reset = Boolean(prev);
135
+ start = Math.max(0, st.size - maxInitialBytes);
136
+ skipFirst = start > 0;
137
+ }
138
+ const buf = readRange(fd, start, st.size);
139
+ const records = [];
140
+ let from = 0;
141
+ if (skipFirst) {
142
+ const nl = buf.indexOf(0x0a);
143
+ from = nl === -1 ? buf.length : nl + 1;
144
+ }
145
+ let consumed = from;
146
+ for (let at = buf.indexOf(0x0a, from); at !== -1; at = buf.indexOf(0x0a, from)) {
147
+ const rec =
148
+ at - from > MAX_LINE_BYTES ? null : parseLine(buf.toString("utf8", from, at), allow);
149
+ if (rec) records.push({ offset: start + from, record: rec });
150
+ from = at + 1;
151
+ consumed = from;
152
+ }
153
+ files.set(path, { ino: st.ino, offset: start + consumed });
154
+ return { records, reset, size: st.size };
155
+ } finally {
156
+ closeSync(fd);
157
+ }
158
+ },
159
+ forget(path) {
160
+ files.delete(path);
161
+ },
162
+ get size() {
163
+ return files.size;
164
+ },
165
+ };
166
+ }
167
+
168
+ /**
169
+ * The last `limit` allowed records before byte offset `before` (the end of the
170
+ * file when null), in file order. `before` in the result is the offset of the
171
+ * first record returned, to send back for the page before it, or null when the
172
+ * page reached the start of the file. A last line still being written (no
173
+ * newline yet) is not returned.
174
+ *
175
+ * @param {string} path
176
+ * @param {{before?: number|null, limit?: number, allow?: Set<string>|null}} [opts]
177
+ * @returns {{records: {offset:number, record:object}[], before: number|null}}
178
+ */
179
+ export function readTailPage(path, { before = null, limit = 200, allow = null } = {}) {
180
+ let size;
181
+ try {
182
+ const st = statSync(path);
183
+ if (!st.isFile()) return { records: [], before: null };
184
+ size = st.size;
185
+ } catch {
186
+ return { records: [], before: null };
187
+ }
188
+ let end = before === null || before === undefined ? size : Math.min(Number(before), size);
189
+ const fd = openSync(path, "r");
190
+ const found = [];
191
+ let pos = end;
192
+ let scanned = 0;
193
+ let reachedStart = false;
194
+ try {
195
+ // A file whose last line has no newline is mid-write: that line starts
196
+ // after the last newline and is left out.
197
+ if (before === null || before === undefined) {
198
+ const tail = readRange(fd, Math.max(0, end - CHUNK), end);
199
+ if (tail.length && tail[tail.length - 1] !== 0x0a) {
200
+ const nl = tail.lastIndexOf(0x0a);
201
+ end = nl === -1 ? Math.max(0, end - tail.length) : end - tail.length + nl + 1;
202
+ if (nl === -1 && end > 0) end = trimToLineStart(fd, end);
203
+ pos = end;
204
+ }
205
+ }
206
+ // `carry` holds the bytes of a line whose start is not read yet; past the
207
+ // size cap it is dropped and the line is skipped once its start is found.
208
+ let carry = Buffer.alloc(0);
209
+ let carryOversized = false;
210
+ while (found.length < limit) {
211
+ if (pos <= 0) {
212
+ if (carry.length && !carryOversized) take(carry, 0);
213
+ reachedStart = true;
214
+ break;
215
+ }
216
+ if (scanned >= MAX_SCAN_BYTES) break;
217
+ const from = Math.max(0, pos - CHUNK);
218
+ const chunk = readRange(fd, from, pos);
219
+ scanned += chunk.length;
220
+ const buf = carryOversized ? chunk : Buffer.concat([chunk, carry]);
221
+ let lineEnd = buf.length;
222
+ let nl = buf.lastIndexOf(0x0a, lineEnd - 1);
223
+ // The bytes after the chunk's last newline belong to the carried line.
224
+ if (carryOversized && nl !== -1) {
225
+ lineEnd = nl;
226
+ carryOversized = false;
227
+ nl = buf.lastIndexOf(0x0a, lineEnd - 1);
228
+ } else if (carryOversized) {
229
+ pos = from;
230
+ continue;
231
+ }
232
+ while (nl !== -1 && found.length < limit) {
233
+ const lineStart = nl + 1;
234
+ if (lineEnd > lineStart) take(buf.subarray(lineStart, lineEnd), from + lineStart);
235
+ lineEnd = nl;
236
+ nl = lineEnd > 0 ? buf.lastIndexOf(0x0a, lineEnd - 1) : -1;
237
+ }
238
+ if (found.length >= limit) break;
239
+ carry = buf.subarray(0, lineEnd);
240
+ if (carry.length > MAX_LINE_BYTES) {
241
+ carry = Buffer.alloc(0);
242
+ carryOversized = true;
243
+ }
244
+ pos = from;
245
+ }
246
+ } finally {
247
+ closeSync(fd);
248
+ }
249
+ found.reverse();
250
+ const first = found.length ? found[0].offset : null;
251
+ return {
252
+ records: found,
253
+ before: reachedStart || first === 0 ? null : (first ?? pos),
254
+ };
255
+
256
+ function take(bytes, offset) {
257
+ if (bytes.length > MAX_LINE_BYTES) return;
258
+ const rec = parseLine(bytes.toString("utf8"), allow);
259
+ if (rec) found.push({ offset, record: rec });
260
+ }
261
+ }
262
+
263
+ /** The start of the line `end` falls in, scanning back at most MAX_SCAN_BYTES. */
264
+ function trimToLineStart(fd, end) {
265
+ let pos = end;
266
+ while (pos > 0 && end - pos < MAX_SCAN_BYTES) {
267
+ const from = Math.max(0, pos - CHUNK);
268
+ const chunk = readRange(fd, from, pos);
269
+ const nl = chunk.lastIndexOf(0x0a);
270
+ if (nl !== -1) return from + nl + 1;
271
+ pos = from;
272
+ }
273
+ return 0;
274
+ }