@inhuman.tools/harness-sdk 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +7 -0
- package/dist/client-agents-tree.d.ts +123 -0
- package/dist/client-agents-tree.d.ts.map +1 -0
- package/dist/client-agents-tree.js +504 -0
- package/dist/client-agents-tree.js.map +1 -0
- package/dist/client-agents.d.ts +50 -0
- package/dist/client-agents.d.ts.map +1 -0
- package/dist/client-agents.js +299 -0
- package/dist/client-agents.js.map +1 -0
- package/dist/client.d.ts +89 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +589 -0
- package/dist/client.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -0
- package/dist/launch/continue-pick.d.ts +17 -0
- package/dist/launch/continue-pick.d.ts.map +1 -0
- package/dist/launch/continue-pick.js +20 -0
- package/dist/launch/continue-pick.js.map +1 -0
- package/dist/launch/index.d.ts +6 -0
- package/dist/launch/index.d.ts.map +1 -0
- package/dist/launch/index.js +6 -0
- package/dist/launch/index.js.map +1 -0
- package/dist/launch/model-flags.d.ts +44 -0
- package/dist/launch/model-flags.d.ts.map +1 -0
- package/dist/launch/model-flags.js +216 -0
- package/dist/launch/model-flags.js.map +1 -0
- package/dist/launch/streams.d.ts +15 -0
- package/dist/launch/streams.d.ts.map +1 -0
- package/dist/launch/streams.js +2 -0
- package/dist/launch/streams.js.map +1 -0
- package/dist/launch/term-continue.d.ts +13 -0
- package/dist/launch/term-continue.d.ts.map +1 -0
- package/dist/launch/term-continue.js +17 -0
- package/dist/launch/term-continue.js.map +1 -0
- package/dist/launch/term-session-key.d.ts +8 -0
- package/dist/launch/term-session-key.d.ts.map +1 -0
- package/dist/launch/term-session-key.js +76 -0
- package/dist/launch/term-session-key.js.map +1 -0
- package/dist/observable-abort-signal.d.ts +7 -0
- package/dist/observable-abort-signal.d.ts.map +1 -0
- package/dist/observable-abort-signal.js +56 -0
- package/dist/observable-abort-signal.js.map +1 -0
- package/dist/open-external.d.ts +2 -0
- package/dist/open-external.d.ts.map +1 -0
- package/dist/open-external.js +24 -0
- package/dist/open-external.js.map +1 -0
- package/dist/paths.d.ts +7 -0
- package/dist/paths.d.ts.map +1 -0
- package/dist/paths.js +16 -0
- package/dist/paths.js.map +1 -0
- package/dist/text/agent-cover.d.ts +43 -0
- package/dist/text/agent-cover.d.ts.map +1 -0
- package/dist/text/agent-cover.js +123 -0
- package/dist/text/agent-cover.js.map +1 -0
- package/dist/text/format-shell.d.ts +5 -0
- package/dist/text/format-shell.d.ts.map +1 -0
- package/dist/text/format-shell.js +53 -0
- package/dist/text/format-shell.js.map +1 -0
- package/dist/text/identity.d.ts +67 -0
- package/dist/text/identity.d.ts.map +1 -0
- package/dist/text/identity.js +433 -0
- package/dist/text/identity.js.map +1 -0
- package/dist/text/index.d.ts +14 -0
- package/dist/text/index.d.ts.map +1 -0
- package/dist/text/index.js +15 -0
- package/dist/text/index.js.map +1 -0
- package/dist/text/liveness.d.ts +62 -0
- package/dist/text/liveness.d.ts.map +1 -0
- package/dist/text/liveness.js +270 -0
- package/dist/text/liveness.js.map +1 -0
- package/dist/text/mcp-identity.d.ts +25 -0
- package/dist/text/mcp-identity.d.ts.map +1 -0
- package/dist/text/mcp-identity.js +133 -0
- package/dist/text/mcp-identity.js.map +1 -0
- package/dist/text/pretty-identity.d.ts +15 -0
- package/dist/text/pretty-identity.d.ts.map +1 -0
- package/dist/text/pretty-identity.js +112 -0
- package/dist/text/pretty-identity.js.map +1 -0
- package/dist/text/sugarcoat.d.ts +29 -0
- package/dist/text/sugarcoat.d.ts.map +1 -0
- package/dist/text/sugarcoat.js +148 -0
- package/dist/text/sugarcoat.js.map +1 -0
- package/dist/text/unwrap-program.d.ts +2 -0
- package/dist/text/unwrap-program.d.ts.map +1 -0
- package/dist/text/unwrap-program.js +137 -0
- package/dist/text/unwrap-program.js.map +1 -0
- package/dist/text/work-text.d.ts +72 -0
- package/dist/text/work-text.d.ts.map +1 -0
- package/dist/text/work-text.js +337 -0
- package/dist/text/work-text.js.map +1 -0
- package/dist/transport/unix.d.ts +19 -0
- package/dist/transport/unix.d.ts.map +1 -0
- package/dist/transport/unix.js +68 -0
- package/dist/transport/unix.js.map +1 -0
- package/package.json +79 -3
- package/src/client-agents-tree.ts +642 -0
- package/src/client-agents.ts +230 -0
- package/src/client.ts +686 -0
- package/src/index.ts +27 -0
- package/src/launch/continue-pick.ts +28 -0
- package/src/launch/index.ts +5 -0
- package/src/launch/model-flags.ts +273 -0
- package/src/launch/streams.ts +13 -0
- package/src/launch/term-continue.ts +31 -0
- package/src/launch/term-session-key.ts +70 -0
- package/src/observable-abort-signal.ts +66 -0
- package/src/open-external.ts +22 -0
- package/src/paths.ts +20 -0
- package/src/text/agent-cover.ts +136 -0
- package/src/text/format-shell.ts +52 -0
- package/src/text/identity.ts +473 -0
- package/src/text/index.ts +29 -0
- package/src/text/liveness.ts +322 -0
- package/src/text/mcp-identity.ts +148 -0
- package/src/text/pretty-identity.ts +120 -0
- package/src/text/sugarcoat.ts +165 -0
- package/src/text/unwrap-program.ts +133 -0
- package/src/text/work-text.ts +388 -0
- package/src/transport/unix.ts +84 -0
- package/README.md +0 -3
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scheme result projection for the tape and for plain-text clients: sugarcoat the
|
|
3
|
+
* stored source, and classify printed values into typed parts. No colour and no
|
|
4
|
+
* widget types. `sugarcoat-paint.ts` adds those, so a headless client can read this
|
|
5
|
+
* file without loading the paint substrate.
|
|
6
|
+
*/
|
|
7
|
+
import { toSExprString } from "@here.build/arrival-serializer";
|
|
8
|
+
import { schemeToSugarcoat } from "@here.build/arrival-sugarcoat";
|
|
9
|
+
import type { ToolOutcomeSnap } from "@inhuman.tools/harness-model/schemas";
|
|
10
|
+
|
|
11
|
+
import { unwrapTopLevelSource } from "./unwrap-program.js";
|
|
12
|
+
|
|
13
|
+
export function sugarcoatSource(src: string): string {
|
|
14
|
+
if (src.trim() === "") return src;
|
|
15
|
+
try {
|
|
16
|
+
const sweet = schemeToSugarcoat(src);
|
|
17
|
+
return sweet === "" ? src : sweet;
|
|
18
|
+
} catch {
|
|
19
|
+
return src;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Tape/object paint for a scheme_repl program. Splices wrapping begin/list/`[]` first. */
|
|
24
|
+
export function sugarcoatProgram(src: string): string {
|
|
25
|
+
return sugarcoatSource(unwrapTopLevelSource(src));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** JSON-quoted payloads (`"a\\nb"`) become the string with real newlines. Bare `C:\\new` stays. */
|
|
29
|
+
export function decodeDisplayString(text: string): string {
|
|
30
|
+
const trimmed = text.trim();
|
|
31
|
+
if (trimmed.length < 2 || !trimmed.startsWith('"') || !trimmed.endsWith('"')) return text;
|
|
32
|
+
try {
|
|
33
|
+
const parsed: unknown = JSON.parse(trimmed);
|
|
34
|
+
return typeof parsed === "string" ? parsed : text;
|
|
35
|
+
} catch {
|
|
36
|
+
return text;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type SchemePaintKind = "text" | "markdown" | "number" | "boolean" | "syntax";
|
|
41
|
+
|
|
42
|
+
export type SchemePaintPart = {
|
|
43
|
+
readonly kind: SchemePaintKind;
|
|
44
|
+
readonly text: string;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export type SchemePaintReceipt =
|
|
48
|
+
| { readonly kind: "none" }
|
|
49
|
+
| SchemePaintPart
|
|
50
|
+
| {
|
|
51
|
+
readonly kind: "parts";
|
|
52
|
+
readonly parts: readonly SchemePaintPart[];
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/** Scheme boolean spellings. The highlighter colours the same atoms. */
|
|
56
|
+
export const BOOLEAN_ATOMS: ReadonlySet<string> = new Set(["#t", "#f", "#true", "#false"]);
|
|
57
|
+
|
|
58
|
+
/** Prefixes `toSExprString` emits. A cut marker is recognized anywhere so a
|
|
59
|
+
* leading display note does not hide it. */
|
|
60
|
+
function serializerForm(text: string): boolean {
|
|
61
|
+
if (text.includes("\n#| … output hard-truncated at ") || text.includes("…(+")) return true;
|
|
62
|
+
const trimmed = text.trimStart();
|
|
63
|
+
return (
|
|
64
|
+
trimmed.startsWith(";;") ||
|
|
65
|
+
trimmed.startsWith("#|") ||
|
|
66
|
+
trimmed.startsWith("(") ||
|
|
67
|
+
trimmed.startsWith("[") ||
|
|
68
|
+
trimmed.startsWith("{") ||
|
|
69
|
+
trimmed.startsWith('"') ||
|
|
70
|
+
trimmed.startsWith("'") ||
|
|
71
|
+
trimmed.startsWith("#\\") ||
|
|
72
|
+
trimmed.startsWith(":")
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function formatSchemeResult(snap: ToolOutcomeSnap | null | undefined): string {
|
|
77
|
+
const painted = schemePaintReceipt(snap);
|
|
78
|
+
if (painted.kind === "none") return "";
|
|
79
|
+
if (painted.kind === "parts") return painted.parts.map((part) => part.text).join("\n");
|
|
80
|
+
return painted.text;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export type FilePaintHint = { readonly path: string; readonly text: string };
|
|
84
|
+
|
|
85
|
+
/** One receipt per printed value. A display string stays plain. Serializer text is syntax. */
|
|
86
|
+
export function schemePaintReceipt(
|
|
87
|
+
snap: ToolOutcomeSnap | null | undefined,
|
|
88
|
+
hints: readonly FilePaintHint[] = [],
|
|
89
|
+
): SchemePaintReceipt {
|
|
90
|
+
if (snap == null) return { kind: "none" };
|
|
91
|
+
if (snap.ok === false) {
|
|
92
|
+
const text = decodeDisplayString(snap.text ?? "");
|
|
93
|
+
return text === "" ? { kind: "none" } : { kind: "text", text };
|
|
94
|
+
}
|
|
95
|
+
if (!("value" in snap) || snap.value === undefined) {
|
|
96
|
+
const text = decodeDisplayString(typeof snap.text === "string" ? snap.text : "");
|
|
97
|
+
return text === "" ? { kind: "none" } : { kind: "text", text };
|
|
98
|
+
}
|
|
99
|
+
const items = Array.isArray(snap.value) ? snap.value : [snap.value];
|
|
100
|
+
const parts = items
|
|
101
|
+
.map((item) => schemePart(item, hints))
|
|
102
|
+
.filter((part): part is SchemePaintPart => part !== undefined);
|
|
103
|
+
if (parts.length === 0) return { kind: "none" };
|
|
104
|
+
if (parts.length === 1) {
|
|
105
|
+
const only = parts[0];
|
|
106
|
+
return only === undefined ? { kind: "none" } : only;
|
|
107
|
+
}
|
|
108
|
+
return { kind: "parts", parts };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function isMarkdownPath(path: string): boolean {
|
|
112
|
+
return path.endsWith(".md") || path.endsWith(".mdx");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function fileSchemePart(path: string, text: string): SchemePaintPart | undefined {
|
|
116
|
+
const body = decodeDisplayString(text);
|
|
117
|
+
if (body === "") return undefined;
|
|
118
|
+
return isMarkdownPath(path) ? { kind: "markdown", text: body } : { kind: "text", text: body };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function isFilePrint(value: unknown): value is { text: string; face: { kind: "file"; path: string } } {
|
|
122
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
|
|
123
|
+
const rec = value as { text?: unknown; face?: { kind?: unknown; path?: unknown } };
|
|
124
|
+
return typeof rec.text === "string" && rec.face?.kind === "file" && typeof rec.face.path === "string";
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function hintedFilePart(text: string, hints: readonly FilePaintHint[]): SchemePaintPart | undefined {
|
|
128
|
+
const matched = new Set(hints.filter((hint) => hint.text === text).map((hint) => hint.path));
|
|
129
|
+
if (matched.size !== 1) return undefined;
|
|
130
|
+
const [path] = matched;
|
|
131
|
+
return path === undefined ? undefined : fileSchemePart(path, text);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function schemePart(value: unknown, hints: readonly FilePaintHint[] = []): SchemePaintPart | undefined {
|
|
135
|
+
if (typeof value === "boolean") return { kind: "boolean", text: value ? "#t" : "#f" };
|
|
136
|
+
if (typeof value === "number" && Number.isFinite(value)) return { kind: "number", text: String(value) };
|
|
137
|
+
if (typeof value === "bigint") return { kind: "number", text: String(value) };
|
|
138
|
+
if (isFilePrint(value)) return fileSchemePart(value.face.path, value.text);
|
|
139
|
+
if (typeof value === "string") {
|
|
140
|
+
const text = decodeDisplayString(value);
|
|
141
|
+
const hinted = hintedFilePart(text, hints);
|
|
142
|
+
if (hinted !== undefined) return hinted;
|
|
143
|
+
if (BOOLEAN_ATOMS.has(text)) return { kind: "boolean", text };
|
|
144
|
+
// Whole-value number, not a tokenizer prefix. `#t` is a boolean, not `#`+digit.
|
|
145
|
+
if (
|
|
146
|
+
text !== "" &&
|
|
147
|
+
!/\s/.test(text) &&
|
|
148
|
+
!BOOLEAN_ATOMS.has(text) &&
|
|
149
|
+
(Number.isFinite(Number(text)) || /^[+-]?#[xbodei]/i.test(text))
|
|
150
|
+
) {
|
|
151
|
+
return { kind: "number", text };
|
|
152
|
+
}
|
|
153
|
+
if (text === "") return undefined;
|
|
154
|
+
// Eval already stored `toSExprString` as a string. Those forms highlight;
|
|
155
|
+
// a display sentence and a file body do not start this way.
|
|
156
|
+
return serializerForm(text) ? { kind: "syntax", text } : { kind: "text", text };
|
|
157
|
+
}
|
|
158
|
+
try {
|
|
159
|
+
const text = toSExprString(value);
|
|
160
|
+
return text === "" ? undefined : { kind: "syntax", text };
|
|
161
|
+
} catch {
|
|
162
|
+
const text = decodeDisplayString(String(value));
|
|
163
|
+
return text === "" ? undefined : { kind: "text", text };
|
|
164
|
+
}
|
|
165
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// Tape projection of substrate `unwrap-begin.ts`. Execution splices the AST;
|
|
2
|
+
// this file splices the stored expr so the identity line matches what ran.
|
|
3
|
+
// CHANGE ONLY with that file — same three wrappers, same quote/dot refusals.
|
|
4
|
+
|
|
5
|
+
import { tokenize } from "@here.build/arrival/lsp-internals";
|
|
6
|
+
|
|
7
|
+
const SPLICE_HEADS = new Set(["begin", "list"]);
|
|
8
|
+
const CLOSE = new Set([")", "]", "}"]);
|
|
9
|
+
const QUOTE_PREFIX = new Set(["'", "`", ",", ",@"]);
|
|
10
|
+
|
|
11
|
+
function isOpen(tok: string): boolean {
|
|
12
|
+
return (
|
|
13
|
+
tok === "(" || tok === "[" || tok === "{" || (tok.startsWith("#") && !tok.startsWith("#\\") && tok.endsWith("("))
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function isSkippable(tok: string): boolean {
|
|
18
|
+
return /^\s+$/.test(tok) || tok.startsWith(";") || tok.startsWith("#|") || tok.startsWith("#;");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
type Token = { readonly token: string; readonly offset: number };
|
|
22
|
+
|
|
23
|
+
function tokenAt(tokens: readonly Token[], index: number): Token | undefined {
|
|
24
|
+
return tokens[index];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function skipSkippable(tokens: readonly Token[], index: number): number {
|
|
28
|
+
let i = index;
|
|
29
|
+
for (;;) {
|
|
30
|
+
const tok = tokenAt(tokens, i);
|
|
31
|
+
if (tok == null || !isSkippable(tok.token)) return i;
|
|
32
|
+
i += 1;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function splitTopLevel(source: string): string[] {
|
|
37
|
+
const tokens = tokenize(source, true);
|
|
38
|
+
const starts: number[] = [];
|
|
39
|
+
let depth = 0;
|
|
40
|
+
let between = true;
|
|
41
|
+
for (const { token, offset } of tokens) {
|
|
42
|
+
if (isSkippable(token)) continue;
|
|
43
|
+
if (between) {
|
|
44
|
+
starts.push(offset);
|
|
45
|
+
between = false;
|
|
46
|
+
}
|
|
47
|
+
if (isOpen(token)) depth++;
|
|
48
|
+
else if (CLOSE.has(token)) {
|
|
49
|
+
if (depth > 0) depth--;
|
|
50
|
+
if (depth === 0) between = true;
|
|
51
|
+
} else if (depth === 0 && !QUOTE_PREFIX.has(token)) {
|
|
52
|
+
between = true;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return starts.map((s, i) => source.slice(s, starts[i + 1] ?? source.length).trim()).filter(Boolean);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function matchGroup(
|
|
59
|
+
tokens: readonly Token[],
|
|
60
|
+
openIdx: number,
|
|
61
|
+
closeTok: string,
|
|
62
|
+
): { readonly closeIdx: number; readonly dotted: boolean } | undefined {
|
|
63
|
+
let depth = 0;
|
|
64
|
+
let dotted = false;
|
|
65
|
+
for (let i = openIdx; i < tokens.length; i++) {
|
|
66
|
+
const row = tokenAt(tokens, i);
|
|
67
|
+
if (row == null) return undefined;
|
|
68
|
+
const tok = row.token;
|
|
69
|
+
if (isSkippable(tok)) continue;
|
|
70
|
+
if (isOpen(tok)) {
|
|
71
|
+
depth += 1;
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
if (CLOSE.has(tok)) {
|
|
75
|
+
depth -= 1;
|
|
76
|
+
if (depth === 0) {
|
|
77
|
+
if (tok !== closeTok) return undefined;
|
|
78
|
+
return { closeIdx: i, dotted };
|
|
79
|
+
}
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (depth === 1 && tok === ".") dotted = true;
|
|
83
|
+
}
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function wrapperInterior(src: string): string[] | undefined {
|
|
88
|
+
const tokens = tokenize(src, true);
|
|
89
|
+
const firstIdx = skipSkippable(tokens, 0);
|
|
90
|
+
const first = tokenAt(tokens, firstIdx);
|
|
91
|
+
if (first == null || QUOTE_PREFIX.has(first.token)) return undefined;
|
|
92
|
+
|
|
93
|
+
if (first.token === "[" || first.token === "#(") {
|
|
94
|
+
const closed = matchGroup(tokens, firstIdx, first.token === "[" ? "]" : ")");
|
|
95
|
+
if (closed == null || closed.dotted) return undefined;
|
|
96
|
+
if (skipSkippable(tokens, closed.closeIdx + 1) < tokens.length) return undefined;
|
|
97
|
+
const closer = tokenAt(tokens, closed.closeIdx);
|
|
98
|
+
if (closer == null) return undefined;
|
|
99
|
+
return splitTopLevel(src.slice(first.offset + first.token.length, closer.offset));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (first.token !== "(") return undefined;
|
|
103
|
+
const nameIdx = skipSkippable(tokens, firstIdx + 1);
|
|
104
|
+
const name = tokenAt(tokens, nameIdx);
|
|
105
|
+
if (name == null || !SPLICE_HEADS.has(name.token.toLowerCase())) return undefined;
|
|
106
|
+
const closed = matchGroup(tokens, firstIdx, ")");
|
|
107
|
+
if (closed == null || closed.dotted) return undefined;
|
|
108
|
+
if (skipSkippable(tokens, closed.closeIdx + 1) < tokens.length) return undefined;
|
|
109
|
+
const closer = tokenAt(tokens, closed.closeIdx);
|
|
110
|
+
if (closer == null) return undefined;
|
|
111
|
+
return splitTopLevel(src.slice(name.offset + name.token.length, closer.offset));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function unwrapTopLevelSource(src: string): string {
|
|
115
|
+
if (src.trim() === "") return src;
|
|
116
|
+
try {
|
|
117
|
+
const out: string[] = [];
|
|
118
|
+
let spliced = false;
|
|
119
|
+
const visit = (stmt: string): void => {
|
|
120
|
+
const body = wrapperInterior(stmt);
|
|
121
|
+
if (body === undefined) {
|
|
122
|
+
out.push(stmt);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
spliced = true;
|
|
126
|
+
for (const inner of body) visit(inner);
|
|
127
|
+
};
|
|
128
|
+
for (const stmt of splitTopLevel(src)) visit(stmt);
|
|
129
|
+
return spliced ? out.join("\n") : src;
|
|
130
|
+
} catch {
|
|
131
|
+
return src;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a tool or scheme call says, as plain data: state, identity, receipt. No
|
|
3
|
+
* paint. `work-cells.ts` turns a `WorkCellFace` into layout nodes; the headless
|
|
4
|
+
* eval client prints the same face as text. Nothing here may import quickdraw —
|
|
5
|
+
* a helper that needs it belongs in `work-cells.ts`.
|
|
6
|
+
*/
|
|
7
|
+
import {
|
|
8
|
+
ApprovableJob,
|
|
9
|
+
type BlobPeek,
|
|
10
|
+
documentRoot,
|
|
11
|
+
FileMutateToolJob,
|
|
12
|
+
type KernelRoot,
|
|
13
|
+
MCPToolJob,
|
|
14
|
+
type PriorText,
|
|
15
|
+
ProgrammaticJob,
|
|
16
|
+
reactChunks,
|
|
17
|
+
SessionRoot,
|
|
18
|
+
SessionTurn,
|
|
19
|
+
shellTailOf,
|
|
20
|
+
ToolCallJob,
|
|
21
|
+
} from "@inhuman.tools/harness-model";
|
|
22
|
+
|
|
23
|
+
import { agentExpandBlock } from "./agent-cover.js";
|
|
24
|
+
import { coverAskAnnotation, identityLine, isPathShapedName } from "./identity.js";
|
|
25
|
+
import { outcomeSnapOf, schemeCellState, toolCellState, type SchemeCellState, type ToolCellState } from "./liveness.js";
|
|
26
|
+
import { prettyResult } from "./pretty-identity.js";
|
|
27
|
+
import { schemePaintReceipt, type SchemePaintPart } from "./sugarcoat.js";
|
|
28
|
+
|
|
29
|
+
export type WorkCellState = ToolCellState | SchemeCellState;
|
|
30
|
+
|
|
31
|
+
export type ReceiptFace =
|
|
32
|
+
| { readonly kind: "none" }
|
|
33
|
+
| { readonly kind: "text"; readonly text: string }
|
|
34
|
+
| { readonly kind: "markdown"; readonly text: string }
|
|
35
|
+
| { readonly kind: "stream"; readonly text: string }
|
|
36
|
+
| { readonly kind: "syntax"; readonly text: string }
|
|
37
|
+
| { readonly kind: "number"; readonly text: string }
|
|
38
|
+
| { readonly kind: "boolean"; readonly text: string }
|
|
39
|
+
| { readonly kind: "parts"; readonly parts: readonly SchemePaintPart[] }
|
|
40
|
+
| {
|
|
41
|
+
readonly kind: "diff";
|
|
42
|
+
readonly old: string;
|
|
43
|
+
readonly new: string;
|
|
44
|
+
readonly path: string;
|
|
45
|
+
/** 1-based file line of the span. Omitted when the span is the whole file. */
|
|
46
|
+
readonly lineStart?: number;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export function receiptPlainText(receipt: ReceiptFace): string {
|
|
50
|
+
if (receipt.kind === "none" || receipt.kind === "diff") return "";
|
|
51
|
+
if (receipt.kind === "parts") return receipt.parts.map((part) => part.text).join("\n");
|
|
52
|
+
return receipt.text;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export type WorkCellFace = {
|
|
56
|
+
readonly key: string;
|
|
57
|
+
readonly state: WorkCellState;
|
|
58
|
+
readonly name: string;
|
|
59
|
+
readonly object: string;
|
|
60
|
+
readonly status: string;
|
|
61
|
+
readonly caption: string;
|
|
62
|
+
readonly pathShaped: boolean;
|
|
63
|
+
/** Quiet server / louder action (MCP). Plain tools stay one voice Text. */
|
|
64
|
+
readonly split: boolean;
|
|
65
|
+
readonly agent: boolean;
|
|
66
|
+
readonly pretty: boolean;
|
|
67
|
+
readonly prettyBlock: string;
|
|
68
|
+
/** What the worker actually answered. Empty until a reply lands. */
|
|
69
|
+
readonly response: string;
|
|
70
|
+
readonly indent: boolean;
|
|
71
|
+
readonly collapsed: boolean;
|
|
72
|
+
readonly receipt: ReceiptFace;
|
|
73
|
+
/** Cover rim already names this work (`coverAskAnnotation`). */
|
|
74
|
+
readonly rimNamed: boolean;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
function stringArg(args: Readonly<Record<string, unknown>> | null, key: string): string {
|
|
78
|
+
const value = args?.[key];
|
|
79
|
+
return typeof value === "string" ? value : "";
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Whole-file `read_file` of the same path on this turn — already on the tape when host prior did not sync. */
|
|
83
|
+
function priorFromTape(work: ToolCallJob, filePath: string, span: string): string | null {
|
|
84
|
+
if (filePath === "") return null;
|
|
85
|
+
const session = documentRoot(work);
|
|
86
|
+
if (!(session instanceof SessionRoot)) return null;
|
|
87
|
+
const turn = session.head;
|
|
88
|
+
if (!(turn instanceof SessionTurn)) return null;
|
|
89
|
+
for (const chunk of reactChunks(turn)) {
|
|
90
|
+
if (!(chunk instanceof ToolCallJob) || chunk.name !== "read_file" || chunk === work) continue;
|
|
91
|
+
const args = chunk.args;
|
|
92
|
+
if (stringArg(args, "path") !== filePath) continue;
|
|
93
|
+
if (args?.offset !== undefined || args?.limit !== undefined) continue;
|
|
94
|
+
const text = resultText(chunk);
|
|
95
|
+
if (text !== "" && (span === "" || containsSpan(text, span))) return text;
|
|
96
|
+
}
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The prior stamped on the job: inline, or the reference to its blob. Field reads, not
|
|
102
|
+
* `instanceof` — duplicate class identity must not drop the file.
|
|
103
|
+
*/
|
|
104
|
+
function stampedPrior(work: ToolCallJob): PriorText | null {
|
|
105
|
+
const host = work as { prior?: PriorText | null; priorContent?: unknown };
|
|
106
|
+
if (host.prior !== undefined) return host.prior;
|
|
107
|
+
return typeof host.priorContent === "string" ? { kind: "inline", text: host.priorContent } : null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Host prior text. A blob prior whose text the cache does not hold yet reads as no prior, so the
|
|
112
|
+
* face is the one a replica without the prior paints, and asks the cache for the text.
|
|
113
|
+
*/
|
|
114
|
+
function priorOf(work: ToolCallJob, filePath: string, span: string, blobs?: BlobPeek): string | null {
|
|
115
|
+
const stamped = stampedPrior(work);
|
|
116
|
+
let text: string | null = null;
|
|
117
|
+
if (stamped?.kind === "inline") text = stamped.text;
|
|
118
|
+
else if (stamped?.kind === "blob") {
|
|
119
|
+
const { sha256 } = stamped.blob;
|
|
120
|
+
text = blobs?.peek(sha256) ?? null;
|
|
121
|
+
if (text === null) blobs?.want(sha256);
|
|
122
|
+
}
|
|
123
|
+
return text ?? priorFromTape(work, filePath, span);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function containsSpan(file: string, span: string): boolean {
|
|
127
|
+
if (span === "" || file.includes(span)) return true;
|
|
128
|
+
return file.replaceAll("\r\n", "\n").includes(span.replaceAll("\r\n", "\n"));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function spanLineStart(file: string | null | undefined, span: string): number | undefined {
|
|
132
|
+
if (file == null || span === "") return undefined;
|
|
133
|
+
const normalizedFile = file.replaceAll("\r\n", "\n");
|
|
134
|
+
const normalizedSpan = span.replaceAll("\r\n", "\n");
|
|
135
|
+
let idx = normalizedFile.indexOf(normalizedSpan);
|
|
136
|
+
if (idx === -1) {
|
|
137
|
+
const first = normalizedSpan.split("\n")[0] ?? "";
|
|
138
|
+
if (first === "" || first === normalizedSpan) return undefined;
|
|
139
|
+
idx = normalizedFile.indexOf(first);
|
|
140
|
+
}
|
|
141
|
+
if (idx === -1) return undefined;
|
|
142
|
+
let line = 1;
|
|
143
|
+
for (let i = 0; i < idx; i++) if (normalizedFile[i] === "\n") line++;
|
|
144
|
+
return line <= 1 ? undefined : line;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Literal insert. String.replace treats `$` in new_string as a substitution pattern. */
|
|
148
|
+
function replaceSpan(haystack: string, needle: string, replacement: string, all: boolean): string {
|
|
149
|
+
if (all) return haystack.split(needle).join(replacement);
|
|
150
|
+
const at = haystack.indexOf(needle);
|
|
151
|
+
return at === -1 ? haystack : haystack.slice(0, at) + replacement + haystack.slice(at + needle.length);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function editFileDiff(work: ToolCallJob, blobs?: BlobPeek): ReceiptFace {
|
|
155
|
+
const args = work.args;
|
|
156
|
+
const old = stringArg(args, "old_string");
|
|
157
|
+
const next = stringArg(args, "new_string");
|
|
158
|
+
if (old === "" && next === "") return { kind: "none" };
|
|
159
|
+
const filePath = stringArg(args, "path");
|
|
160
|
+
const replaceAll = args?.replace_all === true;
|
|
161
|
+
const prior = priorOf(work, filePath, old, blobs);
|
|
162
|
+
if (prior !== null && old !== "" && prior.includes(old)) {
|
|
163
|
+
return {
|
|
164
|
+
kind: "diff",
|
|
165
|
+
old: prior,
|
|
166
|
+
new: replaceSpan(prior, old, next, replaceAll),
|
|
167
|
+
path: filePath,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
const stampedLine = (work as { priorLineStart?: unknown }).priorLineStart;
|
|
171
|
+
const lineStart =
|
|
172
|
+
spanLineStart(prior, old) ??
|
|
173
|
+
(typeof stampedLine === "number" && Number.isFinite(stampedLine) && stampedLine > 1 ? stampedLine : undefined);
|
|
174
|
+
return {
|
|
175
|
+
kind: "diff",
|
|
176
|
+
old,
|
|
177
|
+
new: next,
|
|
178
|
+
path: filePath,
|
|
179
|
+
...(lineStart === undefined ? {} : { lineStart }),
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Create = empty-old. Overwrite = stored prior text. Binary / unstamped exist is not a create Diff. */
|
|
184
|
+
function writeFileDiff(work: ToolCallJob, blobs?: BlobPeek): ReceiptFace {
|
|
185
|
+
const args = work.args;
|
|
186
|
+
const next = stringArg(args, "content");
|
|
187
|
+
const filePath = stringArg(args, "path");
|
|
188
|
+
if (next === "") return { kind: "none" };
|
|
189
|
+
const prior = priorOf(work, filePath, "", blobs);
|
|
190
|
+
if (prior !== null) {
|
|
191
|
+
return { kind: "diff", old: prior, new: next, path: filePath };
|
|
192
|
+
}
|
|
193
|
+
if (work instanceof FileMutateToolJob && work.existed === true) return { kind: "none" };
|
|
194
|
+
return { kind: "diff", old: "", new: next, path: filePath };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function fileHintsOf(work: ProgrammaticJob): { readonly path: string; readonly text: string }[] {
|
|
198
|
+
const hints: { path: string; text: string }[] = [];
|
|
199
|
+
for (const inner of work.toolCalls) {
|
|
200
|
+
if (inner.name !== "read_file") continue;
|
|
201
|
+
const text = resultText(inner);
|
|
202
|
+
const path = stringArg(inner.args, "path");
|
|
203
|
+
if (text === "" || path === "") continue;
|
|
204
|
+
hints.push({ path, text });
|
|
205
|
+
}
|
|
206
|
+
return hints;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function resultText(work: ToolCallJob | ProgrammaticJob): string {
|
|
210
|
+
const snap = outcomeSnapOf(work);
|
|
211
|
+
if (snap === null) return "";
|
|
212
|
+
if (typeof snap.text === "string" && snap.text !== "") return snap.text;
|
|
213
|
+
if ("value" in snap) {
|
|
214
|
+
try {
|
|
215
|
+
return typeof snap.value === "string" ? snap.value : JSON.stringify(snap.value);
|
|
216
|
+
} catch {
|
|
217
|
+
return String(snap.value);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return "";
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function shellReceipt(work: ToolCallJob): string {
|
|
224
|
+
const snap = outcomeSnapOf(work);
|
|
225
|
+
const value = snap?.value;
|
|
226
|
+
if (value !== null && typeof value === "object" && !Array.isArray(value)) {
|
|
227
|
+
const rec = value as { stdout?: unknown; stderr?: unknown };
|
|
228
|
+
const parts: string[] = [];
|
|
229
|
+
if (typeof rec.stdout === "string" && rec.stdout !== "") parts.push(rec.stdout);
|
|
230
|
+
if (typeof rec.stderr === "string" && rec.stderr !== "") parts.push(rec.stderr);
|
|
231
|
+
if (parts.length > 0) return parts.join("\n");
|
|
232
|
+
}
|
|
233
|
+
return resultText(work);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** `applyDenyReceipt` copies this into `outcomeJson.text`. The tape shows that copy once. */
|
|
237
|
+
function denialExplanation(work: ToolCallJob): string | null {
|
|
238
|
+
const snap = outcomeSnapOf(work);
|
|
239
|
+
const vote = work.policyResult;
|
|
240
|
+
const denied =
|
|
241
|
+
snap?.code === "policy_denied" ||
|
|
242
|
+
((vote === "block" || vote === "deny") && snap?.ok !== true && work.humanApprove !== true);
|
|
243
|
+
if (!denied) return null;
|
|
244
|
+
const policy = (work.policyMessage ?? "").trim();
|
|
245
|
+
const outcome = resultText(work);
|
|
246
|
+
const outcomeTrim = outcome.trim();
|
|
247
|
+
if (policy !== "" && (outcomeTrim === "" || outcomeTrim === policy)) return policy;
|
|
248
|
+
return outcomeTrim === "" ? "" : outcome;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function shellDenialLine(text: string): ReceiptFace {
|
|
252
|
+
return { kind: "parts", parts: [{ kind: "text", text }] };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Suggestion Diff from args. Pin uses this even after `approved`, while tape live does not. */
|
|
256
|
+
export function mutateSuggestionReceipt(work: ToolCallJob, blobs?: BlobPeek): ReceiptFace {
|
|
257
|
+
if (work.name === "edit_file") return editFileDiff(work, blobs);
|
|
258
|
+
if (work.name === "write_file") return writeFileDiff(work, blobs);
|
|
259
|
+
return { kind: "none" };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function receiptFace(work: ToolCallJob | ProgrammaticJob, state: WorkCellState, blobs?: BlobPeek): ReceiptFace {
|
|
263
|
+
if (state === "live") {
|
|
264
|
+
if (work instanceof ToolCallJob && work.name === "shell") {
|
|
265
|
+
const tail = shellTailOf(work);
|
|
266
|
+
if (tail !== null && tail.text !== "") return { kind: "stream", text: tail.text };
|
|
267
|
+
}
|
|
268
|
+
return { kind: "none" };
|
|
269
|
+
}
|
|
270
|
+
if (state === "ask") {
|
|
271
|
+
if (work instanceof ToolCallJob) {
|
|
272
|
+
const suggestion = mutateSuggestionReceipt(work, blobs);
|
|
273
|
+
if (suggestion.kind !== "none") return suggestion;
|
|
274
|
+
}
|
|
275
|
+
if (work instanceof ToolCallJob && work.name === "shell") {
|
|
276
|
+
const policy = work.policyMessage ?? "";
|
|
277
|
+
return policy === "" ? { kind: "none" } : { kind: "text", text: policy };
|
|
278
|
+
}
|
|
279
|
+
if (work instanceof MCPToolJob) {
|
|
280
|
+
return { kind: "none" };
|
|
281
|
+
}
|
|
282
|
+
return { kind: "none" };
|
|
283
|
+
}
|
|
284
|
+
if (work instanceof ProgrammaticJob) {
|
|
285
|
+
return schemePaintReceipt(outcomeSnapOf(work), fileHintsOf(work));
|
|
286
|
+
}
|
|
287
|
+
const explanation = denialExplanation(work);
|
|
288
|
+
if (explanation !== null) {
|
|
289
|
+
if (explanation === "") return { kind: "none" };
|
|
290
|
+
return work.name === "shell" ? shellDenialLine(explanation) : { kind: "text", text: explanation };
|
|
291
|
+
}
|
|
292
|
+
if (work.name === "edit_file") return editFileDiff(work, blobs);
|
|
293
|
+
if (work.name === "write_file") {
|
|
294
|
+
const diff = writeFileDiff(work, blobs);
|
|
295
|
+
if (diff.kind !== "none") return diff;
|
|
296
|
+
const text = resultText(work);
|
|
297
|
+
return text === "" ? { kind: "none" } : { kind: "text", text };
|
|
298
|
+
}
|
|
299
|
+
if (work.name === "read_file") {
|
|
300
|
+
// The tape row is the path only. File text is not a receipt.
|
|
301
|
+
return { kind: "none" };
|
|
302
|
+
}
|
|
303
|
+
if (work.name === "shell") {
|
|
304
|
+
const sealed = shellReceipt(work);
|
|
305
|
+
return sealed === "" ? { kind: "none" } : { kind: "text", text: sealed };
|
|
306
|
+
}
|
|
307
|
+
const serialized = prettyResult(work, outcomeSnapOf(work));
|
|
308
|
+
if (serialized !== null && serialized !== "") {
|
|
309
|
+
return serialized.includes("**") || serialized.includes("`") || serialized.includes("![")
|
|
310
|
+
? { kind: "markdown", text: serialized }
|
|
311
|
+
: { kind: "text", text: serialized };
|
|
312
|
+
}
|
|
313
|
+
const text = resultText(work);
|
|
314
|
+
return text === "" ? { kind: "none" } : { kind: "text", text };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** Kernel glance overlay for a named-agent call. `status` is the dim suffix. */
|
|
318
|
+
export type CallStatus = {
|
|
319
|
+
readonly status?: string;
|
|
320
|
+
readonly model?: string;
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
function faceText(value: unknown): string {
|
|
324
|
+
return typeof value === "string" ? value : "";
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** The worker's reply, not the prompt we sent. Failures stay the gutter. */
|
|
328
|
+
function agentResponseText(work: ToolCallJob | ProgrammaticJob): string {
|
|
329
|
+
if (!(work instanceof ToolCallJob)) return "";
|
|
330
|
+
const snap = outcomeSnapOf(work);
|
|
331
|
+
if (snap === null || snap.ok !== true) return "";
|
|
332
|
+
if ("value" in snap && snap.value != null) {
|
|
333
|
+
if (typeof snap.value === "string") return snap.value.trim();
|
|
334
|
+
try {
|
|
335
|
+
return JSON.stringify(snap.value, null, 2);
|
|
336
|
+
} catch {
|
|
337
|
+
return String(snap.value);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
return typeof snap.text === "string" ? snap.text.trim() : "";
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
export function workCellFace(
|
|
344
|
+
work: ToolCallJob | ProgrammaticJob,
|
|
345
|
+
key: string,
|
|
346
|
+
indent: boolean,
|
|
347
|
+
collapsed = true,
|
|
348
|
+
glance: CallStatus | string = "",
|
|
349
|
+
kernel?: KernelRoot | null,
|
|
350
|
+
blobs?: BlobPeek,
|
|
351
|
+
): WorkCellFace {
|
|
352
|
+
const jobState = work instanceof ProgrammaticJob ? schemeCellState(work) : toolCellState(work);
|
|
353
|
+
const line = identityLine(work, kernel);
|
|
354
|
+
const comment =
|
|
355
|
+
work instanceof ApprovableJob && work.approvalComment != null && work.approvalComment !== ""
|
|
356
|
+
? work.approvalComment
|
|
357
|
+
: "";
|
|
358
|
+
const agent = line.agent === true;
|
|
359
|
+
const pretty = line.pretty === true && !agent;
|
|
360
|
+
const tape = typeof glance === "string" ? { status: glance } : glance;
|
|
361
|
+
return {
|
|
362
|
+
key,
|
|
363
|
+
// Live bag with an open inner paints sealed chrome, not a spinner.
|
|
364
|
+
state:
|
|
365
|
+
work instanceof ProgrammaticJob &&
|
|
366
|
+
jobState === "live" &&
|
|
367
|
+
work.toolCalls.some((inner) => {
|
|
368
|
+
const innerState = toolCellState(inner);
|
|
369
|
+
return innerState === "live" || innerState === "ask";
|
|
370
|
+
})
|
|
371
|
+
? "ok"
|
|
372
|
+
: jobState,
|
|
373
|
+
name: faceText(line.name),
|
|
374
|
+
object: agent ? faceText(tape.status).trim() : faceText(line.object),
|
|
375
|
+
status: [faceText(line.status), comment].filter((part) => part !== "").join(" "),
|
|
376
|
+
caption: faceText(line.caption),
|
|
377
|
+
pathShaped: !pretty && work instanceof ToolCallJob && isPathShapedName(faceText(work.name)),
|
|
378
|
+
split: !pretty && !agent && work instanceof MCPToolJob,
|
|
379
|
+
agent,
|
|
380
|
+
pretty,
|
|
381
|
+
prettyBlock: agent ? agentExpandBlock(faceText(line.block), faceText(tape.model)) : faceText(line.block),
|
|
382
|
+
response: agent ? agentResponseText(work) : "",
|
|
383
|
+
indent,
|
|
384
|
+
collapsed,
|
|
385
|
+
receipt: receiptFace(work, jobState, blobs),
|
|
386
|
+
rimNamed: coverAskAnnotation(work, { kernel }) !== undefined,
|
|
387
|
+
};
|
|
388
|
+
}
|