@dyx9/rosetta 0.0.0-stage → 0.0.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 +59 -0
- package/LICENSE +21 -0
- package/README.md +101 -2
- package/bin/rosetta.js +10 -0
- package/build/dist/assets/abnfDiagram-WEYOIA3C-CwZlxHkI.js +1 -0
- package/build/dist/assets/arc-B0x0q6O1.js +1 -0
- package/build/dist/assets/architecture-WOLXFQ4H-CkOpgBe5.js +1 -0
- package/build/dist/assets/architectureDiagram-ZHVYKT53-DT2OTBIB.js +36 -0
- package/build/dist/assets/array-BifhSqXX.js +1 -0
- package/build/dist/assets/bash-CLZ0U2zV.js +1 -0
- package/build/dist/assets/blockDiagram-RJAE7QA3-DXKyW4Qj.js +140 -0
- package/build/dist/assets/c4Diagram-XDPPPE54-CqHMZj3I.js +38 -0
- package/build/dist/assets/channel-BmjZSxDJ.js +1 -0
- package/build/dist/assets/chunk-2E7WHVMD-DTKlOCW9.js +88 -0
- package/build/dist/assets/chunk-2Q5K7J3B-C1jixKkw.js +1 -0
- package/build/dist/assets/chunk-4EMNKR2T-BsnKxZVc.js +1 -0
- package/build/dist/assets/chunk-55XCU76K-Ddul4yK8.js +1 -0
- package/build/dist/assets/chunk-5VM5RSS4-ZNzvKenW.js +15 -0
- package/build/dist/assets/chunk-7S6XSTFO-D73i4Kfs.js +2 -0
- package/build/dist/assets/chunk-AQDTSKZB-DJMpZsbr.js +72 -0
- package/build/dist/assets/chunk-DSNLSHJZ-KWPl82ns.js +129 -0
- package/build/dist/assets/chunk-DUW6YSOI-Dgx8z5s3.js +1 -0
- package/build/dist/assets/chunk-HAGFOTED-Co5xWe5K.js +62 -0
- package/build/dist/assets/chunk-HNE4Q36P-D9ufLjJL.js +1 -0
- package/build/dist/assets/chunk-J5ZVWO5B-oDCd-gWO.js +1 -0
- package/build/dist/assets/chunk-JWPE2WC7-DVXcaiue.js +1 -0
- package/build/dist/assets/chunk-NGNAAXSQ-C2Wy9Xmz.js +136 -0
- package/build/dist/assets/chunk-NQOKRVIF-DuVeWA2D.js +213 -0
- package/build/dist/assets/chunk-UHW7PFBP-DrzHcxiz.js +1 -0
- package/build/dist/assets/chunk-VQ5W62QI-tyK9wvZy.js +1 -0
- package/build/dist/assets/chunk-WQQL4CDK-BtfOu2ju.js +10 -0
- package/build/dist/assets/chunk-XXDRQBXY-BFig8xVb.js +1 -0
- package/build/dist/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +1 -0
- package/build/dist/assets/chunk-YZO67HFG-Cw5eVjq1.js +1 -0
- package/build/dist/assets/chunk-ZAUX2LTX-CIpKyMEe.js +1 -0
- package/build/dist/assets/chunk-ZCHMQSOR-Djve8pwS.js +2 -0
- package/build/dist/assets/classDiagram-v2-4MYWR3UA-D4XAcUCd.js +217 -0
- package/build/dist/assets/cose-bilkent-JH36ORCC-DHRGyJqU.js +1 -0
- package/build/dist/assets/cynefin-EF2NZ3EQ-Cv-uxuqE.js +1 -0
- package/build/dist/assets/cynefinDiagram-WF24UDRP-MFe-UzcY.js +62 -0
- package/build/dist/assets/cytoscape.esm-BegBNzOV.js +321 -0
- package/build/dist/assets/dagre-NK4GIZVX-BsY0-Q-N.js +4 -0
- package/build/dist/assets/defaultLocale-BFoDCU3G.js +1 -0
- package/build/dist/assets/diagram-CBK3MWTC-9zfXZ0bX.js +24 -0
- package/build/dist/assets/diagram-I5XIIV2D-CfWjvEof.js +24 -0
- package/build/dist/assets/diagram-ODIL2UCW-BpJaYO2i.js +30 -0
- package/build/dist/assets/diagram-PFBEJUXE-CtpxT_FQ.js +3 -0
- package/build/dist/assets/diagram-RJA3I4VV-DuLQrOcT.js +41 -0
- package/build/dist/assets/diagram-TYUNFSNR-w6Mohdl5.js +200 -0
- package/build/dist/assets/dist-DI7WHQIh.js +1 -0
- package/build/dist/assets/ebnfDiagram-V4P3TTPT-DlwHfKyL.js +1 -0
- package/build/dist/assets/elk-QONTBCTQ-DEvtD9nx.js +27 -0
- package/build/dist/assets/erDiagram-KYQIGQAA-DQuhkec3.js +99 -0
- package/build/dist/assets/eventmodeling-K75KTNOO-BO57PAOs.js +1 -0
- package/build/dist/assets/flowDiagram-IUGLLWBU-B_Ib7Avh.js +1 -0
- package/build/dist/assets/ganttDiagram-R7GPNTJY-CDGbtnUY.js +292 -0
- package/build/dist/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
- package/build/dist/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
- package/build/dist/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
- package/build/dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
- package/build/dist/assets/geist-mono-cyrillic-ext-wght-normal-X_5orZeX.woff2 +0 -0
- package/build/dist/assets/geist-mono-cyrillic-wght-normal-DiZS0aHC.woff2 +0 -0
- package/build/dist/assets/geist-mono-latin-ext-wght-normal-Bwz-egvJ.woff2 +0 -0
- package/build/dist/assets/geist-mono-latin-wght-normal-XN7g48iV.woff2 +0 -0
- package/build/dist/assets/geist-mono-symbols2-wght-normal-CO5SzqOn.woff2 +0 -0
- package/build/dist/assets/geist-mono-vietnamese-wght-normal-DadHysG0.woff2 +0 -0
- package/build/dist/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
- package/build/dist/assets/gitGraph-VSP46ZUC-D9ZZKP8z.js +1 -0
- package/build/dist/assets/gitGraphDiagram-PDYL7XHY-Da8Oypep.js +106 -0
- package/build/dist/assets/github-dark-C-LZuMrd.js +1 -0
- package/build/dist/assets/github-light-EUqPIrTm.js +1 -0
- package/build/dist/assets/graphlib-DS17s2tU.js +1 -0
- package/build/dist/assets/index-Cf0aBmwz.css +2 -0
- package/build/dist/assets/index-Ct3brajV.js +471 -0
- package/build/dist/assets/info-OHQRW6UA-BF836x6L.js +1 -0
- package/build/dist/assets/infoDiagram-MVDRKAX5-DpOoH-IR.js +2 -0
- package/build/dist/assets/init-C-OQMol4.js +1 -0
- package/build/dist/assets/ishikawaDiagram-HPEHV2GE-DT-Ft7vW.js +70 -0
- package/build/dist/assets/javascript-BgS3c2Ky.js +1 -0
- package/build/dist/assets/journeyDiagram-BOSHW7O7-BAF7tJ1H.js +139 -0
- package/build/dist/assets/json-qhed-kSA.js +1 -0
- package/build/dist/assets/kanban-definition-FNSA4HC5-B_w2r2eo.js +89 -0
- package/build/dist/assets/katex-B_JcIwqj.js +257 -0
- package/build/dist/assets/line-DMjgRylK.js +1 -0
- package/build/dist/assets/linear-BEdVl1PM.js +1 -0
- package/build/dist/assets/mermaid-parser.core-BDmgiU7m.js +7 -0
- package/build/dist/assets/mermaid.core-8Kj_dqOs.js +44 -0
- package/build/dist/assets/mindmap-definition-6OY57UG3-DOcaArfN.js +98 -0
- package/build/dist/assets/ordinal-BDEzSJ7C.js +1 -0
- package/build/dist/assets/packet-JDAUHWVQ-zWLPVpvX.js +1 -0
- package/build/dist/assets/path-fybaL0A-.js +1 -0
- package/build/dist/assets/pegDiagram-ECIUJNBX-sYwsFyAl.js +1 -0
- package/build/dist/assets/pie-XZMESJXO-B-h0LV9S.js +1 -0
- package/build/dist/assets/pieDiagram-GNMPY74T-FToCIvky.js +39 -0
- package/build/dist/assets/python-gzcpVVnB.js +1 -0
- package/build/dist/assets/quadrantDiagram-D57K4MC3-tiXtbFLP.js +7 -0
- package/build/dist/assets/radar-ABXABTNO--ew_ZFJl.js +1 -0
- package/build/dist/assets/railroad-I3PHUGI6-cxsf_ZsE.js +1 -0
- package/build/dist/assets/railroad-abnf-LNEFI6M7-BIK-9C0Z.js +1 -0
- package/build/dist/assets/railroad-ebnf-DWYD2UWJ-BHw1M8VQ.js +1 -0
- package/build/dist/assets/railroad-peg-JR7BVNR7-MHCdZHAZ.js +1 -0
- package/build/dist/assets/railroadDiagram-Y72NRBL7-wjsZpnJA.js +1 -0
- package/build/dist/assets/requirementDiagram-EQYSW5RA-CHkzAP3w.js +84 -0
- package/build/dist/assets/rough.esm-Dy-Kn_BL.js +1 -0
- package/build/dist/assets/sankeyDiagram-SY24JHO5-BBLq6ndO.js +40 -0
- package/build/dist/assets/sequenceDiagram-BLEHNHQU-DW_h9VZQ.js +169 -0
- package/build/dist/assets/sizeCapture-INFHLROL-3uHXrFqA.js +1 -0
- package/build/dist/assets/src-MbiMZlpk.js +1 -0
- package/build/dist/assets/stateDiagram-v2-A6IESKWZ-BgpDkVUs.js +291 -0
- package/build/dist/assets/swimlanes-B3ENRKFQ-DJHWFYmN.js +1 -0
- package/build/dist/assets/swimlanesDiagram-AYQRV4AK-Cq_MGOxD.js +8 -0
- package/build/dist/assets/timeline-definition-JXYKGLGB-DQ-XYDnd.js +120 -0
- package/build/dist/assets/treeView-D4JQ5SDB-ydIf6KAy.js +1 -0
- package/build/dist/assets/treemap-SAJKECNS-CB6tZuFJ.js +1 -0
- package/build/dist/assets/typescript-bsJCZSQ-.js +1 -0
- package/build/dist/assets/usecaseDiagram-WPOLBNNX-D_A5G5wv.js +328 -0
- package/build/dist/assets/vennDiagram-Q3Y3IDCW-j2yUit_4.js +34 -0
- package/build/dist/assets/wardley-7MLQ67FV-BztBXEva.js +1 -0
- package/build/dist/assets/wardleyDiagram-IVAIDKWA-CyrXADWS.js +78 -0
- package/build/dist/assets/xychartDiagram-4URNHDU4-BM2yVuEH.js +7 -0
- package/build/dist/favicon.svg +1 -0
- package/build/dist/icons.svg +24 -0
- package/build/dist/index.html +14 -0
- package/build/server/agent/conversation.js +244 -0
- package/build/server/agent/harness.js +435 -0
- package/build/server/agent/sessions.js +222 -0
- package/build/server/agent/turn.js +56 -0
- package/build/server/config.js +92 -0
- package/build/server/http/auth.js +31 -0
- package/build/server/http/chat.js +67 -0
- package/build/server/http/feedback.js +30 -0
- package/build/server/http/routes.js +231 -0
- package/build/server/http/stream.js +39 -0
- package/build/server/http/util.js +40 -0
- package/build/server/index.js +112 -0
- package/build/server/records/operations.js +53 -0
- package/build/server/records/recording.js +186 -0
- package/build/server/records/turn-recording.js +151 -0
- package/build/server/stone/file-boundary.js +57 -0
- package/build/server/stone/prompt.js +83 -0
- package/build/server/stone/stone.js +245 -0
- package/build/server/stone/verify.js +141 -0
- package/build/src/shared/protocol.js +25 -0
- package/build/stones/example/AGENTS.md +25 -0
- package/build/stones/example/checks/hotspot.py +74 -0
- package/build/stones/example/checks/syntax.py +13 -0
- package/build/stones/example/constraints/rig-limits.md +15 -0
- package/build/stones/example/docs/reference.md +26 -0
- package/build/stones/example/examples/good/crossfade.py +15 -0
- package/build/stones/example/examples/violations/hotspot.py +17 -0
- package/build/stones/example/rosetta.json +23 -0
- package/build/stones/example/skills/writing-cues/SKILL.md +21 -0
- package/package.json +74 -4
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/** Adds one response's usage to a running total, starting the total when there is none. */
|
|
2
|
+
export function addTokenUsage(total, usage) {
|
|
3
|
+
return {
|
|
4
|
+
input: (total?.input ?? 0) + usage.input,
|
|
5
|
+
output: (total?.output ?? 0) + usage.output,
|
|
6
|
+
cacheRead: (total?.cacheRead ?? 0) + usage.cacheRead,
|
|
7
|
+
cacheWrite: (total?.cacheWrite ?? 0) + usage.cacheWrite,
|
|
8
|
+
totalTokens: (total?.totalTokens ?? 0) + usage.totalTokens,
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
/** Adds a question to the transcript and returns the recording of its reply. */
|
|
12
|
+
export function startTurnRecording(session, question) {
|
|
13
|
+
return new TurnRecording(session, question);
|
|
14
|
+
}
|
|
15
|
+
/** Gathers the model responses, tools, and documents of one question under a single reply. */
|
|
16
|
+
export class TurnRecording {
|
|
17
|
+
#session;
|
|
18
|
+
#reply;
|
|
19
|
+
/** Text of the responses that have ended. */
|
|
20
|
+
#completedText = "";
|
|
21
|
+
/** Text of the response still arriving. */
|
|
22
|
+
#currentText = "";
|
|
23
|
+
#hasProviderOutcome = false;
|
|
24
|
+
/** Responses that reported usage, and those that did not. */
|
|
25
|
+
#measured = 0;
|
|
26
|
+
#missing = 0;
|
|
27
|
+
#estimatedCostUsd = null;
|
|
28
|
+
#unpricedMessages = 0;
|
|
29
|
+
#ended = false;
|
|
30
|
+
#timer;
|
|
31
|
+
constructor(session, question) {
|
|
32
|
+
this.#session = session;
|
|
33
|
+
session.add({ role: "user", at: new Date().toISOString(), text: question });
|
|
34
|
+
this.#reply = {
|
|
35
|
+
role: "assistant",
|
|
36
|
+
at: new Date().toISOString(),
|
|
37
|
+
text: "",
|
|
38
|
+
status: "generating",
|
|
39
|
+
userFoundHelpful: null,
|
|
40
|
+
tokens: null,
|
|
41
|
+
usageComplete: false,
|
|
42
|
+
contextPercent: null,
|
|
43
|
+
tools: [],
|
|
44
|
+
documents: [],
|
|
45
|
+
skills: [],
|
|
46
|
+
};
|
|
47
|
+
session.add(this.#reply);
|
|
48
|
+
// A partial answer reaches the disk while the model is still writing.
|
|
49
|
+
this.#timer = setInterval(() => session.save(), 2000);
|
|
50
|
+
this.#timer.unref();
|
|
51
|
+
}
|
|
52
|
+
/** Records feedback on the reply. False while the reply is still being written. */
|
|
53
|
+
setFeedback(value) {
|
|
54
|
+
if (!this.#ended)
|
|
55
|
+
return false;
|
|
56
|
+
this.#reply.userFoundHelpful = value;
|
|
57
|
+
this.#session.save();
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
event(event) {
|
|
61
|
+
if (this.#ended)
|
|
62
|
+
return;
|
|
63
|
+
const reply = this.#reply;
|
|
64
|
+
switch (event.type) {
|
|
65
|
+
case "assistant_delta":
|
|
66
|
+
this.#currentText += event.data.text;
|
|
67
|
+
reply.text = this.#completedText + this.#currentText;
|
|
68
|
+
return;
|
|
69
|
+
case "assistant_message":
|
|
70
|
+
this.#responseEnded(event.data);
|
|
71
|
+
return;
|
|
72
|
+
case "tool_used":
|
|
73
|
+
addOnce(reply.tools, event.data.name);
|
|
74
|
+
return;
|
|
75
|
+
case "document_read":
|
|
76
|
+
addOnce(reply.documents, event.data.path);
|
|
77
|
+
return;
|
|
78
|
+
case "skill_loaded":
|
|
79
|
+
addOnce(reply.skills, event.data.skill);
|
|
80
|
+
return;
|
|
81
|
+
case "context_usage":
|
|
82
|
+
reply.contextPercent = event.data.percent;
|
|
83
|
+
return;
|
|
84
|
+
case "provider_outcome":
|
|
85
|
+
this.#hasProviderOutcome = true;
|
|
86
|
+
reply.status = event.data.outcome;
|
|
87
|
+
if (typeof event.data.error === "string")
|
|
88
|
+
reply.error = event.data.error;
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
/** One model response ended: keep its text, and count its usage and cost. */
|
|
93
|
+
#responseEnded({ text, usage, estimatedCostUsd: cost, }) {
|
|
94
|
+
const reply = this.#reply;
|
|
95
|
+
// Text that arrived in pieces is kept as it arrived; otherwise the response's own text is used.
|
|
96
|
+
this.#completedText += this.#currentText || text;
|
|
97
|
+
this.#currentText = "";
|
|
98
|
+
reply.text = this.#completedText;
|
|
99
|
+
if (usage) {
|
|
100
|
+
reply.tokens = addTokenUsage(reply.tokens, usage);
|
|
101
|
+
this.#measured++;
|
|
102
|
+
}
|
|
103
|
+
else
|
|
104
|
+
this.#missing++;
|
|
105
|
+
if (usage && typeof cost === "number" && Number.isFinite(cost) && cost >= 0)
|
|
106
|
+
this.#estimatedCostUsd = (this.#estimatedCostUsd ?? 0) + cost;
|
|
107
|
+
else
|
|
108
|
+
this.#unpricedMessages++;
|
|
109
|
+
reply.usageComplete = this.#measured > 0 && this.#missing === 0;
|
|
110
|
+
this.#session.save();
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Closes the reply and returns what the turn measured. The caller's outcome is
|
|
114
|
+
* written, except that "failed" does not replace an outcome the provider gave:
|
|
115
|
+
* that one says more. A second call does nothing.
|
|
116
|
+
*/
|
|
117
|
+
finish(override) {
|
|
118
|
+
if (this.#ended)
|
|
119
|
+
return;
|
|
120
|
+
clearInterval(this.#timer);
|
|
121
|
+
this.#ended = true;
|
|
122
|
+
const reply = this.#reply;
|
|
123
|
+
if (override &&
|
|
124
|
+
!(override.outcome === "failed" && this.#hasProviderOutcome))
|
|
125
|
+
reply.status = override.outcome;
|
|
126
|
+
if (override?.error)
|
|
127
|
+
reply.error = override.error;
|
|
128
|
+
if (reply.status === "generating")
|
|
129
|
+
reply.status = "incomplete";
|
|
130
|
+
// A response cut off while arriving never reported its usage.
|
|
131
|
+
if (this.#currentText)
|
|
132
|
+
reply.usageComplete = false;
|
|
133
|
+
this.#session.save();
|
|
134
|
+
return {
|
|
135
|
+
outcome: reply.status,
|
|
136
|
+
error: reply.error,
|
|
137
|
+
tokens: reply.tokens,
|
|
138
|
+
estimatedCostUsd: this.#estimatedCostUsd,
|
|
139
|
+
costComplete: this.#estimatedCostUsd !== null &&
|
|
140
|
+
this.#unpricedMessages === 0 &&
|
|
141
|
+
reply.usageComplete &&
|
|
142
|
+
reply.status === "completed",
|
|
143
|
+
usageComplete: reply.usageComplete,
|
|
144
|
+
contextPercent: reply.contextPercent,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
function addOnce(list, value) {
|
|
149
|
+
if (!list.includes(value))
|
|
150
|
+
list.push(value);
|
|
151
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { lstat, readdir, realpath, stat } from "node:fs/promises";
|
|
2
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep, } from "node:path";
|
|
3
|
+
/** The one containment rule for stone files; callers pass paths with symbolic links resolved. */
|
|
4
|
+
export function contains(root, path) {
|
|
5
|
+
const rel = relative(root, path);
|
|
6
|
+
return rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
|
|
7
|
+
}
|
|
8
|
+
/** Application boundary for a trusted stone that is not modified while serving. */
|
|
9
|
+
export async function stoneToolPath(root, requested = ".", scan = "none", signal) {
|
|
10
|
+
signal?.throwIfAborted();
|
|
11
|
+
const candidate = resolve(root, requested);
|
|
12
|
+
const path = await realpath(candidate);
|
|
13
|
+
if (!contains(root, path))
|
|
14
|
+
throw new Error("Access denied: tools can only access the active stone.");
|
|
15
|
+
const info = await stat(path);
|
|
16
|
+
if (!info.isFile() && !info.isDirectory())
|
|
17
|
+
throw new Error("Access denied: only regular files and directories are supported.");
|
|
18
|
+
if (info.isDirectory() && scan !== "none") {
|
|
19
|
+
// Check before invoking SDK tools, including ignored/hidden entries. Refusing
|
|
20
|
+
// links avoids relying on subprocess defaults or host search configuration.
|
|
21
|
+
const pending = [path];
|
|
22
|
+
while (pending.length) {
|
|
23
|
+
signal?.throwIfAborted();
|
|
24
|
+
const dir = pending.pop();
|
|
25
|
+
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
|
26
|
+
if (!entry.isFile() && !entry.isDirectory()) {
|
|
27
|
+
throw new Error("Access denied: directory scans cannot include symbolic links or special files. Choose a directory containing only stone resources.");
|
|
28
|
+
}
|
|
29
|
+
if (entry.isDirectory() && scan === "recursive")
|
|
30
|
+
pending.push(join(dir, entry.name));
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return path;
|
|
35
|
+
}
|
|
36
|
+
/** Resolve missing destination suffixes too, without creating/pruning anything. */
|
|
37
|
+
async function canonicalDestination(path) {
|
|
38
|
+
try {
|
|
39
|
+
await lstat(path);
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
if (error.code !== "ENOENT")
|
|
43
|
+
throw error;
|
|
44
|
+
return join(await canonicalDestination(dirname(path)), basename(path));
|
|
45
|
+
}
|
|
46
|
+
// A dangling symlink is an error, not a missing directory we can safely accept.
|
|
47
|
+
return realpath(path);
|
|
48
|
+
}
|
|
49
|
+
export async function assertRecordingsOutsideStone(stonePath, directory) {
|
|
50
|
+
if (directory === false)
|
|
51
|
+
return;
|
|
52
|
+
const root = await realpath(stonePath);
|
|
53
|
+
const destination = await canonicalDestination(resolve(directory));
|
|
54
|
+
if (contains(root, destination)) {
|
|
55
|
+
throw new Error("ROSETTA_RECORDINGS must be outside the active stone so agent tools cannot read saved conversations.");
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the agent is told before it sees a question.
|
|
3
|
+
*
|
|
4
|
+
* Kept apart from stone loading: one file reads a directory, this one decides
|
|
5
|
+
* the posture the assistant takes.
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* Replaces pi's coding-assistant system prompt outright, rather than appending
|
|
9
|
+
* to it: the stock prompt describes an agent that edits files and runs
|
|
10
|
+
* commands, which is the opposite of the posture wanted here.
|
|
11
|
+
*
|
|
12
|
+
* A stone may ship SYSTEM.md to replace this entirely. `{{agentName}}`,
|
|
13
|
+
* `{{languageName}}` and `{{answersAbout}}` are substituted from rosetta.json in
|
|
14
|
+
* either case.
|
|
15
|
+
*
|
|
16
|
+
* The assistant *is* {{agentName}} rather than an assistant working for it: "You
|
|
17
|
+
* are the assistant for X" makes X a separate entity, and the model then
|
|
18
|
+
* introduces itself as "X's assistant".
|
|
19
|
+
*
|
|
20
|
+
* Deliberately says nothing about the domain: what the scripts control, and
|
|
21
|
+
* what a wrong one costs, belongs in the stone. Presenting output as a draft is
|
|
22
|
+
* kept because it is right for any assistant whose users may not be able to
|
|
23
|
+
* judge the code themselves.
|
|
24
|
+
*/
|
|
25
|
+
const DEFAULT_SYSTEM_PROMPT = `You are {{agentName}}.
|
|
26
|
+
|
|
27
|
+
You help people write scripts in {{languageName}}. Your users are not all programmers.
|
|
28
|
+
|
|
29
|
+
# Scope
|
|
30
|
+
|
|
31
|
+
You answer questions about {{answersAbout}}, and nothing else.
|
|
32
|
+
|
|
33
|
+
If asked anything outside that scope — general programming unrelated to this language,
|
|
34
|
+
questions about this computer, its files, its environment or its configuration, or any
|
|
35
|
+
other subject — decline in one sentence and say what you do cover. Do not comply, and do
|
|
36
|
+
not explain how the request could be accomplished elsewhere.
|
|
37
|
+
|
|
38
|
+
# Grounding
|
|
39
|
+
|
|
40
|
+
Everything you state about the language must come from the reference material you have been
|
|
41
|
+
given. You have tools to read further documentation and examples; use them rather than
|
|
42
|
+
guessing.
|
|
43
|
+
|
|
44
|
+
If the material does not answer a question, say that you do not know and suggest who might.
|
|
45
|
+
An admission of ignorance is a correct answer. An invented function name is not.
|
|
46
|
+
|
|
47
|
+
# Producing scripts
|
|
48
|
+
|
|
49
|
+
Present every script as a draft for a person to review rather than as ready to run.
|
|
50
|
+
|
|
51
|
+
Before offering a script, check it against every constraint in your reference material. If a
|
|
52
|
+
request cannot be satisfied without breaking one, say which rule applies and offer the
|
|
53
|
+
nearest version that respects it.
|
|
54
|
+
|
|
55
|
+
Never use a function that is not documented in your reference material.`;
|
|
56
|
+
/**
|
|
57
|
+
* Appended after the stone's prompt, whether that is SYSTEM.md or the default.
|
|
58
|
+
*
|
|
59
|
+
* It goes here rather than in the template because it is a Rosetta-level
|
|
60
|
+
* contract about a tool Rosetta registers — a stone author overriding the
|
|
61
|
+
* prompt must not be able to silently switch the safety gate off.
|
|
62
|
+
*/
|
|
63
|
+
const VERIFICATION_CONTRACT = `
|
|
64
|
+
|
|
65
|
+
# Checking your work
|
|
66
|
+
|
|
67
|
+
A check_script tool is available and you must use it.
|
|
68
|
+
|
|
69
|
+
Draft the script, call check_script, and only then write it into your reply. Never show a
|
|
70
|
+
script you have not checked: the person reading may not be able to tell a working script from
|
|
71
|
+
a broken one.
|
|
72
|
+
|
|
73
|
+
If a check fails, read the diagnostics, fix the script and check again. If it still fails
|
|
74
|
+
after that second attempt, do not show the script at all. Say plainly what the checker
|
|
75
|
+
rejected and what you would need in order to get it right.`;
|
|
76
|
+
/** `template` is a stone's SYSTEM.md, or undefined to use the built-in prompt. */
|
|
77
|
+
export function buildSystemPrompt(template, fields, options = { verified: false }) {
|
|
78
|
+
const base = (template ?? DEFAULT_SYSTEM_PROMPT)
|
|
79
|
+
.replaceAll("{{agentName}}", fields.agentName)
|
|
80
|
+
.replaceAll("{{languageName}}", fields.languageName)
|
|
81
|
+
.replaceAll("{{answersAbout}}", fields.answersAbout);
|
|
82
|
+
return options.verified ? base + VERIFICATION_CONTRACT : base;
|
|
83
|
+
}
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { glob, readFile, realpath, stat } from "node:fs/promises";
|
|
3
|
+
import { relative, resolve } from "node:path";
|
|
4
|
+
import { closest, distance } from "fastest-levenshtein";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { BUNDLED_LANGUAGES } from "../../src/shared/protocol.js";
|
|
7
|
+
import { contains } from "./file-boundary.js";
|
|
8
|
+
import { buildSystemPrompt } from "./prompt.js";
|
|
9
|
+
import { resolveCommand } from "./verify.js";
|
|
10
|
+
/**
|
|
11
|
+
* What is always in context when the manifest does not say.
|
|
12
|
+
*
|
|
13
|
+
* AGENTS.md carries the cheatsheet and hard constraints; constraints/ carries
|
|
14
|
+
* the rules the language cannot express; examples/ carries the counter-examples,
|
|
15
|
+
* which teach a rule better than stating it does.
|
|
16
|
+
*
|
|
17
|
+
* Reference material under docs/ is left to the read/grep tools: it is the part
|
|
18
|
+
* of a stone most likely to grow, and the part the model can most safely be
|
|
19
|
+
* trusted to look up when a question actually calls for it. A stone can add it
|
|
20
|
+
* with `context` in rosetta.json.
|
|
21
|
+
*/
|
|
22
|
+
const DEFAULT_CONTEXT = ["AGENTS.md", "constraints/**/*.md", "examples/**/*"];
|
|
23
|
+
/** Past this, a stone should move its bulkiest material out of `context`. */
|
|
24
|
+
const CONTEXT_WARN_CHARS = 200_000;
|
|
25
|
+
const MAX_FILE_BYTES = 512 * 1024;
|
|
26
|
+
/**
|
|
27
|
+
* A stone that cannot be loaded. Carries every problem found, not just the
|
|
28
|
+
* first, so an author fixing a manifest sees the whole list in one run.
|
|
29
|
+
*/
|
|
30
|
+
export class StoneError extends Error {
|
|
31
|
+
problems;
|
|
32
|
+
constructor(problems) {
|
|
33
|
+
super(problems.join("; "));
|
|
34
|
+
this.name = "StoneError";
|
|
35
|
+
this.problems = problems;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* The manifest schema.
|
|
40
|
+
*
|
|
41
|
+
* Strict, so an unrecognised key is an error rather than a silent no-op — a
|
|
42
|
+
* misspelled "suggestions" that quietly does nothing is the failure this
|
|
43
|
+
* milestone exists to catch, and JSON has no comments, so a stray key is
|
|
44
|
+
* almost always a typo.
|
|
45
|
+
*
|
|
46
|
+
* Everything is optional: a stone with an empty manifest still runs, and the
|
|
47
|
+
* gaps that matter come back as warnings from loadStone rather than errors.
|
|
48
|
+
*/
|
|
49
|
+
const ManifestSchema = z.strictObject({
|
|
50
|
+
agentName: z.string().min(1).optional(),
|
|
51
|
+
languageName: z.string().min(1).optional(),
|
|
52
|
+
welcomeMessage: z.string().min(1).optional(),
|
|
53
|
+
answersAbout: z.string().min(1).optional(),
|
|
54
|
+
syntaxHighlighting: z.string().min(1).optional(),
|
|
55
|
+
suggestedQuestions: z.array(z.string()).optional(),
|
|
56
|
+
alwaysInContext: z
|
|
57
|
+
.array(z.string())
|
|
58
|
+
.min(1, "must list at least one glob, or the assistant has no reference material")
|
|
59
|
+
.optional(),
|
|
60
|
+
checks: z
|
|
61
|
+
.array(z.strictObject({
|
|
62
|
+
name: z.string().min(1),
|
|
63
|
+
// An argv array, never a shell string: the script being checked is
|
|
64
|
+
// model-generated and must never reach a shell.
|
|
65
|
+
command: z.array(z.string().min(1)).min(1),
|
|
66
|
+
input: z.enum(["stdin", "file"]).optional(),
|
|
67
|
+
timeoutMs: z.number().int().positive().max(120_000).optional(),
|
|
68
|
+
successExitCodes: z.array(z.number().int()).optional(),
|
|
69
|
+
}))
|
|
70
|
+
.optional(),
|
|
71
|
+
});
|
|
72
|
+
const FIELD_NAMES = Object.keys(ManifestSchema.shape);
|
|
73
|
+
/** Operator details that moved from the manifest to .env, so an older stone is told where they went. */
|
|
74
|
+
const MOVED_TO_ENV = new Map([
|
|
75
|
+
["contact", "ROSETTA_PRIVACY_CONTACT"],
|
|
76
|
+
["controller", "ROSETTA_PRIVACY_CONTROLLER"],
|
|
77
|
+
["lawfulBasis", "ROSETTA_PRIVACY_LAWFUL_BASIS"],
|
|
78
|
+
["providerProcessing", "ROSETTA_PRIVACY_PROVIDER_PROCESSING"],
|
|
79
|
+
["additionalInformation", "ROSETTA_PRIVACY_ADDITIONAL_INFORMATION"],
|
|
80
|
+
["privacyUpdated", "ROSETTA_PRIVACY_UPDATED"],
|
|
81
|
+
]);
|
|
82
|
+
/** zod issues, rendered for someone editing a JSON file rather than reading a stack trace. */
|
|
83
|
+
function formatIssue(issue, file) {
|
|
84
|
+
if (issue.code === "unrecognized_keys") {
|
|
85
|
+
return issue.keys.map((key) => {
|
|
86
|
+
const variable = MOVED_TO_ENV.get(key);
|
|
87
|
+
if (variable)
|
|
88
|
+
return `${file}: "${key}" moved to .env — set ${variable} instead`;
|
|
89
|
+
const near = closest(key.toLowerCase(), FIELD_NAMES);
|
|
90
|
+
return distance(key.toLowerCase(), near) <= 3
|
|
91
|
+
? `${file}: unknown field "${key}" — did you mean "${near}"?`
|
|
92
|
+
: `${file}: unknown field "${key}"`;
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
// zod prefixes type errors with "Invalid input: "; the file and field already
|
|
96
|
+
// say that much, so drop it.
|
|
97
|
+
const message = issue.message.replace(/^Invalid input:\s*/, "");
|
|
98
|
+
const field = issue.path.join(".");
|
|
99
|
+
return [field ? `${file}: "${field}" ${message}` : `${file}: ${message}`];
|
|
100
|
+
}
|
|
101
|
+
export async function loadStone(path) {
|
|
102
|
+
const dir = resolve(path);
|
|
103
|
+
const manifest = await readManifest(dir);
|
|
104
|
+
const warnings = [];
|
|
105
|
+
if (manifest.languageName === undefined) {
|
|
106
|
+
warnings.push('rosetta.json has no "languageName"; the prompt will call it "the language"');
|
|
107
|
+
}
|
|
108
|
+
if (manifest.answersAbout === undefined) {
|
|
109
|
+
warnings.push('rosetta.json has no "answersAbout"; the assistant will not know what to decline');
|
|
110
|
+
}
|
|
111
|
+
const syntaxHighlighting = manifest.syntaxHighlighting ?? "text";
|
|
112
|
+
if (syntaxHighlighting !== "text" &&
|
|
113
|
+
!BUNDLED_LANGUAGES.includes(syntaxHighlighting)) {
|
|
114
|
+
warnings.push(`"syntaxHighlighting": "${syntaxHighlighting}" is not a bundled grammar (${BUNDLED_LANGUAGES.join(", ")}); code will render as plain text`);
|
|
115
|
+
}
|
|
116
|
+
// A stone that names its context explicitly should hear about a glob that
|
|
117
|
+
// matches nothing; the defaults are a convention, and an absent optional
|
|
118
|
+
// directory is not worth a warning.
|
|
119
|
+
const explicit = manifest.alwaysInContext !== undefined;
|
|
120
|
+
const context = await collectContext(dir, manifest.alwaysInContext ?? DEFAULT_CONTEXT, explicit, warnings);
|
|
121
|
+
if (!context.some((file) => file.path === "AGENTS.md")) {
|
|
122
|
+
warnings.push("No AGENTS.md in context — the assistant starts with no always-on reference");
|
|
123
|
+
}
|
|
124
|
+
const checks = resolveChecks(manifest.checks, dir, warnings);
|
|
125
|
+
const stone = {
|
|
126
|
+
path: dir,
|
|
127
|
+
agentName: manifest.agentName ?? "Rosetta",
|
|
128
|
+
languageName: manifest.languageName ?? "the language",
|
|
129
|
+
welcomeMessage: manifest.welcomeMessage ?? "Ask a question about the scripting language.",
|
|
130
|
+
suggestedQuestions: manifest.suggestedQuestions ?? [],
|
|
131
|
+
answersAbout: manifest.answersAbout ??
|
|
132
|
+
"the scripting language documented in this stone",
|
|
133
|
+
syntaxHighlighting,
|
|
134
|
+
context,
|
|
135
|
+
checks,
|
|
136
|
+
warnings,
|
|
137
|
+
};
|
|
138
|
+
return {
|
|
139
|
+
...stone,
|
|
140
|
+
systemPrompt: buildSystemPrompt(await readPromptTemplate(dir), stone, {
|
|
141
|
+
verified: checks.length > 0,
|
|
142
|
+
}),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
/** Reads and validates rosetta.json. Every problem in it is reported in one StoneError. */
|
|
146
|
+
async function readManifest(dir) {
|
|
147
|
+
const manifestPath = resolve(dir, "rosetta.json");
|
|
148
|
+
let raw;
|
|
149
|
+
try {
|
|
150
|
+
raw = await readFile(manifestPath, "utf8");
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
throw new StoneError([
|
|
154
|
+
`${manifestPath} not found — a stone needs a rosetta.json`,
|
|
155
|
+
]);
|
|
156
|
+
}
|
|
157
|
+
let parsed;
|
|
158
|
+
try {
|
|
159
|
+
parsed = JSON.parse(raw);
|
|
160
|
+
}
|
|
161
|
+
catch (error) {
|
|
162
|
+
throw new StoneError([
|
|
163
|
+
`${manifestPath} is not valid JSON: ${error.message}`,
|
|
164
|
+
]);
|
|
165
|
+
}
|
|
166
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
167
|
+
throw new StoneError([`${manifestPath} must contain a JSON object`]);
|
|
168
|
+
}
|
|
169
|
+
const result = ManifestSchema.safeParse(parsed);
|
|
170
|
+
if (!result.success) {
|
|
171
|
+
throw new StoneError(result.error.issues.flatMap((issue) => formatIssue(issue, "rosetta.json")));
|
|
172
|
+
}
|
|
173
|
+
return result.data;
|
|
174
|
+
}
|
|
175
|
+
/** The manifest's checks with their defaults filled in and stone-relative commands resolved. */
|
|
176
|
+
function resolveChecks(declared, dir, warnings) {
|
|
177
|
+
const checks = (declared ?? []).map((check) => ({
|
|
178
|
+
name: check.name,
|
|
179
|
+
command: resolveCommand(check.command, dir),
|
|
180
|
+
input: check.input ?? "stdin",
|
|
181
|
+
timeoutMs: check.timeoutMs ?? 10_000,
|
|
182
|
+
successExitCodes: check.successExitCodes ?? [0],
|
|
183
|
+
}));
|
|
184
|
+
for (const check of checks) {
|
|
185
|
+
const binary = check.command[0];
|
|
186
|
+
// Only a path can be checked here; a bare name is resolved from PATH at
|
|
187
|
+
// run time, and the tool reports "command not found" clearly if it is not.
|
|
188
|
+
if (binary.includes("/") && !existsSync(binary)) {
|
|
189
|
+
warnings.push(`checks "${check.name}": ${binary} does not exist`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return checks;
|
|
193
|
+
}
|
|
194
|
+
/** The stone's SYSTEM.md, or undefined when it has none and the built-in prompt applies. */
|
|
195
|
+
async function readPromptTemplate(dir) {
|
|
196
|
+
const templatePath = await realpath(resolve(dir, "SYSTEM.md")).catch(() => undefined);
|
|
197
|
+
if (!templatePath)
|
|
198
|
+
return undefined;
|
|
199
|
+
// An override that resolves outside the stone is refused.
|
|
200
|
+
if (!contains(await realpath(dir), templatePath)) {
|
|
201
|
+
throw new StoneError(["SYSTEM.md resolves outside the stone"]);
|
|
202
|
+
}
|
|
203
|
+
return readFile(templatePath, "utf8");
|
|
204
|
+
}
|
|
205
|
+
async function collectContext(dir, patterns, explicit, warnings) {
|
|
206
|
+
const root = await realpath(dir);
|
|
207
|
+
const seen = new Set();
|
|
208
|
+
const files = [];
|
|
209
|
+
for (const pattern of patterns) {
|
|
210
|
+
const matches = [];
|
|
211
|
+
for await (const match of glob(pattern, { cwd: dir }))
|
|
212
|
+
matches.push(match);
|
|
213
|
+
matches.sort();
|
|
214
|
+
if (!matches.length && explicit)
|
|
215
|
+
warnings.push(`context pattern "${pattern}" matched nothing`);
|
|
216
|
+
for (const rel of matches) {
|
|
217
|
+
const abs = await realpath(resolve(dir, rel));
|
|
218
|
+
if (!contains(root, abs)) {
|
|
219
|
+
warnings.push(`skipped ${rel}: outside the stone`);
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
if (seen.has(abs))
|
|
223
|
+
continue;
|
|
224
|
+
const info = await stat(abs);
|
|
225
|
+
if (!info.isFile())
|
|
226
|
+
continue;
|
|
227
|
+
if (info.size > MAX_FILE_BYTES) {
|
|
228
|
+
warnings.push(`skipped ${rel}: larger than ${MAX_FILE_BYTES / 1024} KB`);
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
const content = await readFile(abs, "utf8");
|
|
232
|
+
if (content.includes("\0")) {
|
|
233
|
+
warnings.push(`skipped ${rel}: not a text file`);
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
seen.add(abs);
|
|
237
|
+
files.push({ path: relative(dir, resolve(dir, rel)), content });
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
const total = files.reduce((sum, file) => sum + file.content.length, 0);
|
|
241
|
+
if (total > CONTEXT_WARN_CHARS) {
|
|
242
|
+
warnings.push(`always-on context is ${Math.round(total / 1000)}k characters; consider moving reference material out of "context" so the tools fetch it instead`);
|
|
243
|
+
}
|
|
244
|
+
return files;
|
|
245
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runs a stone's checkers against a draft script.
|
|
3
|
+
*
|
|
4
|
+
* Deliberately knows nothing about pi: it spawns processes and reports what
|
|
5
|
+
* they said. The harness wraps this in a tool.
|
|
6
|
+
*/
|
|
7
|
+
import { spawn } from "node:child_process";
|
|
8
|
+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
import { join, resolve } from "node:path";
|
|
11
|
+
/** Beyond this the model is reading noise, not diagnostics. */
|
|
12
|
+
const MAX_OUTPUT_CHARS = 4_000;
|
|
13
|
+
/** Placeholder in argv that receives the temp file path when input is "file". */
|
|
14
|
+
const FILE_PLACEHOLDER = "{}";
|
|
15
|
+
/**
|
|
16
|
+
* Runs a stone's checks on a draft script, in the directory `cwd`.
|
|
17
|
+
*
|
|
18
|
+
* Ordered, and stopped at the first failure: a domain check run against
|
|
19
|
+
* syntactically invalid input produces noise, not information.
|
|
20
|
+
*/
|
|
21
|
+
export async function runChecks(checks, script, cwd, signal) {
|
|
22
|
+
const results = [];
|
|
23
|
+
for (const check of checks) {
|
|
24
|
+
const result = await runCheck(check, script, cwd, signal);
|
|
25
|
+
results.push(result);
|
|
26
|
+
if (!result.ok)
|
|
27
|
+
return { ok: false, checks: results };
|
|
28
|
+
}
|
|
29
|
+
return { ok: true, checks: results };
|
|
30
|
+
}
|
|
31
|
+
/** Runs one check, giving it the script on stdin or in a temporary file that is removed afterwards. */
|
|
32
|
+
async function runCheck(check, script, cwd, signal) {
|
|
33
|
+
const cancelled = {
|
|
34
|
+
name: check.name,
|
|
35
|
+
ok: false,
|
|
36
|
+
output: `${check.name} was cancelled`,
|
|
37
|
+
};
|
|
38
|
+
if (signal?.aborted)
|
|
39
|
+
return cancelled;
|
|
40
|
+
let scratch;
|
|
41
|
+
let argv = check.command;
|
|
42
|
+
try {
|
|
43
|
+
if (check.input === "file") {
|
|
44
|
+
scratch = await mkdtemp(join(tmpdir(), "rosetta-check-"));
|
|
45
|
+
if (signal?.aborted)
|
|
46
|
+
return cancelled;
|
|
47
|
+
const file = join(scratch, "script");
|
|
48
|
+
await writeFile(file, script, "utf8");
|
|
49
|
+
argv = argv.includes(FILE_PLACEHOLDER)
|
|
50
|
+
? argv.map((part) => (part === FILE_PLACEHOLDER ? file : part))
|
|
51
|
+
: [...argv, file];
|
|
52
|
+
}
|
|
53
|
+
// Cancellation can arrive while file input is being prepared.
|
|
54
|
+
if (signal?.aborted)
|
|
55
|
+
return cancelled;
|
|
56
|
+
return await runChecker(check, argv, script, cwd, signal);
|
|
57
|
+
}
|
|
58
|
+
finally {
|
|
59
|
+
if (scratch)
|
|
60
|
+
await rm(scratch, { recursive: true, force: true }).catch(() => { });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/** Starts the checker and resolves with how it ended: its exit, a timeout, a cancel, or a failure to start. */
|
|
64
|
+
function runChecker(check, argv, script, cwd, signal) {
|
|
65
|
+
return new Promise((settle) => {
|
|
66
|
+
const [command, ...args] = argv;
|
|
67
|
+
// shell: false is the point. The script is model-generated, so it must
|
|
68
|
+
// never reach a shell where backticks or $(...) would be interpreted.
|
|
69
|
+
// The model chooses the script; the stone author chose the command.
|
|
70
|
+
const child = spawn(command, args, {
|
|
71
|
+
cwd,
|
|
72
|
+
shell: false,
|
|
73
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
74
|
+
});
|
|
75
|
+
let output = "";
|
|
76
|
+
let settled = false;
|
|
77
|
+
const collect = (chunk) => {
|
|
78
|
+
if (output.length < MAX_OUTPUT_CHARS)
|
|
79
|
+
output += chunk.toString("utf8");
|
|
80
|
+
};
|
|
81
|
+
child.stdout.on("data", collect);
|
|
82
|
+
child.stderr.on("data", collect);
|
|
83
|
+
// An 'error' event with no listener is fatal in Node, and every session shares this
|
|
84
|
+
// one process, so one misbehaving checker would take the server down with every
|
|
85
|
+
// other session on it.
|
|
86
|
+
//
|
|
87
|
+
// The reachable case is EPIPE on stdin: a checker that reads a prefix and
|
|
88
|
+
// exits, or one this function SIGKILLs on timeout or abort, closes the read
|
|
89
|
+
// end while a script larger than the pipe buffer is still being written.
|
|
90
|
+
// Ignoring is right rather than merely quiet — what decides the result is the
|
|
91
|
+
// exit code delivered to 'close' below, not whether the write landed.
|
|
92
|
+
const ignoreStreamError = () => { };
|
|
93
|
+
child.stdin.on("error", ignoreStreamError);
|
|
94
|
+
child.stdout.on("error", ignoreStreamError);
|
|
95
|
+
child.stderr.on("error", ignoreStreamError);
|
|
96
|
+
const finish = (ok, text) => {
|
|
97
|
+
if (settled)
|
|
98
|
+
return;
|
|
99
|
+
settled = true;
|
|
100
|
+
clearTimeout(timer);
|
|
101
|
+
signal?.removeEventListener("abort", onAbort);
|
|
102
|
+
settle({
|
|
103
|
+
name: check.name,
|
|
104
|
+
ok,
|
|
105
|
+
output: text.trim().slice(0, MAX_OUTPUT_CHARS),
|
|
106
|
+
});
|
|
107
|
+
};
|
|
108
|
+
const timer = setTimeout(() => {
|
|
109
|
+
child.kill("SIGKILL");
|
|
110
|
+
finish(false, `${check.name} timed out after ${check.timeoutMs}ms`);
|
|
111
|
+
}, check.timeoutMs);
|
|
112
|
+
const onAbort = () => {
|
|
113
|
+
child.kill("SIGKILL");
|
|
114
|
+
finish(false, `${check.name} was cancelled`);
|
|
115
|
+
};
|
|
116
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
117
|
+
child.once("error", (error) => {
|
|
118
|
+
const hint = error.code === "ENOENT"
|
|
119
|
+
? `command not found: ${command}. Check "checks" in rosetta.json.`
|
|
120
|
+
: error.message;
|
|
121
|
+
finish(false, hint);
|
|
122
|
+
});
|
|
123
|
+
child.once("close", (code) => {
|
|
124
|
+
finish(check.successExitCodes.includes(code ?? -1), output);
|
|
125
|
+
});
|
|
126
|
+
if (check.input === "stdin") {
|
|
127
|
+
child.stdin.end(script, "utf8");
|
|
128
|
+
}
|
|
129
|
+
else {
|
|
130
|
+
child.stdin.end();
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
/** Resolves a stone-relative command so a checker can ship inside the stone. */
|
|
135
|
+
export function resolveCommand(command, stonePath) {
|
|
136
|
+
const [head, ...rest] = command;
|
|
137
|
+
if (head?.startsWith("./") || head?.startsWith("../")) {
|
|
138
|
+
return [resolve(stonePath, head), ...rest];
|
|
139
|
+
}
|
|
140
|
+
return command;
|
|
141
|
+
}
|