@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
package/src/plugins/identity.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
// Which plugin a found file belongs to, and what it is called there. The list a person sees and
|
|
2
2
|
// the choice of what loads both use this, so a switch on the page names exactly what stops loading.
|
|
3
|
+
import { readFileSync } from 'node:fs'
|
|
3
4
|
import { basename, dirname } from 'node:path'
|
|
4
5
|
|
|
5
|
-
import * as
|
|
6
|
+
import * as frontmatter from '../core/frontmatter'
|
|
6
7
|
import { BUILTIN_PREFIX, type Found } from './discover'
|
|
7
8
|
import { packageManifest } from './manifest'
|
|
8
9
|
import type { ContributionKind } from './types'
|
|
@@ -24,7 +25,7 @@ export function describeFound(item: Found): { name: string; description: string
|
|
|
24
25
|
const file = basename(item.path)
|
|
25
26
|
if (item.kind === 'extension') return { name: file.startsWith('index.') ? basename(dirname(item.path)) : file, description: null }
|
|
26
27
|
if (item.kind === 'context' || item.kind === 'theme' || item.kind === 'hook') return { name: file, description: null }
|
|
27
|
-
const described =
|
|
28
|
+
const described = describeFile(item.path)
|
|
28
29
|
return { name: described?.name ?? file, description: described?.description ?? '' }
|
|
29
30
|
}
|
|
30
31
|
|
|
@@ -35,3 +36,16 @@ export function foundIds(item: Found): [string, string] {
|
|
|
35
36
|
const pluginId = pluginIdOf(item)
|
|
36
37
|
return [contributionId(pluginId, FOUND_KINDS[item.kind], describeFound(item).name), pluginId]
|
|
37
38
|
}
|
|
39
|
+
|
|
40
|
+
/** A Markdown file's `name` and `description` frontmatter; a `SKILL.md` is named after its folder. */
|
|
41
|
+
export function describeFile(path: string): { name: string; description: string } | null {
|
|
42
|
+
let text: string
|
|
43
|
+
try {
|
|
44
|
+
text = readFileSync(path, 'utf8')
|
|
45
|
+
} catch {
|
|
46
|
+
return null
|
|
47
|
+
}
|
|
48
|
+
const front = frontmatter.parse(text)?.front ?? {}
|
|
49
|
+
const fallback = basename(path) === 'SKILL.md' ? basename(dirname(path)) : basename(path).replace(/\.md$/, '')
|
|
50
|
+
return { name: typeof front.name === 'string' && front.name.trim() ? front.name.trim() : fallback, description: typeof front.description === 'string' ? front.description.trim() : '' }
|
|
51
|
+
}
|
|
@@ -83,7 +83,7 @@ describe('installing a package', () => {
|
|
|
83
83
|
it('refuses a folder that is not there, and a plugin that was not installed', async () => {
|
|
84
84
|
const { base, call } = setup()
|
|
85
85
|
await expect(call('plugins.install', { source: join(base, 'missing') })).rejects.toThrow('does not exist')
|
|
86
|
-
await expect(call('plugins.remove', { pluginId: '
|
|
86
|
+
await expect(call('plugins.remove', { pluginId: 'wiki' })).rejects.toThrow('was not installed as a package')
|
|
87
87
|
await expect(call('plugins.update', { pluginId: 'nope' })).rejects.toThrow()
|
|
88
88
|
})
|
|
89
89
|
|
package/src/plugins/install.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { ensureSharedDir } from '../accounts/env'
|
|
|
10
10
|
import { invalid, notFound } from '../core/errors'
|
|
11
11
|
import { now } from '../core/fs'
|
|
12
12
|
import type { Harness } from '../harness'
|
|
13
|
+
import { loadPi } from '../pi/runtime'
|
|
13
14
|
import { ensureAdopted, list, type PluginScope } from './api'
|
|
14
15
|
import * as attachments from './attachments'
|
|
15
16
|
import { withoutInstallScripts } from './discover'
|
|
@@ -26,7 +27,7 @@ export function normalizeSource(input: string): string {
|
|
|
26
27
|
}
|
|
27
28
|
|
|
28
29
|
async function manager(harness: Harness) {
|
|
29
|
-
const pi = await
|
|
30
|
+
const pi = await loadPi()
|
|
30
31
|
// The shared folder is where `settings.json` really is; an account's own is a link to it.
|
|
31
32
|
const agentDir = ensureSharedDir(harness.accounts.dir)
|
|
32
33
|
const settings = pi.SettingsManager.create(harness.stateDir, agentDir)
|
package/src/plugins/inventory.ts
CHANGED
|
@@ -7,13 +7,12 @@ import type { HarnessEnv } from '../core/env'
|
|
|
7
7
|
import { charCount } from '../core/text'
|
|
8
8
|
import { allowed } from '../mcp/connections'
|
|
9
9
|
import type { Connection } from '../mcp/types'
|
|
10
|
-
import type { CallerContext } from '../
|
|
10
|
+
import type { CallerContext } from '../core/caller'
|
|
11
11
|
import { BUILT_IN_DYNAMIC, compile, toolGroups, type Launch } from '../prompt/compile'
|
|
12
|
-
import
|
|
12
|
+
import type { Materialised } from '../tree/materialise'
|
|
13
13
|
import { ASK_USER } from '../tools/askUser'
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import { INCLUDED_PLUGIN, YOURS_PLUGIN } from './builtin'
|
|
14
|
+
import { DEFAULT_TOOL_NAMES } from '../tools/definitions'
|
|
15
|
+
import { HARNESS_TREE_PLUGIN } from './builtin'
|
|
17
16
|
import type { Contribution, Inventory, InventoryItem, Plugin, PluginDescriptor } from './types'
|
|
18
17
|
|
|
19
18
|
export interface InventoryInput {
|
|
@@ -28,6 +27,8 @@ export interface InventoryInput {
|
|
|
28
27
|
plugins: readonly Plugin[]
|
|
29
28
|
descriptors: readonly PluginDescriptor[]
|
|
30
29
|
saved: readonly Connection[]
|
|
30
|
+
/** The harness tree the caller runs, when it has one. */
|
|
31
|
+
tree?: Materialised | null
|
|
31
32
|
}
|
|
32
33
|
|
|
33
34
|
const size = (path: string | null): number | null => {
|
|
@@ -47,10 +48,8 @@ export function inventory(input: InventoryInput): Inventory {
|
|
|
47
48
|
items.push({ pluginId, contributionId: null, kind, name, delivery, chars, cut: false, ...rest })
|
|
48
49
|
}
|
|
49
50
|
|
|
50
|
-
const pack = compile(env, context, launch)
|
|
51
|
+
const pack = compile(env, context, launch, input.tree ?? null)
|
|
51
52
|
const dynamic = new Set([...BUILT_IN_DYNAMIC.map((entry) => entry.id), ...(launch.sections ?? []).filter((section) => section.dynamic).map((section) => section.id)])
|
|
52
|
-
const active = registry.enabledFor(env.root, context.projectId ?? null, context.agentId ?? null)
|
|
53
|
-
const owner = (entry: registry.RegistryItem): string => (entry.builtin ? INCLUDED_PLUGIN.id : YOURS_PLUGIN.id)
|
|
54
53
|
let systemChars = 0
|
|
55
54
|
let contextChars = 0
|
|
56
55
|
|
|
@@ -68,26 +67,28 @@ export function inventory(input: InventoryInput): Inventory {
|
|
|
68
67
|
}
|
|
69
68
|
continue
|
|
70
69
|
}
|
|
71
|
-
if (section.id === 'rules') {
|
|
72
|
-
for (const rule of active.filter((entry) => entry.kind === 'rules')) {
|
|
73
|
-
item(owner(rule), 'instruction', rule.name, delivery, charCount(`### ${rule.name} (${rule.id})\n\n${registry.itemBody(rule).trim()}`), { contributionId: `${owner(rule)}/${rule.id}`, cut: section.cut })
|
|
74
|
-
}
|
|
75
|
-
continue
|
|
76
|
-
}
|
|
77
70
|
const plugin = descriptors.find((descriptor) => descriptor.sections?.some((declared) => declared.id === section.id))
|
|
78
71
|
if (!plugin) unattributed.push({ kind: 'section', id: section.id })
|
|
79
72
|
const name = plugin?.sections?.find((declared) => declared.id === section.id)?.name ?? section.title
|
|
80
73
|
item(plugin?.id ?? '', 'instruction', name, delivery, section.size, { contributionId: plugin ? `${plugin.id}/instruction/${name}` : null, cut: section.cut })
|
|
81
74
|
}
|
|
82
75
|
|
|
83
|
-
//
|
|
84
|
-
|
|
85
|
-
|
|
76
|
+
// The harness tree's skills are loaded only when used; its plugins run in the tool loop.
|
|
77
|
+
const tree = input.tree ?? null
|
|
78
|
+
if (tree) {
|
|
79
|
+
for (const rel of Object.keys(tree.tree).filter((path) => /^skills\/[^/]+\/SKILL\.md$/.test(path)).sort()) {
|
|
80
|
+
const name = rel.split('/')[1]!
|
|
81
|
+
item(HARNESS_TREE_PLUGIN.id, 'skill', name, 'on-demand', charCount(tree.tree[rel]!), { contributionId: `${HARNESS_TREE_PLUGIN.id}/skill/${name}` })
|
|
82
|
+
}
|
|
83
|
+
for (const path of tree.extensionPaths) {
|
|
84
|
+
const name = path.split('/').pop()!
|
|
85
|
+
item(HARNESS_TREE_PLUGIN.id, 'code', name, 'code', size(path), { contributionId: `${HARNESS_TREE_PLUGIN.id}/code/${name}` })
|
|
86
|
+
}
|
|
86
87
|
}
|
|
87
88
|
|
|
88
89
|
const names = [...input.tools]
|
|
89
90
|
const extensionToolNames = new Set(input.plugins.filter((plugin) => ['project', 'package', 'shared', 'home'].includes(plugin.source)).flatMap((plugin) => plugin.contributions.filter((entry) => entry.kind === 'tool' && entry.state === 'on').map((entry) => entry.name)))
|
|
90
|
-
for (const name of [ASK_USER,
|
|
91
|
+
for (const name of [ASK_USER, ...DEFAULT_TOOL_NAMES, ...(input.connections ? ['mcp_list_tools', 'mcp_call_tool'] : [])]) if (!names.includes(name)) names.push(name)
|
|
91
92
|
for (const name of names) {
|
|
92
93
|
if (extensionToolNames.has(name)) continue
|
|
93
94
|
const plugin = descriptors.find((descriptor) => descriptor.tools?.some((tool) => tool.name === name))
|
package/src/plugins/plugins.ts
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
// The harness decides what a Pi session loads. Pi's own discovery is switched off for sessions
|
|
2
2
|
// and terminals; they receive the paths chosen here, so nothing reaches a model unlisted.
|
|
3
|
-
import { chmodSync, readdirSync } from 'node:fs'
|
|
3
|
+
import { chmodSync, lstatSync, readdirSync, rmSync, unlinkSync } from 'node:fs'
|
|
4
4
|
import { join } from 'node:path'
|
|
5
5
|
|
|
6
6
|
import type { Bus } from '../core/bus'
|
|
7
7
|
import { now, readJsonOr, writeJson } from '../core/fs'
|
|
8
|
-
import { removeLinkedSkills } from '../skills/registry'
|
|
9
8
|
import { attached, type Attachments, type AttachScope } from './attachments'
|
|
10
9
|
import { piResolver, type Found, type ResolveInput, type Resolver } from './discover'
|
|
11
10
|
import { foundIds, pluginIdOf } from './identity'
|
|
@@ -187,3 +186,40 @@ export class Plugins {
|
|
|
187
186
|
this.options.bus.emit({ type: 'plugins.changed' })
|
|
188
187
|
}
|
|
189
188
|
}
|
|
189
|
+
|
|
190
|
+
// Prefix of the entries an earlier version of the harness linked into a Pi agent folder's `skills/`.
|
|
191
|
+
const LINKED_SKILL_PREFIX = 'agentx-'
|
|
192
|
+
|
|
193
|
+
function isLinkedSkill(path: string): boolean {
|
|
194
|
+
let meta
|
|
195
|
+
try {
|
|
196
|
+
meta = lstatSync(path)
|
|
197
|
+
} catch {
|
|
198
|
+
return false
|
|
199
|
+
}
|
|
200
|
+
if (meta.isSymbolicLink()) return true
|
|
201
|
+
if (!meta.isDirectory()) return false
|
|
202
|
+
// A single-file skill was linked as `agentx-<name>/SKILL.md` inside a folder the harness made.
|
|
203
|
+
try {
|
|
204
|
+
const entries = readdirSync(path)
|
|
205
|
+
return entries.length === 1 && entries[0] === 'SKILL.md' && lstatSync(join(path, 'SKILL.md')).isSymbolicLink()
|
|
206
|
+
} catch {
|
|
207
|
+
return false
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Removes the skill links an earlier version of the harness kept in a Pi agent folder's `skills/`. */
|
|
212
|
+
export function removeLinkedSkills(skillsDir: string): void {
|
|
213
|
+
let names: string[]
|
|
214
|
+
try {
|
|
215
|
+
names = readdirSync(skillsDir)
|
|
216
|
+
} catch {
|
|
217
|
+
return
|
|
218
|
+
}
|
|
219
|
+
for (const name of names) {
|
|
220
|
+
const path = join(skillsDir, name)
|
|
221
|
+
if (!name.startsWith(LINKED_SKILL_PREFIX) || !isLinkedSkill(path)) continue
|
|
222
|
+
if (lstatSync(path).isSymbolicLink()) unlinkSync(path)
|
|
223
|
+
else rmSync(path, { recursive: true, force: true })
|
|
224
|
+
}
|
|
225
|
+
}
|
|
@@ -3,17 +3,16 @@ import { tmpdir } from 'node:os'
|
|
|
3
3
|
import { join } from 'node:path'
|
|
4
4
|
import { afterEach, expect, test } from 'vitest'
|
|
5
5
|
|
|
6
|
-
import { dataRoot
|
|
6
|
+
import { dataRoot } from '../../tests/helpers'
|
|
7
|
+
import type { CallerContext } from '../core/caller'
|
|
7
8
|
import { createEnv, type HarnessEnv } from '../core/env'
|
|
8
9
|
import { writeJson } from '../core/fs'
|
|
9
10
|
import { closeIndex } from '../core/index'
|
|
10
|
-
import
|
|
11
|
-
import
|
|
12
|
-
import
|
|
13
|
-
import
|
|
14
|
-
import {
|
|
15
|
-
import { reindexWiki } from '../memory/wikiIndex'
|
|
16
|
-
import { compile, fit, launchText, load, memoryDelta, profileFor, profiles, record, REFERENCES_BUDGET, render, saveForSession, sessionFile, sessionLaunch, setProfile, type ContextPack, type ContextUpdate, type HostSection, type Launch } from './compile'
|
|
11
|
+
import { materialise } from '../tree/materialise'
|
|
12
|
+
import * as store from '../tree/store'
|
|
13
|
+
import type { Tree } from '../tree/surface'
|
|
14
|
+
import * as notes from '../wiki/notes'
|
|
15
|
+
import { compile, fit, load, render, sessionFile, sessionLaunch, SECTION_ORDER, type ContextUpdate, type HostSection, type Launch } from './compile'
|
|
17
16
|
|
|
18
17
|
const roots: string[] = []
|
|
19
18
|
afterEach(() => roots.splice(0).forEach(closeIndex))
|
|
@@ -21,33 +20,33 @@ afterEach(() => roots.splice(0).forEach(closeIndex))
|
|
|
21
20
|
function setup(): HarnessEnv {
|
|
22
21
|
const root = dataRoot()
|
|
23
22
|
roots.push(root)
|
|
24
|
-
writeJson(join(root, 'projects.json'), [
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
ensureProjectNamespace(root, 'xingular-agent', 'Xingular Agent')
|
|
29
|
-
const wiki = wikiDir(root)
|
|
30
|
-
writeFileSync(join(wiki, 'projects/xingular-agent/decisions.md'), '---\nid: decisions\ntitle: Decisions\ntype: decision\n---\n\nTauri 2 for the shell.\n')
|
|
31
|
-
writeFileSync(join(wiki, 'facts/pnpm.md'), '---\nid: pnpm\ntitle: Use pnpm\nkind: preference\nnamespace: /\nconfidence: high\n---\n\nUse pnpm, not npm.\n')
|
|
32
|
-
writeFileSync(join(wiki, 'facts/on-call.md'), '---\nid: on-call\ntitle: On call this week\nkind: fact\nnamespace: /\nconfidence: high\nvolatile: true\n---\n\nAstro team is on call.\n')
|
|
33
|
-
writeFileSync(join(wiki, 'facts/site-only.md'), '---\nid: site-only\ntitle: Site uses Astro\nkind: fact\nnamespace: /projects/site\n---\n\nAstro renders the site.\n')
|
|
34
|
-
writeFile(join(root, 'registry/rules/tests.md'), '---\nname: Run tests\n---\n\nAlways run the tests.\n')
|
|
35
|
-
writeFile(join(root, 'registry/skills/review/SKILL.md'), '---\nname: review\ndescription: Review a change\n---\n\nRead the diff first.\n')
|
|
36
|
-
writeJson(join(root, 'config/registry-enabled.json'), { global: ['rules/tests'], p1: ['skills/review'] })
|
|
23
|
+
writeJson(join(root, 'projects.json'), [{ id: 'p1', name: 'Xingular Agent', rootPath: '/src/xingular-agent', autoMaintenance: true, createdAt: 't', slug: 'xingular-agent' }])
|
|
24
|
+
notes.ensureLayout(root, 'Test')
|
|
25
|
+
notes.write(root, 'projects/xingular-agent/decisions', '---\ntitle: Decisions\n---\n\nTauri 2 for the shell. See [[library/glossary]].\n')
|
|
26
|
+
notes.write(root, 'library/glossary', '# Glossary\n\nA harness is the system around a model.\n')
|
|
37
27
|
mkdirSync(join(root, 'runs/run-1'), { recursive: true })
|
|
38
|
-
reindexWiki(root)
|
|
39
28
|
return createEnv(root)
|
|
40
29
|
}
|
|
41
30
|
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
31
|
+
const tree: Tree = {
|
|
32
|
+
'systemprompt.md': 'You draft release notes.\n',
|
|
33
|
+
'guardrails.md': 'Never publish without a review.\n- When writing a changelog, load the `changelog` skill.\n',
|
|
34
|
+
'memory.md': 'Releases ship on Tuesdays.\n',
|
|
35
|
+
'tool_guidance.md': 'Prefer grep over reading whole files.\n',
|
|
36
|
+
'skills/changelog/SKILL.md': '---\nname: changelog\ndescription: writing a changelog\n---\nGroup by type.\n',
|
|
37
|
+
'sub_agents/reader/AGENT.md': '---\ndescription: reads long logs and answers briefly\n---\nRead the log and answer in three lines.\n',
|
|
38
|
+
'scripts/count.py': 'print(1)\n'
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const agent = (extra: Partial<CallerContext> = {}): CallerContext => ({ agentId: 'nora', agentName: 'Nora', projectId: 'p1', runId: 'run-1', actor: 'Chat', ...extra })
|
|
42
|
+
const identity: HostSection = { id: 'identity', title: 'Identity', body: 'You are Nora.', budget: 1000, order: 10 }
|
|
43
|
+
const work = (body: string): HostSection => ({ id: 'work', title: 'Active work', body, budget: 1000, order: 70, dynamic: true })
|
|
44
|
+
const hosted = (extra: Partial<Launch> = {}): Launch => ({ sections: [identity, work('You have no unresolved work.')], tools: ['read'], ...extra })
|
|
45
45
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
const hosted = (extra: Partial<Launch> = {}): Launch => ({ sections: [identity, role, work('You have no unresolved work.\n')], ...extra })
|
|
46
|
+
function scoped(env: HarnessEnv) {
|
|
47
|
+
store.seed(env.root, 'nora', tree)
|
|
48
|
+
return materialise(env.root, { scope: 'nora' }, { subagents: true })
|
|
49
|
+
}
|
|
51
50
|
|
|
52
51
|
test('cuts at a paragraph and points to the rest', () => {
|
|
53
52
|
expect(fit('short', 100, '(more)')).toEqual({ body: 'short', cut: false })
|
|
@@ -56,126 +55,46 @@ test('cuts at a paragraph and points to the rest', () => {
|
|
|
56
55
|
expect(cut.body).toBe(`${'a'.repeat(40)}\n\n${'b'.repeat(40)}\n\n(more)`)
|
|
57
56
|
})
|
|
58
57
|
|
|
59
|
-
test('
|
|
60
|
-
const env = setup()
|
|
61
|
-
setProfile(env.root, 'nora', 'off')
|
|
62
|
-
const revision = references.pin(env.root, 'skill', 'skills/review')
|
|
63
|
-
writeFileSync(join(env.root, 'registry/skills/review/SKILL.md'), 'Changed instructions.')
|
|
64
|
-
const newer = references.pin(env.root, 'skill', 'skills/review')
|
|
65
|
-
const caller = agent({ runId: 'run-1' })
|
|
66
|
-
const pack = compile(env, caller, hosted({ references: [{ kind: 'skill', id: 'skills/review', label: 'Review', revision }] }))
|
|
67
|
-
expect(ids(pack)).toEqual(['identity', 'role', 'references'])
|
|
68
|
-
const selected = pack.sections.find((entry) => entry.id === 'references')!
|
|
69
|
-
expect(selected.body).toContain('Read the diff first.')
|
|
70
|
-
expect(selected.body).not.toContain('Changed instructions.')
|
|
71
|
-
expect(revision).not.toBe(newer)
|
|
72
|
-
record(env.root, caller, pack)
|
|
73
|
-
expect(references.readPinned(env.root, 'run-1', 'skills/review', revision, 0)).toContain('Read the diff first.')
|
|
74
|
-
expect(() => references.readPinned(env.root, 'run-1', 'skills/review', newer, 0)).toThrow()
|
|
75
|
-
expect(readRunContext(env.root, 'run-1')[0].revisionId).toBe(revision)
|
|
76
|
-
})
|
|
77
|
-
|
|
78
|
-
test('application lines sit with the references and large sources keep retrieval pointers', () => {
|
|
58
|
+
test('the harness tree supplies the instructions, guardrails, memory, subagents, scripts and tool guidance, in order', () => {
|
|
79
59
|
const env = setup()
|
|
80
|
-
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
const selected = pack.sections.find((entry) => entry.id === 'references')!
|
|
84
|
-
expect(selected.body).toContain('Agent messaging is unavailable')
|
|
85
|
-
expect(selected.cut).toBe(true)
|
|
86
|
-
expect(selected.size).toBeLessThanOrEqual(REFERENCES_BUDGET)
|
|
87
|
-
expect(selected.body).toContain('wiki_read(id="skills/review", revision=')
|
|
88
|
-
expect(selected.sources[0].revision).toBe(revision)
|
|
89
|
-
})
|
|
90
|
-
|
|
91
|
-
test('a session gets blocks, rules, skills, knowledge and tools', () => {
|
|
92
|
-
const env = setup()
|
|
93
|
-
const pack = compile(env, session('p1'), { connections: 'Use get_capabilities.' })
|
|
94
|
-
expect(pack.profile).toBe('session')
|
|
95
|
-
expect(ids(pack)).toEqual(['blocks', 'rules', 'skills', 'knowledge', 'tools'])
|
|
60
|
+
const pack = compile(env, agent(), hosted(), scoped(env))
|
|
61
|
+
const ids = pack.sections.map((entry) => entry.id)
|
|
62
|
+
expect(ids).toEqual(['identity', 'system', 'guardrails', 'memory', 'subagents', 'scripts', 'work', 'wiki', 'tools'])
|
|
96
63
|
const text = render(env, pack)
|
|
97
|
-
|
|
98
|
-
expect(text).
|
|
99
|
-
|
|
100
|
-
expect(
|
|
101
|
-
expect(
|
|
64
|
+
expect(text).toContain('You draft release notes.')
|
|
65
|
+
expect(text).toContain('Never publish without a review.')
|
|
66
|
+
expect(text).toContain('Releases ship on Tuesdays.')
|
|
67
|
+
expect(text).toContain('- reader — reads long logs and answers briefly')
|
|
68
|
+
expect(text).toMatch(/scripts: count\.py/)
|
|
69
|
+
expect(text).toContain('### How to use your tools\n\nPrefer grep over reading whole files.')
|
|
70
|
+
expect(pack.harness).toEqual({ scope: 'nora', fingerprint: store.head(env.root, 'nora')!.fingerprint })
|
|
71
|
+
expect(pack.sections.find((entry) => entry.id === 'system')!.sources).toEqual([{ id: 'nora', kind: 'harness', revision: pack.harness!.fingerprint }])
|
|
102
72
|
})
|
|
103
73
|
|
|
104
|
-
test('
|
|
74
|
+
test('without a tree, only the application, the Wiki and the tools speak', () => {
|
|
105
75
|
const env = setup()
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
expect(rules.cut && rules.size <= 3000 && rules.body.endsWith('wiki_list kind rule.)')).toBe(true)
|
|
110
|
-
expect(ids(pack)).toEqual(['blocks', 'rules', 'skills', 'knowledge', 'tools'])
|
|
111
|
-
expect(pack.sections.filter((entry) => entry.id !== 'rules').every((entry) => !entry.cut)).toBe(true)
|
|
76
|
+
const pack = compile(env, agent(), hosted())
|
|
77
|
+
expect(pack.sections.map((entry) => entry.id)).toEqual(['identity', 'work', 'wiki', 'tools'])
|
|
78
|
+
expect(pack.harness).toBeNull()
|
|
112
79
|
})
|
|
113
80
|
|
|
114
|
-
test('
|
|
81
|
+
test('the Wiki section lists the current project\'s notes and only describes wiki_write when it is on', () => {
|
|
115
82
|
const env = setup()
|
|
116
|
-
const
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
{ group: 'Agents', tools: ['list_agents', 'get_agent'], guidance: 'Agents (list_agents, get_agent): see and message the other agents.', order: 40 },
|
|
122
|
-
{ group: 'Browser', tools: [], guidance: 'Browser: act in the managed browser.', order: 50 },
|
|
123
|
-
{ group: 'Files', tools: ['files_list', 'files_read'], guidance: 'Saved files (files_list, files_read): read the shared vault.', order: 80 }
|
|
124
|
-
]
|
|
125
|
-
}))
|
|
126
|
-
expect(pack.toolGroups.map((group) => group.group)).toEqual(['Memory', 'Knowledge', 'Work', 'Agents', 'Native', 'Files'])
|
|
127
|
-
expect(pack.toolGroups[3].tools).toEqual(['list_agents', 'get_agent'])
|
|
128
|
-
const tools = pack.sections.at(-1)!.body
|
|
129
|
-
expect(tools).toContain('Work (dispatch_work)')
|
|
130
|
-
expect(tools).toContain('Files (read)')
|
|
131
|
-
expect(tools).not.toContain('Browser')
|
|
132
|
-
expect(tools).not.toContain('connected apps')
|
|
133
|
-
expect(pack.profile).toBe('agent')
|
|
134
|
-
expect(ids(pack)).toEqual(['identity', 'role', 'blocks', 'rules', 'skills', 'work', 'knowledge', 'tools'])
|
|
135
|
-
const text = render(env, pack)
|
|
136
|
-
for (const expected of ['# Xingular Harness context\n\nYou are "Nora", an Xingular Agent agent.\n\n## Role', '<block label="persona"', '<block label="notes"', 'You have no unresolved work.', '- /projects/xingular-agent', 'Site uses Astro']) expect(text).toContain(expected)
|
|
137
|
-
// A volatile fact is never carried, even when it matches.
|
|
138
|
-
expect(text).not.toContain('On call this week')
|
|
139
|
-
// A skill enabled for one project is not listed for an agent, only pointed to.
|
|
140
|
-
expect(text).not.toContain('review (skills/review)')
|
|
83
|
+
const text = render(env, compile(env, agent(), hosted()))
|
|
84
|
+
expect(text).toContain('- projects/xingular-agent/decisions — Decisions')
|
|
85
|
+
expect(text).toContain('wiki_search, wiki_read and wiki_list read project notes.')
|
|
86
|
+
expect(text).not.toContain('wiki_write')
|
|
87
|
+
expect(render(env, compile(env, agent(), hosted({ tools: ['read', 'wiki_write'] })))).toContain('wiki_write saves one')
|
|
141
88
|
})
|
|
142
89
|
|
|
143
|
-
test('
|
|
90
|
+
test('selected notes are references for this request only', () => {
|
|
144
91
|
const env = setup()
|
|
145
|
-
const
|
|
146
|
-
|
|
147
|
-
expect(
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
expect(
|
|
151
|
-
expect(note).toContain('<block label="project/xingular-agent"')
|
|
152
|
-
expect(note).toContain('Uses pnpm.')
|
|
153
|
-
expect(note).not.toContain('<block label="user"')
|
|
154
|
-
expect(memoryDelta(env, directory, session('p1'))).toBeNull()
|
|
155
|
-
})
|
|
156
|
-
|
|
157
|
-
test('a profile limits the sections and is recorded', () => {
|
|
158
|
-
const env = setup()
|
|
159
|
-
expect(profileFor(env.root, session('p1'))).toBe('full')
|
|
160
|
-
setProfile(env.root, 's1', 'minimal')
|
|
161
|
-
const pack = compile(env, session('p1'))
|
|
162
|
-
expect([pack.contextProfile, ids(pack)]).toEqual(['minimal', ['rules', 'tools']])
|
|
163
|
-
setProfile(env.root, 's1', 'off')
|
|
164
|
-
const off = compile(env, session('p1'))
|
|
165
|
-
expect([off.sections, off.toolGroups]).toEqual([[], []])
|
|
166
|
-
setProfile(env.root, 's1', 'full')
|
|
167
|
-
expect(profiles(env.root)).toEqual({})
|
|
168
|
-
expect(() => setProfile(env.root, '../x', 'off')).toThrow()
|
|
169
|
-
})
|
|
170
|
-
|
|
171
|
-
test('the pack is recorded with the run', () => {
|
|
172
|
-
const env = setup()
|
|
173
|
-
const text = launchText(env, session('p1'))
|
|
174
|
-
const saved = load(env.root, 'run-1')!
|
|
175
|
-
expect(render(env, saved)).toBe(text)
|
|
176
|
-
expect(saved.sections.some((entry) => entry.id === 'blocks' && entry.sources.some((source) => source.id === 'blocks/shared/user'))).toBe(true)
|
|
177
|
-
expect(readRunContext(env.root, 'run-1').some((entry) => entry.id === 'rules/tests' && entry.via === 'entrypoint')).toBe(true)
|
|
178
|
-
expect(load(env.root, 'missing')).toBeNull()
|
|
92
|
+
const launch = hosted({ references: [{ kind: 'note', path: 'library/glossary' }, { kind: 'line', id: 'l1', label: 'Line', text: 'The user pinned a screenshot.' }] })
|
|
93
|
+
const section = compile(env, agent(), launch).sections.find((entry) => entry.id === 'references')!
|
|
94
|
+
expect(section.body).toContain('The user pinned a screenshot.')
|
|
95
|
+
expect(section.body).toContain('A harness is the system around a model.')
|
|
96
|
+
expect(section.sources[0]).toMatchObject({ id: 'library/glossary', kind: 'note' })
|
|
97
|
+
expect(SECTION_ORDER.references).toBeLessThan(SECTION_ORDER.wiki)
|
|
179
98
|
})
|
|
180
99
|
|
|
181
100
|
function delivered(path: string, update: ContextUpdate): void {
|
|
@@ -184,64 +103,28 @@ function delivered(path: string, update: ContextUpdate): void {
|
|
|
184
103
|
|
|
185
104
|
test('state stays out of the system prompt and only delivered changes are resent', () => {
|
|
186
105
|
const env = setup()
|
|
187
|
-
const
|
|
188
|
-
const first = sessionLaunch(env,
|
|
189
|
-
|
|
190
|
-
expect(first.system).not.toContain('<block')
|
|
106
|
+
const materialised = scoped(env)
|
|
107
|
+
const first = sessionLaunch(env, agent(), hosted(), null, materialised)
|
|
108
|
+
expect(first.system).toContain('You draft release notes.')
|
|
191
109
|
expect(first.system).not.toContain('You have no unresolved work.')
|
|
192
|
-
expect(first.
|
|
193
|
-
expect(
|
|
194
|
-
expect(initial.content).toContain('You have no unresolved work.')
|
|
195
|
-
// Recording a prepared launch is not evidence that the session received its context.
|
|
196
|
-
expect(sessionLaunch(env, caller, hosted(), null).update).not.toBeNull()
|
|
110
|
+
expect(first.update!.content).toContain('You have no unresolved work.')
|
|
111
|
+
expect(first.update!.content).toContain('## Wiki')
|
|
197
112
|
const history = join(mkdtempSync(join(tmpdir(), 'harness-history-')), 'chat.jsonl')
|
|
198
|
-
delivered(history,
|
|
199
|
-
const unchanged = sessionLaunch(env,
|
|
113
|
+
delivered(history, first.update!)
|
|
114
|
+
const unchanged = sessionLaunch(env, agent(), hosted(), history, materialised)
|
|
200
115
|
expect(unchanged.system).toBe(first.system)
|
|
201
116
|
expect(unchanged.update).toBeNull()
|
|
202
|
-
|
|
203
|
-
const changed = sessionLaunch(env,
|
|
117
|
+
notes.write(env.root, 'projects/xingular-agent/release', '# Release checklist\n')
|
|
118
|
+
const changed = sessionLaunch(env, agent(), hosted(), history, materialised)
|
|
204
119
|
expect(changed.system).toBe(first.system)
|
|
205
|
-
expect(changed.update!.content).toContain('
|
|
120
|
+
expect(changed.update!.content).toContain('projects/xingular-agent/release')
|
|
206
121
|
expect(changed.update!.content).not.toContain('## Active work')
|
|
207
|
-
expect(
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
const
|
|
211
|
-
expect(
|
|
212
|
-
expect(
|
|
213
|
-
})
|
|
214
|
-
|
|
215
|
-
test('pinned application text reaches native history with context off without changing the system prefix', () => {
|
|
216
|
-
const env = setup()
|
|
217
|
-
setProfile(env.root, 'nora', 'off')
|
|
218
|
-
const before = sessionLaunch(env, agent(), hosted(), null)
|
|
219
|
-
const after = sessionLaunch(env, agent(), { sections: [identity, role, work('You have no unresolved work.\n', 'Recent task reports:\n\nTask t1: Reviewed evidence shown to the user\n')] }, null)
|
|
220
|
-
expect(after.system).toBe(before.system)
|
|
221
|
-
expect(after.update!.content).toContain('Reviewed evidence shown to the user')
|
|
222
|
-
expect(after.update!.content).not.toContain('You have no unresolved work.')
|
|
223
|
-
expect(before.update!.content).not.toContain('Reviewed evidence')
|
|
224
|
-
})
|
|
225
|
-
|
|
226
|
-
test('references apply to each request and profile changes withdraw old state', () => {
|
|
227
|
-
const env = setup()
|
|
228
|
-
const revision = references.pin(env.root, 'skill', 'skills/review')
|
|
229
|
-
const launch = hosted({ references: [{ kind: 'skill', id: 'skills/review', label: 'Review', revision }] })
|
|
230
|
-
const first = sessionLaunch(env, agent(), launch, null)
|
|
231
|
-
expect(first.system).not.toContain('Read the diff first.')
|
|
232
|
-
const history = join(mkdtempSync(join(tmpdir(), 'harness-history-')), 'chat.jsonl')
|
|
233
|
-
delivered(history, first.update!)
|
|
234
|
-
const repeated = sessionLaunch(env, agent(), launch, history).update!
|
|
235
|
-
expect(repeated.content).toContain('Read the diff first.')
|
|
236
|
-
expect(repeated.content).not.toContain('## Memory blocks')
|
|
237
|
-
const removed = sessionLaunch(env, agent(), hosted(), history).update!
|
|
238
|
-
expect(removed.content).toContain('## references')
|
|
239
|
-
expect(removed.content).not.toContain('Read the diff first.')
|
|
240
|
-
setProfile(env.root, 'nora', 'off')
|
|
241
|
-
const withdrawn = sessionLaunch(env, agent(), hosted(), history).update!
|
|
242
|
-
expect(withdrawn.snapshot).toEqual([])
|
|
243
|
-
for (const id of ['blocks', 'references', 'work', 'knowledge']) expect(withdrawn.content).toContain(`## ${id}`)
|
|
244
|
-
expect(sessionLaunch(env, agent(), launch, history).update!.content).toContain('Read the diff first.')
|
|
122
|
+
expect(load(env.root, 'run-1')!.delivery!.updatedSectionIds).toEqual(['wiki'])
|
|
123
|
+
// A new harness version changes the stable prefix, never the delivered state.
|
|
124
|
+
store.commit(env.root, 'nora', { ...tree, 'memory.md': 'Releases ship on Thursdays.\n' }, { kind: 'edit' })
|
|
125
|
+
const next = sessionLaunch(env, agent(), hosted(), history, materialise(env.root, { scope: 'nora' }, { subagents: true }))
|
|
126
|
+
expect(next.system).toContain('Releases ship on Thursdays.')
|
|
127
|
+
expect(next.system).not.toBe(first.system)
|
|
245
128
|
})
|
|
246
129
|
|
|
247
130
|
test('the snapshot follows the active branch and refreshes after compaction', () => {
|
|
@@ -251,11 +134,17 @@ test('the snapshot follows the active branch and refreshes after compaction', ()
|
|
|
251
134
|
const history = join(directory, 'chat.jsonl')
|
|
252
135
|
delivered(history, initial)
|
|
253
136
|
const original = readFileSync(history, 'utf8')
|
|
254
|
-
// An unrelated branch's snapshot must not decide what this branch already received.
|
|
255
137
|
writeFileSync(history, `${original}${JSON.stringify({ type: 'custom_message', id: 'other', parentId: null, customType: 'xingular-context', details: { xingularContext: [] } })}\n${JSON.stringify({ type: 'message', id: 'leaf', parentId: 'context-1' })}\n`)
|
|
256
138
|
expect(sessionLaunch(env, agent(), hosted(), history).update).toBeNull()
|
|
257
139
|
writeFileSync(history, `${original}${JSON.stringify({ type: 'compaction', id: 'summary', parentId: 'context-1' })}\n`)
|
|
258
|
-
expect(sessionLaunch(env, agent(), hosted(), history).update!.content).toContain('##
|
|
140
|
+
expect(sessionLaunch(env, agent(), hosted(), history).update!.content).toContain('## Wiki')
|
|
259
141
|
expect(sessionFile(directory, join(directory, 'older.jsonl'))).toBe(join(directory, 'older.jsonl'))
|
|
260
142
|
expect(sessionFile(directory)).toBe(history)
|
|
261
143
|
})
|
|
144
|
+
|
|
145
|
+
test('a subagent section is left out when the session cannot start subagents', () => {
|
|
146
|
+
const env = setup()
|
|
147
|
+
store.seed(env.root, 'nora', tree)
|
|
148
|
+
const pack = compile(env, agent(), hosted(), materialise(env.root, { scope: 'nora' }, { subagents: false }))
|
|
149
|
+
expect(pack.sections.some((entry) => entry.id === 'subagents')).toBe(false)
|
|
150
|
+
})
|