@mcrescenzo/opencode-workflows 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/CHANGELOG.md +14 -0
- package/CODE_OF_CONDUCT.md +134 -0
- package/CONTRIBUTING.md +95 -0
- package/LICENSE +21 -0
- package/README.md +746 -0
- package/SECURITY.md +38 -0
- package/commands/repo-bughunt.md +94 -0
- package/commands/repo-review.md +148 -0
- package/commands/workflow-live-gates-release-check.md +143 -0
- package/docs/workflow-plugin.md +400 -0
- package/opencode-workflows.js +5 -0
- package/package.json +86 -0
- package/skills/opencode-workflow-authoring/SKILL.md +180 -0
- package/skills/repo-review-command-protocol/SKILL.md +56 -0
- package/skills/workflow-model-tiering/SKILL.md +57 -0
- package/skills/workflow-plan-review/SKILL.md +91 -0
- package/workflow-kernel/approval-hashing.js +39 -0
- package/workflow-kernel/async-util.js +33 -0
- package/workflow-kernel/audited-shell-policy.js +200 -0
- package/workflow-kernel/authority-policy.js +670 -0
- package/workflow-kernel/budget-accounting.js +142 -0
- package/workflow-kernel/capability-adapter.js +753 -0
- package/workflow-kernel/child-agent-runner.js +1264 -0
- package/workflow-kernel/constants.js +117 -0
- package/workflow-kernel/diagnostics.js +152 -0
- package/workflow-kernel/drain-runtime.js +421 -0
- package/workflow-kernel/errors.js +181 -0
- package/workflow-kernel/event-journal.js +487 -0
- package/workflow-kernel/extension-registry.js +144 -0
- package/workflow-kernel/free-text-redactor.js +91 -0
- package/workflow-kernel/gate-shapes.js +82 -0
- package/workflow-kernel/git-util.js +45 -0
- package/workflow-kernel/index.js +72 -0
- package/workflow-kernel/integration-mode.js +155 -0
- package/workflow-kernel/lane-effort-policy.js +134 -0
- package/workflow-kernel/lifecycle-control.js +608 -0
- package/workflow-kernel/live-gate-probes.js +916 -0
- package/workflow-kernel/notification-toast-cards.js +393 -0
- package/workflow-kernel/notification-toast-policy.js +179 -0
- package/workflow-kernel/notification-toast-scope.js +100 -0
- package/workflow-kernel/notification-toast.js +287 -0
- package/workflow-kernel/path-policy.js +219 -0
- package/workflow-kernel/result-readback.js +106 -0
- package/workflow-kernel/role-template-loading.js +606 -0
- package/workflow-kernel/run-context.js +139 -0
- package/workflow-kernel/run-observability.js +43 -0
- package/workflow-kernel/run-store-fs.js +231 -0
- package/workflow-kernel/run-store-locks.js +180 -0
- package/workflow-kernel/run-store-projections.js +421 -0
- package/workflow-kernel/run-store-rehydrate.js +68 -0
- package/workflow-kernel/run-store-state.js +147 -0
- package/workflow-kernel/run-store-status-format.js +1154 -0
- package/workflow-kernel/run-store-status.js +107 -0
- package/workflow-kernel/sandbox-executor.js +1131 -0
- package/workflow-kernel/session-access.js +56 -0
- package/workflow-kernel/structured-output.js +143 -0
- package/workflow-kernel/test-fix-drain-adapter.js +119 -0
- package/workflow-kernel/text-json.js +136 -0
- package/workflow-kernel/workflow-plugin.js +3017 -0
- package/workflow-kernel/workflow-source.js +444 -0
- package/workflow-kernel/worktree-adapter.js +256 -0
- package/workflow-kernel/worktree-git.js +147 -0
- package/workflows/repo-bughunt.js +362 -0
- package/workflows/repo-cleanup.js +383 -0
- package/workflows/repo-complexity.js +404 -0
- package/workflows/repo-deps.js +457 -0
- package/workflows/repo-modernize.js +395 -0
- package/workflows/repo-perf.js +394 -0
- package/workflows/repo-review.js +831 -0
- package/workflows/repo-security-audit.js +466 -0
- package/workflows/repo-test-gaps.js +377 -0
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { hasFunction } from "./text-json.js";
|
|
2
|
+
|
|
3
|
+
export function sessionShape(pluginContext) {
|
|
4
|
+
return pluginContext.__workflowSessionShape ?? pluginContext.client?.__workflowSessionShape ?? "v1";
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function sessionApi(pluginContext) {
|
|
8
|
+
const session = pluginContext.client?.session ?? {};
|
|
9
|
+
const useV2 = sessionShape(pluginContext) === "v2";
|
|
10
|
+
return {
|
|
11
|
+
raw: session,
|
|
12
|
+
has(name) {
|
|
13
|
+
return hasFunction(session, name);
|
|
14
|
+
},
|
|
15
|
+
async create(input = {}) {
|
|
16
|
+
const body = {
|
|
17
|
+
...(input.parentID ? { parentID: input.parentID } : {}),
|
|
18
|
+
...(input.title ? { title: input.title } : {}),
|
|
19
|
+
...(input.agent ? { agent: input.agent } : {}),
|
|
20
|
+
...(input.model ? { model: input.model } : {}),
|
|
21
|
+
...(input.permission ? { permission: input.permission } : {}),
|
|
22
|
+
};
|
|
23
|
+
return useV2
|
|
24
|
+
? await session.create({ directory: input.directory, ...body })
|
|
25
|
+
: await session.create({ body, query: { directory: input.directory } });
|
|
26
|
+
},
|
|
27
|
+
async prompt(input = {}) {
|
|
28
|
+
const body = input.body ?? {};
|
|
29
|
+
return useV2
|
|
30
|
+
? await session.prompt({ sessionID: input.sessionID, directory: input.directory, ...body })
|
|
31
|
+
: await session.prompt({ path: { id: input.sessionID }, query: { directory: input.directory }, body });
|
|
32
|
+
},
|
|
33
|
+
async promptAsync(input = {}) {
|
|
34
|
+
const body = input.body ?? {};
|
|
35
|
+
return useV2
|
|
36
|
+
? await session.promptAsync({ sessionID: input.sessionID, directory: input.directory, ...body })
|
|
37
|
+
: await session.promptAsync({ path: { id: input.sessionID }, query: { directory: input.directory }, body });
|
|
38
|
+
},
|
|
39
|
+
async abort(input = {}) {
|
|
40
|
+
return useV2
|
|
41
|
+
? await session.abort({ sessionID: input.sessionID, directory: input.directory })
|
|
42
|
+
: await session.abort({ path: { id: input.sessionID }, query: { directory: input.directory } });
|
|
43
|
+
},
|
|
44
|
+
async messages(input = {}) {
|
|
45
|
+
return useV2
|
|
46
|
+
? await session.messages({ sessionID: input.sessionID, directory: input.directory, limit: input.limit })
|
|
47
|
+
: await session.messages({ path: { id: input.sessionID }, query: { directory: input.directory, limit: input.limit } });
|
|
48
|
+
},
|
|
49
|
+
async shell(input = {}) {
|
|
50
|
+
const body = input.body ?? {};
|
|
51
|
+
return useV2
|
|
52
|
+
? await session.shell({ sessionID: input.sessionID, directory: input.directory, ...body })
|
|
53
|
+
: await session.shell({ path: { id: input.sessionID }, query: { directory: input.directory }, body });
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import Ajv from "ajv";
|
|
2
|
+
|
|
3
|
+
import { MAX_RESULT_BYTES } from "./constants.js";
|
|
4
|
+
import { hash, stableStringify } from "./text-json.js";
|
|
5
|
+
|
|
6
|
+
export const ajv = new Ajv({ allErrors: true, strict: false });
|
|
7
|
+
export const MAX_SCHEMA_SNAPSHOT_BYTES = 16 * 1024;
|
|
8
|
+
|
|
9
|
+
const VALIDATOR_HASH_CACHE_MAX = 256;
|
|
10
|
+
|
|
11
|
+
// Bounded, insertion-ordered validator cache keyed by canonical schema hash.
|
|
12
|
+
// schema values originate from guest workflow source (untrusted per AGENTS.md/SECURITY.md)
|
|
13
|
+
// and this cache lives for the whole plugin process, shared across every workflow run, so
|
|
14
|
+
// it must be bounded to honor the "bound all module-level maps / no unbounded accumulation"
|
|
15
|
+
// invariant. Uses the same oldest-first eviction as BoundedTimestampSet in
|
|
16
|
+
// workflow-kernel/lifecycle-control.js (Map preserves insertion order).
|
|
17
|
+
class BoundedValidatorCache {
|
|
18
|
+
constructor({ max = VALIDATOR_HASH_CACHE_MAX } = {}) {
|
|
19
|
+
this.max = max;
|
|
20
|
+
this.items = new Map();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
get(key) {
|
|
24
|
+
return this.items.get(key);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
set(key, value) {
|
|
28
|
+
if (this.items.has(key)) this.items.delete(key);
|
|
29
|
+
this.items.set(key, value);
|
|
30
|
+
while (this.items.size > this.max) {
|
|
31
|
+
const oldest = this.items.keys().next().value;
|
|
32
|
+
if (oldest === undefined) break;
|
|
33
|
+
this.items.delete(oldest);
|
|
34
|
+
}
|
|
35
|
+
return this;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
get size() {
|
|
39
|
+
return this.items.size;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const validatorCache = new WeakMap();
|
|
44
|
+
const validatorHashCache = new BoundedValidatorCache();
|
|
45
|
+
|
|
46
|
+
// Maps a schema $id to the canonical content hash of the schema last compiled under it.
|
|
47
|
+
// Ajv treats $id as a stable, unique content identity: ajv.getSchema($id) returns whatever
|
|
48
|
+
// validator was FIRST registered under that id and never re-checks it against the schema
|
|
49
|
+
// passed on this call. Guest workflow schemas are untrusted (AGENTS.md/SECURITY.md) and can
|
|
50
|
+
// reuse one $id for a differently-shaped schema, which would otherwise be validated with the
|
|
51
|
+
// stale wrong rules. We record the content hash per id so we can detect drift and
|
|
52
|
+
// remove+recompile instead of trusting the id string forever. Bounded for the same
|
|
53
|
+
// module-level-map reason as validatorHashCache.
|
|
54
|
+
const registeredSchemaIdHashes = new BoundedValidatorCache();
|
|
55
|
+
|
|
56
|
+
// Compile `schema` into an AJV validator, reusing an already-registered $id validator ONLY when
|
|
57
|
+
// the schema content still hashes to what was registered under that $id. On content drift (a reused
|
|
58
|
+
// $id carrying a different shape) the stale registration is removed before recompiling, so a payload
|
|
59
|
+
// is never silently validated against a different schema's rules. Falls back to a plain compile for
|
|
60
|
+
// schemas without a usable $id (AJV never rejects those as duplicate ids).
|
|
61
|
+
export function compileSchemaWithIdentity(schema) {
|
|
62
|
+
if (!schema || typeof schema !== "object" || Array.isArray(schema)) return ajv.compile(schema);
|
|
63
|
+
const id = typeof schema.$id === "string" && schema.$id.trim() ? schema.$id : null;
|
|
64
|
+
if (!id) return ajv.compile(schema);
|
|
65
|
+
const schemaKey = hash(stableStringify(schema));
|
|
66
|
+
const existing = ajv.getSchema(id);
|
|
67
|
+
if (existing && registeredSchemaIdHashes.get(id) === schemaKey) return existing;
|
|
68
|
+
// Either the $id is registered with a different (or unverified) schema, or we hold no proof its
|
|
69
|
+
// content matches: drop the stale registration before compiling the schema passed on this call.
|
|
70
|
+
if (existing) ajv.removeSchema(id);
|
|
71
|
+
const validate = ajv.compile(schema);
|
|
72
|
+
registeredSchemaIdHashes.set(id, schemaKey);
|
|
73
|
+
return validate;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function compiledValidator(schema) {
|
|
77
|
+
if (!schema || typeof schema !== "object") return ajv.compile(schema);
|
|
78
|
+
let validate = validatorCache.get(schema);
|
|
79
|
+
if (validate) return validate;
|
|
80
|
+
const schemaKey = hash(stableStringify(schema));
|
|
81
|
+
validate = validatorHashCache.get(schemaKey);
|
|
82
|
+
if (!validate) {
|
|
83
|
+
// Content-cache miss: compile via the identity-checked path so a reused $id can't return a
|
|
84
|
+
// stale validator compiled for a different schema.
|
|
85
|
+
validate = compileSchemaWithIdentity(schema);
|
|
86
|
+
validatorHashCache.set(schemaKey, validate);
|
|
87
|
+
}
|
|
88
|
+
validatorCache.set(schema, validate);
|
|
89
|
+
return validate;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function validateStructuredResult(schema, value) {
|
|
93
|
+
const validate = compiledValidator(schema);
|
|
94
|
+
if (validate(value)) return;
|
|
95
|
+
throw new Error(ajv.errorsText(validate.errors, { separator: "\n", dataVar: "result" }));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function structuredFormat(schema) {
|
|
99
|
+
return { type: "json_schema", schema };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function boundedSchemaSnapshot(schema, maxBytes = MAX_SCHEMA_SNAPSHOT_BYTES) {
|
|
103
|
+
if (!schema) return { status: "absent" };
|
|
104
|
+
const canonical = stableStringify(schema);
|
|
105
|
+
const bytes = Buffer.byteLength(canonical, "utf8");
|
|
106
|
+
const schemaHash = hash(canonical);
|
|
107
|
+
if (bytes > maxBytes) return { status: "oversized", bytes, hash: schemaHash };
|
|
108
|
+
return { status: "present", bytes, hash: schemaHash, schema };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
export function structuredTextInstruction(schema) {
|
|
113
|
+
return [
|
|
114
|
+
"Your final response MUST be a single valid JSON object matching this JSON Schema.",
|
|
115
|
+
"Do not include markdown, code fences, commentary, or any text before or after the JSON.",
|
|
116
|
+
"JSON Schema:\n" + JSON.stringify(schema),
|
|
117
|
+
].join("\n\n");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function structuredCorrectiveInstruction(schema, validationMessage) {
|
|
121
|
+
return [
|
|
122
|
+
`Your previous response failed validation: ${String(validationMessage ?? "unknown validation error")}.`,
|
|
123
|
+
"Reply again with ONLY a corrected JSON object. Do not include markdown, code fences, commentary, or any text before or after the JSON.",
|
|
124
|
+
"JSON Schema:\n" + JSON.stringify(schema),
|
|
125
|
+
].join("\n\n");
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function parseStructuredTextResult(text) {
|
|
129
|
+
const raw = String(text ?? "").trim();
|
|
130
|
+
if (!raw) throw new Error("Structured text fallback returned an empty response");
|
|
131
|
+
try {
|
|
132
|
+
return JSON.parse(raw);
|
|
133
|
+
} catch (e) {
|
|
134
|
+
const s = raw.indexOf("{"), t = raw.lastIndexOf("}");
|
|
135
|
+
if (s >= 0 && t > s) return JSON.parse(raw.slice(s, t + 1));
|
|
136
|
+
throw e;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function assertResultSize(value) {
|
|
141
|
+
const bytes = Buffer.byteLength(JSON.stringify(value ?? null), "utf8");
|
|
142
|
+
if (bytes > MAX_RESULT_BYTES) throw new Error(`Workflow output exceeds ${MAX_RESULT_BYTES} bytes`);
|
|
143
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
|
|
4
|
+
const execFileAsync = promisify(execFile);
|
|
5
|
+
const DEFAULT_COMMAND_TIMEOUT_MS = 2 * 60 * 1000;
|
|
6
|
+
const DEFAULT_COMMAND_MAX_BUFFER = 10 * 1024 * 1024;
|
|
7
|
+
|
|
8
|
+
export async function defaultRunCommand(command, options = {}) {
|
|
9
|
+
const [bin, ...args] = Array.isArray(command) ? command : String(command).split(/\s+/).filter(Boolean);
|
|
10
|
+
if (!bin) throw new Error("test-fix adapter requires a test command");
|
|
11
|
+
try {
|
|
12
|
+
const result = await execFileAsync(bin, args, {
|
|
13
|
+
cwd: options.cwd,
|
|
14
|
+
encoding: "utf8",
|
|
15
|
+
timeout: Number.isFinite(options.timeoutMs) ? options.timeoutMs : DEFAULT_COMMAND_TIMEOUT_MS,
|
|
16
|
+
maxBuffer: Number.isFinite(options.maxBuffer) ? options.maxBuffer : DEFAULT_COMMAND_MAX_BUFFER,
|
|
17
|
+
signal: options.signal,
|
|
18
|
+
});
|
|
19
|
+
return { exitCode: 0, stdout: result.stdout ?? "", stderr: result.stderr ?? "" };
|
|
20
|
+
} catch (error) {
|
|
21
|
+
return { exitCode: Number.isInteger(error.code) ? error.code : 1, stdout: error.stdout ?? "", stderr: error.stderr ?? error.message ?? "" };
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function commandText(command) {
|
|
26
|
+
return Array.isArray(command) ? command.join(" ") : String(command);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function groupTestFailures(output) {
|
|
30
|
+
const text = String(output ?? "");
|
|
31
|
+
const failures = [];
|
|
32
|
+
for (const line of text.split(/\r?\n/)) {
|
|
33
|
+
const trimmed = line.trim();
|
|
34
|
+
if (!trimmed) continue;
|
|
35
|
+
const failMatch = trimmed.match(/^(?:FAIL|not ok)\s+(.+?)(?::\s*(.+))?$/i);
|
|
36
|
+
if (failMatch) {
|
|
37
|
+
const target = failMatch[1].trim();
|
|
38
|
+
failures.push({ id: `test:${target}`, target, summary: failMatch[2]?.trim() || trimmed, raw: trimmed });
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
if (failures.length === 0 && text.trim()) failures.push({ id: "test:unknown", target: "unknown", summary: text.trim().slice(0, 300), raw: text.trim() });
|
|
42
|
+
return failures;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function createTestFixDrainAdapter(options = {}) {
|
|
46
|
+
const cwd = options.cwd ?? process.cwd();
|
|
47
|
+
const testCommand = options.testCommand ?? ["npm", "test"];
|
|
48
|
+
const runCommand = options.runCommand ?? defaultRunCommand;
|
|
49
|
+
const createdFollowups = [];
|
|
50
|
+
const commandRuns = [];
|
|
51
|
+
|
|
52
|
+
async function runTests(context = {}) {
|
|
53
|
+
const result = await runCommand(testCommand, { cwd, phase: context.phase ?? "test", signal: options.signal, timeoutMs: options.timeoutMs, maxBuffer: options.maxBuffer });
|
|
54
|
+
commandRuns.push({ command: commandText(testCommand), phase: context.phase ?? "test", exitCode: result.exitCode });
|
|
55
|
+
return result;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
name: "test-fix",
|
|
60
|
+
commandRuns,
|
|
61
|
+
async discover() {
|
|
62
|
+
const result = await runTests({ phase: "discover" });
|
|
63
|
+
if (result.exitCode === 0) return [];
|
|
64
|
+
return groupTestFailures(`${result.stdout}\n${result.stderr}`).map((failure) => ({
|
|
65
|
+
...failure,
|
|
66
|
+
status: "open",
|
|
67
|
+
issue_type: "test-failure",
|
|
68
|
+
output: `${result.stdout}\n${result.stderr}`.trim(),
|
|
69
|
+
}));
|
|
70
|
+
},
|
|
71
|
+
async classify(item) {
|
|
72
|
+
if (!item?.target || item.target === "unknown") return { status: "human-gated", reason: "failure target could not be isolated" };
|
|
73
|
+
if (item.humanGated === true) return { status: "human-gated", reason: "failure marked human-gated" };
|
|
74
|
+
return { status: "ready", reason: "isolated failing test target" };
|
|
75
|
+
},
|
|
76
|
+
async claim(item) {
|
|
77
|
+
return { itemId: item.id, claimed: true, target: item.target };
|
|
78
|
+
},
|
|
79
|
+
async buildLanePacket(item, context = {}) {
|
|
80
|
+
return {
|
|
81
|
+
itemId: item.id,
|
|
82
|
+
target: item.target,
|
|
83
|
+
summary: item.summary,
|
|
84
|
+
failingOutput: item.output,
|
|
85
|
+
attempt: context.attempt,
|
|
86
|
+
instructions: [
|
|
87
|
+
"Repair only the failing test target and directly related implementation.",
|
|
88
|
+
"Return a generic lane report with changed files, commands, evidence, risks, and followups.",
|
|
89
|
+
],
|
|
90
|
+
};
|
|
91
|
+
},
|
|
92
|
+
async validate(item, integrationState, context = {}) {
|
|
93
|
+
const result = await runTests({ phase: "validate" });
|
|
94
|
+
const accepted = result.exitCode === 0 && context.laneReport?.readyForIntegration === true;
|
|
95
|
+
return {
|
|
96
|
+
itemId: item.id,
|
|
97
|
+
accepted,
|
|
98
|
+
reason: accepted ? "test command passed after repair" : "test command still fails after repair",
|
|
99
|
+
diffScopeOk: integrationState?.status !== "review-required",
|
|
100
|
+
followupsHandled: true,
|
|
101
|
+
acceptanceChecklist: ["test command rerun", "lane report ready for integration"],
|
|
102
|
+
validationCommands: [commandText(testCommand)],
|
|
103
|
+
followups: accepted ? [] : [{ title: `Unresolved test failure: ${item.target}`, description: `${result.stdout}\n${result.stderr}`.trim() }],
|
|
104
|
+
};
|
|
105
|
+
},
|
|
106
|
+
async close(item, evidence = {}) {
|
|
107
|
+
return { itemId: item.id, status: "closed", reason: evidence.validationReport?.reason ?? "test repaired" };
|
|
108
|
+
},
|
|
109
|
+
async createFollowup(followup = {}) {
|
|
110
|
+
const created = { id: `test-followup-${createdFollowups.length + 1}`, ...followup };
|
|
111
|
+
createdFollowups.push(created);
|
|
112
|
+
return created;
|
|
113
|
+
},
|
|
114
|
+
async proveDry() {
|
|
115
|
+
const result = await runTests({ phase: "proveDry" });
|
|
116
|
+
return { dry: result.exitCode === 0, command: commandText(testCommand), output: `${result.stdout}\n${result.stderr}`.trim() };
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
|
|
3
|
+
import { MAX_ARGS_PREVIEW_CHARS, MAX_STATUS_STRING_CHARS, SENSITIVE_KEY_RE } from "./constants.js";
|
|
4
|
+
import { redactFreeTextSecrets } from "./free-text-redactor.js";
|
|
5
|
+
|
|
6
|
+
const SAFE_USAGE_KEY_RE = /^(tokens|tokenUsage|usageTokens)$/i;
|
|
7
|
+
const SAFE_USAGE_METRIC_KEYS = new Set(["input", "output", "reasoning", "total", "cached"]);
|
|
8
|
+
|
|
9
|
+
export function stableStringify(value) {
|
|
10
|
+
// R29: mirror JSON.stringify / writeJsonAtomic for undefined so an in-memory hash
|
|
11
|
+
// matches the hash recomputed from the persisted (JSON.stringify'd) file. JSON.stringify
|
|
12
|
+
// drops undefined-valued object keys and coerces undefined array elements to null; a
|
|
13
|
+
// standalone undefined serializes to the JS value undefined, which we represent with a
|
|
14
|
+
// stable sentinel only at the top level (object/array recursion never reaches it).
|
|
15
|
+
if (value === undefined) return "undefined";
|
|
16
|
+
if (typeof value === "bigint") return JSON.stringify(String(value));
|
|
17
|
+
if (value === null || typeof value !== "object") return JSON.stringify(value);
|
|
18
|
+
if (Array.isArray(value)) return `[${value.map((item) => (item === undefined ? "null" : stableStringify(item))).join(",")}]`;
|
|
19
|
+
return `{${Object.keys(value)
|
|
20
|
+
.sort()
|
|
21
|
+
.filter((key) => value[key] !== undefined)
|
|
22
|
+
.map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`)
|
|
23
|
+
.join(",")}}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function hash(value) {
|
|
27
|
+
return crypto.hash("sha256", value, "hex");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function hashStable(value) {
|
|
31
|
+
return hash(stableStringify(value));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function jsonLine(value) {
|
|
35
|
+
return `${JSON.stringify(value)}\n`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function hasFunction(value, key) {
|
|
39
|
+
return typeof value?.[key] === "function";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function textPart(text) {
|
|
43
|
+
return { type: "text", text };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Cap a string to at most `max` characters without ever appending the (oversized)
|
|
47
|
+
// truncation marker. Used only for the degenerate small-max branch of truncateText,
|
|
48
|
+
// where the marker itself would not fit. Prefers a short "..." ellipsis when there is
|
|
49
|
+
// room for it, otherwise falls back to a plain slice; never splits a trailing surrogate.
|
|
50
|
+
function hardTruncate(string, max) {
|
|
51
|
+
if (max <= 0) return "";
|
|
52
|
+
if (max <= 3) return string.slice(0, max);
|
|
53
|
+
let head = string.slice(0, max - 3);
|
|
54
|
+
const last = head.charCodeAt(head.length - 1);
|
|
55
|
+
if (last >= 0xd800 && last <= 0xdbff) head = head.slice(0, -1);
|
|
56
|
+
return `${head}...`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function truncateText(text, max = MAX_STATUS_STRING_CHARS) {
|
|
60
|
+
const string = String(text ?? "");
|
|
61
|
+
if (string.length <= max) return string;
|
|
62
|
+
// The reported drop-count must reflect how many characters are ACTUALLY dropped
|
|
63
|
+
// (string.length - head.length), not string.length - max: the suffix is carved out
|
|
64
|
+
// of the max-character budget, so the head keeps fewer than max chars. There is a
|
|
65
|
+
// mutual dependency — the suffix length depends on the count's digits, and the head
|
|
66
|
+
// length depends on the suffix length — so iterate to a fixed point (converges in a
|
|
67
|
+
// few steps because the count changes by at most one digit per pass).
|
|
68
|
+
let n = string.length - max;
|
|
69
|
+
let head = "";
|
|
70
|
+
let suffix = "";
|
|
71
|
+
for (let i = 0; i < 5; i++) {
|
|
72
|
+
suffix = `...[truncated ${n} chars]`;
|
|
73
|
+
// Degenerate case: when the truncation marker alone is at least as long as the
|
|
74
|
+
// whole budget, appending it to any head would blow past max. Hard-cap at exactly
|
|
75
|
+
// max characters instead so the result.length <= max invariant holds for every max.
|
|
76
|
+
if (suffix.length >= max) return hardTruncate(string, max);
|
|
77
|
+
head = string.slice(0, Math.max(0, max - suffix.length));
|
|
78
|
+
const last = head.charCodeAt(head.length - 1);
|
|
79
|
+
if (last >= 0xd800 && last <= 0xdbff) head = head.slice(0, -1);
|
|
80
|
+
const dropped = string.length - head.length;
|
|
81
|
+
if (dropped === n) break;
|
|
82
|
+
n = dropped;
|
|
83
|
+
}
|
|
84
|
+
return `${head}${suffix}`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function isSafeUsageObject(key, value) {
|
|
88
|
+
if (!SAFE_USAGE_KEY_RE.test(key) || !value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
89
|
+
const entries = Object.entries(value);
|
|
90
|
+
return entries.length > 0 && entries.every(([metric, child]) => SAFE_USAGE_METRIC_KEYS.has(metric) && Number.isFinite(child));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function redactValue(value, { depth = 0, maxDepth = 5, maxString = MAX_STATUS_STRING_CHARS, maxArray = 50 } = {}) {
|
|
94
|
+
if (value === null || value === undefined) return value;
|
|
95
|
+
if (typeof value === "string") return truncateText(redactFreeTextSecrets(value), maxString);
|
|
96
|
+
if (typeof value === "number" || typeof value === "boolean") return value;
|
|
97
|
+
if (typeof value === "bigint") return String(value);
|
|
98
|
+
if (depth >= maxDepth) return "[redacted:depth]";
|
|
99
|
+
if (Array.isArray(value)) {
|
|
100
|
+
const limit = Number.isFinite(maxArray) ? maxArray : value.length;
|
|
101
|
+
return value.slice(0, limit).map((item) => redactValue(item, { depth: depth + 1, maxDepth, maxString, maxArray }));
|
|
102
|
+
}
|
|
103
|
+
if (typeof value === "object") {
|
|
104
|
+
const redacted = {};
|
|
105
|
+
for (const [key, child] of Object.entries(value)) {
|
|
106
|
+
redacted[key] = SENSITIVE_KEY_RE.test(key) && !isSafeUsageObject(key, child) ? "[redacted]" : redactValue(child, { depth: depth + 1, maxDepth, maxString, maxArray });
|
|
107
|
+
}
|
|
108
|
+
return redacted;
|
|
109
|
+
}
|
|
110
|
+
return `[redacted:${typeof value}]`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function redactDurableValue(value) {
|
|
114
|
+
return redactValue(value, {
|
|
115
|
+
maxDepth: Number.POSITIVE_INFINITY,
|
|
116
|
+
maxString: Number.POSITIVE_INFINITY,
|
|
117
|
+
maxArray: Number.POSITIVE_INFINITY,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function jsonPreview(value, max = MAX_ARGS_PREVIEW_CHARS) {
|
|
122
|
+
const text = JSON.stringify(redactValue(value), null, 2);
|
|
123
|
+
return truncateText(text, max);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function responseText(result) {
|
|
127
|
+
return (result?.data?.parts ?? [])
|
|
128
|
+
.filter((part) => part?.type === "text" && typeof part.text === "string")
|
|
129
|
+
.map((part) => part.text)
|
|
130
|
+
.join("\n")
|
|
131
|
+
.trim();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function extractTextFromError(error) {
|
|
135
|
+
return error?.message || String(error);
|
|
136
|
+
}
|