@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,102 @@
|
|
|
1
|
+
// The application's part in refinement (the seventh extension point). The harness runs the
|
|
2
|
+
// method; the application says where an execution may work, as whom, with which tools, and what
|
|
3
|
+
// it produced. Nothing here names an application concept.
|
|
4
|
+
import { cpSync, existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync } from 'node:fs'
|
|
5
|
+
import { tmpdir } from 'node:os'
|
|
6
|
+
import { join, relative, sep } from 'node:path'
|
|
7
|
+
|
|
8
|
+
import type { CallerContext } from '../core/caller'
|
|
9
|
+
import type { Launch } from '../prompt/compile'
|
|
10
|
+
import type { Effort } from '../sessions/runner'
|
|
11
|
+
import type { RefineTask } from './types'
|
|
12
|
+
|
|
13
|
+
/** How one execution of a task runs: who it works as and with what. */
|
|
14
|
+
export interface RolloutSession {
|
|
15
|
+
caller: CallerContext
|
|
16
|
+
tools: { allow: string[]; gated?: string[] }
|
|
17
|
+
launch?: Launch
|
|
18
|
+
model?: string | null
|
|
19
|
+
effort?: Effort | null
|
|
20
|
+
accountId?: string | null
|
|
21
|
+
poolId?: string | null
|
|
22
|
+
connections?: boolean | { readOnly?: boolean }
|
|
23
|
+
providers?: boolean
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface RolloutWorkspace {
|
|
27
|
+
/** The folder the execution works in. It must not be a folder a person is working in. */
|
|
28
|
+
cwd: string
|
|
29
|
+
/** Called after what the execution produced has been read. */
|
|
30
|
+
cleanup?: () => Promise<void>
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface RefineHost {
|
|
34
|
+
/** A fresh, isolated folder for one execution. */
|
|
35
|
+
prepareWorkspace(input: { refineId: string; label: string; task: RefineTask }): Promise<RolloutWorkspace>
|
|
36
|
+
/** The session settings an execution runs with. */
|
|
37
|
+
session(input: { refineId: string; label: string; task: RefineTask }): Promise<RolloutSession>
|
|
38
|
+
/** What one execution produced, filename → readable text. Defaults to its reply and the files it changed. */
|
|
39
|
+
deliverable?(input: { cwd: string; reply: string; task: RefineTask }): Promise<Record<string, string>>
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const TEXT_LIMIT = 200_000
|
|
43
|
+
const FILES_LIMIT = 40
|
|
44
|
+
|
|
45
|
+
function files(dir: string, into: string[] = [], base = dir): string[] {
|
|
46
|
+
let names: string[]
|
|
47
|
+
try {
|
|
48
|
+
names = readdirSync(dir)
|
|
49
|
+
} catch {
|
|
50
|
+
return into
|
|
51
|
+
}
|
|
52
|
+
for (const name of names.sort()) {
|
|
53
|
+
if (name === '.git' || name === 'node_modules' || name.startsWith('.')) continue
|
|
54
|
+
const path = join(dir, name)
|
|
55
|
+
try {
|
|
56
|
+
if (statSync(path).isDirectory()) files(path, into, base)
|
|
57
|
+
else into.push(relative(base, path).split(sep).join('/'))
|
|
58
|
+
} catch {
|
|
59
|
+
// Skip what cannot be read.
|
|
60
|
+
}
|
|
61
|
+
if (into.length > 2_000) break
|
|
62
|
+
}
|
|
63
|
+
return into
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function readable(path: string): string | null {
|
|
67
|
+
try {
|
|
68
|
+
const buffer = readFileSync(path)
|
|
69
|
+
if (buffer.length > TEXT_LIMIT || buffer.includes(0)) return null
|
|
70
|
+
return buffer.toString('utf8')
|
|
71
|
+
} catch {
|
|
72
|
+
return null
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The reply, and every text file the execution added or changed relative to the task's source. */
|
|
77
|
+
export function defaultDeliverable(cwd: string, reply: string, sourceDir: string | null | undefined): Record<string, string> {
|
|
78
|
+
const out: Record<string, string> = {}
|
|
79
|
+
if (reply.trim()) out['reply.md'] = reply
|
|
80
|
+
let count = 0
|
|
81
|
+
for (const rel of files(cwd)) {
|
|
82
|
+
const text = readable(join(cwd, rel))
|
|
83
|
+
if (text === null) continue
|
|
84
|
+
const before = sourceDir && existsSync(join(sourceDir, rel)) ? readable(join(sourceDir, rel)) : null
|
|
85
|
+
if (before === text) continue
|
|
86
|
+
out[`files/${rel}`] = text
|
|
87
|
+
if (++count >= FILES_LIMIT) break
|
|
88
|
+
}
|
|
89
|
+
return out
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** For a host that supplies nothing: a temporary copy of the task's source, the caller as the scope, and Pi's file and shell tools. */
|
|
93
|
+
export const DEFAULT_REFINE_HOST: RefineHost = {
|
|
94
|
+
async prepareWorkspace({ task }) {
|
|
95
|
+
const cwd = mkdtempSync(join(tmpdir(), 'harness-rollout-'))
|
|
96
|
+
if (task.sourceDir && existsSync(task.sourceDir)) cpSync(task.sourceDir, cwd, { recursive: true })
|
|
97
|
+
return { cwd, cleanup: async () => rmSync(cwd, { recursive: true, force: true }) }
|
|
98
|
+
},
|
|
99
|
+
async session({ task }) {
|
|
100
|
+
return { caller: { agentId: task.scope, actor: 'Code' }, tools: { allow: ['read', 'grep', 'find', 'ls', 'edit', 'write', 'bash'] } }
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// Budget accounting: R = 4k rollouts and P = 3k improvement stages per mode. Base executions
|
|
2
|
+
// count toward each mode; retries are separate attempt counts; selection and reflection are
|
|
3
|
+
// recorded outside this ledger.
|
|
4
|
+
//
|
|
5
|
+
// Ported from Harness² (Apache-2.0, Google LLC; `harness2/core/ledger.py`).
|
|
6
|
+
|
|
7
|
+
/** R = 4k: k shared base + 2k candidates + k finals. */
|
|
8
|
+
export const expectedRollouts = (k: number): number => 4 * k
|
|
9
|
+
/** P = 3k: k INV + k DOM + k compositions. */
|
|
10
|
+
export const expectedSessions = (k: number): number => 3 * k
|
|
11
|
+
|
|
12
|
+
export class BudgetError extends Error {
|
|
13
|
+
constructor(message: string) {
|
|
14
|
+
super(message)
|
|
15
|
+
this.name = 'BudgetError'
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface LedgerEntry {
|
|
20
|
+
kind: 'rollout' | 'session'
|
|
21
|
+
label: string
|
|
22
|
+
usd: number
|
|
23
|
+
cached: boolean
|
|
24
|
+
attempts: number
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface LedgerRecord {
|
|
28
|
+
mode: string
|
|
29
|
+
taskId: string
|
|
30
|
+
k: number
|
|
31
|
+
R: number
|
|
32
|
+
expectedR: number
|
|
33
|
+
P: number
|
|
34
|
+
expectedP: number
|
|
35
|
+
sessionAttempts: number
|
|
36
|
+
usd: number
|
|
37
|
+
rolloutUsd: number
|
|
38
|
+
sessionUsd: number
|
|
39
|
+
rollouts: string[]
|
|
40
|
+
sessions: string[]
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const round = (value: number): number => Math.round(value * 1e6) / 1e6
|
|
44
|
+
|
|
45
|
+
export class Ledger {
|
|
46
|
+
readonly entries: LedgerEntry[] = []
|
|
47
|
+
|
|
48
|
+
constructor(readonly mode: string, readonly taskId: string, readonly k: number) {}
|
|
49
|
+
|
|
50
|
+
rollout(label: string, usd = 0, cached = false): void {
|
|
51
|
+
this.entries.push({ kind: 'rollout', label, usd, cached, attempts: 1 })
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
session(label: string, usd = 0, options: { cached?: boolean; attempts?: number } = {}): void {
|
|
55
|
+
this.entries.push({ kind: 'session', label, usd, cached: options.cached ?? false, attempts: options.attempts ?? 1 })
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
labels(kind: LedgerEntry['kind']): string[] {
|
|
59
|
+
return this.entries.filter((entry) => entry.kind === kind).map((entry) => entry.label)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
get R(): number {
|
|
63
|
+
return this.labels('rollout').length
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
get P(): number {
|
|
67
|
+
return this.labels('session').length
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
usd(kind?: LedgerEntry['kind']): number {
|
|
71
|
+
return round(this.entries.filter((entry) => !kind || entry.kind === kind).reduce((sum, entry) => sum + entry.usd, 0))
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Throws unless the entries show exactly R=4k and P=3k with no repeated label. */
|
|
75
|
+
assertIdentity(): void {
|
|
76
|
+
const wantR = expectedRollouts(this.k)
|
|
77
|
+
const wantP = expectedSessions(this.k)
|
|
78
|
+
const rl = this.labels('rollout')
|
|
79
|
+
const sl = this.labels('session')
|
|
80
|
+
const dupes = [...new Set([...rl.filter((label, i) => rl.indexOf(label) !== i), ...sl.filter((label, i) => sl.indexOf(label) !== i)])].sort()
|
|
81
|
+
if (!dupes.length && this.R === wantR && this.P === wantP) return
|
|
82
|
+
const detail = [`${this.mode}-${this.k} ${this.taskId}: R=${this.R} (want ${wantR}), P=${this.P} (want ${wantP})`, ` rollouts: ${rl.join(', ') || '(none)'}`, ` sessions: ${sl.join(', ') || '(none)'}`]
|
|
83
|
+
if (dupes.length) detail.push(` DUPLICATE labels: ${dupes.join(', ')} -- two stages wrote the same run id and one overwrote the other`)
|
|
84
|
+
throw new BudgetError(detail.join('\n'))
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
toRecord(): LedgerRecord {
|
|
88
|
+
return {
|
|
89
|
+
mode: this.mode,
|
|
90
|
+
taskId: this.taskId,
|
|
91
|
+
k: this.k,
|
|
92
|
+
R: this.R,
|
|
93
|
+
expectedR: expectedRollouts(this.k),
|
|
94
|
+
P: this.P,
|
|
95
|
+
expectedP: expectedSessions(this.k),
|
|
96
|
+
sessionAttempts: this.entries.filter((entry) => entry.kind === 'session').reduce((sum, entry) => sum + entry.attempts, 0),
|
|
97
|
+
usd: this.usd(),
|
|
98
|
+
rolloutUsd: this.usd('rollout'),
|
|
99
|
+
sessionUsd: this.usd('session'),
|
|
100
|
+
rollouts: this.labels('rollout'),
|
|
101
|
+
sessions: this.labels('session')
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// The experience library: what earlier refinements in one scope and area adopted, and why.
|
|
2
|
+
// Read by improvers before each task, appended after it. The whole tree is carried, never a
|
|
3
|
+
// summary.
|
|
4
|
+
//
|
|
5
|
+
// <data>/library/<scope>/<area>/INDEX.md
|
|
6
|
+
// <data>/library/<scope>/<area>/harnesses/<task>/harness/<rel> the full adopted tree
|
|
7
|
+
// <data>/library/<scope>/<area>/harnesses/<task>/EXPERIENCE.md why it was picked, and the lesson
|
|
8
|
+
//
|
|
9
|
+
// Ported from Harness² (Apache-2.0, Google LLC; `harness2/core/library.py`).
|
|
10
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
11
|
+
import { join } from 'node:path'
|
|
12
|
+
|
|
13
|
+
import { safeId } from '../core/fs'
|
|
14
|
+
import { PI_SURFACE, type SurfaceSpec, type Tree } from '../tree/surface'
|
|
15
|
+
import { alwaysOnText, pluginNames, readTree, skillNames, subagentNames, writeTree } from '../tree/tree'
|
|
16
|
+
import { patterns } from '../tree/surface'
|
|
17
|
+
import { assertTreeHasNoGraderVerdicts } from './evidence'
|
|
18
|
+
|
|
19
|
+
export interface Experience {
|
|
20
|
+
cue: string
|
|
21
|
+
lesson: string
|
|
22
|
+
evidence: string
|
|
23
|
+
confidence?: string
|
|
24
|
+
/** The edit-by-edit record the lesson rests on. */
|
|
25
|
+
edits?: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function hasExperience(experience: Partial<Experience> | null | undefined): experience is Experience {
|
|
29
|
+
return Boolean(experience) && (['cue', 'lesson', 'evidence'] as const).every((key) => typeof experience![key] === 'string' && experience![key]!.trim().length > 0)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** One folder name for a task or area: separators collapse, nothing escapes. */
|
|
33
|
+
export function slug(text: string): string {
|
|
34
|
+
return text.replace(/\s*[/\\]\s*/g, '__').replace(/[^A-Za-z0-9_.\-]/g, '_').replace(/^\.+/, '_').slice(0, 120).trim() || 'unknown'
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface LibraryEntry {
|
|
38
|
+
task: string
|
|
39
|
+
label: string
|
|
40
|
+
runId: string
|
|
41
|
+
cue: string | null
|
|
42
|
+
inventory: string
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function libraryRoot(root: string): string {
|
|
46
|
+
return join(root, 'library')
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export class Library {
|
|
50
|
+
constructor(readonly root: string, readonly scope: string, readonly area: string = scope, readonly surface: SurfaceSpec = PI_SURFACE) {
|
|
51
|
+
safeId(scope)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
get dir(): string {
|
|
55
|
+
return join(libraryRoot(this.root), this.scope, slug(this.area))
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
get index(): string {
|
|
59
|
+
return join(this.dir, 'INDEX.md')
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
entryDir(task: string): string {
|
|
63
|
+
return join(this.dir, 'harnesses', slug(task))
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The folder handed to an improver, or null when nothing has been learned here yet. */
|
|
67
|
+
contextDir(): string | null {
|
|
68
|
+
return existsSync(this.index) && this.entries().length ? this.dir : null
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
entries(): string[] {
|
|
72
|
+
const dir = join(this.dir, 'harnesses')
|
|
73
|
+
try {
|
|
74
|
+
return readdirSync(dir, { withFileTypes: true }).filter((entry) => entry.isDirectory() && existsSync(join(dir, entry.name, 'EXPERIENCE.md'))).map((entry) => entry.name).sort()
|
|
75
|
+
} catch {
|
|
76
|
+
return []
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
treeOf(task: string): Tree {
|
|
81
|
+
return readTree(this.surface, join(this.entryDir(task), 'harness'))
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
experienceText(task: string): string | null {
|
|
85
|
+
try {
|
|
86
|
+
return readFileSync(join(this.entryDir(task), 'EXPERIENCE.md'), 'utf8')
|
|
87
|
+
} catch {
|
|
88
|
+
return null
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
list(): LibraryEntry[] {
|
|
93
|
+
return this.entries().map((name) => {
|
|
94
|
+
const text = readFileSync(join(this.dir, 'harnesses', name, 'EXPERIENCE.md'), 'utf8')
|
|
95
|
+
return {
|
|
96
|
+
task: name,
|
|
97
|
+
label: /^- adopted: `([^`]+)`/m.exec(text)?.[1] ?? '',
|
|
98
|
+
runId: /\(run `([^`]+)`\)/.exec(text)?.[1] ?? '',
|
|
99
|
+
cue: field(text, 'cue'),
|
|
100
|
+
inventory: inventory(this.surface, this.treeOf(name))
|
|
101
|
+
}
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Drops these tasks' entries and rebuilds the index; unknown tasks are ignored. */
|
|
106
|
+
forget(tasks: readonly string[]): void {
|
|
107
|
+
let removed = false
|
|
108
|
+
for (const task of tasks) {
|
|
109
|
+
const dir = this.entryDir(task)
|
|
110
|
+
if (existsSync(dir)) {
|
|
111
|
+
rmSync(dir, { recursive: true, force: true })
|
|
112
|
+
removed = true
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (removed) this.rebuildIndex()
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Stores one adopted harness and why it was adopted, then rebuilds the index. Nothing is
|
|
120
|
+
* stored without a supported lesson. Returns the entry folder, or null.
|
|
121
|
+
*/
|
|
122
|
+
add(task: string, tree: Tree, input: { why?: string; label?: string; runId?: string; experience?: Partial<Experience> | null }): string | null {
|
|
123
|
+
const experience = input.experience
|
|
124
|
+
if (!hasExperience(experience)) return null
|
|
125
|
+
assertTreeHasNoGraderVerdicts(tree)
|
|
126
|
+
assertTreeHasNoGraderVerdicts({ 'EXPERIENCE.md': JSON.stringify(experience) + (input.why ?? '') })
|
|
127
|
+
const dir = this.entryDir(task)
|
|
128
|
+
rmSync(join(dir, 'harness'), { recursive: true, force: true })
|
|
129
|
+
writeTree(this.surface, tree, join(dir, 'harness'), { stubs: false })
|
|
130
|
+
const lines = [`# ${task}`, '', `- adopted: \`${input.label ?? ''}\`${input.runId ? ` (run \`${input.runId}\`)` : ''}`, `- components: ${inventory(this.surface, tree)}`, '']
|
|
131
|
+
if (input.why?.trim()) lines.push('## Why this was chosen', input.why.trim(), '')
|
|
132
|
+
if (experience.edits?.trim()) lines.push('## What each edit did', experience.edits.trim(), '')
|
|
133
|
+
lines.push('## Transferable lesson')
|
|
134
|
+
for (const key of ['cue', 'lesson', 'evidence', 'confidence'] as const) if (experience[key]) lines.push(`- **${key}**: ${String(experience[key]).trim()}`)
|
|
135
|
+
lines.push('')
|
|
136
|
+
mkdirSync(dir, { recursive: true })
|
|
137
|
+
writeFileSync(join(dir, 'EXPERIENCE.md'), lines.join('\n'))
|
|
138
|
+
this.rebuildIndex()
|
|
139
|
+
return dir
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
rebuildIndex(): string {
|
|
143
|
+
const rows = [`# Library — ${this.area}`, '', 'Each entry is an earlier task in THIS area whose harness was chosen, stored with its FULL component tree and why it was picked. Browse this index, then open the entries that look relevant to the matter in front of you.', '']
|
|
144
|
+
const names = this.entries()
|
|
145
|
+
if (!names.length) rows.push('_(empty — this is the first task in this area)_')
|
|
146
|
+
for (const name of names) {
|
|
147
|
+
const cue = field(readFileSync(join(this.dir, 'harnesses', name, 'EXPERIENCE.md'), 'utf8'), 'cue')
|
|
148
|
+
rows.push(`## \`harnesses/${name}/\``, `- harness: \`harnesses/${name}/harness/\` — ${inventory(this.surface, this.treeOf(name))}`, `- why: \`harnesses/${name}/EXPERIENCE.md\``)
|
|
149
|
+
if (cue) rows.push(`- cue: ${cue}`)
|
|
150
|
+
rows.push('')
|
|
151
|
+
}
|
|
152
|
+
mkdirSync(this.dir, { recursive: true })
|
|
153
|
+
writeFileSync(this.index, rows.join('\n'))
|
|
154
|
+
return this.index
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** A one-line census of the components in a tree. */
|
|
159
|
+
export function inventory(surface: SurfaceSpec, tree: Tree): string {
|
|
160
|
+
const scripts = Object.keys(tree).filter((rel) => patterns(surface).script.test(rel)).length
|
|
161
|
+
const counts: [string, number][] = [
|
|
162
|
+
['skills', skillNames(surface, tree).length],
|
|
163
|
+
['sub_agents', subagentNames(surface, tree).length],
|
|
164
|
+
['scripts', scripts],
|
|
165
|
+
['plugins', pluginNames(surface, tree).length]
|
|
166
|
+
]
|
|
167
|
+
const bits = counts.filter(([, n]) => n).map(([name, n]) => `${n} ${name}`)
|
|
168
|
+
return `${bits.length ? bits.join(', ') : 'always-on edits only'}; ${alwaysOnText(surface, tree).length.toLocaleString('en-US')} always-on chars`
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function field(text: string, name: string): string | null {
|
|
172
|
+
for (const line of text.split('\n')) if (line.startsWith(`- **${name}**:`)) return line.split(':').slice(1).join(':').trim()
|
|
173
|
+
return null
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Every scope that has a library. */
|
|
177
|
+
export function libraryScopes(root: string): string[] {
|
|
178
|
+
try {
|
|
179
|
+
return readdirSync(libraryRoot(root), { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort()
|
|
180
|
+
} catch {
|
|
181
|
+
return []
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Every area a scope has entries in. */
|
|
186
|
+
export function libraryAreas(root: string, scope: string): string[] {
|
|
187
|
+
try {
|
|
188
|
+
return readdirSync(join(libraryRoot(root), safeId(scope)), { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort()
|
|
189
|
+
} catch {
|
|
190
|
+
return []
|
|
191
|
+
}
|
|
192
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// Refinement through real Pi sessions with a scripted model: executions are probe runs in their
|
|
2
|
+
// own folders, improvers edit the harness with Pi's own file tools, and every session is recorded.
|
|
3
|
+
import { mkdtempSync, readFileSync } from 'node:fs'
|
|
4
|
+
import { tmpdir } from 'node:os'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
import { afterEach, expect, it } from 'vitest'
|
|
7
|
+
|
|
8
|
+
import { fauxModel } from '../../tests/faux'
|
|
9
|
+
import { memorySecretStore } from '../accounts/secrets'
|
|
10
|
+
import { createHarness, type Harness } from '../harness'
|
|
11
|
+
import { listRuns } from '../sessions/runStore'
|
|
12
|
+
import * as store from '../tree/store'
|
|
13
|
+
import { DEFAULT_REFINE_HOST } from './host'
|
|
14
|
+
import { Library } from './library'
|
|
15
|
+
import { IMPROVER_TRIGGER, PiExecutor, PiImprover, PROBE_TRIGGER } from './pi'
|
|
16
|
+
import { refineDir, runRefinement } from './runner'
|
|
17
|
+
|
|
18
|
+
const open: Harness[] = []
|
|
19
|
+
afterEach(async () => {
|
|
20
|
+
for (const harness of open.splice(0)) await harness.close()
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
type Message = { role: string; content: unknown }
|
|
24
|
+
const text = (message: Message | undefined): string => (!message ? '' : Array.isArray(message.content) ? message.content.map((part: { text?: string }) => part.text ?? '').join('') : String(message.content))
|
|
25
|
+
|
|
26
|
+
it('runs probes and improvers as Pi sessions, harvests the edited tree, and records a lesson', async () => {
|
|
27
|
+
const root = mkdtempSync(join(tmpdir(), 'refine-pi-'))
|
|
28
|
+
const harness = createHarness({ dataRoot: root, stateDir: mkdtempSync(join(tmpdir(), 'refine-pi-state-')), secrets: memorySecretStore() })
|
|
29
|
+
open.push(harness)
|
|
30
|
+
harness.accounts.create('Main', 'apiKey')
|
|
31
|
+
const faux = await fauxModel(harness)
|
|
32
|
+
const base = { 'systemprompt.md': 'Answer with the total.\n', 'guardrails.md': '', 'memory.md': '', 'tool_guidance.md': '' }
|
|
33
|
+
store.seed(root, 'ledger', base)
|
|
34
|
+
|
|
35
|
+
const seen: string[] = []
|
|
36
|
+
// One responder for every request: what it answers depends on what it is asked.
|
|
37
|
+
const respond = (context: { messages: Message[] }) => {
|
|
38
|
+
const last = context.messages.at(-1)
|
|
39
|
+
const first = context.messages.filter((message) => message.role === 'user').map(text).join('\n')
|
|
40
|
+
if (last?.role === 'toolResult') return faux.says('<notes>Recorded that credits count: the base total dropped them.</notes>')
|
|
41
|
+
if (first.includes('YOUR DIMENSION') || first.includes('composing THE final harness')) {
|
|
42
|
+
seen.push('improver')
|
|
43
|
+
return faux.calls(['write', { path: 'context/harness/memory.md', content: 'Credits are negative amounts and count toward the total.\n' }])
|
|
44
|
+
}
|
|
45
|
+
if (first.includes('complete record of one improvement round')) {
|
|
46
|
+
seen.push('reflector')
|
|
47
|
+
return faux.says('<edits>\ncomponent: memory.md\ncandidates: inv1, dom1, agg1\nchange: noted that credits count\nobserved: the probe total matched the signed sum\nverdict: helped\n</edits>\n<experience>\ncue: When a ledger mixes credits and debits\nlesson: Record in memory.md that credits are negative amounts that count.\nevidence: the memory.md row; the probe total included credits\nconfidence: medium, one probe\n</experience>')
|
|
48
|
+
}
|
|
49
|
+
seen.push('probe')
|
|
50
|
+
return faux.says('Total: 85.15')
|
|
51
|
+
}
|
|
52
|
+
faux.script(Array.from({ length: 30 }, () => respond))
|
|
53
|
+
|
|
54
|
+
const id = 'pi-refine'
|
|
55
|
+
const dir = refineDir(root, id)
|
|
56
|
+
const runtime = { root: () => root, runs: harness.runs, run: (input: Parameters<Harness['sessions']['run']>[0]) => harness.sessions.run({ ...input, model: faux.model }) }
|
|
57
|
+
const summary = await runRefinement({
|
|
58
|
+
root,
|
|
59
|
+
id,
|
|
60
|
+
task: { id: 'ledger-1', scope: 'ledger', prompt: 'Sum every signed amount: 100.25, -20.10, 5.00.' },
|
|
61
|
+
k: 1,
|
|
62
|
+
mode: 'parallel',
|
|
63
|
+
executor: new PiExecutor(runtime, DEFAULT_REFINE_HOST, id, dir),
|
|
64
|
+
improver: new PiImprover(runtime, dir, { model: faux.model }),
|
|
65
|
+
proposeWidth: 1,
|
|
66
|
+
rolloutWidth: 1
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
expect(summary.status).toBe('ok')
|
|
70
|
+
expect(summary.recursion?.ledger).toMatchObject({ R: 4, P: 3 })
|
|
71
|
+
expect(summary.recursion?.fellBack).toEqual([])
|
|
72
|
+
expect(seen.filter((kind) => kind === 'improver')).toHaveLength(3)
|
|
73
|
+
expect(seen.filter((kind) => kind === 'probe')).toHaveLength(4)
|
|
74
|
+
// The chosen harness carries the improver's edit, saved as a version without moving the head.
|
|
75
|
+
const chosen = store.readVersion(root, 'ledger', summary.chosenFingerprint!)
|
|
76
|
+
expect(chosen['memory.md']).toBe('Credits are negative amounts and count toward the total.\n')
|
|
77
|
+
expect(store.head(root, 'ledger')!.fingerprint).toBe(summary.baseFingerprint)
|
|
78
|
+
expect(readFileSync(join(dir, 'rollouts', 'agg1', 'deliverables', 'reply.md'), 'utf8')).toBe('Total: 85.15')
|
|
79
|
+
expect(new Library(root, 'ledger').list().map((entry) => entry.cue)).toEqual(['When a ledger mixes credits and debits'])
|
|
80
|
+
|
|
81
|
+
// Every execution and improver session is an ordinary recorded run, marked by its trigger.
|
|
82
|
+
const runs = listRuns(root, {})
|
|
83
|
+
expect(runs.filter((run) => run.trigger === PROBE_TRIGGER)).toHaveLength(4)
|
|
84
|
+
expect(runs.filter((run) => run.trigger === PROBE_TRIGGER).every((run) => run.harnessScope === 'ledger' && run.status === 'completed')).toBe(true)
|
|
85
|
+
expect(runs.filter((run) => run.trigger === IMPROVER_TRIGGER)).toHaveLength(4)
|
|
86
|
+
|
|
87
|
+
// Adopting makes the chosen harness the head.
|
|
88
|
+
const adopted = harness.refine.adopt(id)
|
|
89
|
+
expect(adopted.fingerprint).toBe(summary.chosenFingerprint)
|
|
90
|
+
})
|