@smartmemory/compose 0.3.6-beta → 0.3.8
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/.claude/skills/compose/SKILL.md +42 -88
- package/.compose-deps.json +1 -13
- package/README.md +72 -5
- package/bin/compose.js +754 -347
- package/bin/git-hooks/pre-push.template +29 -0
- package/bin/judgment-import.js +7 -0
- package/bin/judgment-migrate.js +387 -0
- package/contracts/comp-obs-contract.schema.json +9 -3
- package/contracts/feature-json.schema.json +5 -0
- package/contracts/fluid-record.schema.json +209 -0
- package/contracts/judgment-record.schema.json +425 -4
- package/contracts/lifecycle-backfill.schema.json +322 -0
- package/dist/assets/App-Z4MU-H_F.js +916 -0
- package/dist/assets/_baseUniq-ClWoCPFl.js +1 -0
- package/dist/assets/arc-DY26UIVo.js +1 -0
- package/dist/assets/architectureDiagram-Q4EWVU46-6Ggq4DqJ.js +36 -0
- package/dist/assets/blockDiagram-DXYQGD6D-CH3Ked0l.js +132 -0
- package/dist/assets/{browser-BSM23If2.js → browser-BWkrenen.js} +6 -6
- package/dist/assets/{c4Diagram-LMCZKHZV-DZf45Fbz.js → c4Diagram-AHTNJAMY-Bk8dYilu.js} +1 -1
- package/dist/assets/channel-SnZzzh7k.js +1 -0
- package/dist/assets/{chunk-JWPE2WC7-_7ujgd_Q.js → chunk-4BX2VUAB-BMR0XaAQ.js} +1 -1
- package/dist/assets/chunk-4TB4RGXK-JytR14a9.js +206 -0
- package/dist/assets/{chunk-XXDRQBXY-DfdVhbmA.js → chunk-55IACEB6-B4Q97BCP.js} +1 -1
- package/dist/assets/{chunk-VR4S4FIN-Dt9NZ67m.js → chunk-EDXVE4YY-R_qarkSf.js} +1 -1
- package/dist/assets/{chunk-5VM5RSS4-BY4_PV5H.js → chunk-FMBD7UC4-C9s7KR9m.js} +1 -1
- package/dist/assets/chunk-OYMX7WX6-BySQzVxc.js +231 -0
- package/dist/assets/{chunk-2Q5K7J3B-Dn1spZYu.js → chunk-QZHKN3VN-DdpSYZsW.js} +1 -1
- package/dist/assets/{chunk-32BRIVSS-pURGrJDk.js → chunk-YZCP3GAM-iE_tzriw.js} +1 -1
- package/dist/assets/classDiagram-6PBFFD2Q-CBu92dSH.js +1 -0
- package/dist/assets/classDiagram-v2-HSJHXN6E-CBu92dSH.js +1 -0
- package/dist/assets/clone-DgklGjHm.js +1 -0
- package/dist/assets/{cose-bilkent-JH36ORCC-BieYif4o.js → cose-bilkent-S5V4N54A-BdlU6ZX_.js} +1 -1
- package/dist/assets/dagre-KV5264BT-Cp3F5KTn.js +4 -0
- package/dist/assets/diagram-5BDNPKRD-DiR6_2q_.js +10 -0
- package/dist/assets/diagram-G4DWMVQ6-w0i-p5HX.js +24 -0
- package/dist/assets/diagram-MMDJMWI5-tIHhwUv3.js +43 -0
- package/dist/assets/diagram-TYMM5635-BAeY3B19.js +24 -0
- package/dist/assets/erDiagram-SMLLAGMA-Ckx_Knko.js +85 -0
- package/dist/assets/flowDiagram-DWJPFMVM-DeoNka6J.js +162 -0
- package/dist/assets/ganttDiagram-T4ZO3ILL-BmGnFbEg.js +292 -0
- package/dist/assets/gitGraphDiagram-UUTBAWPF-Dk48IHsx.js +106 -0
- package/dist/assets/graph-BNzKGvoy.js +1 -0
- package/dist/assets/graph-CI_1htl0.js +331 -0
- package/dist/assets/index-BEfrNBp8.js +123 -0
- package/dist/assets/index-yyrA5OZd.css +1 -0
- package/dist/assets/infoDiagram-42DDH7IO-BRf827i0.js +2 -0
- package/dist/assets/{ishikawaDiagram-FXEZZL3T-CzEB9fQS.js → ishikawaDiagram-UXIWVN3A-0kCZaeCM.js} +5 -5
- package/dist/assets/{journeyDiagram-5HDEW3XC-Bz8TCdz2.js → journeyDiagram-VCZTEJTY-rvU7ayRt.js} +1 -1
- package/dist/assets/{kanban-definition-HUTT4EX6-tozrMoV_.js → kanban-definition-6JOO6SKY-DpQwX1C5.js} +7 -7
- package/dist/assets/katex-DkKDou_j.js +257 -0
- package/dist/assets/layout-BI8cXFPI.js +1 -0
- package/dist/assets/{linear-Ck7gpa5N.js → linear-a0glcDiw.js} +1 -1
- package/dist/assets/min-vPHfnXcC.js +1 -0
- package/dist/assets/{mindmap-definition-LN4V7U3C-DTcHO0DJ.js → mindmap-definition-QFDTVHPH-D14eF-7C.js} +7 -7
- package/dist/assets/mobile-B7m9EO9D.js +17 -0
- package/dist/assets/pieDiagram-DEJITSTG-Cno-gETh.js +30 -0
- package/dist/assets/quadrantDiagram-34T5L4WZ-BUQM1Hfm.js +7 -0
- package/dist/assets/{requirementDiagram-TGXJPOKE-bnI2zJeT.js → requirementDiagram-MS252O5E-pOXlN2-q.js} +3 -3
- package/dist/assets/sankeyDiagram-XADWPNL6-Crynd3_b.js +10 -0
- package/dist/assets/sequenceDiagram-FGHM5R23-D9fZdCM8.js +157 -0
- package/dist/assets/stateDiagram-FHFEXIEX-CW9qVec8.js +1 -0
- package/dist/assets/stateDiagram-v2-QKLJ7IA2-DkVLzHbY.js +1 -0
- package/dist/assets/{timeline-definition-FHXFAJF6-D267GQFF.js → timeline-definition-GMOUNBTQ-BcHzhm_8.js} +3 -3
- package/dist/assets/vennDiagram-DHZGUBPP-BfytJcWk.js +34 -0
- package/dist/assets/wardley-RL74JXVD-DLj-IjyB.js +162 -0
- package/dist/assets/wardleyDiagram-NUSXRM2D-Ds0Ue68c.js +20 -0
- package/dist/assets/xychartDiagram-5P7HB3ND-vjWDXFL6.js +7 -0
- package/dist/index.html +3 -3
- package/lib/agent-string.js +7 -5
- package/lib/append-integrity.js +81 -0
- package/lib/backfill-evidence.js +109 -0
- package/lib/bug-escalation.js +39 -4
- package/lib/build-stream-schema.js +3 -1
- package/lib/build-stream-writer.js +25 -0
- package/lib/build.js +1624 -195
- package/lib/canon-guard.js +245 -0
- package/lib/canon-override.js +196 -0
- package/lib/canon-registry.js +291 -0
- package/lib/cli-commands.js +144 -0
- package/lib/codex-preflight.js +50 -15
- package/lib/colleague/context.js +215 -0
- package/lib/colleague/writeback.js +95 -0
- package/lib/completion-gate.js +1421 -0
- package/lib/completion-writer.js +47 -47
- package/lib/consumer-fanout.js +105 -11
- package/lib/coverage-gate.js +200 -0
- package/lib/dir-lock.js +170 -0
- package/lib/dispatch-ledger.js +301 -0
- package/lib/dispatch-metrics.js +236 -0
- package/lib/experiment-judge.js +6 -1
- package/lib/feature-json.js +1 -1
- package/lib/feature-reconciler.js +8 -0
- package/lib/feature-validator.js +64 -1
- package/lib/feature-writer.js +66 -2
- package/lib/fluid/factory.js +167 -0
- package/lib/fluid/ideabox-dates.js +73 -0
- package/lib/fluid/ideabox-migrate.js +154 -0
- package/lib/fluid/ideabox-ops.js +585 -0
- package/lib/fluid/ideabox-view.js +146 -0
- package/lib/fluid/import-ideabox.js +186 -0
- package/lib/fluid/local-provider.js +606 -0
- package/lib/fluid/provider.js +684 -0
- package/lib/fluid/record-shape.js +214 -0
- package/lib/fluid/record-store.js +328 -0
- package/lib/fluid/render-ideabox.js +261 -0
- package/lib/fluid/schema.js +40 -0
- package/lib/fluid/smartmemory-provider.js +1695 -0
- package/lib/gsd.js +63 -14
- package/lib/guard-cli.js +175 -0
- package/lib/guard-custody.js +141 -0
- package/lib/guard-descriptors.js +530 -0
- package/lib/guard-enrol.js +254 -0
- package/lib/health-score.js +1 -1
- package/lib/hooks-status.js +32 -3
- package/lib/ideabox-cli.js +315 -0
- package/lib/ideabox.js +121 -21
- package/lib/judgment/store/index.js +166 -0
- package/lib/judgment/store/records.js +184 -25
- package/lib/judgment/trace.js +380 -0
- package/lib/judgment-attest.js +259 -0
- package/lib/judgment-decision-write.js +277 -0
- package/lib/judgment-decisions.js +466 -0
- package/lib/judgment-gen.js +375 -22
- package/lib/judgment-verify.js +153 -0
- package/lib/judgment-writer.js +2842 -262
- package/lib/lane-gate.js +2 -0
- package/lib/lifecycle-modes.js +4 -4
- package/lib/lineage.js +400 -0
- package/lib/local-claude-connector.js +250 -54
- package/lib/maya-client.js +302 -0
- package/lib/maya-config.js +53 -0
- package/lib/maya-identity.js +283 -0
- package/lib/mcp-enforcement.js +21 -35
- package/lib/migrate-anon.js +5 -0
- package/lib/migrate-roadmap.js +15 -0
- package/lib/new.js +13 -1
- package/lib/pipeline-compat.js +104 -0
- package/lib/policy-catalog.js +295 -0
- package/lib/policy-check.js +0 -0
- package/lib/process-termination.js +98 -0
- package/lib/resolve-workspace.js +5 -1
- package/lib/result-normalizer.js +428 -153
- package/lib/review-normalize.js +4 -0
- package/lib/roadmap-errors.js +65 -0
- package/lib/roadmap-preservers.js +24 -4
- package/lib/roadmap-residue.js +299 -0
- package/lib/smartmemory-client.js +614 -78
- package/lib/smartmemory-config.js +54 -0
- package/lib/smartmemory-ingest.js +19 -2
- package/lib/step-prompt.js +7 -6
- package/lib/stratum-engine.js +53 -4
- package/lib/stratum-mcp-client.js +391 -31
- package/lib/test-bootstrap.js +31 -0
- package/lib/tool-inventory.js +122 -0
- package/lib/version-check.js +91 -19
- package/lib/vision-writer.js +88 -1
- package/package.json +7 -6
- package/pipelines/bug-fix.stratum.yaml +205 -211
- package/pipelines/build-quick.profiles.json +12 -0
- package/pipelines/build-quick.stratum.yaml +263 -350
- package/pipelines/content.stratum.yaml +81 -77
- package/pipelines/coverage-sweep.stratum.yaml +49 -30
- package/pipelines/plan.stratum.yaml +76 -86
- package/pipelines/refactor.stratum.yaml +125 -125
- package/pipelines/research.stratum.yaml +56 -58
- package/pipelines/review-fix.profiles.json +6 -0
- package/pipelines/review-fix.stratum.yaml +110 -83
- package/presets/team-feature.profiles.json +6 -0
- package/presets/team-feature.stratum.yaml +93 -66
- package/presets/team-research.profiles.json +6 -0
- package/presets/team-research.stratum.yaml +89 -80
- package/presets/team-review.profiles.json +8 -0
- package/presets/team-review.stratum.yaml +98 -80
- package/scripts/cost-census.mjs +70 -0
- package/scripts/guard-sign/compose-guard-sign.sh +62 -0
- package/server/agent-health.js +22 -0
- package/server/agent-hooks.js +14 -1
- package/server/agent-server.js +5 -248
- package/server/agent-spawn.js +3 -4
- package/server/agent-workspace.js +294 -0
- package/server/build-routes.js +6 -5
- package/server/build-stream-bridge.js +53 -0
- package/server/cc-session-watcher.js +4 -1
- package/server/coalescing-buffer.js +7 -1
- package/server/completion-projection.js +228 -0
- package/server/compose-mcp-tools.js +124 -24
- package/server/compose-mcp.js +91 -790
- package/server/decision-event-emit.js +41 -2
- package/server/decision-event-id.js +17 -0
- package/server/decision-events-snapshot.js +3 -0
- package/server/design-routes.js +14 -8
- package/server/feature-scan.js +76 -2
- package/server/file-watcher.js +170 -21
- package/server/ideabox-routes.js +166 -224
- package/server/index.js +70 -100
- package/server/lifecycle-guard.js +240 -10
- package/server/lifecycle-phase-history.js +276 -0
- package/server/maya-routes.js +507 -0
- package/server/mcp-tool-defs.js +940 -0
- package/server/mcp-tool-policy.js +35 -3
- package/server/model-tiers.js +22 -5
- package/server/pipeline-routes.js +21 -11
- package/server/project-root.js +58 -19
- package/server/remote-utils.js +3 -1
- package/server/schema-validator.js +7 -1
- package/server/session-manager.js +5 -6
- package/server/session-routes.js +3 -1
- package/server/stratum-client.js +57 -10
- package/server/stratum-sync.js +6 -3
- package/server/summarizer.js +3 -4
- package/server/supervisor.js +0 -1
- package/server/vision-routes.js +208 -98
- package/server/vision-server.js +86 -23
- package/server/vision-store.js +60 -6
- package/server/vision-utils.js +3 -4
- package/server/workspace-activity.js +18 -0
- package/server/workspace-middleware.js +2 -2
- package/server/workspace-runtime.js +243 -0
- package/server/worktree-gc.js +1 -0
- package/dist/assets/App-BG3ngu8H.js +0 -896
- package/dist/assets/abnfDiagram-VRR7QNED-CjB_sD3D.js +0 -1
- package/dist/assets/arc-_v4hR_uD.js +0 -1
- package/dist/assets/architectureDiagram-ZJ3FMSHR-DreJmzXQ.js +0 -36
- package/dist/assets/blockDiagram-677ZJIJ3-BG9-c0O1.js +0 -132
- package/dist/assets/channel-B3U5wFAT.js +0 -1
- package/dist/assets/chunk-EX3LRPZG-DdELs1qP.js +0 -231
- package/dist/assets/chunk-MOJQB5TN-D-ky35G-.js +0 -88
- package/dist/assets/chunk-RYQCIY6F-Dag_kVlO.js +0 -1
- package/dist/assets/chunk-V7JOEXUC-BtewURat.js +0 -206
- package/dist/assets/classDiagram-OUVF2IWQ-B6fCN-ht.js +0 -1
- package/dist/assets/classDiagram-v2-EOCWNBFH-B6fCN-ht.js +0 -1
- package/dist/assets/cynefin-VYW2F7L2-CT2BA6KE.js +0 -178
- package/dist/assets/cynefinDiagram-TSTJHNR4-Bh6exbyg.js +0 -62
- package/dist/assets/dagre-VKFMJZFB-aXMLSmQL.js +0 -4
- package/dist/assets/diagram-FQU43EPY-Dr7JAOuQ.js +0 -3
- package/dist/assets/diagram-G47NLZAW-DUvA3FQK.js +0 -24
- package/dist/assets/diagram-NH7WQ7WH-BQUARqcu.js +0 -24
- package/dist/assets/diagram-OA4YK3LP-dDUc1zHi.js +0 -30
- package/dist/assets/diagram-WEI45ONY-B2h5Qlb1.js +0 -41
- package/dist/assets/ebnfDiagram-CCIWWBDH-DThRGupB.js +0 -1
- package/dist/assets/erDiagram-Q63AITRT-BUCsprO2.js +0 -85
- package/dist/assets/flowDiagram-23GEKE2U-DXtNNi6r.js +0 -156
- package/dist/assets/ganttDiagram-NO4QXBWP-D4zbBHh_.js +0 -292
- package/dist/assets/gitGraphDiagram-IHSO6WYX-DpoQws0W.js +0 -106
- package/dist/assets/graph-BXPQrYYB.js +0 -331
- package/dist/assets/graph-C9eacEi8.js +0 -1
- package/dist/assets/index-3ZH5eMcZ.js +0 -119
- package/dist/assets/index-LIwREYgH.css +0 -1
- package/dist/assets/infoDiagram-FWYZ7A6U-Bbas2GAo.js +0 -2
- package/dist/assets/katex-C5jXJg4s.js +0 -257
- package/dist/assets/layout-DEXfKzaS.js +0 -1
- package/dist/assets/map-Czzmt4hB.js +0 -1
- package/dist/assets/mobile-CaoXUwAr.js +0 -17
- package/dist/assets/pegDiagram-2B236MQR-CHiINrNy.js +0 -1
- package/dist/assets/pieDiagram-ENE6RG2P-CfS4YFlR.js +0 -39
- package/dist/assets/quadrantDiagram-ABIIQ3AL-CadesS9w.js +0 -7
- package/dist/assets/railroadDiagram-RFXS5EU6-CgWEspBN.js +0 -1
- package/dist/assets/sankeyDiagram-HTMAVEWB-YWKFgOGw.js +0 -40
- package/dist/assets/sequenceDiagram-DBY2YBRQ-BvkNOyF9.js +0 -162
- package/dist/assets/sizeCapture-X5ZJPWSS-DlFPA2yO.js +0 -1
- package/dist/assets/stateDiagram-2N3HPSRC-h8NIx0kQ.js +0 -1
- package/dist/assets/stateDiagram-v2-6OUMAXLB-DjPgZtJ9.js +0 -1
- package/dist/assets/swimlanes-5IMT3BWC-CT5n22kG.js +0 -2
- package/dist/assets/swimlanesDiagram-G3AALYLV-Dn318Bhq.js +0 -8
- package/dist/assets/vennDiagram-L72KCM5P-Dj-wWLYG.js +0 -34
- package/dist/assets/wardleyDiagram-EHGQE667-BxCeYxkG.js +0 -78
- package/dist/assets/xychartDiagram-FW5EYKEG-DMFqWn7z.js +0 -7
- package/lib/staleness.js +0 -87
- package/server/ideabox-cache.js +0 -77
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { processTermination } from './process-termination.js';
|
|
1
3
|
/**
|
|
2
4
|
* local-claude-connector.js — V2/V3 (STRAT-TS-FANOUT-CONSUMER).
|
|
3
5
|
*
|
|
@@ -17,8 +19,11 @@
|
|
|
17
19
|
* and reports usage. The SDK `query` is injectable for tests.
|
|
18
20
|
*/
|
|
19
21
|
|
|
22
|
+
import { randomUUID } from 'node:crypto';
|
|
20
23
|
import { query as sdkQuery } from '@anthropic-ai/claude-agent-sdk';
|
|
21
24
|
|
|
25
|
+
import { appendEvent, resolveDispatchLedgerCwd } from './dispatch-ledger.js';
|
|
26
|
+
|
|
22
27
|
const SENSITIVE_ENV_VARS = ['ANTHROPIC_API_KEY', 'OPENAI_API_KEY', 'CLAUDE_API_KEY', 'CLAUDECODE'];
|
|
23
28
|
|
|
24
29
|
function isRecord(value) {
|
|
@@ -29,6 +34,77 @@ function nonneg(value) {
|
|
|
29
34
|
return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : 0;
|
|
30
35
|
}
|
|
31
36
|
|
|
37
|
+
function reportedNumber(value) {
|
|
38
|
+
return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function reportedString(value) {
|
|
42
|
+
return typeof value === 'string' && value.length > 0 ? value : null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function isBlockedDispatch(value) {
|
|
46
|
+
const candidates = [value?.status, value?.outcome, value?.code, value?.subtype];
|
|
47
|
+
return candidates.some((candidate) => {
|
|
48
|
+
const normalized = typeof candidate === 'string' ? candidate.toLowerCase() : '';
|
|
49
|
+
return normalized === 'blocked'
|
|
50
|
+
|| normalized === 'budget_exhausted'
|
|
51
|
+
|| normalized === 'budget-exhausted';
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function attachDispatchId(value, dispatchId) {
|
|
56
|
+
if ((typeof value !== 'object' && typeof value !== 'function') || value === null) return;
|
|
57
|
+
try {
|
|
58
|
+
Object.defineProperty(value, 'dispatchId', {
|
|
59
|
+
configurable: true,
|
|
60
|
+
enumerable: false,
|
|
61
|
+
value: dispatchId,
|
|
62
|
+
});
|
|
63
|
+
} catch {
|
|
64
|
+
// Capture metadata must never replace the SDK's original behavior.
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function recordDispatch(dispatchId, context, capture, outcome, elapsedMs, effortExecuted) {
|
|
69
|
+
try {
|
|
70
|
+
const event = {
|
|
71
|
+
kind: 'dispatch',
|
|
72
|
+
dispatch_id: dispatchId,
|
|
73
|
+
site: reportedString(context.site) ?? 'unattributed',
|
|
74
|
+
agent: 'claude',
|
|
75
|
+
outcome,
|
|
76
|
+
model: reportedString(capture.model),
|
|
77
|
+
effort_intended: reportedString(context.effort_intended),
|
|
78
|
+
// Executed effort is only recorded once a model run is confirmed — the SDK
|
|
79
|
+
// emitted a model identity (init or result). A transport/startup failure
|
|
80
|
+
// before any model spoke leaves effort_executed null, so the executed-effort
|
|
81
|
+
// curve is not polluted by tiers that were requested but never actually ran
|
|
82
|
+
// (mirrors the codex route, whose effort_executed comes from run telemetry).
|
|
83
|
+
effort_executed: capture.model ? reportedString(effortExecuted) : null,
|
|
84
|
+
tokens_in: reportedNumber(capture.inputTokens),
|
|
85
|
+
tokens_out: reportedNumber(capture.outputTokens),
|
|
86
|
+
tokens_total: capture.inputTokens !== null || capture.outputTokens !== null
|
|
87
|
+
? (reportedNumber(capture.inputTokens) ?? 0) + (reportedNumber(capture.outputTokens) ?? 0)
|
|
88
|
+
: null,
|
|
89
|
+
usd: reportedNumber(capture.costUsd),
|
|
90
|
+
duration_ms: reportedNumber(capture.durationMs) ?? reportedNumber(elapsedMs),
|
|
91
|
+
};
|
|
92
|
+
for (const [field, value] of [
|
|
93
|
+
['build_id', context.build_id],
|
|
94
|
+
['feature_code', context.feature_code],
|
|
95
|
+
['step_id', context.step_id],
|
|
96
|
+
]) {
|
|
97
|
+
if (reportedString(value) !== null) event[field] = value;
|
|
98
|
+
}
|
|
99
|
+
if (typeof context.attempt === 'number' && Number.isFinite(context.attempt)) {
|
|
100
|
+
event.attempt = context.attempt;
|
|
101
|
+
}
|
|
102
|
+
appendEvent(resolveDispatchLedgerCwd(context.project_cwd), event);
|
|
103
|
+
} catch {
|
|
104
|
+
// Dispatch capture is fail-open by contract.
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
32
108
|
/**
|
|
33
109
|
* Run a controlled claude agent locally.
|
|
34
110
|
*
|
|
@@ -39,24 +115,66 @@ function nonneg(value) {
|
|
|
39
115
|
* @param {string[]} [opts.allowedTools] enforced tool allowlist (read-only review)
|
|
40
116
|
* @param {string[]} [opts.disallowedTools]
|
|
41
117
|
* @param {object} [opts.thinking]
|
|
118
|
+
* @param {string} [opts.effort] reasoning-effort tier (low|medium|high|xhigh|max)
|
|
42
119
|
* @param {AbortController} [opts.abortController] abort → interrupt the run
|
|
43
120
|
* @param {(ev:{tool:string,input:object})=>void} [opts.onToolUse] per tool_use block
|
|
44
121
|
* @param {NodeJS.ProcessEnv} [opts.env]
|
|
45
122
|
* @param {Function} [opts.query] SDK `query` seam for tests
|
|
123
|
+
* @param {object} [opts.telemetry] Compose-only dispatch context
|
|
46
124
|
* @returns {Promise<{text:string, usage:object, telemetry:object}>}
|
|
47
125
|
*/
|
|
48
126
|
export async function runLocalClaudeAgent(prompt, opts = {}) {
|
|
49
127
|
const query = opts.query ?? sdkQuery;
|
|
128
|
+
const controller = opts.abortController;
|
|
129
|
+
controller?.signal.throwIfAborted();
|
|
130
|
+
// C2: group cancellation needs POSIX process groups. Windows has none, so
|
|
131
|
+
// instead of refusing the run (which killed EVERY local dispatch there,
|
|
132
|
+
// cancellable or not) we skip the custom spawn hook and let the SDK's own
|
|
133
|
+
// abortController tear the run down. That is weaker — the SDK kills only the
|
|
134
|
+
// CLI leader, so grandchildren can outlive a cancel — but a non-cancel run
|
|
135
|
+
// must never fail before spawn.
|
|
136
|
+
const platform = opts.platform ?? process.platform;
|
|
137
|
+
const ownProcessGroup = Boolean(controller) && platform !== 'win32';
|
|
138
|
+
const children = [];
|
|
139
|
+
let stderr = '';
|
|
140
|
+
const terminate = () => { for (const child of children) void child.terminate(); };
|
|
141
|
+
controller?.signal.addEventListener('abort', terminate, { once: true });
|
|
142
|
+
const { telemetry } = opts;
|
|
143
|
+
const telemetryContext = telemetry && typeof telemetry === 'object'
|
|
144
|
+
? telemetry
|
|
145
|
+
: {};
|
|
50
146
|
const env = { ...(opts.env ?? process.env) };
|
|
51
147
|
for (const key of SENSITIVE_ENV_VARS) delete env[key];
|
|
52
148
|
|
|
149
|
+
// Only forward a non-empty effort so routes without a configured tier keep the
|
|
150
|
+
// SDK's default behavior; the applied value is what we record as effort_executed.
|
|
151
|
+
const appliedEffort = reportedString(opts.effort);
|
|
53
152
|
const sdkOptions = {
|
|
54
153
|
cwd: opts.cwd ?? process.cwd(),
|
|
55
154
|
model: opts.model ?? process.env.CLAUDE_MODEL ?? 'claude-sonnet-4-6',
|
|
56
155
|
permissionMode: 'acceptEdits',
|
|
57
156
|
env,
|
|
157
|
+
...(ownProcessGroup ? { spawnClaudeCodeProcess: (options) => {
|
|
158
|
+
controller.signal.throwIfAborted();
|
|
159
|
+
const child = spawn(options.command, options.args, {
|
|
160
|
+
cwd: options.cwd, env: options.env, detached: true, stdio: ['pipe', 'pipe', 'pipe'],
|
|
161
|
+
});
|
|
162
|
+
const termination = processTermination(child, true, opts.cancellationGraceMs);
|
|
163
|
+
children.push(termination);
|
|
164
|
+
child.stderr.setEncoding('utf8');
|
|
165
|
+
child.stderr.on('data', chunk => { stderr = (stderr + chunk).slice(-16384); });
|
|
166
|
+
const sdkAbort = () => { void termination.terminate(); };
|
|
167
|
+
options.signal.addEventListener('abort', sdkAbort, { once: true });
|
|
168
|
+
void termination.close.then(() => options.signal.removeEventListener('abort', sdkAbort));
|
|
169
|
+
if (options.signal.aborted) sdkAbort();
|
|
170
|
+
return { stdin: child.stdin, stdout: child.stdout, pid: child.pid,
|
|
171
|
+
get killed() { return child.killed; }, get exitCode() { return child.exitCode; },
|
|
172
|
+
kill: () => { void termination.terminate(); return true; },
|
|
173
|
+
on: child.on.bind(child), once: child.once.bind(child), off: child.off.bind(child) };
|
|
174
|
+
} } : {}),
|
|
58
175
|
...(opts.abortController ? { abortController: opts.abortController } : {}),
|
|
59
176
|
...(opts.thinking !== undefined ? { thinking: opts.thinking } : {}),
|
|
177
|
+
...(appliedEffort !== null ? { effort: appliedEffort } : {}),
|
|
60
178
|
};
|
|
61
179
|
// A read-only profile passes an explicit allowlist; without one, the agent
|
|
62
180
|
// gets the full claude_code preset (unrestricted).
|
|
@@ -83,67 +201,145 @@ export async function runLocalClaudeAgent(prompt, opts = {}) {
|
|
|
83
201
|
let inputTokens = 0;
|
|
84
202
|
let outputTokens = 0;
|
|
85
203
|
let costUsd = 0;
|
|
204
|
+
const capture = {
|
|
205
|
+
model: null,
|
|
206
|
+
inputTokens: null,
|
|
207
|
+
outputTokens: null,
|
|
208
|
+
costUsd: null,
|
|
209
|
+
durationMs: null,
|
|
210
|
+
};
|
|
211
|
+
let resultError = null;
|
|
212
|
+
let resultFailureOutcome = null;
|
|
213
|
+
const dispatchId = randomUUID();
|
|
214
|
+
const dispatchStartedAt = Date.now();
|
|
86
215
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
216
|
+
try {
|
|
217
|
+
for await (const raw of query({ prompt, options: sdkOptions })) {
|
|
218
|
+
if (!isRecord(raw)) continue;
|
|
219
|
+
if (raw.type === 'system' && raw.subtype === 'init' && typeof raw.model === 'string') {
|
|
220
|
+
resolvedModel = raw.model;
|
|
221
|
+
capture.model = raw.model;
|
|
222
|
+
}
|
|
223
|
+
if (raw.type === 'assistant' && isRecord(raw.message) && Array.isArray(raw.message.content)) {
|
|
224
|
+
for (const block of raw.message.content) {
|
|
225
|
+
if (!isRecord(block)) continue;
|
|
226
|
+
if (block.type === 'text' && typeof block.text === 'string') {
|
|
227
|
+
assistantText += block.text;
|
|
228
|
+
// COMP-AGENT-LANES: optional live-relay seam, mirroring onToolUse.
|
|
229
|
+
// Without a subscriber the text only accumulates (historical shape).
|
|
230
|
+
if (typeof opts.onAssistantText === 'function') opts.onAssistantText(block.text);
|
|
231
|
+
}
|
|
232
|
+
if (block.type === 'tool_use' && typeof block.name === 'string' && typeof opts.onToolUse === 'function') {
|
|
233
|
+
opts.onToolUse({ tool: block.name, input: isRecord(block.input) ? block.input : {} });
|
|
234
|
+
}
|
|
98
235
|
}
|
|
99
236
|
}
|
|
237
|
+
if (raw.type !== 'result') continue;
|
|
238
|
+
durationMs = nonneg(raw.duration_ms);
|
|
239
|
+
capture.durationMs = reportedNumber(raw.duration_ms);
|
|
240
|
+
capture.costUsd = reportedNumber(raw.total_cost_usd);
|
|
241
|
+
if (reportedString(raw.model) !== null) capture.model = raw.model;
|
|
242
|
+
if (isRecord(raw.usage)) {
|
|
243
|
+
capture.inputTokens = reportedNumber(raw.usage.input_tokens);
|
|
244
|
+
capture.outputTokens = reportedNumber(raw.usage.output_tokens);
|
|
245
|
+
capture.model = reportedString(raw.usage.model) ?? capture.model;
|
|
246
|
+
}
|
|
247
|
+
if (raw.subtype !== 'success') {
|
|
248
|
+
// F3: a failed run still consumed billable tokens/cost. Capture them from
|
|
249
|
+
// the error result (SDKResultError carries usage + total_cost_usd) and
|
|
250
|
+
// attach to the thrown Error — same usage shape as the success return — so
|
|
251
|
+
// the consumer failure path can debit the engine/GSD ledgers. Without this,
|
|
252
|
+
// repeated failures evade budget exhaustion.
|
|
253
|
+
const failCost = nonneg(raw.total_cost_usd);
|
|
254
|
+
const failIn = isRecord(raw.usage) ? nonneg(raw.usage.input_tokens) : 0;
|
|
255
|
+
const failOut = isRecord(raw.usage) ? nonneg(raw.usage.output_tokens) : 0;
|
|
256
|
+
const errors = Array.isArray(raw.errors) ? raw.errors.filter((v) => typeof v === 'string') : [];
|
|
257
|
+
const err = new Error(errors.join('; ') || `claude query failed: ${String(raw.subtype)}`);
|
|
258
|
+
err.usage = {
|
|
259
|
+
input_tokens: failIn,
|
|
260
|
+
output_tokens: failOut,
|
|
261
|
+
tokens: failIn + failOut,
|
|
262
|
+
cost_usd: failCost,
|
|
263
|
+
usd: failCost,
|
|
264
|
+
duration_ms: durationMs,
|
|
265
|
+
ms: durationMs,
|
|
266
|
+
model: resolvedModel,
|
|
267
|
+
};
|
|
268
|
+
err.costUsd = failCost;
|
|
269
|
+
resultError = err;
|
|
270
|
+
resultFailureOutcome = isBlockedDispatch(raw) ? 'blocked' : 'error';
|
|
271
|
+
throw err;
|
|
272
|
+
}
|
|
273
|
+
if (typeof raw.result === 'string') finalText = raw.result;
|
|
274
|
+
costUsd = nonneg(raw.total_cost_usd);
|
|
275
|
+
if (isRecord(raw.usage)) {
|
|
276
|
+
inputTokens = nonneg(raw.usage.input_tokens);
|
|
277
|
+
outputTokens = nonneg(raw.usage.output_tokens);
|
|
278
|
+
}
|
|
100
279
|
}
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
output_tokens: failOut,
|
|
117
|
-
tokens: failIn + failOut,
|
|
118
|
-
cost_usd: failCost,
|
|
119
|
-
usd: failCost,
|
|
280
|
+
|
|
281
|
+
// H2: do NOT throwIfAborted() here. A run that RESOLVES after its
|
|
282
|
+
// timeout/abort fired still reported billable usage, and the caller
|
|
283
|
+
// (result-normalizer's late-resolve branch) is the single place that turns
|
|
284
|
+
// that into AgentTimeoutError/AgentAbortedError/UserInterruptError with the
|
|
285
|
+
// usage attached. Throwing a bare AbortError here loses the usage and the
|
|
286
|
+
// attempt is never billed.
|
|
287
|
+
const result = {
|
|
288
|
+
text: finalText ?? assistantText,
|
|
289
|
+
usage: {
|
|
290
|
+
input_tokens: inputTokens,
|
|
291
|
+
output_tokens: outputTokens,
|
|
292
|
+
tokens: inputTokens + outputTokens,
|
|
293
|
+
cost_usd: costUsd,
|
|
294
|
+
usd: costUsd,
|
|
120
295
|
duration_ms: durationMs,
|
|
121
296
|
ms: durationMs,
|
|
122
297
|
model: resolvedModel,
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
298
|
+
},
|
|
299
|
+
telemetry: { durationMs, model: resolvedModel },
|
|
300
|
+
};
|
|
301
|
+
recordDispatch(
|
|
302
|
+
dispatchId,
|
|
303
|
+
telemetryContext,
|
|
304
|
+
capture,
|
|
305
|
+
'ok',
|
|
306
|
+
Date.now() - dispatchStartedAt,
|
|
307
|
+
appliedEffort,
|
|
308
|
+
);
|
|
309
|
+
attachDispatchId(result, dispatchId);
|
|
310
|
+
return result;
|
|
311
|
+
} catch (error) {
|
|
312
|
+
if (error !== resultError && error && typeof error === 'object') {
|
|
313
|
+
const errorUsage = isRecord(error.usage) ? error.usage : {};
|
|
314
|
+
const errorTelemetry = isRecord(error.telemetry) ? error.telemetry : {};
|
|
315
|
+
capture.model = reportedString(errorTelemetry.model)
|
|
316
|
+
?? reportedString(errorUsage.model)
|
|
317
|
+
?? capture.model;
|
|
318
|
+
capture.inputTokens = reportedNumber(errorUsage.input_tokens) ?? capture.inputTokens;
|
|
319
|
+
capture.outputTokens = reportedNumber(errorUsage.output_tokens) ?? capture.outputTokens;
|
|
320
|
+
capture.costUsd = reportedNumber(errorUsage.cost_usd)
|
|
321
|
+
?? reportedNumber(errorUsage.usd)
|
|
322
|
+
?? capture.costUsd;
|
|
323
|
+
capture.durationMs = reportedNumber(errorUsage.duration_ms)
|
|
324
|
+
?? reportedNumber(errorUsage.ms)
|
|
325
|
+
?? reportedNumber(errorTelemetry.durationMs)
|
|
326
|
+
?? capture.durationMs;
|
|
132
327
|
}
|
|
328
|
+
recordDispatch(
|
|
329
|
+
dispatchId,
|
|
330
|
+
telemetryContext,
|
|
331
|
+
capture,
|
|
332
|
+
resultFailureOutcome ?? (isBlockedDispatch(error) ? 'blocked' : 'error'),
|
|
333
|
+
Date.now() - dispatchStartedAt,
|
|
334
|
+
appliedEffort,
|
|
335
|
+
);
|
|
336
|
+
attachDispatchId(error, dispatchId);
|
|
337
|
+
if (controller?.signal.aborted) { terminate(); await Promise.all(children.map(child => child.finish())); }
|
|
338
|
+
if (stderr) { error.stderr = stderr; error.cause ??= new Error(stderr.trim()); }
|
|
339
|
+
throw error;
|
|
340
|
+
} finally {
|
|
341
|
+
controller?.signal.removeEventListener('abort', terminate);
|
|
342
|
+
if (controller?.signal.aborted) terminate();
|
|
343
|
+
await Promise.all(children.map(child => child.finish()));
|
|
133
344
|
}
|
|
134
|
-
|
|
135
|
-
return {
|
|
136
|
-
text: finalText ?? assistantText,
|
|
137
|
-
usage: {
|
|
138
|
-
input_tokens: inputTokens,
|
|
139
|
-
output_tokens: outputTokens,
|
|
140
|
-
tokens: inputTokens + outputTokens,
|
|
141
|
-
cost_usd: costUsd,
|
|
142
|
-
usd: costUsd,
|
|
143
|
-
duration_ms: durationMs,
|
|
144
|
-
ms: durationMs,
|
|
145
|
-
model: resolvedModel,
|
|
146
|
-
},
|
|
147
|
-
telemetry: { durationMs, model: resolvedModel },
|
|
148
|
-
};
|
|
149
345
|
}
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* maya-client.js — COMP-FOH FOH-6 S1
|
|
3
|
+
*
|
|
4
|
+
* HTTP client for Maya's chat surface (`/Users/ruze/reg/my/SmartMemory/maya/`,
|
|
5
|
+
* read-only upstream — POST /api/chat, routes.py:5354; GET /health,
|
|
6
|
+
* maya_server.py:46). Speaks HTTP and nothing else; context composition and
|
|
7
|
+
* write-back belong to the relay.
|
|
8
|
+
*
|
|
9
|
+
* Failure conventions, per design-foh-6.md §2 and the smartmemory-client
|
|
10
|
+
* doctrine:
|
|
11
|
+
* - 401 → ONE retry with the SAME token (transient), then MayaAuthError.
|
|
12
|
+
* Never re-provision here: the identity owns the standing conversation,
|
|
13
|
+
* and a 401 does not prove expiry.
|
|
14
|
+
* - a 2xx we cannot trust (non-JSON body, success !== true, missing
|
|
15
|
+
* message_id) is a failure with kind 'malformed-response', not a success —
|
|
16
|
+
* message_id is the write-back idempotency key, so a reply without one
|
|
17
|
+
* cannot be treated as a completed turn.
|
|
18
|
+
* - network failure / deadline → status 0.
|
|
19
|
+
* - health() never throws.
|
|
20
|
+
*
|
|
21
|
+
* The chat deadline defaults generously (her turn does LLM work upstream);
|
|
22
|
+
* the live VERIFY used 120s.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** Non-auth failure of a Maya call. */
|
|
26
|
+
export class MayaHttpError extends Error {
|
|
27
|
+
constructor(message, status, kind) {
|
|
28
|
+
super(message);
|
|
29
|
+
this.name = 'MayaHttpError';
|
|
30
|
+
this.status = status;
|
|
31
|
+
this.kind = kind;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** 401 after the single same-token retry. The relay maps this to the auth
|
|
36
|
+
* funnel with its explicit continuity-costing actions. */
|
|
37
|
+
export class MayaAuthError extends MayaHttpError {
|
|
38
|
+
constructor(message) {
|
|
39
|
+
super(message, 401);
|
|
40
|
+
this.name = 'MayaAuthError';
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @param {object} opts
|
|
46
|
+
* @param {string} opts.baseUrl
|
|
47
|
+
* @param {() => (string|Promise<string>)} opts.getToken read at CALL time
|
|
48
|
+
* @param {number} [opts.timeoutMs] health deadline (default 3000)
|
|
49
|
+
* @param {number} [opts.chatTimeoutMs] chat deadline (default 120000)
|
|
50
|
+
*/
|
|
51
|
+
export function createMayaClient({ baseUrl, getToken, timeoutMs = 3000, chatTimeoutMs = 120000 }) {
|
|
52
|
+
async function fetchJson(path, { init, deadline }) {
|
|
53
|
+
const controller = new AbortController();
|
|
54
|
+
const timer = setTimeout(() => controller.abort(), deadline);
|
|
55
|
+
let res;
|
|
56
|
+
let text;
|
|
57
|
+
try {
|
|
58
|
+
res = await fetch(`${baseUrl}${path}`, { ...init, signal: controller.signal });
|
|
59
|
+
text = await res.text();
|
|
60
|
+
} catch (err) {
|
|
61
|
+
throw new MayaHttpError(
|
|
62
|
+
`maya: ${path} request failed: ${err?.message ?? 'unknown error'}`, 0,
|
|
63
|
+
);
|
|
64
|
+
} finally {
|
|
65
|
+
clearTimeout(timer);
|
|
66
|
+
}
|
|
67
|
+
let parsed = null;
|
|
68
|
+
let parseFailed = false;
|
|
69
|
+
try { parsed = JSON.parse(text); } catch { parseFailed = true; }
|
|
70
|
+
return { status: res.status, ok: res.ok, body: parsed, parseFailed };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Liveness only. Unauthenticated, never throws. */
|
|
74
|
+
async function health() {
|
|
75
|
+
try {
|
|
76
|
+
const res = await fetchJson('/health', { deadline: timeoutMs });
|
|
77
|
+
return { ok: res.ok, status: res.status };
|
|
78
|
+
} catch {
|
|
79
|
+
return { ok: false };
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* One conversational turn. `channelContext` travels verbatim as the wire's
|
|
85
|
+
* `channel_context: [{author, text}]` and is OMITTED (not sent empty) when
|
|
86
|
+
* absent — an empty array still exercises Maya's context-injection path.
|
|
87
|
+
*
|
|
88
|
+
* @param {{message: string, channelContext?: Array<{author: string, text: string}>,
|
|
89
|
+
* surface?: string, timezone?: string}} args
|
|
90
|
+
* @returns {Promise<{success: true, response: string, message_id: string,
|
|
91
|
+
* memory_available?: boolean}>}
|
|
92
|
+
*/
|
|
93
|
+
async function chat({ message, channelContext, surface, timezone = 'UTC' }) {
|
|
94
|
+
const body = { message, timezone };
|
|
95
|
+
if (Array.isArray(channelContext) && channelContext.length) {
|
|
96
|
+
body.channel_context = channelContext;
|
|
97
|
+
}
|
|
98
|
+
if (surface !== undefined) body.surface = surface;
|
|
99
|
+
|
|
100
|
+
// Captured ONCE per chat() call: "one same-token retry" is a literal
|
|
101
|
+
// contract — reading the token again for the retry could pick up a
|
|
102
|
+
// concurrent paste/re-provision and silently move the retry onto a
|
|
103
|
+
// different identity and conversation (Codex r1 P2).
|
|
104
|
+
const token = await getToken();
|
|
105
|
+
const send = () => fetchJson('/api/chat', {
|
|
106
|
+
deadline: chatTimeoutMs,
|
|
107
|
+
init: {
|
|
108
|
+
method: 'POST',
|
|
109
|
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
|
110
|
+
body: JSON.stringify(body),
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
let res = await send();
|
|
115
|
+
if (res.status === 401) res = await send(); // one same-token retry
|
|
116
|
+
if (res.status === 401) {
|
|
117
|
+
throw new MayaAuthError('maya: authentication rejected after one same-token retry');
|
|
118
|
+
}
|
|
119
|
+
if (!res.ok) {
|
|
120
|
+
throw new MayaHttpError(`maya: chat failed (HTTP ${res.status})`, res.status);
|
|
121
|
+
}
|
|
122
|
+
if (res.parseFailed) {
|
|
123
|
+
throw new MayaHttpError(
|
|
124
|
+
`maya: chat returned a 2xx (HTTP ${res.status}) with a non-JSON body`,
|
|
125
|
+
res.status, 'malformed-response',
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
const reply = res.body;
|
|
129
|
+
if (reply?.success !== true || typeof reply?.response !== 'string'
|
|
130
|
+
|| typeof reply?.message_id !== 'string' || !reply.message_id) {
|
|
131
|
+
throw new MayaHttpError(
|
|
132
|
+
'maya: chat returned a 2xx body this relay cannot trust '
|
|
133
|
+
+ '(needs success:true, a response string, and a message_id)',
|
|
134
|
+
res.status, 'malformed-response',
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
return reply;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Streaming conversational turn. Token chunks are display-only; only a
|
|
141
|
+
* validated final envelope completes the turn. */
|
|
142
|
+
async function chatStream({ message, channelContext, surface, timezone = 'UTC', onToken }) {
|
|
143
|
+
const body = { message, timezone };
|
|
144
|
+
if (Array.isArray(channelContext) && channelContext.length) {
|
|
145
|
+
body.channel_context = channelContext;
|
|
146
|
+
}
|
|
147
|
+
if (surface !== undefined) body.surface = surface;
|
|
148
|
+
|
|
149
|
+
// One deadline and one captured token cover both the HTTP-status retry and
|
|
150
|
+
// the complete response stream. A mid-stream 401 is never retried.
|
|
151
|
+
const token = await getToken();
|
|
152
|
+
const controller = new AbortController();
|
|
153
|
+
const timer = setTimeout(() => controller.abort(), chatTimeoutMs);
|
|
154
|
+
let streamOpened = false;
|
|
155
|
+
let detached = false;
|
|
156
|
+
|
|
157
|
+
const send = () => fetch(`${baseUrl}/api/chat/stream`, {
|
|
158
|
+
method: 'POST',
|
|
159
|
+
headers: {
|
|
160
|
+
'Content-Type': 'application/json',
|
|
161
|
+
Accept: 'text/event-stream',
|
|
162
|
+
Authorization: `Bearer ${token}`,
|
|
163
|
+
},
|
|
164
|
+
body: JSON.stringify(body),
|
|
165
|
+
signal: controller.signal,
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
try {
|
|
169
|
+
let res = await send();
|
|
170
|
+
if (res.status === 401) {
|
|
171
|
+
await res.body?.cancel();
|
|
172
|
+
res = await send();
|
|
173
|
+
}
|
|
174
|
+
if (res.status === 401) {
|
|
175
|
+
throw new MayaAuthError('maya: authentication rejected after one same-token retry');
|
|
176
|
+
}
|
|
177
|
+
if (!res.ok) {
|
|
178
|
+
throw new MayaHttpError(`maya: chat failed (HTTP ${res.status})`, res.status);
|
|
179
|
+
}
|
|
180
|
+
const contentType = res.headers.get('content-type')?.split(';', 1)[0].trim().toLowerCase();
|
|
181
|
+
if (contentType !== 'text/event-stream' || !res.body) {
|
|
182
|
+
throw new MayaHttpError(
|
|
183
|
+
`maya: chat stream returned a 2xx (HTTP ${res.status}) without text/event-stream`,
|
|
184
|
+
res.status, 'malformed-response',
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
streamOpened = true;
|
|
188
|
+
|
|
189
|
+
const handleFrame = (frame) => {
|
|
190
|
+
let event = '';
|
|
191
|
+
const data = [];
|
|
192
|
+
for (const line of frame.split('\n')) {
|
|
193
|
+
if (line.startsWith(':')) continue;
|
|
194
|
+
const colon = line.indexOf(':');
|
|
195
|
+
const field = colon === -1 ? line : line.slice(0, colon);
|
|
196
|
+
let value = colon === -1 ? '' : line.slice(colon + 1);
|
|
197
|
+
if (value.startsWith(' ')) value = value.slice(1);
|
|
198
|
+
if (field === 'event') event = value;
|
|
199
|
+
if (field === 'data') data.push(value);
|
|
200
|
+
}
|
|
201
|
+
if (!['token', 'final', 'error'].includes(event)) return null;
|
|
202
|
+
|
|
203
|
+
let envelope;
|
|
204
|
+
try { envelope = JSON.parse(data.join('\n')); } catch {
|
|
205
|
+
throw new MayaHttpError(
|
|
206
|
+
'maya: chat stream contained a malformed event payload',
|
|
207
|
+
res.status, 'malformed-response',
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
const payload = envelope?.payload;
|
|
211
|
+
if (event === 'token') {
|
|
212
|
+
if (typeof payload?.text === 'string' && typeof onToken === 'function') {
|
|
213
|
+
onToken(payload.text);
|
|
214
|
+
}
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
if (event === 'error') {
|
|
218
|
+
const detail = typeof payload?.detail === 'string' ? payload.detail : 'maya: stream failed';
|
|
219
|
+
if (payload?.status_code === 401) throw new MayaAuthError(detail);
|
|
220
|
+
throw new MayaHttpError(detail, payload?.status_code);
|
|
221
|
+
}
|
|
222
|
+
if (payload?.success !== true || typeof payload?.response !== 'string'
|
|
223
|
+
|| typeof payload?.message_id !== 'string' || !payload.message_id) {
|
|
224
|
+
throw new MayaHttpError(
|
|
225
|
+
'maya: chat stream final payload cannot be trusted '
|
|
226
|
+
+ '(needs success:true, a response string, and a message_id)',
|
|
227
|
+
res.status, 'malformed-response',
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
return payload;
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
const reader = res.body.getReader();
|
|
234
|
+
const decoder = new TextDecoder('utf-8');
|
|
235
|
+
let buffer = '';
|
|
236
|
+
|
|
237
|
+
// Maya persists the turn AFTER emitting `final` (routes.py:5106-5310:
|
|
238
|
+
// conversation history, engagement, storage enqueue) — her own web
|
|
239
|
+
// client drains to EOF. Aborting on receipt of `final` would race that
|
|
240
|
+
// post-final work, so the validated reply returns immediately while a
|
|
241
|
+
// detached drain consumes the stream to EOF. The overall deadline still
|
|
242
|
+
// bounds the socket: on expiry the drain is aborted, never the reply.
|
|
243
|
+
const finishAfterFinal = (reply) => {
|
|
244
|
+
detached = true;
|
|
245
|
+
(async () => {
|
|
246
|
+
try {
|
|
247
|
+
while (!(await reader.read()).done) { /* contents ignored */ }
|
|
248
|
+
} catch { /* deadline abort or teardown — the reply already landed */ }
|
|
249
|
+
finally {
|
|
250
|
+
clearTimeout(timer);
|
|
251
|
+
controller.abort();
|
|
252
|
+
}
|
|
253
|
+
})();
|
|
254
|
+
return reply;
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
while (true) {
|
|
258
|
+
const { done, value } = await reader.read();
|
|
259
|
+
if (done) {
|
|
260
|
+
buffer += decoder.decode();
|
|
261
|
+
} else {
|
|
262
|
+
buffer += decoder.decode(value, { stream: true });
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
let boundary;
|
|
266
|
+
while ((boundary = buffer.indexOf('\n\n')) !== -1) {
|
|
267
|
+
const frame = buffer.slice(0, boundary);
|
|
268
|
+
buffer = buffer.slice(boundary + 2);
|
|
269
|
+
const reply = handleFrame(frame);
|
|
270
|
+
if (reply) return done ? reply : finishAfterFinal(reply);
|
|
271
|
+
}
|
|
272
|
+
if (done) break;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (buffer) {
|
|
276
|
+
const reply = handleFrame(buffer);
|
|
277
|
+
if (reply) return reply;
|
|
278
|
+
}
|
|
279
|
+
throw new MayaHttpError(
|
|
280
|
+
'maya: chat stream ended without a valid final event',
|
|
281
|
+
res.status, 'malformed-response',
|
|
282
|
+
);
|
|
283
|
+
} catch (err) {
|
|
284
|
+
const failure = err instanceof MayaHttpError
|
|
285
|
+
? err
|
|
286
|
+
: new MayaHttpError(
|
|
287
|
+
`maya: /api/chat/stream request failed: ${err?.message ?? 'unknown error'}`, 0,
|
|
288
|
+
);
|
|
289
|
+
// The relay uses this to preserve the JSON-before-open / SSE-after-open
|
|
290
|
+
// boundary even when an error event arrives before the first token.
|
|
291
|
+
if (streamOpened) failure.streamStarted = true;
|
|
292
|
+
throw failure;
|
|
293
|
+
} finally {
|
|
294
|
+
if (!detached) {
|
|
295
|
+
clearTimeout(timer);
|
|
296
|
+
controller.abort();
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
return { health, chat, chatStream };
|
|
302
|
+
}
|