@xingular/harness 2.1.1 → 3.0.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 +18 -0
- package/NOTICE.md +22 -0
- package/README.md +70 -37
- package/dist/cli.js +6572 -6700
- package/dist/terminal-pi.js +51 -38
- package/docs/terminal-tui.md +50 -12
- package/package.json +2 -2
- package/src/accounts/accounts.ts +4 -3
- package/src/capabilities/api.ts +1 -1
- package/src/cli.ts +7 -3
- package/src/core/bus.test.ts +15 -1
- package/src/core/bus.ts +39 -0
- package/src/core/caller.ts +34 -0
- package/src/core/fs.ts +17 -1
- package/src/harness.ts +55 -66
- package/src/{memory → history}/recall.test.ts +1 -1
- package/src/{memory → history}/recall.ts +1 -1
- package/src/{memory → history}/trajectory.test.ts +4 -4
- package/src/{memory → history}/trajectory.ts +1 -1
- package/src/hub/hub.test.ts +4 -5
- package/src/hub/hub.ts +2 -2
- package/src/index.ts +28 -14
- package/src/mcp/connections.ts +1 -1
- package/src/mcp/service.ts +1 -1
- package/src/pi/proxy.test.ts +68 -0
- package/src/pi/runtime.test.ts +61 -0
- package/src/pi/runtime.ts +50 -0
- package/src/plugins/api.ts +11 -9
- package/src/plugins/builtin.ts +22 -40
- package/src/plugins/catalogue.test.ts +13 -41
- package/src/plugins/catalogue.ts +3 -32
- package/src/plugins/discover.ts +2 -1
- package/src/plugins/firstParty.test.ts +1 -1
- package/src/plugins/hooks.test.ts +21 -71
- package/src/plugins/hooks.ts +6 -7
- package/src/plugins/identity.ts +16 -2
- package/src/plugins/install.test.ts +1 -1
- package/src/plugins/install.ts +2 -1
- package/src/plugins/inventory.ts +19 -18
- package/src/plugins/plugins.ts +38 -2
- package/src/prompt/compile.test.ts +82 -193
- package/src/prompt/compile.ts +98 -363
- package/src/refine/cli.ts +114 -0
- package/src/refine/demo.ts +124 -0
- package/src/refine/evidence.ts +349 -0
- package/src/refine/host.ts +102 -0
- package/src/refine/ledger.ts +104 -0
- package/src/refine/library.ts +192 -0
- package/src/refine/pi.test.ts +90 -0
- package/src/refine/pi.ts +218 -0
- package/src/refine/prompts.ts +430 -0
- package/src/refine/recursion.ts +353 -0
- package/src/refine/refine.test.ts +140 -0
- package/src/refine/runner.ts +205 -0
- package/src/refine/selection.ts +291 -0
- package/src/refine/service.ts +194 -0
- package/src/refine/trajectory.ts +71 -0
- package/src/refine/types.ts +91 -0
- package/src/server/methods.ts +137 -123
- package/src/server/server.test.ts +37 -30
- package/src/sessions/contracts.ts +7 -2
- package/src/sessions/conversation.test.ts +2 -27
- package/src/sessions/planMode.test.ts +8 -1
- package/src/sessions/planMode.ts +17 -9
- package/src/sessions/policy.ts +1 -1
- package/src/sessions/pricing.ts +3 -1
- package/src/sessions/questionBatches.test.ts +15 -3
- package/src/sessions/registry.ts +13 -1
- package/src/sessions/runStore.ts +3 -1
- package/src/sessions/runner.ts +10 -5
- package/src/sessions/sessions.test.ts +60 -13
- package/src/sessions/sessions.ts +43 -18
- package/src/sessions/subagentService.test.ts +22 -1
- package/src/sessions/subagentService.ts +14 -1
- package/src/sessions/subagents.ts +2 -0
- package/src/terminal/cli.ts +24 -48
- package/src/terminal/logs.test.ts +26 -0
- package/src/terminal/logs.ts +67 -0
- package/src/terminal/piExtension.lifecycle.test.ts +1 -1
- package/src/terminal/piExtension.ts +3 -3
- package/src/terminal/pluginControl.test.ts +7 -8
- package/src/terminal/socket.ts +3 -3
- package/src/terminal/terminal.test.ts +41 -39
- package/src/terminal/terminals.ts +31 -13
- package/src/terminal/tui/application.test.ts +58 -0
- package/src/terminal/tui/application.ts +15 -4
- package/src/terminal/tui/clipboardFiles.test.ts +94 -0
- package/src/terminal/tui/clipboardFiles.ts +86 -0
- package/src/terminal/tui/commands.test.ts +13 -1
- package/src/terminal/tui/commands.ts +16 -0
- package/src/terminal/tui/logPanel.test.ts +61 -0
- package/src/terminal/tui/logPanel.ts +175 -0
- package/src/terminal/tui/piAdapter.ts +7 -4
- package/src/terminal/tui/questionPicker.ts +168 -0
- package/src/terminal/tui/questions.test.ts +86 -19
- package/src/terminal/tui/questions.ts +27 -22
- package/src/terminal/tui/runtime.ts +2 -0
- package/src/tools/askUser.test.ts +23 -2
- package/src/tools/askUser.ts +15 -5
- package/src/tools/definitions.ts +49 -132
- package/src/tools/dispatch.ts +78 -0
- package/src/tools/harnessTools.ts +23 -0
- package/src/tools/spawnSubagent.ts +3 -3
- package/src/tools/types.ts +3 -1
- package/src/tree/components.ts +221 -0
- package/src/tree/materialise.ts +122 -0
- package/src/tree/seed.ts +123 -0
- package/src/tree/store.ts +193 -0
- package/src/tree/surface.ts +135 -0
- package/src/tree/tree.test.ts +126 -0
- package/src/tree/tree.ts +346 -0
- package/src/tree/trust.ts +86 -0
- package/src/web/tools.ts +27 -5
- package/src/web/web.test.ts +26 -1
- package/src/wiki/notes.ts +313 -0
- package/src/memory/api.test.ts +0 -82
- package/src/memory/api.ts +0 -303
- package/src/memory/blocks.test.ts +0 -78
- package/src/memory/blocks.ts +0 -408
- package/src/memory/caller.ts +0 -69
- package/src/memory/curation.test.ts +0 -206
- package/src/memory/curation.ts +0 -64
- package/src/memory/dispatch.ts +0 -64
- package/src/memory/facts.test.ts +0 -41
- package/src/memory/facts.ts +0 -248
- package/src/memory/graph.ts +0 -103
- package/src/memory/health.test.ts +0 -130
- package/src/memory/health.ts +0 -74
- package/src/memory/maintainer.ts +0 -272
- package/src/memory/maintenance.test.ts +0 -192
- package/src/memory/maintenance.ts +0 -679
- package/src/memory/migrate.ts +0 -79
- package/src/memory/operations.ts +0 -150
- package/src/memory/proposals.test.ts +0 -168
- package/src/memory/proposals.ts +0 -584
- package/src/memory/references.ts +0 -77
- package/src/memory/runContext.ts +0 -93
- package/src/memory/store.test.ts +0 -66
- package/src/memory/store.ts +0 -385
- package/src/memory/wiki.test.ts +0 -47
- package/src/memory/wiki.ts +0 -423
- package/src/memory/wikiIndex.test.ts +0 -88
- package/src/memory/wikiIndex.ts +0 -117
- package/src/plugins/proposal.ts +0 -121
- package/src/skills/registry.test.ts +0 -104
- package/src/skills/registry.ts +0 -413
- package/src/tools/maintenance.ts +0 -29
- package/src/tools/memoryTools.ts +0 -22
- package/src/tools/pluginPropose.ts +0 -41
- package/src/tools/registryWrite.test.ts +0 -101
- package/src/tools/registryWrite.ts +0 -182
- /package/src/{memory → core}/secrets.ts +0 -0
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// `xingular-harness refine`: refinement from a terminal.
|
|
2
|
+
//
|
|
3
|
+
// refine demo [--k N] [--mode parallel|sequential] [--out <dir>]
|
|
4
|
+
// Runs the deterministic ledger demo end to end with no model and no credentials.
|
|
5
|
+
// refine run --tree <dir> --task <file.md> [--source <dir>] [--scope <id>] [--k N]
|
|
6
|
+
// [--mode parallel|sequential] [--data <dir>] [--state <dir>] [--model <id>]
|
|
7
|
+
// Refines a harness tree on one task with the default Pi account.
|
|
8
|
+
import { mkdtempSync, readFileSync } from 'node:fs'
|
|
9
|
+
import { homedir, tmpdir } from 'node:os'
|
|
10
|
+
import { basename, join, resolve } from 'node:path'
|
|
11
|
+
|
|
12
|
+
import { createHarness } from '../harness'
|
|
13
|
+
import * as store from '../tree/store'
|
|
14
|
+
import { PI_SURFACE } from '../tree/surface'
|
|
15
|
+
import { readTree } from '../tree/tree'
|
|
16
|
+
import { DemoExecutor, DemoImprover, demoBaseTree, demoTasks } from './demo'
|
|
17
|
+
import { Library } from './library'
|
|
18
|
+
import { runRefinement, type RefineSummary } from './runner'
|
|
19
|
+
import type { RecursionMode } from './types'
|
|
20
|
+
|
|
21
|
+
export const REFINE_USAGE = `Usage: xingular-harness refine <demo | run> [options]
|
|
22
|
+
|
|
23
|
+
demo [--k N] [--mode parallel|sequential] [--out <dir>]
|
|
24
|
+
Run the deterministic demo: no model, no credentials.
|
|
25
|
+
run --tree <dir> --task <file.md> [--source <dir>] [--scope <id>] [--k N]
|
|
26
|
+
[--mode parallel|sequential] [--data <dir>] [--state <dir>] [--model <id>]
|
|
27
|
+
Refine a harness on one task with the default account. Costs about
|
|
28
|
+
4k executions and 3k improver sessions.
|
|
29
|
+
`
|
|
30
|
+
|
|
31
|
+
interface Parsed {
|
|
32
|
+
command: string | undefined
|
|
33
|
+
values: Record<string, string>
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function parse(argv: string[]): Parsed {
|
|
37
|
+
const [command, ...rest] = argv
|
|
38
|
+
const values: Record<string, string> = {}
|
|
39
|
+
for (let i = 0; i < rest.length; i++) {
|
|
40
|
+
const arg = rest[i]!
|
|
41
|
+
if (arg.startsWith('--') && i + 1 < rest.length) values[arg.slice(2)] = rest[++i]!
|
|
42
|
+
}
|
|
43
|
+
return { command, values }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function mode(value: string | undefined): RecursionMode {
|
|
47
|
+
if (!value || value === 'parallel') return 'parallel'
|
|
48
|
+
if (value === 'sequential') return 'sequential'
|
|
49
|
+
throw new Error('--mode is parallel or sequential.')
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function describe(summary: RefineSummary, write: (text: string) => void): void {
|
|
53
|
+
const ledger = summary.recursion?.ledger
|
|
54
|
+
write(`${summary.taskId}: ${summary.status}${ledger ? ` · R=${ledger.R}/${ledger.expectedR} executions, P=${ledger.P}/${ledger.expectedP} improver sessions` : ''} · $${summary.usd.toFixed(4)}\n`)
|
|
55
|
+
if (summary.selection?.chosenLabel) write(` chose ${summary.selection.chosenLabel}${summary.chosenIsBase ? ' (unchanged harness)' : ''}: ${summary.selection.why.split('\n')[0]}\n`)
|
|
56
|
+
if (summary.experience?.lesson) write(` lesson: ${summary.experience.lesson}\n`)
|
|
57
|
+
for (const error of summary.errors) write(` ! ${error}\n`)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function runRefineCli(argv: string[], io: { write: (text: string) => void } = { write: (text) => void process.stdout.write(text) }): Promise<number> {
|
|
61
|
+
const { command, values } = parse(argv)
|
|
62
|
+
try {
|
|
63
|
+
if (command === 'demo') {
|
|
64
|
+
const root = values.out ? resolve(values.out) : mkdtempSync(join(tmpdir(), 'harness-refine-demo-'))
|
|
65
|
+
const k = Number(values.k ?? 1)
|
|
66
|
+
store.seed(root, 'demo', demoBaseTree(), 'demo base harness')
|
|
67
|
+
const executor = new DemoExecutor(root)
|
|
68
|
+
const improver = new DemoImprover()
|
|
69
|
+
for (const task of demoTasks(root)) {
|
|
70
|
+
const summary = await runRefinement({ root, id: `${task.id}-${mode(values.mode)}-${k}`, task, k, mode: mode(values.mode), executor, improver })
|
|
71
|
+
describe(summary, io.write)
|
|
72
|
+
}
|
|
73
|
+
io.write(`library: ${new Library(root, 'demo', 'accounting').entries().join(', ') || '(empty)'}\nrecords: ${join(root, 'refine')}\n`)
|
|
74
|
+
return 0
|
|
75
|
+
}
|
|
76
|
+
if (command === 'run') {
|
|
77
|
+
if (!values.tree || !values.task) {
|
|
78
|
+
io.write(REFINE_USAGE)
|
|
79
|
+
return 2
|
|
80
|
+
}
|
|
81
|
+
const base = join(homedir(), '.xingular-harness')
|
|
82
|
+
const harness = createHarness({ dataRoot: resolve(values.data ?? join(base, 'data')), stateDir: resolve(values.state ?? join(base, 'state')) })
|
|
83
|
+
try {
|
|
84
|
+
const scope = values.scope ?? basename(resolve(values.tree)).replace(/[^A-Za-z0-9_-]/g, '-')
|
|
85
|
+
const tree = readTree(PI_SURFACE, resolve(values.tree))
|
|
86
|
+
store.commit(harness.env.root, scope, tree, { kind: 'import', note: `from ${values.tree}` })
|
|
87
|
+
const prompt = readFileSync(resolve(values.task), 'utf8')
|
|
88
|
+
const { id } = harness.refine.start({
|
|
89
|
+
task: { id: basename(values.task).replace(/\.md$/, '').replace(/[^A-Za-z0-9_-]/g, '-'), prompt, scope, sourceDir: values.source ? resolve(values.source) : null },
|
|
90
|
+
k: Number(values.k ?? 1),
|
|
91
|
+
mode: mode(values.mode),
|
|
92
|
+
improver: values.model ? { model: values.model } : {}
|
|
93
|
+
})
|
|
94
|
+
harness.bus.on((notice) => {
|
|
95
|
+
if (notice.type === 'refine.event' && notice.id === id) {
|
|
96
|
+
const event = notice.event as { type: string; label?: string; phase?: string }
|
|
97
|
+
if (event.type === 'phase') io.write(`· ${event.phase}\n`)
|
|
98
|
+
else if (event.type === 'stage.finished' || event.type === 'rollout.finished') io.write(` ${event.type === 'stage.finished' ? 'proposed' : 'ran'} ${event.label}\n`)
|
|
99
|
+
}
|
|
100
|
+
})
|
|
101
|
+
const summary = await harness.refine.wait(id)
|
|
102
|
+
if (summary) describe(summary, io.write)
|
|
103
|
+
return summary?.status === 'ok' ? 0 : 1
|
|
104
|
+
} finally {
|
|
105
|
+
await harness.close()
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
io.write(REFINE_USAGE)
|
|
109
|
+
return command ? 2 : 0
|
|
110
|
+
} catch (error) {
|
|
111
|
+
io.write(`${error instanceof Error ? error.message : String(error)}\n`)
|
|
112
|
+
return 1
|
|
113
|
+
}
|
|
114
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// A deterministic local executor and improver that exercise the real refinement workflow with
|
|
2
|
+
// no model and no credentials: the base harness sums only positive ledger amounts, and the
|
|
3
|
+
// scripted improver removes that filter.
|
|
4
|
+
//
|
|
5
|
+
// Ported from Harness² (Apache-2.0, Google LLC; `harness2/demo.py`).
|
|
6
|
+
import { spawnSync } from 'node:child_process'
|
|
7
|
+
import { cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
8
|
+
import { tmpdir } from 'node:os'
|
|
9
|
+
import { join } from 'node:path'
|
|
10
|
+
|
|
11
|
+
import type { Tree } from '../tree/surface'
|
|
12
|
+
import type { Executor, Improver, ImproverCall, ImproverReply, RefineTask, Rollout } from './types'
|
|
13
|
+
|
|
14
|
+
export const DEMO_SCRIPT = `import { readFileSync } from 'node:fs'
|
|
15
|
+
|
|
16
|
+
const rows = readFileSync(process.argv[2], 'utf8').trim().split('\\n').slice(1)
|
|
17
|
+
const cents = rows.map((row) => Math.round(Number(row) * 100))
|
|
18
|
+
const total = cents.filter((amount) => amount > 0).reduce((sum, amount) => sum + amount, 0)
|
|
19
|
+
console.log(\`Total: \${(total / 100).toFixed(2)}\`)
|
|
20
|
+
`
|
|
21
|
+
|
|
22
|
+
export function demoBaseTree(): Tree {
|
|
23
|
+
return {
|
|
24
|
+
'systemprompt.md': 'Reconcile the ledger using node scripts/summarize.mjs.\n',
|
|
25
|
+
'guardrails.md': '',
|
|
26
|
+
'memory.md': '',
|
|
27
|
+
'tool_guidance.md': '',
|
|
28
|
+
'scripts/summarize.mjs': DEMO_SCRIPT
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const DEMO_LEDGERS: Record<string, string> = {
|
|
33
|
+
'ledger-1': '100.25\n-20.10\n5.00\n',
|
|
34
|
+
'ledger-2': '35.50\n-10.25\n-5.00\n'
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Writes the demo's source ledgers under `root` and returns the tasks. */
|
|
38
|
+
export function demoTasks(root: string, scope = 'demo'): RefineTask[] {
|
|
39
|
+
return Object.entries(DEMO_LEDGERS).map(([id, amounts]) => {
|
|
40
|
+
const sourceDir = join(root, 'sources', id)
|
|
41
|
+
mkdirSync(sourceDir, { recursive: true })
|
|
42
|
+
writeFileSync(join(sourceDir, 'ledger.csv'), `amount\n${amounts}`)
|
|
43
|
+
writeFileSync(join(sourceDir, 'reference_answer.txt'), 'never shown to a model')
|
|
44
|
+
return {
|
|
45
|
+
id,
|
|
46
|
+
scope,
|
|
47
|
+
domain: 'accounting',
|
|
48
|
+
prompt: 'Sum every signed amount in ledger.csv. Return Total: with two decimal places.',
|
|
49
|
+
sourceDir,
|
|
50
|
+
leakGlobs: ['reference_answer.txt'],
|
|
51
|
+
prompts: { work: 'reconcile signed amounts in a transaction ledger' }
|
|
52
|
+
}
|
|
53
|
+
})
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export class DemoExecutor implements Executor {
|
|
57
|
+
executions = 0
|
|
58
|
+
|
|
59
|
+
constructor(private readonly root: string) {}
|
|
60
|
+
|
|
61
|
+
async execute(task: RefineTask, harnessDir: string | null, label: string, runId: string): Promise<Rollout> {
|
|
62
|
+
this.executions++
|
|
63
|
+
const dir = join(this.root, 'executions', runId)
|
|
64
|
+
mkdirSync(dir, { recursive: true })
|
|
65
|
+
const script = join(harnessDir ?? '', 'scripts', 'summarize.mjs')
|
|
66
|
+
const result = spawnSync(process.execPath, [script, join(task.sourceDir ?? '', 'ledger.csv')], { encoding: 'utf8', timeout: 10_000 })
|
|
67
|
+
if (result.status !== 0) {
|
|
68
|
+
return { label, runId, dir, ok: false, delivered: false, costUsd: 0, wallMs: 0, error: result.stderr || 'script failed' }
|
|
69
|
+
}
|
|
70
|
+
writeFileSync(join(dir, 'answer.txt'), result.stdout)
|
|
71
|
+
writeFileSync(join(dir, 'trajectory.md'), `[ACTION] Run scripts/summarize.mjs on ledger.csv\n[OBSERVATION]\n${result.stdout}`)
|
|
72
|
+
return { label, runId, dir, ok: true, delivered: true, costUsd: 0, wallMs: 0, error: '' }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
trajectory(rollout: Rollout): string {
|
|
76
|
+
return readFileSync(join(rollout.dir, 'trajectory.md'), 'utf8')
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
deliverableFiles(rollout: Rollout): Record<string, string> {
|
|
80
|
+
const path = join(rollout.dir, 'answer.txt')
|
|
81
|
+
return existsSync(path) ? { 'answer.txt': readFileSync(path, 'utf8') } : {}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
validateTree(_task: RefineTask, tree: Tree): void {
|
|
85
|
+
if (!tree['scripts/summarize.mjs']) throw new Error('The local executor requires scripts/summarize.mjs')
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function signedTotal(ledger: string): string {
|
|
90
|
+
const cents = ledger.trim().split('\n').slice(1).map((row) => Math.round(Number(row) * 100))
|
|
91
|
+
return (cents.reduce((sum, amount) => sum + amount, 0) / 100).toFixed(2)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Scripted edits, selection and reflection. */
|
|
95
|
+
export class DemoImprover implements Improver {
|
|
96
|
+
readonly calls: string[] = []
|
|
97
|
+
|
|
98
|
+
async call(input: ImproverCall): Promise<ImproverReply> {
|
|
99
|
+
this.calls.push(input.label)
|
|
100
|
+
const context = input.contextDir
|
|
101
|
+
let workspace: string | null = null
|
|
102
|
+
let text: string
|
|
103
|
+
if (input.label.startsWith('select.')) {
|
|
104
|
+
const expected = `Total: ${signedTotal(readFileSync(join(context, 'source', 'ledger.csv'), 'utf8'))}`
|
|
105
|
+
const ids = readdirSync(join(context, 'candidates')).filter((name) => name.endsWith('.md')).sort()
|
|
106
|
+
const chosen = ids.find((name) => readFileSync(join(context, 'candidates', name), 'utf8').includes(expected))?.replace(/\.md$/, '') ?? ids[0]!.replace(/\.md$/, '')
|
|
107
|
+
text = `<why>The signed total matches the source ledger.</why>\n\`\`\`choice\n${chosen}\n\`\`\``
|
|
108
|
+
} else if (input.label.startsWith('experience.')) {
|
|
109
|
+
const changed = readFileSync(join(context, 'harness', 'scripts', 'summarize.mjs'), 'utf8') !== readFileSync(join(context, 'base_harness', 'scripts', 'summarize.mjs'), 'utf8')
|
|
110
|
+
text =
|
|
111
|
+
`<edits>\ncomponent: scripts/summarize.mjs\ncandidates: inv1\nchange: dropped the positive-only filter from the amount sum\nobserved: the probe total included the credits the base run omitted\nverdict: ${changed ? 'helped' : 'no observed effect'}\n</edits>\n\n` +
|
|
112
|
+
(changed
|
|
113
|
+
? '<experience>\ncue: When a ledger includes credits and debits\nlesson: Include both signs when summing in scripts/summarize.mjs.\nevidence: The scripts/summarize.mjs row -- removing the positive-only filter changed the probe total to include the credits omitted by the base trajectory.\nconfidence: high for this observed arithmetic contrast\n</experience>'
|
|
114
|
+
: 'NO_ENTRY')
|
|
115
|
+
} else {
|
|
116
|
+
workspace = mkdtempSync(join(tmpdir(), 'harness-demo-'))
|
|
117
|
+
cpSync(join(context, 'harness'), workspace, { recursive: true })
|
|
118
|
+
const script = join(workspace, 'scripts', 'summarize.mjs')
|
|
119
|
+
writeFileSync(script, readFileSync(script, 'utf8').replace('.filter((amount) => amount > 0)', ''))
|
|
120
|
+
text = '<notes>Keep signed amounts: the base total omitted credits in the source ledger.</notes>'
|
|
121
|
+
}
|
|
122
|
+
return { label: input.label, text, costUsd: 0, attempts: 1, cached: false, workspace }
|
|
123
|
+
}
|
|
124
|
+
}
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
// The evidence folder an improver reads. Evidence is served as files and the prompt is a short
|
|
2
|
+
// pointer, so nothing is truncated to fit a prompt:
|
|
3
|
+
//
|
|
4
|
+
// context/task.md the task as the agent received it
|
|
5
|
+
// context/source/ the material it was done from
|
|
6
|
+
// context/rollouts/<label>.md full trajectories
|
|
7
|
+
// context/deliverables/<label>/ what each rollout produced
|
|
8
|
+
// context/harness/<rel> the tree being edited
|
|
9
|
+
// context/library/INDEX.md earlier tasks in this area and their full harnesses
|
|
10
|
+
// context/pool/<label>/ candidate harnesses, their diffs and rollouts
|
|
11
|
+
// context/decisions/<label>.md earlier edit and composition notes
|
|
12
|
+
//
|
|
13
|
+
// Ported from Harness² (Apache-2.0, Google LLC; `harness2/core/context.py`).
|
|
14
|
+
import { copyFileSync, cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
|
15
|
+
import { dirname, extname, join, relative, sep } from 'node:path'
|
|
16
|
+
|
|
17
|
+
import { PI_SURFACE, type Tree } from '../tree/surface'
|
|
18
|
+
import { readTree } from '../tree/tree'
|
|
19
|
+
import type { Executor, RefineTask, Rollout } from './types'
|
|
20
|
+
|
|
21
|
+
/** Strings that only a grader's own output contains. A tripwire, not the defence itself. */
|
|
22
|
+
export const GRADER_VERDICT_MARKERS = [
|
|
23
|
+
'deterministic_eval_summary',
|
|
24
|
+
'deterministic_eval_result',
|
|
25
|
+
'judge_scope_and_deterministic_status',
|
|
26
|
+
'Judge findings on the rendered result',
|
|
27
|
+
'"passed_count"',
|
|
28
|
+
'"pass_rate"',
|
|
29
|
+
'"test_pass_rate"',
|
|
30
|
+
'review_verdict',
|
|
31
|
+
'"reviewVerdict"'
|
|
32
|
+
] as const
|
|
33
|
+
|
|
34
|
+
const SCANNED = new Set(['.md', '.txt', '.json', '.csv', '.tsv', '.yaml', '.yml', '.html', '.htm', '.log', '.py', '.js', '.mjs', '.ts', '.sh', '.xml', '.ini', '.toml', '.patch', '.diff', ''])
|
|
35
|
+
|
|
36
|
+
/** Minimum per-observation truncation; lower values fabricate defects. */
|
|
37
|
+
export const MIN_OBS_TRUNC = 10_000
|
|
38
|
+
|
|
39
|
+
function files(dir: string): string[] {
|
|
40
|
+
const out: string[] = []
|
|
41
|
+
let names: string[]
|
|
42
|
+
try {
|
|
43
|
+
names = readdirSync(dir)
|
|
44
|
+
} catch {
|
|
45
|
+
return out
|
|
46
|
+
}
|
|
47
|
+
for (const name of names.sort()) {
|
|
48
|
+
const path = join(dir, name)
|
|
49
|
+
let isDir = false
|
|
50
|
+
try {
|
|
51
|
+
isDir = statSync(path).isDirectory()
|
|
52
|
+
} catch {
|
|
53
|
+
continue
|
|
54
|
+
}
|
|
55
|
+
if (isDir) out.push(...files(path))
|
|
56
|
+
else out.push(path)
|
|
57
|
+
}
|
|
58
|
+
return out
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function rel(root: string, path: string): string {
|
|
62
|
+
return relative(root, path).split(sep).join('/')
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function globToRegExp(glob: string): RegExp {
|
|
66
|
+
const pattern = glob.replace(/\\/g, '/').replace(/^\.\//, '')
|
|
67
|
+
let out = ''
|
|
68
|
+
for (const ch of pattern) {
|
|
69
|
+
if (ch === '*') out += '[^]*'
|
|
70
|
+
else if (ch === '?') out += '.'
|
|
71
|
+
else out += ch.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
72
|
+
}
|
|
73
|
+
return new RegExp(`^(?:[^]*/)?${out}$`)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Refuses a folder holding a path the task marks as an answer key. */
|
|
77
|
+
export function assertNoLeakPaths(root: string, globs: readonly string[] = []): void {
|
|
78
|
+
if (!globs.length || !existsSync(root)) return
|
|
79
|
+
const rx = globs.map(globToRegExp)
|
|
80
|
+
const hits = files(root).map((path) => rel(root, path)).filter((path) => rx.some((r) => r.test(path)))
|
|
81
|
+
if (hits.length) throw new Error(`answer-key path(s) would enter model context: ${hits.sort().slice(0, 8).join(', ')}`)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Refuses a folder containing a grader's output, by file name and by content. */
|
|
85
|
+
export function assertNoGraderVerdicts(root: string, markers: readonly string[] = GRADER_VERDICT_MARKERS): void {
|
|
86
|
+
if (!existsSync(root)) return
|
|
87
|
+
for (const path of files(root)) {
|
|
88
|
+
const name = rel(root, path)
|
|
89
|
+
for (const marker of markers) {
|
|
90
|
+
if (name.includes(marker)) throw new Error(`grader verdict would enter model context: '${name}' matched marker '${marker}' in its NAME. Find the read path that produced it.`)
|
|
91
|
+
}
|
|
92
|
+
if (!SCANNED.has(extname(path).toLowerCase())) continue
|
|
93
|
+
let body: string
|
|
94
|
+
try {
|
|
95
|
+
body = readFileSync(path, 'utf8')
|
|
96
|
+
} catch {
|
|
97
|
+
continue
|
|
98
|
+
}
|
|
99
|
+
for (const marker of markers) {
|
|
100
|
+
const at = body.indexOf(marker)
|
|
101
|
+
if (at >= 0) throw new Error(`grader verdict would enter model context: '${name}' contains marker '${marker}' at offset ${at}. Find the read path that produced it.`)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The same tripwire on a tree, before it is rolled or stored. */
|
|
107
|
+
export function assertTreeHasNoGraderVerdicts(tree: Record<string, string>, markers: readonly string[] = GRADER_VERDICT_MARKERS): void {
|
|
108
|
+
for (const [name, body] of Object.entries(tree).sort()) {
|
|
109
|
+
for (const marker of markers) {
|
|
110
|
+
if (name.includes(marker)) throw new Error(`harness file '${name}' has grader-verdict marker '${marker}' in its NAME; rename it`)
|
|
111
|
+
if (SCANNED.has(extname(name).toLowerCase()) && body.includes(marker)) throw new Error(`harness file '${name}' contains grader-verdict marker '${marker}'; remove that string -- files carrying it cannot be served as evidence to later tasks`)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function copyFolder(src: string, dst: string): number {
|
|
117
|
+
let n = 0
|
|
118
|
+
for (const path of files(src)) {
|
|
119
|
+
if (path.includes(`${sep}__pycache__${sep}`) || path.endsWith('.pyc')) continue
|
|
120
|
+
const target = join(dst, relative(src, path))
|
|
121
|
+
mkdirSync(dirname(target), { recursive: true })
|
|
122
|
+
copyFileSync(path, target)
|
|
123
|
+
n++
|
|
124
|
+
}
|
|
125
|
+
return n
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Copies the task's source material under `root/source`, minus answer-key paths. */
|
|
129
|
+
export function copySource(task: RefineTask, root: string): number {
|
|
130
|
+
const src = task.sourceDir
|
|
131
|
+
if (!src || !existsSync(src)) return 0
|
|
132
|
+
const leak = (task.leakGlobs ?? []).map(globToRegExp)
|
|
133
|
+
let n = 0
|
|
134
|
+
for (const path of files(src)) {
|
|
135
|
+
const name = rel(src, path)
|
|
136
|
+
if (name.split('/').includes('__pycache__') || leak.some((r) => r.test(name))) continue
|
|
137
|
+
const target = join(root, 'source', name)
|
|
138
|
+
mkdirSync(dirname(target), { recursive: true })
|
|
139
|
+
copyFileSync(path, target)
|
|
140
|
+
n++
|
|
141
|
+
}
|
|
142
|
+
if (n) assertNoLeakPaths(join(root, 'source'), task.leakGlobs)
|
|
143
|
+
return n
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** A line-based unified diff, enough for a reader to see what changed. */
|
|
147
|
+
export function unifiedDiff(before: Tree, after: Tree, fromLabel: string, toLabel: string): string {
|
|
148
|
+
const out: string[] = []
|
|
149
|
+
for (const name of [...new Set([...Object.keys(before), ...Object.keys(after)])].sort()) {
|
|
150
|
+
const a = before[name] ?? ''
|
|
151
|
+
const b = after[name] ?? ''
|
|
152
|
+
if (a === b) continue
|
|
153
|
+
out.push(`--- ${fromLabel}/${name}`, `+++ ${toLabel}/${name}`)
|
|
154
|
+
const x = a ? a.split('\n') : []
|
|
155
|
+
const y = b ? b.split('\n') : []
|
|
156
|
+
// A longest-common-subsequence walk; trees are small enough for the quadratic table.
|
|
157
|
+
const dp: number[][] = Array.from({ length: x.length + 1 }, () => new Array<number>(y.length + 1).fill(0))
|
|
158
|
+
for (let i = x.length - 1; i >= 0; i--) for (let j = y.length - 1; j >= 0; j--) dp[i]![j] = x[i] === y[j] ? dp[i + 1]![j + 1]! + 1 : Math.max(dp[i + 1]![j]!, dp[i]![j + 1]!)
|
|
159
|
+
let i = 0
|
|
160
|
+
let j = 0
|
|
161
|
+
while (i < x.length || j < y.length) {
|
|
162
|
+
if (i < x.length && j < y.length && x[i] === y[j]) {
|
|
163
|
+
out.push(` ${x[i]}`)
|
|
164
|
+
i++
|
|
165
|
+
j++
|
|
166
|
+
} else if (j < y.length && (i >= x.length || dp[i]![j + 1]! >= dp[i + 1]![j]!)) {
|
|
167
|
+
out.push(`+${y[j]}`)
|
|
168
|
+
j++
|
|
169
|
+
} else {
|
|
170
|
+
out.push(`-${x[i]}`)
|
|
171
|
+
i++
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return out.length ? `${out.join('\n')}\n` : ''
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export interface PoolEntry {
|
|
179
|
+
label: string
|
|
180
|
+
/** The candidate's harness folder, or null. */
|
|
181
|
+
harnessDir: string | null
|
|
182
|
+
rollout: Rollout | null
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export interface EvidenceTree {
|
|
186
|
+
root: string
|
|
187
|
+
taskId: string
|
|
188
|
+
rollouts: string[]
|
|
189
|
+
deliverables: string[]
|
|
190
|
+
harnessFiles: number
|
|
191
|
+
library: boolean
|
|
192
|
+
pool: string[]
|
|
193
|
+
failed: string[]
|
|
194
|
+
sourceFiles: number
|
|
195
|
+
decisions: number
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** The evidence-layout listing that goes in a mission; it mentions only what exists. */
|
|
199
|
+
export function layout(tree: EvidenceTree): string {
|
|
200
|
+
const lines = ['context/task.md the task as the agent received it']
|
|
201
|
+
if (tree.sourceFiles) lines.push('context/source/ task source materials')
|
|
202
|
+
if (tree.decisions) lines.push('context/decisions/ earlier edits and composition decisions')
|
|
203
|
+
if (tree.rollouts.length) {
|
|
204
|
+
const names = tree.rollouts.slice(0, 6).map((label) => `${label}.md`).join(', ')
|
|
205
|
+
const more = tree.rollouts.length > 6 ? ` (+${tree.rollouts.length - 6} more)` : ''
|
|
206
|
+
lines.push(`context/rollouts/ FULL trajectories: ${names}${more}`)
|
|
207
|
+
}
|
|
208
|
+
if (tree.deliverables.length) lines.push('context/deliverables/<label>/ the files each rollout produced, one per artifact (browse INDEX.md for the set)')
|
|
209
|
+
if (tree.harnessFiles) lines.push(`context/harness/ the harness you are editing (${tree.harnessFiles} files)`)
|
|
210
|
+
if (tree.pool.length) lines.push(`context/pool/ ${tree.pool.length} candidates, each with its harness and its rollout`)
|
|
211
|
+
if (tree.library) lines.push('context/library/ earlier tasks in this area -- browse INDEX.md first, then open what looks relevant')
|
|
212
|
+
return lines.join('\n')
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export interface BuildInput {
|
|
216
|
+
task: RefineTask
|
|
217
|
+
executor: Executor
|
|
218
|
+
rollouts?: readonly Rollout[]
|
|
219
|
+
harnessDir?: string | null
|
|
220
|
+
libraryDir?: string | null
|
|
221
|
+
pool?: readonly PoolEntry[]
|
|
222
|
+
decisions?: Record<string, string>
|
|
223
|
+
obsTrunc?: number
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function writeRollout(dest: string, rollout: Rollout, executor: Executor, obsTrunc: number): string {
|
|
227
|
+
let failure = ''
|
|
228
|
+
mkdirSync(join(dest, 'rollouts'), { recursive: true })
|
|
229
|
+
try {
|
|
230
|
+
writeFileSync(join(dest, 'rollouts', `${rollout.label}.md`), executor.trajectory(rollout, { obsTrunc }))
|
|
231
|
+
} catch (error) {
|
|
232
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
233
|
+
writeFileSync(join(dest, 'rollouts', `${rollout.label}.md`), `(trajectory unavailable: ${message})`)
|
|
234
|
+
failure = `${rollout.label}: ${message}`
|
|
235
|
+
}
|
|
236
|
+
const dir = join(dest, 'deliverables', rollout.label)
|
|
237
|
+
mkdirSync(dir, { recursive: true })
|
|
238
|
+
let produced: Record<string, string> = {}
|
|
239
|
+
try {
|
|
240
|
+
produced = executor.deliverableFiles(rollout)
|
|
241
|
+
} catch (error) {
|
|
242
|
+
produced = { 'unavailable.md': `(deliverable unavailable: ${error instanceof Error ? error.message : String(error)})` }
|
|
243
|
+
}
|
|
244
|
+
const names = Object.keys(produced).sort()
|
|
245
|
+
for (const name of names) {
|
|
246
|
+
const target = join(dir, name)
|
|
247
|
+
mkdirSync(dirname(target), { recursive: true })
|
|
248
|
+
writeFileSync(target, produced[name]!)
|
|
249
|
+
}
|
|
250
|
+
writeFileSync(join(dir, 'INDEX.md'), names.length ? `# ${rollout.label} produced ${names.length} file(s)\n\n${names.map((name) => `- \`${name}\``).join('\n')}\n` : `# ${rollout.label} produced nothing\n`)
|
|
251
|
+
return failure
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function renderDeliverable(executor: Executor, rollout: Rollout): string {
|
|
255
|
+
const produced = executor.deliverableFiles(rollout)
|
|
256
|
+
const names = Object.keys(produced).sort()
|
|
257
|
+
if (!names.length) return ''
|
|
258
|
+
if (names.length === 1) return produced[names[0]!]!
|
|
259
|
+
return names.map((name) => `## ${name}\n\n${produced[name]}`).join('\n\n')
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export { renderDeliverable }
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Writes the evidence folder and returns what landed, so a mission describes only that. A
|
|
266
|
+
* trajectory is written in full; `obsTrunc` applies per observation.
|
|
267
|
+
*/
|
|
268
|
+
export function build(dest: string, input: BuildInput): EvidenceTree {
|
|
269
|
+
const { task, executor } = input
|
|
270
|
+
rmSync(dest, { recursive: true, force: true })
|
|
271
|
+
mkdirSync(dest, { recursive: true })
|
|
272
|
+
const obsTrunc = Math.max(input.obsTrunc ?? 50_000, MIN_OBS_TRUNC)
|
|
273
|
+
|
|
274
|
+
writeFileSync(join(dest, 'task.md'), task.prompt)
|
|
275
|
+
const sourceFiles = copySource(task, dest)
|
|
276
|
+
for (const [label, notes] of Object.entries(input.decisions ?? {})) {
|
|
277
|
+
mkdirSync(join(dest, 'decisions'), { recursive: true })
|
|
278
|
+
writeFileSync(join(dest, 'decisions', `${label}.md`), notes || 'No decision notes returned.')
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const rollouts: string[] = []
|
|
282
|
+
const deliverables: string[] = []
|
|
283
|
+
const failed: string[] = []
|
|
284
|
+
for (const rollout of input.rollouts ?? []) {
|
|
285
|
+
const failure = writeRollout(dest, rollout, executor, obsTrunc)
|
|
286
|
+
if (failure) failed.push(failure)
|
|
287
|
+
else rollouts.push(rollout.label)
|
|
288
|
+
deliverables.push(rollout.label)
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
let harnessFiles = 0
|
|
292
|
+
if (input.harnessDir && existsSync(input.harnessDir)) {
|
|
293
|
+
assertNoLeakPaths(input.harnessDir, task.leakGlobs)
|
|
294
|
+
harnessFiles = copyFolder(input.harnessDir, join(dest, 'harness'))
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const pool: string[] = []
|
|
298
|
+
for (const entry of input.pool ?? []) {
|
|
299
|
+
const dir = join(dest, 'pool', entry.label)
|
|
300
|
+
mkdirSync(dir, { recursive: true })
|
|
301
|
+
if (entry.harnessDir && existsSync(entry.harnessDir)) {
|
|
302
|
+
assertNoLeakPaths(entry.harnessDir, task.leakGlobs)
|
|
303
|
+
copyFolder(entry.harnessDir, join(dir, 'harness'))
|
|
304
|
+
const reference = readTree(PI_SURFACE, join(dest, 'harness'))
|
|
305
|
+
const candidate = readTree(PI_SURFACE, entry.harnessDir)
|
|
306
|
+
writeFileSync(join(dir, 'harness.diff'), unifiedDiff(reference, candidate, 'current', entry.label))
|
|
307
|
+
}
|
|
308
|
+
if (entry.rollout) {
|
|
309
|
+
try {
|
|
310
|
+
writeFileSync(join(dir, 'rollout.md'), executor.trajectory(entry.rollout, { obsTrunc }))
|
|
311
|
+
writeFileSync(join(dir, 'produced.md'), renderDeliverable(executor, entry.rollout) || '(nothing produced)')
|
|
312
|
+
} catch (error) {
|
|
313
|
+
writeFileSync(join(dir, 'rollout.md'), `(unavailable: ${error instanceof Error ? error.message : String(error)})`)
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
pool.push(entry.label)
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
let library = false
|
|
320
|
+
if (input.libraryDir && existsSync(input.libraryDir) && readdirSync(input.libraryDir).length) {
|
|
321
|
+
assertNoLeakPaths(input.libraryDir, task.leakGlobs)
|
|
322
|
+
cpSync(input.libraryDir, join(dest, 'library'), { recursive: true })
|
|
323
|
+
library = true
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
assertNoLeakPaths(dest, task.leakGlobs)
|
|
327
|
+
assertNoGraderVerdicts(dest)
|
|
328
|
+
if ((input.rollouts ?? []).length && !rollouts.length) {
|
|
329
|
+
throw new Error(`no trajectory could be rendered for ANY rollout -- the improver would be adapting on nothing: ${failed.slice(0, 4).join('; ')}`)
|
|
330
|
+
}
|
|
331
|
+
return { root: dest, taskId: task.id, rollouts, deliverables, harnessFiles, library, pool, failed, sourceFiles, decisions: Object.keys(input.decisions ?? {}).length }
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** The short pointer to the evidence on disk: the job, where the evidence is, and the instruction to open it. */
|
|
335
|
+
export function mission(tree: EvidenceTree, job: string): string {
|
|
336
|
+
return [
|
|
337
|
+
job.trim(),
|
|
338
|
+
'',
|
|
339
|
+
'# YOUR EVIDENCE',
|
|
340
|
+
'',
|
|
341
|
+
'It is on disk, in `context/`. Read it -- do not answer from this message alone.',
|
|
342
|
+
'',
|
|
343
|
+
layout(tree),
|
|
344
|
+
'',
|
|
345
|
+
'Every trajectory EVENT is present: the trajectory was not clipped to fit this mission. An individual observation may contain an explicit renderer-truncation marker; that marker is ours, not behavior by the solver. Do not diagnose it as a solver failure.',
|
|
346
|
+
'',
|
|
347
|
+
'Read `task.md` and the current harness first. Inventory every available rollout or candidate and inspect each at least once before deciding. For a candidate, compare what changed from its editing baseline, what behavior changed in its rollout, what improved, and what regressed or remained unresolved. Then deep-read the evidence relevant to any edit. Cite the specific trajectory moment behind every change; one you cannot trace to a moment is speculation.'
|
|
348
|
+
].join('\n')
|
|
349
|
+
}
|