@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
|
@@ -1,206 +0,0 @@
|
|
|
1
|
-
import { existsSync, mkdtempSync } from 'node:fs'
|
|
2
|
-
import { join } from 'node:path'
|
|
3
|
-
import { tmpdir } from 'node:os'
|
|
4
|
-
import { afterEach, expect, test } from 'vitest'
|
|
5
|
-
|
|
6
|
-
import { createHarness, type Harness } from '../harness'
|
|
7
|
-
import { memorySecretStore } from '../accounts/secrets'
|
|
8
|
-
import type { PiSessionLike } from '../sessions/runner'
|
|
9
|
-
import { registryTools } from '../tools/registryWrite'
|
|
10
|
-
import type { ToolSession } from '../tools/types'
|
|
11
|
-
import * as blocks from './blocks'
|
|
12
|
-
import { memoryEdit, propose } from './api'
|
|
13
|
-
import { enqueueCuration } from './curation'
|
|
14
|
-
import { findMemory, saveAsUser } from './facts'
|
|
15
|
-
import * as registry from '../skills/registry'
|
|
16
|
-
import * as attachments from '../plugins/attachments'
|
|
17
|
-
import { listInbox } from './operations'
|
|
18
|
-
import { wikiDir } from './store'
|
|
19
|
-
|
|
20
|
-
const opened: Harness[] = []
|
|
21
|
-
afterEach(async () => { for (const harness of opened.splice(0)) await harness.close() })
|
|
22
|
-
const self = { agentId: 'nora', agentName: 'Nora', actor: 'Chat', vaultAccess: 'self' as const, runId: 'origin' }
|
|
23
|
-
const curator = { agentId: 'curator', agentName: 'Curator', actor: 'Chat', vaultAccess: 'curator' as const, runId: 'review' }
|
|
24
|
-
|
|
25
|
-
function setup(): Harness {
|
|
26
|
-
let emit: (event: unknown) => void = () => undefined
|
|
27
|
-
const session: PiSessionLike = { sessionId: 'test-session', subscribe: listener => { emit = listener as never; return () => undefined }, prompt: async () => { emit({ type: 'message_end', message: { role: 'assistant', content: [{ type: 'text', text: 'Prefer pnpm for all builds.' }] } }) }, sendCustomMessage: async () => undefined, abort: async () => undefined, dispose: () => undefined }
|
|
28
|
-
const harness = createHarness({ dataRoot: mkdtempSync(join(tmpdir(), 'curation-data-')), stateDir: mkdtempSync(join(tmpdir(), 'curation-state-')), externalMaintenance: true, secrets: memorySecretStore(), sessionFactory: async () => session, maintainerFactory: async () => { throw new Error('Hidden maintenance must not run') } })
|
|
29
|
-
opened.push(harness)
|
|
30
|
-
harness.accounts.create('Main', 'apiKey')
|
|
31
|
-
blocks.ensureAgent(wikiDir(harness.env.root), 'nora')
|
|
32
|
-
return harness
|
|
33
|
-
}
|
|
34
|
-
async function origin(harness: Harness): Promise<void> {
|
|
35
|
-
await harness.sessions.run({ run: { id: 'origin', sessionId: 'nora' }, cwd: harness.env.root, sessionDir: join(harness.env.root, 'history'), text: 'Prefer pnpm for all builds.', caller: self, tools: { allow: [] } })
|
|
36
|
-
await new Promise(resolve => setTimeout(resolve, 30))
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
test('normal callers directly write only their own persona and notes; standalone callers retain their prior scope', () => {
|
|
40
|
-
const h = setup()
|
|
41
|
-
expect(memoryEdit(h.env, self, 'memory.append', { block: 'notes', text: 'Working on release notes.' })).toContain('Updated')
|
|
42
|
-
expect(() => memoryEdit(h.env, self, 'memory.append', { block: 'user', text: 'A shared preference.' })).toThrow('outside your writable memory')
|
|
43
|
-
blocks.ensureAgent(wikiDir(h.env.root), 'other')
|
|
44
|
-
expect(() => memoryEdit(h.env, self, 'memory.append', { block: 'blocks/agents/other/notes', text: 'Not mine.' })).toThrow()
|
|
45
|
-
expect(memoryEdit(h.env, { ...self, vaultAccess: undefined }, 'memory.append', { block: 'user', text: 'Standalone scope.' })).toContain('Updated')
|
|
46
|
-
})
|
|
47
|
-
|
|
48
|
-
test('a shared fact is proposed without writing, then validated against its original run and applied once', async () => {
|
|
49
|
-
const h = setup(); await origin(h)
|
|
50
|
-
const submitted = propose(h.env, self, { kind: 'fact', title: 'Build tool', body: 'Use pnpm for builds.', confidence: 'high', sourceRefs: [{ kind: 'run', ref: 'current', excerpt: 'Prefer pnpm for all builds.' }] })
|
|
51
|
-
const job = h.maintenance.jobs().find(job => job.input.kind === 'proposal')!
|
|
52
|
-
expect(submitted).toContain(job.id)
|
|
53
|
-
expect(() => findMemory(wikiDir(h.env.root), 'build-tool')).toThrow()
|
|
54
|
-
expect(() => h.maintenance.readJob(self, job.id)).toThrow('curator capability')
|
|
55
|
-
const receipt = h.maintenance.applyJob(curator, job.id, undefined, 'Preserve the stated build preference.')
|
|
56
|
-
expect(receipt.status).toBe('completed')
|
|
57
|
-
expect(findMemory(wikiDir(h.env.root), 'build-tool').front.sourceIds).toContain('run:origin')
|
|
58
|
-
expect(h.maintenance.applyJob(curator, job.id, undefined, 'Retry receipt').receipt).toEqual(receipt.receipt)
|
|
59
|
-
})
|
|
60
|
-
|
|
61
|
-
test('an assigned proposal batch is read together and committed as one reconciled Wiki operation', async () => {
|
|
62
|
-
const h = setup(); await origin(h)
|
|
63
|
-
propose(h.env, self, { kind: 'fact', title: 'Build package', body: 'Prefer pnpm for builds.', confidence: 'high', sourceRefs: [{ kind: 'run', ref: 'current', excerpt: 'Prefer pnpm for all builds.' }] })
|
|
64
|
-
propose(h.env, self, { kind: 'fact', title: 'Build tool', body: 'Use pnpm for builds.', confidence: 'high', sourceRefs: [{ kind: 'run', ref: 'current', excerpt: 'Prefer pnpm for all builds.' }] })
|
|
65
|
-
const ids = h.maintenance.jobs().filter(job => job.input.kind === 'proposal').map(job => job.id)
|
|
66
|
-
const batchCurator = { ...curator, maintenanceJobIds: ids }
|
|
67
|
-
expect(h.maintenance.readBatch(batchCurator).jobs).toHaveLength(2)
|
|
68
|
-
|
|
69
|
-
const applied = h.maintenance.applyBatch(batchCurator, ids.map(id => ({ id, decision: 'apply' as const, reason: 'Merge duplicate statements into one evidence-backed fact.' })), {
|
|
70
|
-
pages: [],
|
|
71
|
-
memories: [{ title: 'Build tool', kind: 'preference', body: 'Prefer pnpm for builds.', namespace: '/', confidence: 'high', sourceRefs: [{ kind: 'run', ref: 'origin', excerpt: 'Prefer pnpm for all builds.' }], links: [] }],
|
|
72
|
-
blocks: [],
|
|
73
|
-
flags: []
|
|
74
|
-
})
|
|
75
|
-
|
|
76
|
-
expect(applied.operationId).toMatch(/^batch-/)
|
|
77
|
-
expect(applied.jobs.map(job => job.status)).toEqual(['completed', 'completed'])
|
|
78
|
-
expect(applied.jobs.filter(job => job.receipt?.operationId === applied.operationId)).toHaveLength(1)
|
|
79
|
-
expect(findMemory(wikiDir(h.env.root), 'build-tool').body.trim()).toBe('Prefer pnpm for builds.')
|
|
80
|
-
expect(listInbox(wikiDir(h.env.root)).filter(item => item.operationId === applied.operationId)).toHaveLength(1)
|
|
81
|
-
})
|
|
82
|
-
|
|
83
|
-
test('a batch parks updates whose captured Wiki target changed before review', async () => {
|
|
84
|
-
const h = setup(); await origin(h)
|
|
85
|
-
const wiki = wikiDir(h.env.root)
|
|
86
|
-
saveAsUser(wiki, { id: 'build-tool', title: 'Build tool', kind: 'preference', namespace: '/', body: 'Use pnpm for builds.' })
|
|
87
|
-
propose(h.env, self, { kind: 'fact', id: 'build-tool', title: 'Build tool', body: 'Prefer pnpm for all builds.', confidence: 'high', sourceRefs: [{ kind: 'run', ref: 'current', excerpt: 'Prefer pnpm for all builds.' }] })
|
|
88
|
-
const id = h.maintenance.jobs().find(job => job.input.kind === 'proposal')!.id
|
|
89
|
-
saveAsUser(wiki, { id: 'build-tool', title: 'Build tool', kind: 'preference', namespace: '/', body: 'The user changed this preference.' })
|
|
90
|
-
|
|
91
|
-
const result = h.maintenance.applyBatch({ ...curator, maintenanceJobIds: [id] }, [{ id, decision: 'apply', reason: 'Review the proposed update against its evidence.' }], {
|
|
92
|
-
pages: [],
|
|
93
|
-
memories: [{ id: 'build-tool', title: 'Build tool', kind: 'preference', body: 'Prefer pnpm for all builds.', namespace: '/', confidence: 'high', sourceRefs: [{ kind: 'run', ref: 'origin', excerpt: 'Prefer pnpm for all builds.' }], links: [] }],
|
|
94
|
-
blocks: [],
|
|
95
|
-
flags: []
|
|
96
|
-
})
|
|
97
|
-
|
|
98
|
-
expect(result.jobs[0].status).toBe('needs_user')
|
|
99
|
-
expect(result.operationId).toBeNull()
|
|
100
|
-
expect(findMemory(wiki, 'build-tool').body.trim()).toBe('The user changed this preference.')
|
|
101
|
-
})
|
|
102
|
-
|
|
103
|
-
test('invented evidence is parked and a read-only memory block is not rewritten', async () => {
|
|
104
|
-
const h = setup(); await origin(h)
|
|
105
|
-
propose(h.env, self, { kind: 'fact', title: 'Invented', body: 'Use another tool.', confidence: 'high', sourceRefs: [{ kind: 'run', ref: 'current', excerpt: 'This was never said anywhere.' }] })
|
|
106
|
-
const job = h.maintenance.jobs().find(job => job.input.kind === 'proposal')!
|
|
107
|
-
expect(h.maintenance.applyJob(curator, job.id, undefined, 'Review the claimed evidence').status).toBe('needs_user')
|
|
108
|
-
expect(() => findMemory(wikiDir(h.env.root), 'invented')).toThrow()
|
|
109
|
-
const block = blocks.find(wikiDir(h.env.root), 'blocks/agents/nora/notes')
|
|
110
|
-
blocks.saveAsUser(wikiDir(h.env.root), { id: block.id, body: '', description: block.front.description, limit: 2000, readOnly: true })
|
|
111
|
-
expect(() => propose(h.env, self, { kind: 'block', id: block.id, title: 'Notes', body: 'Changed.' })).toThrow('user-only')
|
|
112
|
-
})
|
|
113
|
-
|
|
114
|
-
test('text resources remain inactive until curated; undo restores attachments and preserves subsequent user edits', async () => {
|
|
115
|
-
const h = setup()
|
|
116
|
-
const tool = (await registryTools({ harness: h })({ caller: self } as ToolSession)).find(tool => tool.name === 'registry_write')!
|
|
117
|
-
await tool.execute({ kind: 'skill', name: 'release-notes', description: 'Write release notes', body: 'Summarize verified changes.', reason: 'Repeated release work.' })
|
|
118
|
-
const job = h.maintenance.jobs().find(job => job.input.kind === 'registry')!
|
|
119
|
-
expect(registry.list(h.env.root).some(item => item.id === 'skills/release-notes')).toBe(false)
|
|
120
|
-
const receipt = h.maintenance.applyJob(curator, job.id, undefined, 'Reuse the repeated release procedure.')
|
|
121
|
-
expect(attachments.attached(attachments.load(h.env.root), ['skills/release-notes'], { agentId: 'nora' }, false)).toBe(true)
|
|
122
|
-
h.maintenance.undoOperation(receipt.receipt!.operationId!)
|
|
123
|
-
expect(registry.list(h.env.root).some(item => item.id === 'skills/release-notes')).toBe(false)
|
|
124
|
-
const next = enqueueCuration(h.env.root, { kind: 'registry', caller: self, proposal: { kind: 'skill', name: 'other-notes', description: 'Notes', body: 'Write verified notes.', reason: 'Workflow improvement.' } })
|
|
125
|
-
const applied = h.maintenance.applyJob(curator, next.id, undefined, 'Apply text-only improvement.')
|
|
126
|
-
registry.writeItem(h.env.root, 'skill', 'other-notes', 'Notes', 'The user changed this.', 'User edit')
|
|
127
|
-
expect(() => h.maintenance.undoOperation(applied.receipt!.operationId!)).toThrow('changed after')
|
|
128
|
-
})
|
|
129
|
-
|
|
130
|
-
test('changed target versions are escalated instead of overwritten', () => {
|
|
131
|
-
const h = setup()
|
|
132
|
-
registry.writeItem(h.env.root, 'skill', 'notes', 'Notes', 'Original notes.', 'Initial')
|
|
133
|
-
const job = enqueueCuration(h.env.root, { kind: 'registry', caller: self, proposal: { kind: 'skill', name: 'notes', description: 'Notes', body: 'Proposed notes.', reason: 'Improve' } })
|
|
134
|
-
registry.writeItem(h.env.root, 'skill', 'notes', 'Notes', 'User edits win.', 'User edit')
|
|
135
|
-
expect(h.maintenance.applyJob(curator, job.id, undefined, 'Review update').status).toBe('needs_user')
|
|
136
|
-
expect(registry.list(h.env.root).find(item => item.id === 'skills/notes')).toBeDefined()
|
|
137
|
-
})
|
|
138
|
-
|
|
139
|
-
test('executable plugins are submitted without saving, activation or trust', async () => {
|
|
140
|
-
const h = setup()
|
|
141
|
-
const tool = (await registryTools({ harness: h })({ caller: self } as ToolSession)).find(tool => tool.name === 'plugin_write')!
|
|
142
|
-
await tool.execute({ name: 'code-change', description: 'A code plugin', files: [{ path: 'extensions/index.ts', content: 'export default () => {}' }] })
|
|
143
|
-
const job = h.maintenance.jobs().find(job => job.input.kind === 'plugin')!
|
|
144
|
-
expect(h.maintenance.applyJob(curator, job.id, undefined, 'Code needs trust').status).toBe('needs_user')
|
|
145
|
-
expect(existsSync(join(h.env.root, 'plugins/code-change'))).toBe(false)
|
|
146
|
-
})
|
|
147
|
-
|
|
148
|
-
test('external jobs persist and repeated schedule occurrences are distinct; no hidden provider runs', async () => {
|
|
149
|
-
const h = setup()
|
|
150
|
-
const source = h.maintenance.ingest('source.txt', Buffer.from('Prefer pnpm for all builds.'))
|
|
151
|
-
expect(h.maintenance.jobs().some(job => job.input.kind === 'ingest' && job.input.sourceId === source.id)).toBe(true)
|
|
152
|
-
h.maintenance.enqueue({ kind: 'review', requestId: 'day-one' })
|
|
153
|
-
h.maintenance.enqueue({ kind: 'review', requestId: 'day-one' })
|
|
154
|
-
h.maintenance.enqueue({ kind: 'review', requestId: 'day-two' })
|
|
155
|
-
expect(h.maintenance.jobs().filter(job => job.input.kind === 'review')).toHaveLength(2)
|
|
156
|
-
await h.maintenance.settled()
|
|
157
|
-
})
|
|
158
|
-
|
|
159
|
-
test('a curator run never enqueues another completed-run maintenance job', async () => {
|
|
160
|
-
const h = setup()
|
|
161
|
-
await h.sessions.run({ run: { id: 'curator-run', sessionId: 'curator' }, cwd: h.env.root, sessionDir: join(h.env.root, 'curator-history'), text: 'Review maintenance.', caller: curator, tools: { allow: [] } })
|
|
162
|
-
await new Promise(resolve => setTimeout(resolve, 30))
|
|
163
|
-
expect(h.maintenance.jobs().some(job => job.input.kind === 'run' && job.input.runId === 'curator-run')).toBe(false)
|
|
164
|
-
})
|
|
165
|
-
|
|
166
|
-
test('curator prose cannot become its own evidence; original source runs can support a review', async () => {
|
|
167
|
-
const h = setup(); await origin(h)
|
|
168
|
-
await h.sessions.run({ run: { id: 'review', sessionId: 'curator' }, cwd: h.env.root, sessionDir: join(h.env.root, 'curator-history'), text: 'Prefer pnpm for all builds.', caller: curator, tools: { allow: [] } })
|
|
169
|
-
await new Promise(resolve => setTimeout(resolve, 30))
|
|
170
|
-
propose(h.env, curator, { kind: 'fact', title: 'Curator claim', body: 'A claim from the review.', confidence: 'high', sourceRefs: [{ kind: 'run', ref: 'current', excerpt: 'Prefer pnpm for all builds.' }] })
|
|
171
|
-
const own = h.maintenance.jobs().find(job => job.input.kind === 'proposal')!
|
|
172
|
-
expect(h.maintenance.applyJob(curator, own.id, undefined, 'Check the original evidence.').status).toBe('needs_user')
|
|
173
|
-
expect(() => findMemory(wikiDir(h.env.root), 'curator-claim')).toThrow()
|
|
174
|
-
const review = enqueueCuration(h.env.root, { kind: 'review', requestId: 'grounded-review' })
|
|
175
|
-
const applied = h.maintenance.applyJob(curator, review.id, { memories: [{ title: 'Original preference', kind: 'preference', body: 'Prefer pnpm.', confidence: 'high', sourceRefs: [{ kind: 'run', ref: 'origin', excerpt: 'Prefer pnpm for all builds.' }] }] }, 'Retain the original recorded preference.')
|
|
176
|
-
expect(applied.status).toBe('completed')
|
|
177
|
-
expect(findMemory(wikiDir(h.env.root), 'original-preference').front.sourceIds).toContain('run:origin')
|
|
178
|
-
})
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
test('a failed evidence preparation remains attributed and can be rejected without rereading invalid input', () => {
|
|
182
|
-
const h = setup()
|
|
183
|
-
const job = enqueueCuration(h.env.root, { kind: 'consolidate', blockIds: ['missing-block'] })
|
|
184
|
-
expect(() => h.maintenance.readJob(curator, job.id)).toThrow('No writable blocks')
|
|
185
|
-
expect(h.maintenance.jobs().find(item => item.id === job.id)).toMatchObject({ status: 'failed', runId: 'review' })
|
|
186
|
-
expect(h.maintenance.decide(curator, job.id, 'reject', 'The target no longer exists.').status).toBe('rejected')
|
|
187
|
-
})
|
|
188
|
-
|
|
189
|
-
test('a propose-only caller writes no memory directly, not even its own persona and notes', () => {
|
|
190
|
-
const h = setup()
|
|
191
|
-
const proposer = { ...self, vaultAccess: 'propose' as const }
|
|
192
|
-
expect(() => memoryEdit(h.env, proposer, 'memory.append', { block: 'notes', text: 'Working on release notes.' })).toThrow('Memory changes are proposals')
|
|
193
|
-
expect(() => memoryEdit(h.env, proposer, 'memory.append', { block: 'persona', text: 'I am terse.' })).toThrow('Memory changes are proposals')
|
|
194
|
-
expect(propose(h.env, proposer, { kind: 'block', id: 'blocks/agents/nora/notes', title: 'Notes', body: 'Working on release notes.' })).toContain('Submitted proposal')
|
|
195
|
-
})
|
|
196
|
-
|
|
197
|
-
test('with the run trigger off, a settled run queues no consolidation of its own', async () => {
|
|
198
|
-
let emit: (event: unknown) => void = () => undefined
|
|
199
|
-
const session: PiSessionLike = { sessionId: 'quiet', subscribe: listener => { emit = listener as never; return () => undefined }, prompt: async () => { emit({ type: 'message_end', message: { role: 'assistant', content: [{ type: 'text', text: 'Prefer pnpm for all builds.' }] } }) }, sendCustomMessage: async () => undefined, abort: async () => undefined, dispose: () => undefined }
|
|
200
|
-
const h = createHarness({ dataRoot: mkdtempSync(join(tmpdir(), 'curation-data-')), stateDir: mkdtempSync(join(tmpdir(), 'curation-state-')), externalMaintenance: true, maintenanceRunTrigger: false, secrets: memorySecretStore(), sessionFactory: async () => session })
|
|
201
|
-
opened.push(h)
|
|
202
|
-
h.accounts.create('Main', 'apiKey')
|
|
203
|
-
await h.sessions.run({ run: { id: 'quiet-run', sessionId: 'nora' }, cwd: h.env.root, sessionDir: join(h.env.root, 'history'), text: 'Prefer pnpm.', caller: self, tools: { allow: [] } })
|
|
204
|
-
await new Promise(resolve => setTimeout(resolve, 30))
|
|
205
|
-
expect(h.maintenance.jobs().filter(job => job.input.kind === 'run')).toEqual([])
|
|
206
|
-
})
|
package/src/memory/curation.ts
DELETED
|
@@ -1,64 +0,0 @@
|
|
|
1
|
-
import { readdirSync } from 'node:fs'
|
|
2
|
-
import { join } from 'node:path'
|
|
3
|
-
import { createHash } from 'node:crypto'
|
|
4
|
-
|
|
5
|
-
import { invalid, notFound } from '../core/errors'
|
|
6
|
-
import { newId, now, readJson, safeId, writeJson } from '../core/fs'
|
|
7
|
-
import type { CallerContext } from './caller'
|
|
8
|
-
import type { Job } from './maintenance'
|
|
9
|
-
import type { MaintenanceRequest } from './maintainer'
|
|
10
|
-
import { captureHashes, parseProposalSet, type ProposalSet, type RegistryProposal, type Evidence, type Trigger } from './proposals'
|
|
11
|
-
import { looksSecret } from './secrets'
|
|
12
|
-
import * as store from './store'
|
|
13
|
-
|
|
14
|
-
export type CurationInput = Job
|
|
15
|
-
| { kind: 'proposal'; namespace: string; proposals: ProposalSet; caller: CallerContext }
|
|
16
|
-
| { kind: 'registry'; proposal: RegistryProposal; caller: CallerContext }
|
|
17
|
-
| { kind: 'plugin'; proposal: unknown; caller: CallerContext }
|
|
18
|
-
export interface CurationJob {
|
|
19
|
-
id: string
|
|
20
|
-
key: string
|
|
21
|
-
input: CurationInput
|
|
22
|
-
status: 'queued' | 'running' | 'completed' | 'failed' | 'rejected' | 'needs_user'
|
|
23
|
-
createdAt: string
|
|
24
|
-
updatedAt: string
|
|
25
|
-
runId: string | null
|
|
26
|
-
reason: string
|
|
27
|
-
hashes: [string, string][]
|
|
28
|
-
prepared?: { request: MaintenanceRequest; evidence: Evidence; namespace: string; trigger: Trigger }
|
|
29
|
-
receipt: { operationId: string | null; notes: string[]; parked: number; flagged: number } | null
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
const directory = (root: string): string => join(store.wikiDir(root), 'maintenance-jobs')
|
|
33
|
-
export function listCuration(root: string): CurationJob[] {
|
|
34
|
-
let names: string[] = []
|
|
35
|
-
try { names = readdirSync(directory(root)) } catch { return [] }
|
|
36
|
-
return names.filter((name) => name.endsWith('.json')).flatMap((name) => {
|
|
37
|
-
const job = readJson<CurationJob>(join(directory(root), name))
|
|
38
|
-
return job && typeof job.id === 'string' && job.input && typeof job.input.kind === 'string' ? [job] : []
|
|
39
|
-
}).sort((a, b) => a.createdAt.localeCompare(b.createdAt))
|
|
40
|
-
}
|
|
41
|
-
export function curationJob(root: string, id: string): CurationJob {
|
|
42
|
-
const job = readJson<CurationJob>(join(directory(root), `${safeId(id)}.json`))
|
|
43
|
-
if (!job) throw notFound('Maintenance job')
|
|
44
|
-
return job
|
|
45
|
-
}
|
|
46
|
-
export function saveCuration(root: string, job: CurationJob): CurationJob {
|
|
47
|
-
writeJson(join(directory(root), `${safeId(job.id)}.json`), { ...job, updatedAt: now() })
|
|
48
|
-
return job
|
|
49
|
-
}
|
|
50
|
-
export function enqueueCuration(root: string, input: CurationInput): CurationJob {
|
|
51
|
-
if (input.kind === 'proposal') input = { ...input, proposals: parseProposalSet(input.proposals) }
|
|
52
|
-
if (Buffer.byteLength(JSON.stringify(input)) > 256 * 1024) throw invalid('The proposal is larger than 256 KiB.')
|
|
53
|
-
if (looksSecret(JSON.stringify(input))) throw invalid('The proposal contains text that looks like a credential.')
|
|
54
|
-
const captured = captureHashes(store.wikiDir(root))
|
|
55
|
-
const revisions = input.kind === 'consolidate' ? input.blockIds.map(id => [id, captured.get(id)]) : undefined
|
|
56
|
-
const key = createHash('sha256').update(JSON.stringify({ input, revisions })).digest('hex')
|
|
57
|
-
const existing = listCuration(root).find((job) => job.key === key)
|
|
58
|
-
if (existing) return existing
|
|
59
|
-
const timestamp = now()
|
|
60
|
-
return saveCuration(root, { id: newId(), key, input, status: 'queued', createdAt: timestamp, updatedAt: timestamp, runId: null, reason: '', hashes: [...captured], receipt: null })
|
|
61
|
-
}
|
|
62
|
-
export function requireCurator(context: CallerContext): void {
|
|
63
|
-
if (context.vaultAccess !== 'curator' || !context.runId) throw invalid('This operation needs a curator capability issued by the host for this run.')
|
|
64
|
-
}
|
package/src/memory/dispatch.ts
DELETED
|
@@ -1,64 +0,0 @@
|
|
|
1
|
-
// One entry point for every memory, recall, and Wiki call a session makes, whichever way it
|
|
2
|
-
// reaches the harness (a Pi tool, the `xagent-wiki` command, the bundled MCP server).
|
|
3
|
-
import type { Bus } from '../core/bus'
|
|
4
|
-
import type { HarnessEnv } from '../core/env'
|
|
5
|
-
import { HarnessError } from '../core/errors'
|
|
6
|
-
import { launchText, load, render } from '../prompt/compile'
|
|
7
|
-
import * as api from './api'
|
|
8
|
-
import type { CallerContext } from './caller'
|
|
9
|
-
import * as recall from './recall'
|
|
10
|
-
import * as trajectory from './trajectory'
|
|
11
|
-
import { reindexQuietly } from './wikiIndex'
|
|
12
|
-
|
|
13
|
-
export type DispatchEnv = HarnessEnv & { bus?: Bus }
|
|
14
|
-
|
|
15
|
-
/** Runs one call and returns its reply as text for a model. */
|
|
16
|
-
export function dispatch(env: DispatchEnv, context: CallerContext, call: string, params: api.Params, contributions?: readonly string[]): string {
|
|
17
|
-
switch (call) {
|
|
18
|
-
case 'wiki.entrypoint': {
|
|
19
|
-
const recorded = context.runId ? safely(() => load(env.root, context.runId!)) : null
|
|
20
|
-
return recorded ? render(env, recorded) : launchText(env, context)
|
|
21
|
-
}
|
|
22
|
-
case 'recall.search':
|
|
23
|
-
return recall.searchTool(env, context, params)
|
|
24
|
-
case 'recall.read':
|
|
25
|
-
return recall.readTool(env, context, params)
|
|
26
|
-
case 'trajectory.search':
|
|
27
|
-
return trajectory.searchTool(env, context, params)
|
|
28
|
-
case 'trajectory.read':
|
|
29
|
-
return trajectory.readTool(env, context, params)
|
|
30
|
-
case 'memory.append':
|
|
31
|
-
case 'memory.replace':
|
|
32
|
-
case 'memory.rethink': {
|
|
33
|
-
const text = api.memoryEdit(env, context, call, params)
|
|
34
|
-
env.bus?.emit({ type: 'wiki.changed' })
|
|
35
|
-
return text
|
|
36
|
-
}
|
|
37
|
-
case 'wiki.search':
|
|
38
|
-
return api.search(env, context, params)
|
|
39
|
-
case 'wiki.read':
|
|
40
|
-
return api.read(env, context, params)
|
|
41
|
-
case 'wiki.list':
|
|
42
|
-
return api.list(env, params)
|
|
43
|
-
case 'wiki.skill':
|
|
44
|
-
return api.skill(env, context, params, contributions)
|
|
45
|
-
case 'wiki.memory':
|
|
46
|
-
return api.memories(env, context, params)
|
|
47
|
-
case 'wiki.propose': {
|
|
48
|
-
const text = api.propose(env, context, params)
|
|
49
|
-
reindexQuietly(env.root)
|
|
50
|
-
env.bus?.emit({ type: 'wiki.changed' })
|
|
51
|
-
return text
|
|
52
|
-
}
|
|
53
|
-
default:
|
|
54
|
-
throw new HarnessError('unknown_method', call.startsWith('memory.') ? `Unknown memory call ${call}.` : `Unknown Wiki call ${call}.`)
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
function safely<T>(read: () => T): T | null {
|
|
59
|
-
try {
|
|
60
|
-
return read()
|
|
61
|
-
} catch {
|
|
62
|
-
return null
|
|
63
|
-
}
|
|
64
|
-
}
|
package/src/memory/facts.test.ts
DELETED
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
import { expect, test } from 'vitest'
|
|
2
|
-
|
|
3
|
-
import { dataRoot } from '../../tests/helpers'
|
|
4
|
-
import { now } from '../core/fs'
|
|
5
|
-
import { appliesTo, isStale, loadMemories, parse, response, saveAsUser, type MemoryFront, type MemoryInput } from './facts'
|
|
6
|
-
import { readManifest, wikiDir } from './store'
|
|
7
|
-
|
|
8
|
-
test('reads legacy procedures as facts without changing their content', () => {
|
|
9
|
-
const parsed = parse('---\nid: legacy\ntitle: Legacy procedure\nkind: procedure\n---\n\nFollow these steps.\n', '/tmp/legacy.md')!
|
|
10
|
-
expect(parsed.front.kind).toBe('fact')
|
|
11
|
-
expect(parsed.body).toBe('Follow these steps.\n')
|
|
12
|
-
const value = response(dataRoot(), parsed)
|
|
13
|
-
expect(value.kind).toBe('fact')
|
|
14
|
-
expect(value.body).toBe('Follow these steps.\n')
|
|
15
|
-
})
|
|
16
|
-
|
|
17
|
-
const input = (title: string, kind: string): MemoryInput => ({ title, kind, namespace: '/', body: 'Use pnpm, not npm.', confidence: 'high', status: 'active' })
|
|
18
|
-
|
|
19
|
-
test('saves, validates and revisions memories', () => {
|
|
20
|
-
const wiki = wikiDir(dataRoot())
|
|
21
|
-
const saved = saveAsUser(wiki, input('Package manager', 'preference'))
|
|
22
|
-
expect(saved.id).toBe('package-manager')
|
|
23
|
-
expect(saved.sourceIds).toEqual(['user'])
|
|
24
|
-
saveAsUser(wiki, { ...input('Package manager', 'preference'), id: 'package-manager', body: 'Use pnpm everywhere.' })
|
|
25
|
-
expect(readManifest(wiki, 'package-manager')).toHaveLength(3)
|
|
26
|
-
expect(() => saveAsUser(wiki, input('Bad', 'opinion'))).toThrow()
|
|
27
|
-
expect(() => saveAsUser(wiki, input('Steps', 'procedure'))).toThrow()
|
|
28
|
-
expect(() => saveAsUser(wiki, { ...input('Scoped', 'fact'), namespace: '/elsewhere' })).toThrow()
|
|
29
|
-
expect(loadMemories(wiki)).toHaveLength(1)
|
|
30
|
-
})
|
|
31
|
-
|
|
32
|
-
test('applies by namespace and status', () => {
|
|
33
|
-
const front: MemoryFront = { id: 'm', title: 'M', kind: 'fact', namespace: '/projects/a', sourceIds: [], confidence: 'high', status: 'active', supersedes: null, links: [], createdAt: '', updatedAt: '', observedAt: '', volatile: false }
|
|
34
|
-
expect(isStale(front)).toBe(false)
|
|
35
|
-
expect(isStale({ ...front, volatile: true, observedAt: '2020-01-01T00:00:00Z' })).toBe(true)
|
|
36
|
-
expect(isStale({ ...front, volatile: true, observedAt: now() })).toBe(false)
|
|
37
|
-
expect(appliesTo(front, '/projects/a')).toBe(true)
|
|
38
|
-
expect(appliesTo(front, '/projects/b')).toBe(false)
|
|
39
|
-
expect(appliesTo({ ...front, namespace: '/' }, null)).toBe(true)
|
|
40
|
-
expect(appliesTo({ ...front, namespace: '/', status: 'archived' }, null)).toBe(false)
|
|
41
|
-
})
|
package/src/memory/facts.ts
DELETED
|
@@ -1,248 +0,0 @@
|
|
|
1
|
-
// Facts: short, typed things to carry between runs (a preference, a decision). Each is a file,
|
|
2
|
-
// `wiki/facts/<id>.md`, with frontmatter and a few lines of body, has sources and revisions like
|
|
3
|
-
// a page, and changes only through the user or a validated proposal.
|
|
4
|
-
import { readFileSync } from 'node:fs'
|
|
5
|
-
import { join } from 'node:path'
|
|
6
|
-
|
|
7
|
-
import { noteChange } from '../core/changes'
|
|
8
|
-
import { HarnessError, invalid, notFound } from '../core/errors'
|
|
9
|
-
import * as frontmatter from '../core/frontmatter'
|
|
10
|
-
import { atomicWrite, now, safeId } from '../core/fs'
|
|
11
|
-
import { comparePaths } from '../core/text'
|
|
12
|
-
import { looksSecret } from './secrets'
|
|
13
|
-
import * as store from './store'
|
|
14
|
-
import { LIBRARY_NAMESPACE, ROOT_NAMESPACE, type WikiLink } from './store'
|
|
15
|
-
|
|
16
|
-
/** A procedure is a skill, not a fact. */
|
|
17
|
-
export const MEMORY_KINDS = ['preference', 'fact', 'decision', 'feedback'] as const
|
|
18
|
-
/** A volatile fact older than this loses rank in search and is reported by Health. */
|
|
19
|
-
export const STALE_DAYS = 30
|
|
20
|
-
export const CONFIDENCE = ['high', 'medium', 'low'] as const
|
|
21
|
-
export const STATUSES = ['active', 'superseded', 'archived'] as const
|
|
22
|
-
/** A fact is short, not a document. Measured in bytes. */
|
|
23
|
-
export const MAX_BODY = 2000
|
|
24
|
-
|
|
25
|
-
export interface MemoryFront {
|
|
26
|
-
id: string
|
|
27
|
-
title: string
|
|
28
|
-
kind: string
|
|
29
|
-
/** `/` for everything, or a project namespace such as `/projects/xagent`. */
|
|
30
|
-
namespace: string
|
|
31
|
-
sourceIds: string[]
|
|
32
|
-
confidence: string
|
|
33
|
-
status: string
|
|
34
|
-
supersedes: string | null
|
|
35
|
-
links: WikiLink[]
|
|
36
|
-
createdAt: string
|
|
37
|
-
updatedAt: string
|
|
38
|
-
/** When the fact was observed to be true. */
|
|
39
|
-
observedAt: string
|
|
40
|
-
/** Current state that will change. Never put in a compiled prompt; loses rank as it ages. */
|
|
41
|
-
volatile: boolean
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
export interface ParsedMemory {
|
|
45
|
-
front: MemoryFront
|
|
46
|
-
body: string
|
|
47
|
-
path: string
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/** Days since the fact was observed (or last updated), when that can be read. */
|
|
51
|
-
export function ageDays(front: MemoryFront): number | null {
|
|
52
|
-
const at = Date.parse(front.observedAt || front.updatedAt)
|
|
53
|
-
return Number.isNaN(at) ? null : Math.trunc((Date.now() - at) / 86_400_000)
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
export function isStale(front: MemoryFront): boolean {
|
|
57
|
-
const days = ageDays(front)
|
|
58
|
-
return front.volatile && front.status === 'active' && days !== null && days >= STALE_DAYS
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
export function memoryDir(wiki: string): string {
|
|
62
|
-
return join(wiki, 'facts')
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/** The reason a fact is not valid, or `null`. */
|
|
66
|
-
export function validate(front: MemoryFront, body: string): string | null {
|
|
67
|
-
try {
|
|
68
|
-
safeId(front.id)
|
|
69
|
-
} catch {
|
|
70
|
-
return 'The fact id must be letters, numbers, - or _.'
|
|
71
|
-
}
|
|
72
|
-
if (!front.title.trim() || !body.trim()) return 'A fact needs a title and a body.'
|
|
73
|
-
if (Buffer.byteLength(body) > MAX_BODY) return `A memory is a short fact; keep the body under ${MAX_BODY} characters and put longer material in a Wiki page.`
|
|
74
|
-
if (!(MEMORY_KINDS as readonly string[]).includes(front.kind)) return `A fact must be one of ${MEMORY_KINDS.join(', ')}.`
|
|
75
|
-
if (!(CONFIDENCE as readonly string[]).includes(front.confidence) || !(STATUSES as readonly string[]).includes(front.status)) return 'Unknown confidence or status.'
|
|
76
|
-
if (front.namespace !== ROOT_NAMESPACE && store.projectNamespaceOf(front.namespace) !== front.namespace && front.namespace !== LIBRARY_NAMESPACE) {
|
|
77
|
-
return "A memory's namespace is / or a project namespace (/projects/<slug>)."
|
|
78
|
-
}
|
|
79
|
-
return null
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
const optionalText = (value: unknown, fallback: string): string | null => (value === undefined ? fallback : typeof value === 'string' ? value : null)
|
|
83
|
-
|
|
84
|
-
export function parse(text: string, path: string): ParsedMemory | null {
|
|
85
|
-
const parsed = frontmatter.parse(text)
|
|
86
|
-
if (!parsed) return null
|
|
87
|
-
const { id, title, kind, supersedes, volatile } = parsed.front
|
|
88
|
-
if (typeof id !== 'string' || typeof title !== 'string' || typeof kind !== 'string') return null
|
|
89
|
-
const namespace = optionalText(parsed.front.namespace, ROOT_NAMESPACE)
|
|
90
|
-
const confidence = optionalText(parsed.front.confidence, 'medium')
|
|
91
|
-
const status = optionalText(parsed.front.status, 'active')
|
|
92
|
-
const createdAt = optionalText(parsed.front.createdAt, '')
|
|
93
|
-
const updatedAt = optionalText(parsed.front.updatedAt, '')
|
|
94
|
-
const observedAt = optionalText(parsed.front.observedAt, '')
|
|
95
|
-
const sourceIds = store.stringList(parsed.front.sourceIds)
|
|
96
|
-
const links = store.linkList(parsed.front.links, id)
|
|
97
|
-
if (namespace === null || confidence === null || status === null || createdAt === null || updatedAt === null || observedAt === null || !sourceIds || !links) return null
|
|
98
|
-
if (supersedes !== undefined && supersedes !== null && typeof supersedes !== 'string') return null
|
|
99
|
-
if (volatile !== undefined && typeof volatile !== 'boolean') return null
|
|
100
|
-
return {
|
|
101
|
-
front: {
|
|
102
|
-
id,
|
|
103
|
-
title,
|
|
104
|
-
// Historical procedures read as facts without rewriting files or creating registry skills.
|
|
105
|
-
kind: kind === 'procedure' ? 'fact' : kind,
|
|
106
|
-
namespace,
|
|
107
|
-
sourceIds,
|
|
108
|
-
confidence,
|
|
109
|
-
status,
|
|
110
|
-
supersedes: supersedes ?? null,
|
|
111
|
-
links,
|
|
112
|
-
createdAt,
|
|
113
|
-
updatedAt,
|
|
114
|
-
observedAt,
|
|
115
|
-
volatile: volatile ?? false
|
|
116
|
-
},
|
|
117
|
-
body: parsed.body,
|
|
118
|
-
path
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
export function loadMemories(wiki: string): ParsedMemory[] {
|
|
123
|
-
const files: string[] = []
|
|
124
|
-
store.markdownFiles(memoryDir(wiki), files)
|
|
125
|
-
const memories: ParsedMemory[] = []
|
|
126
|
-
for (const path of files.sort(comparePaths)) {
|
|
127
|
-
try {
|
|
128
|
-
const memory = parse(readFileSync(path, 'utf8'), path)
|
|
129
|
-
if (memory) memories.push(memory)
|
|
130
|
-
} catch {
|
|
131
|
-
// An unreadable file is not a fact.
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
return memories
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
export function findMemory(wiki: string, id: string): ParsedMemory {
|
|
138
|
-
const memory = loadMemories(wiki).find((entry) => entry.front.id === id)
|
|
139
|
-
if (!memory) throw notFound('Memory')
|
|
140
|
-
return memory
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
export function serialize(front: MemoryFront, body: string): string {
|
|
144
|
-
return frontmatter.serialize(
|
|
145
|
-
{
|
|
146
|
-
id: front.id,
|
|
147
|
-
title: front.title,
|
|
148
|
-
kind: front.kind,
|
|
149
|
-
namespace: front.namespace,
|
|
150
|
-
sourceIds: front.sourceIds,
|
|
151
|
-
confidence: front.confidence,
|
|
152
|
-
status: front.status,
|
|
153
|
-
...(front.supersedes === null ? {} : { supersedes: front.supersedes }),
|
|
154
|
-
links: front.links,
|
|
155
|
-
createdAt: front.createdAt,
|
|
156
|
-
updatedAt: front.updatedAt,
|
|
157
|
-
observedAt: front.observedAt,
|
|
158
|
-
...(front.volatile ? { volatile: true } : {})
|
|
159
|
-
},
|
|
160
|
-
body
|
|
161
|
-
)
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
export function pathFor(wiki: string, id: string): string {
|
|
165
|
-
return join(memoryDir(wiki), `${safeId(id)}.md`)
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
/**
|
|
169
|
-
* Facts that apply in a project namespace (or everywhere): active ones in `/`, `/library`,
|
|
170
|
-
* or that namespace. Others stay retrievable by search.
|
|
171
|
-
*/
|
|
172
|
-
export function appliesTo(front: MemoryFront, projectNamespace: string | null): boolean {
|
|
173
|
-
return front.status === 'active' && (front.namespace === ROOT_NAMESPACE || front.namespace === LIBRARY_NAMESPACE || front.namespace === projectNamespace)
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
export type WikiMemory = Omit<MemoryFront, 'supersedes' | 'volatile'> & { supersedes?: string; volatile?: true; body: string; path: string; revisionId: string | null }
|
|
177
|
-
|
|
178
|
-
export function response(wiki: string, memory: ParsedMemory): WikiMemory {
|
|
179
|
-
const { supersedes, volatile, ...front } = memory.front
|
|
180
|
-
return {
|
|
181
|
-
...front,
|
|
182
|
-
...(supersedes === null ? {} : { supersedes }),
|
|
183
|
-
...(volatile ? { volatile: true as const } : {}),
|
|
184
|
-
body: memory.body,
|
|
185
|
-
path: memory.path,
|
|
186
|
-
revisionId: store.currentRevision(wiki, memory.front.id)
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
export interface MemoryInput {
|
|
191
|
-
id?: string | null
|
|
192
|
-
title: string
|
|
193
|
-
kind: string
|
|
194
|
-
namespace?: string
|
|
195
|
-
body: string
|
|
196
|
-
confidence?: string
|
|
197
|
-
status?: string
|
|
198
|
-
supersedes?: string | null
|
|
199
|
-
volatile?: boolean
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
/** A user's create or edit. Writes a revision before and after, like every other change. */
|
|
203
|
-
export function saveAsUser(wiki: string, input: MemoryInput): WikiMemory {
|
|
204
|
-
const id = input.id ?? store.slug(input.title)
|
|
205
|
-
let existing: ParsedMemory | null = null
|
|
206
|
-
try {
|
|
207
|
-
existing = findMemory(wiki, id)
|
|
208
|
-
} catch {
|
|
209
|
-
existing = null
|
|
210
|
-
}
|
|
211
|
-
const sourceIds = existing ? [...existing.front.sourceIds] : []
|
|
212
|
-
if (!sourceIds.length) sourceIds.push('user')
|
|
213
|
-
const front: MemoryFront = {
|
|
214
|
-
id,
|
|
215
|
-
title: input.title.trim(),
|
|
216
|
-
kind: input.kind,
|
|
217
|
-
namespace: (input.namespace ?? ROOT_NAMESPACE).trim(),
|
|
218
|
-
sourceIds,
|
|
219
|
-
confidence: input.confidence ?? 'medium',
|
|
220
|
-
status: input.status ?? 'active',
|
|
221
|
-
supersedes: input.supersedes || null,
|
|
222
|
-
links: existing ? existing.front.links : [],
|
|
223
|
-
createdAt: existing?.front.createdAt || now(),
|
|
224
|
-
updatedAt: now(),
|
|
225
|
-
// Saving a fact again says it is still true now.
|
|
226
|
-
observedAt: now(),
|
|
227
|
-
volatile: input.volatile ?? false
|
|
228
|
-
}
|
|
229
|
-
const problem = validate(front, input.body)
|
|
230
|
-
if (problem) throw invalid(problem)
|
|
231
|
-
if (looksSecret(input.body)) throw invalid('The fact contains text that looks like a credential.')
|
|
232
|
-
const path = existing?.path ?? pathFor(wiki, id)
|
|
233
|
-
if (existing) {
|
|
234
|
-
let before: string
|
|
235
|
-
try {
|
|
236
|
-
before = readFileSync(existing.path, 'utf8')
|
|
237
|
-
} catch (error) {
|
|
238
|
-
throw new HarnessError('io_error', error instanceof Error ? error.message : String(error))
|
|
239
|
-
}
|
|
240
|
-
store.snapshot(wiki, id, before, 'user', 'Before edit')
|
|
241
|
-
}
|
|
242
|
-
const text = serialize(front, input.body)
|
|
243
|
-
atomicWrite(path, text)
|
|
244
|
-
store.snapshot(wiki, id, text, 'user', existing ? 'Edited' : 'Created')
|
|
245
|
-
noteChange({ wiki, id, path, text, actor: 'user', reason: existing ? 'Edited' : 'Created' })
|
|
246
|
-
store.log(wiki, `- ${now()} · user · memory · \`${id}\` ${existing ? 'edited' : 'created'}`)
|
|
247
|
-
return response(wiki, findMemory(wiki, id))
|
|
248
|
-
}
|