@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,122 @@
|
|
|
1
|
+
// Turning a harness tree into what a Pi session receives: always-on prompt sections (c1–c4),
|
|
2
|
+
// the skills folder Pi advertises natively (c6), the scripts folder named in the prompt (c7),
|
|
3
|
+
// subagent definitions (c5), and plugin extensions that are trusted (c8).
|
|
4
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
|
|
7
|
+
import { invalid } from '../core/errors'
|
|
8
|
+
import * as store from './store'
|
|
9
|
+
import { GUARDRAILS, MEMORY, PI_SURFACE, SYSTEM_PROMPT, TOOL_GUIDANCE, patterns, type Tree } from './surface'
|
|
10
|
+
import { readTree } from './tree'
|
|
11
|
+
import type { TreeTrust } from './trust'
|
|
12
|
+
|
|
13
|
+
/** Where the tree's own sections sit among the application's (identity 10, role 20). */
|
|
14
|
+
export const TREE_SECTION_ORDER = { system: 25, guardrails: 40, memory: 45, subagents: 64, scripts: 66 } as const
|
|
15
|
+
|
|
16
|
+
/** Which harness a session runs. */
|
|
17
|
+
export type HarnessChoice =
|
|
18
|
+
/** A scope's head, or one of its versions. */
|
|
19
|
+
| { scope: string; version?: string | null }
|
|
20
|
+
/** A tree on disk, such as a refinement candidate. It is read, never written. */
|
|
21
|
+
| { dir: string; scope?: string | null }
|
|
22
|
+
|
|
23
|
+
export interface SubagentDefinition {
|
|
24
|
+
name: string
|
|
25
|
+
description: string
|
|
26
|
+
instructions: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface Materialised {
|
|
30
|
+
scope: string | null
|
|
31
|
+
/** The fingerprint of the tree that ran. */
|
|
32
|
+
fingerprint: string
|
|
33
|
+
/** The tree's folder; scripts and skills are read from it in place. */
|
|
34
|
+
dir: string
|
|
35
|
+
tree: Tree
|
|
36
|
+
sections: { id: string; title: string; body: string; order: number }[]
|
|
37
|
+
/** The tool guidance, added after the tool groups. */
|
|
38
|
+
toolGuidance: string
|
|
39
|
+
skillPaths: string[]
|
|
40
|
+
extensionPaths: string[]
|
|
41
|
+
/** Plugin files that did not load because their code is not trusted. */
|
|
42
|
+
untrustedPlugins: string[]
|
|
43
|
+
subagents: SubagentDefinition[]
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function frontmatter(text: string, field: string): string | null {
|
|
47
|
+
const m = new RegExp(`^${field}:\\s*(.+)$`, 'm').exec(text.slice(0, 600))
|
|
48
|
+
return m?.[1]?.trim() ?? null
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function bodyAfterFrontmatter(text: string): string {
|
|
52
|
+
const m = /^---\n[\s\S]*?\n---\n?/.exec(text)
|
|
53
|
+
return (m ? text.slice(m[0].length) : text).trim()
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Finds the tree a choice names. */
|
|
57
|
+
export function resolve(root: string, choice: HarnessChoice): { scope: string | null; dir: string; fingerprint: string; tree: Tree } {
|
|
58
|
+
if ('dir' in choice) {
|
|
59
|
+
if (!existsSync(choice.dir)) throw invalid('That harness folder does not exist.')
|
|
60
|
+
const tree = readTree(PI_SURFACE, choice.dir)
|
|
61
|
+
return { scope: choice.scope ?? null, dir: choice.dir, fingerprint: store.fingerprint(tree), tree }
|
|
62
|
+
}
|
|
63
|
+
const fp = choice.version ?? store.head(root, choice.scope)?.fingerprint
|
|
64
|
+
if (!fp) throw invalid(`The harness '${choice.scope}' has no version yet.`)
|
|
65
|
+
const dir = store.versionPath(root, choice.scope, fp)
|
|
66
|
+
return { scope: choice.scope, dir, fingerprint: fp, tree: store.readVersion(root, choice.scope, fp) }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface MaterialiseOptions {
|
|
70
|
+
trust?: TreeTrust | null
|
|
71
|
+
/** Load improver-written plugin code without trust (refinement probes, when the person allowed it). */
|
|
72
|
+
probeCode?: boolean
|
|
73
|
+
/** Whether the session can start subagents; when it cannot, c5 is not described. */
|
|
74
|
+
subagents?: boolean
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function materialise(root: string, choice: HarnessChoice, options: MaterialiseOptions = {}): Materialised {
|
|
78
|
+
const { scope, dir, fingerprint, tree } = resolve(root, choice)
|
|
79
|
+
const r = patterns(PI_SURFACE)
|
|
80
|
+
const sections: Materialised['sections'] = []
|
|
81
|
+
const add = (id: string, title: string, body: string | undefined, order: number) => {
|
|
82
|
+
if (body?.trim()) sections.push({ id, title, body: body.trim(), order })
|
|
83
|
+
}
|
|
84
|
+
add('system', 'Instructions', tree[SYSTEM_PROMPT], TREE_SECTION_ORDER.system)
|
|
85
|
+
add('guardrails', 'Guardrails', tree[GUARDRAILS], TREE_SECTION_ORDER.guardrails)
|
|
86
|
+
add('memory', 'Memory', tree[MEMORY], TREE_SECTION_ORDER.memory)
|
|
87
|
+
|
|
88
|
+
const subagents: SubagentDefinition[] = []
|
|
89
|
+
for (const rel of Object.keys(tree).sort()) {
|
|
90
|
+
const m = r.subagent.exec(rel)
|
|
91
|
+
if (!m) continue
|
|
92
|
+
const text = tree[rel]!
|
|
93
|
+
subagents.push({ name: m[1]!, description: frontmatter(text, 'description') ?? '', instructions: bodyAfterFrontmatter(text) })
|
|
94
|
+
}
|
|
95
|
+
if (subagents.length && options.subagents !== false) {
|
|
96
|
+
add('subagents', 'Subagents', `Start one with spawn_subagent and agent set to its name. Each works in its own context and returns text.\n${subagents.map((agent) => `- ${agent.name} — ${agent.description}`).join('\n')}`, TREE_SECTION_ORDER.subagents)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const scripts = Object.keys(tree).filter((rel) => r.script.test(rel)).map((rel) => rel.slice('scripts/'.length)).sort()
|
|
100
|
+
if (scripts.length) {
|
|
101
|
+
add('scripts', 'Scripts', `Your helper scripts are in ${join(dir, 'scripts')}: ${scripts.join(', ')}. Run a .py one with python3 and a .mjs one with node, giving its full path. When instructions say \`scripts/<name>\`, they mean this folder.`, TREE_SECTION_ORDER.scripts)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const skillPaths = existsSync(join(dir, 'skills')) && readdirSync(join(dir, 'skills')).length ? [join(dir, 'skills')] : []
|
|
105
|
+
const extensionPaths: string[] = []
|
|
106
|
+
const untrustedPlugins: string[] = []
|
|
107
|
+
for (const rel of Object.keys(tree).filter((path) => r.plugin.test(path)).sort()) {
|
|
108
|
+
const trusted = options.trust?.has((options.trust.review({ [rel]: tree[rel]! })[0]?.hash) ?? '') ?? false
|
|
109
|
+
if (trusted || options.probeCode) extensionPaths.push(join(dir, rel))
|
|
110
|
+
else untrustedPlugins.push(rel)
|
|
111
|
+
}
|
|
112
|
+
return { scope, fingerprint, dir, tree, sections, toolGuidance: (tree[TOOL_GUIDANCE] ?? '').trim(), skillPaths, extensionPaths, untrustedPlugins, subagents }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** One subagent's definition from the tree a run used. */
|
|
116
|
+
export function subagentOf(dir: string, name: string): SubagentDefinition | null {
|
|
117
|
+
if (!/^[a-z0-9][a-z0-9_-]*$/.test(name)) return null
|
|
118
|
+
const path = join(dir, 'sub_agents', name, 'AGENT.md')
|
|
119
|
+
if (!existsSync(path)) return null
|
|
120
|
+
const text = readFileSync(path, 'utf8')
|
|
121
|
+
return { name, description: frontmatter(text, 'description') ?? '', instructions: bodyAfterFrontmatter(text) }
|
|
122
|
+
}
|
package/src/tree/seed.ts
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// A first harness tree for a caller that had none, built from what shaped it before harness
|
|
2
|
+
// trees existed: its instructions (c1), the rules switched on for it (c2), its memory blocks and
|
|
3
|
+
// the person's own block (c3), and the skills switched on for it (c6). Read straight from the
|
|
4
|
+
// files an earlier harness left in the data root; nothing there is changed.
|
|
5
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
|
|
6
|
+
import { join } from 'node:path'
|
|
7
|
+
|
|
8
|
+
import * as frontmatter from '../core/frontmatter'
|
|
9
|
+
import * as attachments from '../plugins/attachments'
|
|
10
|
+
import { GUARDRAILS, MEMORY, PI_SURFACE, SYSTEM_PROMPT, TOOL_GUIDANCE, type Tree } from './surface'
|
|
11
|
+
import { alwaysOnText, autotrigger, validate } from './tree'
|
|
12
|
+
|
|
13
|
+
export interface SeedInput {
|
|
14
|
+
/** The caller whose rules, skills and memory blocks to carry over. */
|
|
15
|
+
agentId?: string | null
|
|
16
|
+
projectId?: string | null
|
|
17
|
+
/** The caller's own instructions. */
|
|
18
|
+
instructions?: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function body(text: string): string {
|
|
22
|
+
return (frontmatter.parse(text)?.body ?? text).trim()
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function read(path: string): string | null {
|
|
26
|
+
try {
|
|
27
|
+
return readFileSync(path, 'utf8')
|
|
28
|
+
} catch {
|
|
29
|
+
return null
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function folders(dir: string): string[] {
|
|
34
|
+
try {
|
|
35
|
+
return readdirSync(dir).filter((name) => !name.startsWith('.')).sort()
|
|
36
|
+
} catch {
|
|
37
|
+
return []
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Where an earlier Wiki kept memory blocks: in place, or where they were moved aside. */
|
|
42
|
+
function blocksDir(root: string): string | null {
|
|
43
|
+
if (existsSync(join(root, 'wiki', 'blocks'))) return join(root, 'wiki', 'blocks')
|
|
44
|
+
const moved = folders(join(root, 'legacy')).filter((name) => name.startsWith('wiki-')).reverse()
|
|
45
|
+
for (const name of moved) if (existsSync(join(root, 'legacy', name, 'blocks'))) return join(root, 'legacy', name, 'blocks')
|
|
46
|
+
return null
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function skillName(stem: string): string {
|
|
50
|
+
return stem.toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^[-_]+|-+$/g, '').slice(0, 64) || 'skill'
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The legacy pieces for a caller, before they are put together. */
|
|
54
|
+
export function legacyPieces(root: string, input: SeedInput): { rules: string[]; skills: Record<string, string>; memory: string[] } {
|
|
55
|
+
const choices = attachments.load(root)
|
|
56
|
+
const scope = { agentId: input.agentId ?? null, projectId: input.projectId ?? null }
|
|
57
|
+
const on = (id: string) => attachments.attached(choices, [id], scope, false)
|
|
58
|
+
|
|
59
|
+
const rules: string[] = []
|
|
60
|
+
const rulesDir = join(root, 'registry', 'rules')
|
|
61
|
+
for (const name of folders(rulesDir)) {
|
|
62
|
+
const stem = name.replace(/\.md$/, '')
|
|
63
|
+
const path = name.endsWith('.md') ? join(rulesDir, name) : join(rulesDir, name, 'SKILL.md')
|
|
64
|
+
const text = read(path)
|
|
65
|
+
if (text && on(`rules/${stem}`)) rules.push(body(text))
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const skills: Record<string, string> = {}
|
|
69
|
+
const skillsDir = join(root, 'registry', 'skills')
|
|
70
|
+
for (const name of folders(skillsDir)) {
|
|
71
|
+
const stem = name.replace(/\.md$/, '')
|
|
72
|
+
let path = join(skillsDir, name)
|
|
73
|
+
try {
|
|
74
|
+
if (statSync(path).isDirectory()) path = join(path, 'SKILL.md')
|
|
75
|
+
} catch {
|
|
76
|
+
continue
|
|
77
|
+
}
|
|
78
|
+
const text = read(path)
|
|
79
|
+
if (!text || !on(`skills/${stem}`)) continue
|
|
80
|
+
const slug = skillName(stem)
|
|
81
|
+
const parsed = frontmatter.parse(text)
|
|
82
|
+
const description = typeof parsed?.front.description === 'string' && parsed.front.description.trim() ? parsed.front.description.trim() : `the ${slug} workflow applies`
|
|
83
|
+
skills[`skills/${slug}/SKILL.md`] = `---\nname: ${slug}\ndescription: ${description.replace(/\n/g, ' ')}\n---\n${body(text)}\n`
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const memory: string[] = []
|
|
87
|
+
const blocks = blocksDir(root)
|
|
88
|
+
if (blocks) {
|
|
89
|
+
const user = read(join(blocks, 'shared', 'user.md'))
|
|
90
|
+
if (user && body(user)) memory.push(`## About the person\n\n${body(user)}`)
|
|
91
|
+
if (input.agentId && /^[A-Za-z0-9_-]+$/.test(input.agentId)) {
|
|
92
|
+
for (const [file, title] of [['persona.md', 'Persona'], ['notes.md', 'Notes']] as const) {
|
|
93
|
+
const text = read(join(blocks, 'agents', input.agentId, file))
|
|
94
|
+
if (text && body(text)) memory.push(`## ${title}\n\n${body(text)}`)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return { rules, skills, memory }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Builds a valid seed tree. Rules become guardrails, blocks become memory, and every skill gets a
|
|
103
|
+
* trigger line. When the always-on text would be over the cap, memory is shortened first, then
|
|
104
|
+
* guardrails; nothing else is dropped.
|
|
105
|
+
*/
|
|
106
|
+
export function seedTree(root: string, input: SeedInput): Tree {
|
|
107
|
+
const pieces = legacyPieces(root, input)
|
|
108
|
+
const tree: Tree = {
|
|
109
|
+
[SYSTEM_PROMPT]: (input.instructions ?? '').trim() ? `${input.instructions!.trim()}\n` : '',
|
|
110
|
+
[GUARDRAILS]: pieces.rules.length ? `${pieces.rules.join('\n\n')}\n` : '',
|
|
111
|
+
[MEMORY]: pieces.memory.length ? `${pieces.memory.join('\n\n')}\n` : '',
|
|
112
|
+
[TOOL_GUIDANCE]: '',
|
|
113
|
+
...pieces.skills
|
|
114
|
+
}
|
|
115
|
+
const cap = PI_SURFACE.alwaysOnMax - 2_000
|
|
116
|
+
for (const rel of [MEMORY, GUARDRAILS, SYSTEM_PROMPT]) {
|
|
117
|
+
const over = alwaysOnText(PI_SURFACE, tree).length - cap
|
|
118
|
+
if (over > 0) tree[rel] = `${tree[rel]!.slice(0, Math.max(0, tree[rel]!.length - over - 40)).trimEnd()}\n\n(Shortened when this harness was created.)\n`
|
|
119
|
+
}
|
|
120
|
+
autotrigger(PI_SURFACE, tree)
|
|
121
|
+
validate(PI_SURFACE, tree, { compileScripts: false })
|
|
122
|
+
return tree
|
|
123
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// Versioned harness trees. Each scope (an opaque id the application chooses, such as one per
|
|
2
|
+
// agent) has content-addressed versions, a head, and a lineage that says where each version came
|
|
3
|
+
// from. A run uses the head it started with; a change makes a new version and never edits one.
|
|
4
|
+
//
|
|
5
|
+
// <data>/harnesses/<scope>/versions/<fingerprint>/tree/<rel>
|
|
6
|
+
// <data>/harnesses/<scope>/versions/<fingerprint>/version.json
|
|
7
|
+
// <data>/harnesses/<scope>/head.json
|
|
8
|
+
// <data>/harnesses/<scope>/lineage.jsonl
|
|
9
|
+
import { createHash } from 'node:crypto'
|
|
10
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
|
11
|
+
import { join } from 'node:path'
|
|
12
|
+
|
|
13
|
+
import { HarnessError, invalid, notFound } from '../core/errors'
|
|
14
|
+
import { appendLine, now, readJson, readText, safeId, writeJson } from '../core/fs'
|
|
15
|
+
import { PI_SURFACE, type SurfaceSpec, type Tree } from './surface'
|
|
16
|
+
import { diffSummary, readTree, validate, writeTree, type TreeCensus, type TreeDiff, type ValidateOptions } from './tree'
|
|
17
|
+
|
|
18
|
+
/** A stable digest of a tree: the same files with the same text give the same value. */
|
|
19
|
+
export function fingerprint(tree: Tree): string {
|
|
20
|
+
const hash = createHash('sha1')
|
|
21
|
+
for (const rel of Object.keys(tree).sort()) {
|
|
22
|
+
hash.update(rel)
|
|
23
|
+
hash.update('\0')
|
|
24
|
+
hash.update(tree[rel]!)
|
|
25
|
+
hash.update('\0')
|
|
26
|
+
}
|
|
27
|
+
return hash.digest('hex').slice(0, 16)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type VersionSource =
|
|
31
|
+
| { kind: 'seed'; note?: string }
|
|
32
|
+
| { kind: 'edit'; note?: string; components?: string[] }
|
|
33
|
+
| { kind: 'refinement'; refineId: string; label: string; note?: string }
|
|
34
|
+
| { kind: 'import'; note?: string }
|
|
35
|
+
| { kind: 'restore'; from: string; note?: string }
|
|
36
|
+
|
|
37
|
+
export interface VersionRecord {
|
|
38
|
+
fingerprint: string
|
|
39
|
+
parent: string | null
|
|
40
|
+
source: VersionSource
|
|
41
|
+
createdAt: string
|
|
42
|
+
census: TreeCensus
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface Head {
|
|
46
|
+
fingerprint: string
|
|
47
|
+
updatedAt: string
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function harnessesDir(root: string): string {
|
|
51
|
+
return join(root, 'harnesses')
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function scopeDir(root: string, scope: string): string {
|
|
55
|
+
return join(harnessesDir(root), safeId(scope))
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function versionDir(root: string, scope: string, fp: string): string {
|
|
59
|
+
if (!/^[0-9a-f]{16}$/.test(fp)) throw invalid('Invalid harness version.')
|
|
60
|
+
return join(scopeDir(root, scope), 'versions', fp)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function scopes(root: string): string[] {
|
|
64
|
+
try {
|
|
65
|
+
return readdirSync(harnessesDir(root), { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort()
|
|
66
|
+
} catch {
|
|
67
|
+
return []
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function head(root: string, scope: string): Head | null {
|
|
72
|
+
return readJson<Head>(join(scopeDir(root, scope), 'head.json'))
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function exists(root: string, scope: string, fp: string): boolean {
|
|
76
|
+
return existsSync(join(versionDir(root, scope, fp), 'version.json'))
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function readVersion(root: string, scope: string, fp: string, surface: SurfaceSpec = PI_SURFACE): Tree {
|
|
80
|
+
const dir = join(versionDir(root, scope, fp), 'tree')
|
|
81
|
+
if (!existsSync(dir)) throw notFound('That harness version')
|
|
82
|
+
return readTree(surface, dir)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function versionRecord(root: string, scope: string, fp: string): VersionRecord | null {
|
|
86
|
+
return readJson<VersionRecord>(join(versionDir(root, scope, fp), 'version.json'))
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Where a version's files are on disk, for a run that loads them. */
|
|
90
|
+
export function versionPath(root: string, scope: string, fp: string): string {
|
|
91
|
+
return join(versionDir(root, scope, fp), 'tree')
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The head tree, or null when the scope has none yet. */
|
|
95
|
+
export function headTree(root: string, scope: string, surface: SurfaceSpec = PI_SURFACE): { fingerprint: string; tree: Tree } | null {
|
|
96
|
+
const current = head(root, scope)
|
|
97
|
+
return current ? { fingerprint: current.fingerprint, tree: readVersion(root, scope, current.fingerprint, surface) } : null
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Every version recorded for a scope, newest first. */
|
|
101
|
+
export function lineage(root: string, scope: string): VersionRecord[] {
|
|
102
|
+
const text = readText(join(scopeDir(root, scope), 'lineage.jsonl')) ?? ''
|
|
103
|
+
const out: VersionRecord[] = []
|
|
104
|
+
for (const line of text.split('\n')) {
|
|
105
|
+
if (!line.trim()) continue
|
|
106
|
+
try {
|
|
107
|
+
out.push(JSON.parse(line) as VersionRecord)
|
|
108
|
+
} catch {
|
|
109
|
+
// A torn final line is skipped; the version files are the record.
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return out.reverse()
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export interface CommitOptions {
|
|
116
|
+
/** Move the head to the new version (the default). */
|
|
117
|
+
moveHead?: boolean
|
|
118
|
+
surface?: SurfaceSpec
|
|
119
|
+
validate?: ValidateOptions
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Saves `tree` as a version of `scope`. An invalid tree is refused with every problem listed.
|
|
124
|
+
* Saving a tree that already exists records nothing new but can still move the head.
|
|
125
|
+
*/
|
|
126
|
+
export function commit(root: string, scope: string, tree: Tree, source: VersionSource, options: CommitOptions = {}): VersionRecord {
|
|
127
|
+
const surface = options.surface ?? PI_SURFACE
|
|
128
|
+
let census: TreeCensus
|
|
129
|
+
try {
|
|
130
|
+
census = validate(surface, tree, options.validate)
|
|
131
|
+
} catch (error) {
|
|
132
|
+
throw new HarnessError('harness_invalid', error instanceof Error ? error.message : String(error), 'Fix the listed problems and save again.')
|
|
133
|
+
}
|
|
134
|
+
const fp = fingerprint(tree)
|
|
135
|
+
const parent = head(root, scope)?.fingerprint ?? null
|
|
136
|
+
let record = versionRecord(root, scope, fp)
|
|
137
|
+
if (!record) {
|
|
138
|
+
writeTree(surface, tree, join(versionDir(root, scope, fp), 'tree'), { stubs: false })
|
|
139
|
+
record = { fingerprint: fp, parent, source, createdAt: now(), census }
|
|
140
|
+
writeJson(join(versionDir(root, scope, fp), 'version.json'), record)
|
|
141
|
+
appendLine(join(scopeDir(root, scope), 'lineage.jsonl'), JSON.stringify(record))
|
|
142
|
+
}
|
|
143
|
+
if (options.moveHead !== false) writeJson(join(scopeDir(root, scope), 'head.json'), { fingerprint: fp, updatedAt: now() } satisfies Head)
|
|
144
|
+
return record
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Gives a scope its first version, unless it already has a head. */
|
|
148
|
+
export function seed(root: string, scope: string, tree: Tree, note?: string, surface: SurfaceSpec = PI_SURFACE): Head {
|
|
149
|
+
const current = head(root, scope)
|
|
150
|
+
if (current) return current
|
|
151
|
+
commit(root, scope, tree, { kind: 'seed', note }, { surface })
|
|
152
|
+
return head(root, scope)!
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Points the head at an earlier version. The version must exist. */
|
|
156
|
+
export function restore(root: string, scope: string, fp: string, note?: string): Head {
|
|
157
|
+
if (!exists(root, scope, fp)) throw notFound('That harness version')
|
|
158
|
+
const from = head(root, scope)?.fingerprint ?? fp
|
|
159
|
+
writeJson(join(scopeDir(root, scope), 'head.json'), { fingerprint: fp, updatedAt: now() } satisfies Head)
|
|
160
|
+
appendLine(join(scopeDir(root, scope), 'restores.jsonl'), JSON.stringify({ to: fp, from, note: note ?? null, at: now() }))
|
|
161
|
+
return head(root, scope)!
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export interface FileDiff {
|
|
165
|
+
path: string
|
|
166
|
+
before: string | null
|
|
167
|
+
after: string | null
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** What changed between two versions, file by file. */
|
|
171
|
+
export function compare(root: string, scope: string, from: string, to: string, surface: SurfaceSpec = PI_SURFACE): { summary: TreeDiff; files: FileDiff[] } {
|
|
172
|
+
const a = readVersion(root, scope, from, surface)
|
|
173
|
+
const b = readVersion(root, scope, to, surface)
|
|
174
|
+
const summary = diffSummary(surface, a, b)
|
|
175
|
+
const files = [...summary.added, ...summary.removed, ...summary.modified].sort().map((path) => ({ path, before: a[path] ?? null, after: b[path] ?? null }))
|
|
176
|
+
return { summary, files }
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Reads a tree folder made outside the harness, such as an export or a hand-written one. */
|
|
180
|
+
export function importFolder(dir: string, surface: SurfaceSpec = PI_SURFACE): { tree: Tree; rejected: string[] } {
|
|
181
|
+
const rejected: string[] = []
|
|
182
|
+
const tree = readTree(surface, dir, rejected)
|
|
183
|
+
return { tree, rejected }
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Reads one file of a tree on disk; used by callers that only need one component. */
|
|
187
|
+
export function readComponentFile(root: string, scope: string, fp: string, rel: string): string | null {
|
|
188
|
+
try {
|
|
189
|
+
return readFileSync(join(versionPath(root, scope, fp), rel), 'utf8')
|
|
190
|
+
} catch {
|
|
191
|
+
return null
|
|
192
|
+
}
|
|
193
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// The editable surface of a harness: the eight components a person or an improver may change,
|
|
2
|
+
// where each lives in the tree, and the rules that keep a tree loadable by Pi.
|
|
3
|
+
//
|
|
4
|
+
// Ported from Harness² (Apache-2.0, Google LLC; `harness2/core/tree.py`, `SurfaceSpec`). The
|
|
5
|
+
// component numbering c1..c8 follows the paper: system prompt, guardrails, memory, tool
|
|
6
|
+
// guidance, subagents, skills, scripts and plugins.
|
|
7
|
+
|
|
8
|
+
/** A harness: relative path → file text, for every file on the editable surface. */
|
|
9
|
+
export type Tree = Record<string, string>
|
|
10
|
+
|
|
11
|
+
export type ComponentId = 'systemPrompt' | 'guardrails' | 'memory' | 'toolGuidance' | 'subagents' | 'skills' | 'scripts' | 'plugins'
|
|
12
|
+
|
|
13
|
+
/** How a component reaches a run, grouped as the paper groups them. */
|
|
14
|
+
export type ComponentGroup = 'context' | 'toolInterface' | 'orchestration' | 'middleware'
|
|
15
|
+
|
|
16
|
+
export interface ComponentInfo {
|
|
17
|
+
id: ComponentId
|
|
18
|
+
/** c1..c8, as numbered in the paper. */
|
|
19
|
+
index: number
|
|
20
|
+
title: string
|
|
21
|
+
group: ComponentGroup
|
|
22
|
+
/** Where it lives: a file for an always-on component, a pattern for the others. */
|
|
23
|
+
path: string
|
|
24
|
+
/** When it is in a run's context. */
|
|
25
|
+
reach: 'always' | 'onDemand' | 'ownContext' | 'toolLoop'
|
|
26
|
+
description: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface SurfaceSpec {
|
|
30
|
+
name: string
|
|
31
|
+
/** Files whose whole text is in every run's prompt. */
|
|
32
|
+
alwaysOn: readonly string[]
|
|
33
|
+
/** The most always-on characters a tree may carry. */
|
|
34
|
+
alwaysOnMax: number
|
|
35
|
+
alwaysOnCapReason: string
|
|
36
|
+
skillDir: string
|
|
37
|
+
/** Pi drops a skill whose frontmatter `name` differs from its folder. */
|
|
38
|
+
skillNameRule: 'must_match' | 'overrides_dir'
|
|
39
|
+
triggerHint: string
|
|
40
|
+
autotriggerFile: string
|
|
41
|
+
triggerLine: string
|
|
42
|
+
subagentDir: string
|
|
43
|
+
scriptDir: string
|
|
44
|
+
/** Script file extensions the surface accepts. */
|
|
45
|
+
scriptExtensions: readonly string[]
|
|
46
|
+
pluginDir: string
|
|
47
|
+
/** Short README files written beside each directory so an editor knows the form. */
|
|
48
|
+
stubs: readonly (readonly [string, string])[]
|
|
49
|
+
components: readonly ComponentInfo[]
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export const SYSTEM_PROMPT = 'systemprompt.md'
|
|
53
|
+
export const GUARDRAILS = 'guardrails.md'
|
|
54
|
+
export const MEMORY = 'memory.md'
|
|
55
|
+
export const TOOL_GUIDANCE = 'tool_guidance.md'
|
|
56
|
+
|
|
57
|
+
/** The Pi surface: the one surface this harness runs. */
|
|
58
|
+
export const PI_SURFACE: SurfaceSpec = {
|
|
59
|
+
name: 'pi',
|
|
60
|
+
alwaysOn: [SYSTEM_PROMPT, GUARDRAILS, MEMORY, TOOL_GUIDANCE],
|
|
61
|
+
alwaysOnMax: 24_000,
|
|
62
|
+
alwaysOnCapReason: 'every token here competes with the agent\'s own output budget on every run',
|
|
63
|
+
skillDir: 'skills',
|
|
64
|
+
skillNameRule: 'must_match',
|
|
65
|
+
triggerHint: 'Fix it by adding a trigger line naming `{name}` to systemprompt.md or guardrails.md, e.g. "When <the situation this skill handles>, load the `{name}` skill."',
|
|
66
|
+
autotriggerFile: GUARDRAILS,
|
|
67
|
+
triggerLine: '- When {desc}, load the `{name}` skill.',
|
|
68
|
+
subagentDir: 'sub_agents',
|
|
69
|
+
scriptDir: 'scripts',
|
|
70
|
+
scriptExtensions: ['.py', '.mjs'],
|
|
71
|
+
pluginDir: 'plugins',
|
|
72
|
+
stubs: [
|
|
73
|
+
['scripts/README.md', '<!-- c7: deterministic helpers. Add one as scripts/<name>.py or scripts/<name>.mjs; the prompt names the folder they are staged in. -->\n'],
|
|
74
|
+
['sub_agents/README.md', '<!-- c5: delegated subagents, started with `spawn_subagent` and `agent: "<name>"`, each in its OWN context. Add one as sub_agents/<name>/AGENT.md with a `description:` in its frontmatter. -->\n'],
|
|
75
|
+
['plugins/README.md', '<!-- c8: Pi extensions. They act INSIDE the tool loop -- the only lever that can enforce something the model cannot skip. Add one as plugins/<name>.ts with a default export. New or changed code waits for the person to trust it. -->\n'],
|
|
76
|
+
['skills/README.md', '<!-- c6: reusable workflows loaded on demand. Add one as skills/<name>/SKILL.md with frontmatter `name:` matching the folder and a `description:`. -->\n']
|
|
77
|
+
],
|
|
78
|
+
components: [
|
|
79
|
+
{ id: 'systemPrompt', index: 1, title: 'System prompt', group: 'context', path: SYSTEM_PROMPT, reach: 'always', description: 'Role and procedure.' },
|
|
80
|
+
{ id: 'guardrails', index: 2, title: 'Guardrails', group: 'context', path: GUARDRAILS, reach: 'always', description: 'Standing rules the agent keeps on every run.' },
|
|
81
|
+
{ id: 'memory', index: 3, title: 'Memory', group: 'context', path: MEMORY, reach: 'always', description: 'Durable, general facts about this kind of work.' },
|
|
82
|
+
{ id: 'toolGuidance', index: 4, title: 'Tool guidance', group: 'toolInterface', path: TOOL_GUIDANCE, reach: 'always', description: 'How to use the tools well.' },
|
|
83
|
+
{ id: 'subagents', index: 5, title: 'Subagents', group: 'orchestration', path: 'sub_agents/<name>/AGENT.md', reach: 'ownContext', description: 'Bounded helpers that run in their own context and return text.' },
|
|
84
|
+
{ id: 'skills', index: 6, title: 'Skills', group: 'context', path: 'skills/<name>/SKILL.md', reach: 'onDemand', description: 'Reusable workflows loaded when a situation matches.' },
|
|
85
|
+
{ id: 'scripts', index: 7, title: 'Scripts', group: 'toolInterface', path: 'scripts/<name>.py', reach: 'onDemand', description: 'Deterministic helpers the agent runs.' },
|
|
86
|
+
{ id: 'plugins', index: 8, title: 'Plugins', group: 'middleware', path: 'plugins/<name>.ts', reach: 'toolLoop', description: 'Pi extensions that act inside the tool loop.' }
|
|
87
|
+
]
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const NAME = '[a-z0-9][a-z0-9_\\-]*'
|
|
91
|
+
|
|
92
|
+
export interface SurfacePatterns {
|
|
93
|
+
skill: RegExp
|
|
94
|
+
skillScript: RegExp
|
|
95
|
+
subagent: RegExp
|
|
96
|
+
script: RegExp
|
|
97
|
+
scriptRef: RegExp
|
|
98
|
+
plugin: RegExp
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const cache = new Map<string, SurfacePatterns>()
|
|
102
|
+
|
|
103
|
+
function escape(text: string): string {
|
|
104
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function patterns(surface: SurfaceSpec): SurfacePatterns {
|
|
108
|
+
const known = cache.get(surface.name)
|
|
109
|
+
if (known) return known
|
|
110
|
+
const skills = escape(surface.skillDir)
|
|
111
|
+
const scripts = escape(surface.scriptDir)
|
|
112
|
+
const extensions = surface.scriptExtensions.map((extension) => escape(extension.slice(1))).join('|')
|
|
113
|
+
const made: SurfacePatterns = {
|
|
114
|
+
skill: new RegExp(`^${skills}/(${NAME})/SKILL\\.md$`),
|
|
115
|
+
skillScript: new RegExp(`^${skills}/${NAME}/scripts/[A-Za-z0-9_.\\-]+$`),
|
|
116
|
+
subagent: new RegExp(`^${escape(surface.subagentDir)}/(${NAME})/AGENT\\.md$`),
|
|
117
|
+
script: new RegExp(`^${scripts}/[a-z0-9_][a-z0-9_]*\\.(?:${extensions})$`),
|
|
118
|
+
scriptRef: new RegExp(`(?:python3?|node)\\s+((?:${skills}/[\\w\\-]+/)?${scripts}/[\\w.\\-]+\\.(?:${extensions}))`, 'g'),
|
|
119
|
+
plugin: new RegExp(`^${escape(surface.pluginDir)}/(${NAME})\\.ts$`)
|
|
120
|
+
}
|
|
121
|
+
cache.set(surface.name, made)
|
|
122
|
+
return made
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Which component a path belongs to, or null when it is off the surface. */
|
|
126
|
+
export function componentOf(surface: SurfaceSpec, rel: string): ComponentId | null {
|
|
127
|
+
const found = surface.components.find((component) => component.path === rel)
|
|
128
|
+
if (found) return found.id
|
|
129
|
+
const r = patterns(surface)
|
|
130
|
+
if (r.skill.test(rel) || r.skillScript.test(rel)) return 'skills'
|
|
131
|
+
if (r.subagent.test(rel)) return 'subagents'
|
|
132
|
+
if (r.script.test(rel)) return 'scripts'
|
|
133
|
+
if (r.plugin.test(rel)) return 'plugins'
|
|
134
|
+
return null
|
|
135
|
+
}
|