@xingular/harness 2.1.2 → 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.
Files changed (132) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/NOTICE.md +22 -0
  3. package/README.md +67 -36
  4. package/dist/cli.js +6023 -6621
  5. package/dist/terminal-pi.js +19 -24
  6. package/docs/terminal-tui.md +27 -7
  7. package/package.json +2 -2
  8. package/src/accounts/accounts.ts +4 -3
  9. package/src/capabilities/api.ts +1 -1
  10. package/src/cli.ts +7 -3
  11. package/src/core/caller.ts +34 -0
  12. package/src/core/fs.ts +17 -1
  13. package/src/harness.ts +55 -66
  14. package/src/{memory → history}/recall.test.ts +1 -1
  15. package/src/{memory → history}/recall.ts +1 -1
  16. package/src/{memory → history}/trajectory.test.ts +4 -4
  17. package/src/{memory → history}/trajectory.ts +1 -1
  18. package/src/hub/hub.test.ts +4 -5
  19. package/src/hub/hub.ts +2 -2
  20. package/src/index.ts +28 -14
  21. package/src/mcp/connections.ts +1 -1
  22. package/src/mcp/service.ts +1 -1
  23. package/src/pi/proxy.test.ts +68 -0
  24. package/src/pi/runtime.test.ts +61 -0
  25. package/src/pi/runtime.ts +50 -0
  26. package/src/plugins/api.ts +11 -9
  27. package/src/plugins/builtin.ts +22 -40
  28. package/src/plugins/catalogue.test.ts +13 -41
  29. package/src/plugins/catalogue.ts +3 -32
  30. package/src/plugins/discover.ts +2 -1
  31. package/src/plugins/firstParty.test.ts +1 -1
  32. package/src/plugins/hooks.test.ts +21 -71
  33. package/src/plugins/hooks.ts +6 -7
  34. package/src/plugins/identity.ts +16 -2
  35. package/src/plugins/install.test.ts +1 -1
  36. package/src/plugins/install.ts +2 -1
  37. package/src/plugins/inventory.ts +19 -18
  38. package/src/plugins/plugins.ts +38 -2
  39. package/src/prompt/compile.test.ts +82 -193
  40. package/src/prompt/compile.ts +98 -363
  41. package/src/refine/cli.ts +114 -0
  42. package/src/refine/demo.ts +124 -0
  43. package/src/refine/evidence.ts +349 -0
  44. package/src/refine/host.ts +102 -0
  45. package/src/refine/ledger.ts +104 -0
  46. package/src/refine/library.ts +192 -0
  47. package/src/refine/pi.test.ts +90 -0
  48. package/src/refine/pi.ts +218 -0
  49. package/src/refine/prompts.ts +430 -0
  50. package/src/refine/recursion.ts +353 -0
  51. package/src/refine/refine.test.ts +140 -0
  52. package/src/refine/runner.ts +205 -0
  53. package/src/refine/selection.ts +291 -0
  54. package/src/refine/service.ts +194 -0
  55. package/src/refine/trajectory.ts +71 -0
  56. package/src/refine/types.ts +91 -0
  57. package/src/server/methods.ts +137 -123
  58. package/src/server/server.test.ts +37 -30
  59. package/src/sessions/contracts.ts +6 -1
  60. package/src/sessions/conversation.test.ts +2 -27
  61. package/src/sessions/policy.ts +1 -1
  62. package/src/sessions/pricing.ts +3 -1
  63. package/src/sessions/registry.ts +5 -1
  64. package/src/sessions/runStore.ts +3 -1
  65. package/src/sessions/runner.ts +10 -5
  66. package/src/sessions/sessions.test.ts +45 -6
  67. package/src/sessions/sessions.ts +40 -17
  68. package/src/sessions/subagentService.test.ts +22 -1
  69. package/src/sessions/subagentService.ts +14 -1
  70. package/src/sessions/subagents.ts +2 -0
  71. package/src/terminal/cli.ts +24 -48
  72. package/src/terminal/pluginControl.test.ts +7 -8
  73. package/src/terminal/socket.ts +3 -3
  74. package/src/terminal/terminal.test.ts +21 -39
  75. package/src/terminal/terminals.ts +20 -13
  76. package/src/terminal/tui/application.test.ts +58 -0
  77. package/src/terminal/tui/application.ts +10 -4
  78. package/src/terminal/tui/clipboardFiles.test.ts +94 -0
  79. package/src/terminal/tui/clipboardFiles.ts +86 -0
  80. package/src/terminal/tui/piAdapter.ts +7 -4
  81. package/src/terminal/tui/runtime.ts +2 -0
  82. package/src/tools/definitions.ts +49 -132
  83. package/src/tools/dispatch.ts +78 -0
  84. package/src/tools/harnessTools.ts +23 -0
  85. package/src/tools/spawnSubagent.ts +3 -3
  86. package/src/tools/types.ts +1 -1
  87. package/src/tree/components.ts +221 -0
  88. package/src/tree/materialise.ts +122 -0
  89. package/src/tree/seed.ts +123 -0
  90. package/src/tree/store.ts +193 -0
  91. package/src/tree/surface.ts +135 -0
  92. package/src/tree/tree.test.ts +126 -0
  93. package/src/tree/tree.ts +346 -0
  94. package/src/tree/trust.ts +86 -0
  95. package/src/wiki/notes.ts +313 -0
  96. package/src/memory/api.test.ts +0 -82
  97. package/src/memory/api.ts +0 -303
  98. package/src/memory/blocks.test.ts +0 -78
  99. package/src/memory/blocks.ts +0 -408
  100. package/src/memory/caller.ts +0 -69
  101. package/src/memory/curation.test.ts +0 -206
  102. package/src/memory/curation.ts +0 -64
  103. package/src/memory/dispatch.ts +0 -64
  104. package/src/memory/facts.test.ts +0 -41
  105. package/src/memory/facts.ts +0 -248
  106. package/src/memory/graph.ts +0 -103
  107. package/src/memory/health.test.ts +0 -130
  108. package/src/memory/health.ts +0 -74
  109. package/src/memory/maintainer.ts +0 -272
  110. package/src/memory/maintenance.test.ts +0 -192
  111. package/src/memory/maintenance.ts +0 -679
  112. package/src/memory/migrate.ts +0 -79
  113. package/src/memory/operations.ts +0 -150
  114. package/src/memory/proposals.test.ts +0 -168
  115. package/src/memory/proposals.ts +0 -584
  116. package/src/memory/references.ts +0 -77
  117. package/src/memory/runContext.ts +0 -93
  118. package/src/memory/store.test.ts +0 -66
  119. package/src/memory/store.ts +0 -385
  120. package/src/memory/wiki.test.ts +0 -47
  121. package/src/memory/wiki.ts +0 -423
  122. package/src/memory/wikiIndex.test.ts +0 -88
  123. package/src/memory/wikiIndex.ts +0 -117
  124. package/src/plugins/proposal.ts +0 -121
  125. package/src/skills/registry.test.ts +0 -104
  126. package/src/skills/registry.ts +0 -413
  127. package/src/tools/maintenance.ts +0 -29
  128. package/src/tools/memoryTools.ts +0 -22
  129. package/src/tools/pluginPropose.ts +0 -41
  130. package/src/tools/registryWrite.test.ts +0 -101
  131. package/src/tools/registryWrite.ts +0 -182
  132. /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
+ })