@xingular/harness 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/README.md +269 -2
  2. package/dist/cli.js +11046 -0
  3. package/dist/terminal-pi.js +326 -0
  4. package/package.json +41 -4
  5. package/src/accounts/accounts.test.ts +223 -0
  6. package/src/accounts/accounts.ts +522 -0
  7. package/src/accounts/env.ts +204 -0
  8. package/src/accounts/secrets.ts +50 -0
  9. package/src/accounts/store.ts +338 -0
  10. package/src/capabilities/api.ts +75 -0
  11. package/src/capabilities/runSnapshot.ts +59 -0
  12. package/src/capabilities/types.ts +34 -0
  13. package/src/cli.ts +110 -0
  14. package/src/core/bus.ts +34 -0
  15. package/src/core/changes.ts +33 -0
  16. package/src/core/env.ts +27 -0
  17. package/src/core/errors.ts +32 -0
  18. package/src/core/frontmatter.ts +47 -0
  19. package/src/core/fs.test.ts +38 -0
  20. package/src/core/fs.ts +92 -0
  21. package/src/core/index.ts +193 -0
  22. package/src/core/path.ts +45 -0
  23. package/src/core/projects.ts +90 -0
  24. package/src/core/text.ts +39 -0
  25. package/src/harness.ts +289 -0
  26. package/src/index.ts +69 -0
  27. package/src/mcp/connections.ts +441 -0
  28. package/src/mcp/manager.ts +130 -0
  29. package/src/mcp/oauth.ts +195 -0
  30. package/src/mcp/service.test.ts +376 -0
  31. package/src/mcp/service.ts +567 -0
  32. package/src/mcp/types.ts +105 -0
  33. package/src/memory/api.test.ts +82 -0
  34. package/src/memory/api.ts +287 -0
  35. package/src/memory/blocks.test.ts +78 -0
  36. package/src/memory/blocks.ts +406 -0
  37. package/src/memory/caller.ts +57 -0
  38. package/src/memory/dispatch.ts +59 -0
  39. package/src/memory/facts.test.ts +41 -0
  40. package/src/memory/facts.ts +248 -0
  41. package/src/memory/graph.ts +103 -0
  42. package/src/memory/health.test.ts +130 -0
  43. package/src/memory/health.ts +74 -0
  44. package/src/memory/maintainer.ts +272 -0
  45. package/src/memory/maintenance.test.ts +176 -0
  46. package/src/memory/maintenance.ts +330 -0
  47. package/src/memory/migrate.ts +79 -0
  48. package/src/memory/operations.ts +138 -0
  49. package/src/memory/proposals.test.ts +168 -0
  50. package/src/memory/proposals.ts +576 -0
  51. package/src/memory/recall.test.ts +104 -0
  52. package/src/memory/recall.ts +271 -0
  53. package/src/memory/references.ts +77 -0
  54. package/src/memory/runContext.ts +93 -0
  55. package/src/memory/secrets.ts +6 -0
  56. package/src/memory/store.test.ts +66 -0
  57. package/src/memory/store.ts +385 -0
  58. package/src/memory/wiki.test.ts +47 -0
  59. package/src/memory/wiki.ts +422 -0
  60. package/src/memory/wikiIndex.test.ts +88 -0
  61. package/src/memory/wikiIndex.ts +116 -0
  62. package/src/plugins/api.ts +181 -0
  63. package/src/plugins/attachments.test.ts +84 -0
  64. package/src/plugins/attachments.ts +128 -0
  65. package/src/plugins/builtin.ts +82 -0
  66. package/src/plugins/catalogue.test.ts +254 -0
  67. package/src/plugins/catalogue.ts +250 -0
  68. package/src/plugins/cli.ts +184 -0
  69. package/src/plugins/discover.ts +151 -0
  70. package/src/plugins/firstParty.test.ts +143 -0
  71. package/src/plugins/firstParty.ts +129 -0
  72. package/src/plugins/hooks.test.ts +250 -0
  73. package/src/plugins/hooks.ts +239 -0
  74. package/src/plugins/identity.ts +37 -0
  75. package/src/plugins/install.test.ts +107 -0
  76. package/src/plugins/install.ts +106 -0
  77. package/src/plugins/inventory.ts +123 -0
  78. package/src/plugins/manifest.ts +44 -0
  79. package/src/plugins/plugins.test.ts +205 -0
  80. package/src/plugins/plugins.ts +184 -0
  81. package/src/plugins/proposal.ts +121 -0
  82. package/src/plugins/runtime.test.ts +40 -0
  83. package/src/plugins/runtime.ts +82 -0
  84. package/src/plugins/trust.ts +142 -0
  85. package/src/plugins/types.ts +130 -0
  86. package/src/prompt/compile.test.ts +261 -0
  87. package/src/prompt/compile.ts +732 -0
  88. package/src/server/methods.ts +308 -0
  89. package/src/server/remote.ts +77 -0
  90. package/src/server/rpc.ts +116 -0
  91. package/src/server/serve.ts +28 -0
  92. package/src/server/server.test.ts +174 -0
  93. package/src/sessions/approvals.ts +118 -0
  94. package/src/sessions/contracts.ts +299 -0
  95. package/src/sessions/conversation.test.ts +150 -0
  96. package/src/sessions/extensionSession.test.ts +74 -0
  97. package/src/sessions/extensionUi.test.ts +65 -0
  98. package/src/sessions/extensionUi.ts +102 -0
  99. package/src/sessions/history.ts +51 -0
  100. package/src/sessions/lineageJoin.ts +75 -0
  101. package/src/sessions/policy.ts +32 -0
  102. package/src/sessions/pricing.ts +60 -0
  103. package/src/sessions/reasoningSummaries.ts +59 -0
  104. package/src/sessions/registry.ts +294 -0
  105. package/src/sessions/runIndex.ts +99 -0
  106. package/src/sessions/runStore.test.ts +152 -0
  107. package/src/sessions/runStore.ts +332 -0
  108. package/src/sessions/runner.ts +556 -0
  109. package/src/sessions/runtimeResources.test.ts +61 -0
  110. package/src/sessions/sessions.test.ts +434 -0
  111. package/src/sessions/sessions.ts +295 -0
  112. package/src/skills/registry.test.ts +104 -0
  113. package/src/skills/registry.ts +394 -0
  114. package/src/terminal/cli.ts +149 -0
  115. package/src/terminal/client.ts +74 -0
  116. package/src/terminal/controller.ts +227 -0
  117. package/src/terminal/piExtension.ts +208 -0
  118. package/src/terminal/pluginControl.test.ts +146 -0
  119. package/src/terminal/pluginMenu.ts +112 -0
  120. package/src/terminal/socket.ts +182 -0
  121. package/src/terminal/standalone.test.ts +115 -0
  122. package/src/terminal/standalone.ts +126 -0
  123. package/src/terminal/terminal.test.ts +302 -0
  124. package/src/terminal/terminals.ts +429 -0
  125. package/src/tools/askUser.ts +45 -0
  126. package/src/tools/definitions.ts +187 -0
  127. package/src/tools/memoryTools.ts +22 -0
  128. package/src/tools/pluginPropose.ts +41 -0
  129. package/src/tools/types.ts +36 -0
  130. package/src/web/safeRequest.ts +171 -0
  131. package/src/web/text.ts +53 -0
  132. package/src/web/tools.ts +134 -0
  133. package/src/web/web.test.ts +141 -0
@@ -0,0 +1,82 @@
1
+ import { existsSync, mkdirSync, writeFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { afterEach, expect, test } from 'vitest'
4
+
5
+ import { dataRoot, writeFile } from '../../tests/helpers'
6
+ import { createEnv, type HarnessEnv } from '../core/env'
7
+ import { writeJson } from '../core/fs'
8
+ import { closeIndex } from '../core/index'
9
+ import { launchText } from '../prompt/compile'
10
+ import { list, memories, memoryEdit, propose, read, search, skill } from './api'
11
+ import * as blocks from './blocks'
12
+ import type { CallerContext } from './caller'
13
+ import { findMemory } from './facts'
14
+ import { readRunContext } from './runContext'
15
+ import { ensureProjectNamespace, wikiDir } from './store'
16
+ import { reindexWiki } from './wikiIndex'
17
+
18
+ const roots: string[] = []
19
+ afterEach(() => roots.splice(0).forEach(closeIndex))
20
+
21
+ function setup(): HarnessEnv {
22
+ const root = dataRoot()
23
+ roots.push(root)
24
+ writeJson(join(root, 'projects.json'), [
25
+ { id: 'p1', name: 'Xingular Agent', rootPath: '/src/xingular-agent', autoMaintenance: true, createdAt: 't', slug: 'xingular-agent' },
26
+ { id: 'p2', name: 'Site', rootPath: '/src/site', autoMaintenance: true, createdAt: 't', slug: 'site' }
27
+ ])
28
+ ensureProjectNamespace(root, 'xingular-agent', 'Xingular Agent')
29
+ ensureProjectNamespace(root, 'site', 'Site')
30
+ const wiki = wikiDir(root)
31
+ writeFileSync(join(wiki, 'projects/site/deploy.md'), '---\nid: deploy\ntitle: Deploying the site\ntype: decision\nsourceIds: [run:r0]\n---\n\nThe site deploys with wrangler.\n')
32
+ writeFileSync(join(wiki, 'facts/pnpm.md'), '---\nid: pnpm\ntitle: Use pnpm\nkind: preference\nnamespace: /\nconfidence: high\n---\n\nUse pnpm, not npm.\n')
33
+ writeFileSync(join(wiki, 'facts/site-only.md'), '---\nid: site-only\ntitle: Site uses Astro\nkind: fact\nnamespace: /projects/site\n---\n\nAstro.\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'] })
37
+ mkdirSync(join(root, 'runs/run-1'), { recursive: true })
38
+ writeJson(join(root, 'runs/run-1/run.json'), { id: 'run-1', projectId: 'p1', mode: 'code', harness: 'pi', sessionType: 'structured', sessionId: 's1', nativeSessionId: null, workingDirectory: null, worktreeId: null, goal: 'Please keep commits small from now on.', startedAt: 't', endedAt: null, status: 'running' })
39
+ reindexWiki(root)
40
+ return createEnv(root)
41
+ }
42
+
43
+ const context = (project: string): CallerContext => ({ runId: 'run-1', projectId: project, actor: 'Code' })
44
+
45
+ test('sessions edit their project brief through memory calls', () => {
46
+ const env = setup()
47
+ expect(memoryEdit(env, context('p1'), 'memory.append', { block: 'project', text: 'Tauri 2 desktop app.' })).toContain('project/xingular-agent')
48
+ expect(launchText(env, context('p1'))).toContain('Tauri 2 desktop app.')
49
+ expect(() => memoryEdit(env, context('p1'), 'memory.append', { block: 'user', text: 'x' })).toThrow()
50
+ // A session reads its project's brief by id, but never an agent's blocks.
51
+ expect(read(env, context('p1'), { id: 'blocks/projects/xingular-agent/brief' })).toContain('Tauri 2 desktop app.')
52
+ blocks.ensureAgent(wikiDir(env.root), 'a1')
53
+ expect(() => read(env, context('p1'), { id: 'blocks/agents/a1/persona' })).toThrow()
54
+ expect(() => memoryEdit(env, { actor: 'Chat' }, 'memory.append', { block: 'user', text: 'x' })).toThrow()
55
+ expect(readRunContext(env.root, 'run-1').some((entry) => entry.id === 'blocks/projects/xingular-agent/brief' && entry.via === 'edit')).toBe(true)
56
+ })
57
+
58
+ test('agents read across project namespaces', () => {
59
+ const env = setup()
60
+ const hits = search(env, context('p1'), { query: 'wrangler' })
61
+ expect(hits).toContain('Deploying the site')
62
+ expect(hits).toContain('/projects/site/deploy')
63
+ const page = read(env, context('p1'), { id: '/projects/site/deploy' })
64
+ expect(page).toContain('The site deploys with wrangler.')
65
+ expect(page).toContain('Sources: run:r0')
66
+ expect(skill(env, context('p2'), { name: 'review' })).toContain('Read the diff first.')
67
+ expect(memories(env, context('p1'), { namespace: '/projects/site' })).toContain('Astro')
68
+ expect(list(env, {})).toContain('/projects/site')
69
+ expect(readRunContext(env.root, 'run-1').some((entry) => entry.id === 'deploy' && entry.via === 'read')).toBe(true)
70
+ })
71
+
72
+ test('proposals apply in the project namespace or wait in the inbox', () => {
73
+ const env = setup()
74
+ const applied = propose(env, context('p1'), { kind: 'memory', title: 'Small commits', type: 'preference', body: 'Keep commits small.', confidence: 'high', sourceRefs: [{ kind: 'run', ref: 'current', excerpt: 'please keep commits small' }] })
75
+ expect(applied).toContain('saved')
76
+ expect(findMemory(wikiDir(env.root), 'small-commits').front.namespace).toBe('/projects/xingular-agent')
77
+ expect(propose(env, context('p1'), { kind: 'page', title: 'Rumor', body: 'Maybe.' })).toContain('Inbox')
78
+ // The caller's own run without a quote from it is not evidence.
79
+ expect(propose(env, context('p1'), { kind: 'memory', title: 'Bare', body: 'Unquoted.', confidence: 'high', sourceRefs: [{ kind: 'run', ref: 'current' }] })).toContain('Inbox')
80
+ expect(propose(env, context('p1'), { kind: 'skill', title: 'Deploy', body: 'Steps.' })).toContain('waiting in the Wiki Inbox')
81
+ expect(existsSync(join(env.root, 'registry/skills/deploy'))).toBe(false)
82
+ })
@@ -0,0 +1,287 @@
1
+ // The Wiki as a model sees it: search, read, list, skills, facts, proposals, and memory-block
2
+ // edits. Results are plain text with `[id@revision]` citations, and every entry a run reads is
3
+ // recorded in `runs/<id>/wiki-context.json`.
4
+ import { dirname } from 'node:path'
5
+
6
+ import type { HarnessEnv } from '../core/env'
7
+ import { HarnessError, invalid, notFound } from '../core/errors'
8
+ import { charCount } from '../core/text'
9
+ import { applicableMemories, clip } from '../prompt/compile'
10
+ import { findRun, readEvents } from '../sessions/runStore'
11
+ import type { RunEvent } from '../sessions/contracts'
12
+ import { takeChars } from '../core/text'
13
+ import * as registry from '../skills/registry'
14
+ import * as blocks from './blocks'
15
+ import { actorOf, blockCaller, projectOf, type CallerContext } from './caller'
16
+ import * as facts from './facts'
17
+ import { apply, captureHashes, parkRegistryProposal, parseProposalSet, type Trigger } from './proposals'
18
+ import * as references from './references'
19
+ import { cite, record, recordSources } from './runContext'
20
+ import * as store from './store'
21
+ import { backlinks, entryId, namespaces, registryEntryKind, resolveEntry, searchEntries } from './wiki'
22
+
23
+ const READ_LIMIT = 20_000
24
+
25
+ export type Params = Record<string, unknown> | null | undefined
26
+
27
+ function textParam(params: Params, key: string): string {
28
+ const value = params?.[key]
29
+ return typeof value === 'string' ? value.trim() : ''
30
+ }
31
+
32
+ const rawParam = (params: Params, key: string): string => (typeof params?.[key] === 'string' ? (params[key] as string) : '')
33
+ const countParam = (params: Params, key: string): number | null => {
34
+ const value = params?.[key]
35
+ return typeof value === 'number' && Number.isInteger(value) && value >= 0 ? value : null
36
+ }
37
+
38
+ // ---- Run evidence -------------------------------------------------------------------------------
39
+
40
+ function transcriptText(goal: string, events: readonly RunEvent[]): string {
41
+ let text = `User: ${goal}\n`
42
+ for (const event of events) {
43
+ const payload = event.payload as { role?: unknown; text?: unknown; name?: unknown; summary?: unknown }
44
+ if (event.type === 'message.completed' && payload.role === 'assistant') text += `Assistant: ${typeof payload.text === 'string' ? payload.text : ''}\n`
45
+ else if (event.type === 'tool.started') text += `Tool ${typeof payload.name === 'string' ? payload.name : 'tool'}: ${typeof payload.summary === 'string' ? payload.summary : ''}\n`
46
+ }
47
+ return text
48
+ }
49
+
50
+ /** What a consolidation job reads of a run, capped. */
51
+ export function transcript(goal: string, events: readonly RunEvent[]): string {
52
+ return takeChars(transcriptText(goal, events), 24_000)
53
+ }
54
+
55
+ /** Everything recorded of a run, uncapped: what an excerpt is checked against. */
56
+ export function evidenceText(root: string, runId: string): string {
57
+ let events: RunEvent[] = []
58
+ try {
59
+ events = readEvents(root, runId)
60
+ } catch {
61
+ events = []
62
+ }
63
+ return transcriptText(findRun(root, runId)?.goal ?? '', events)
64
+ }
65
+
66
+ // ---- Memory blocks -----------------------------------------------------------------------
67
+
68
+ /** `memory.append`, `memory.replace`, and `memory.rethink`. */
69
+ export function memoryEdit(env: HarnessEnv, context: CallerContext, call: string, params: Params): string {
70
+ const caller = blockCaller(env, context)
71
+ const label = textParam(params, 'block')
72
+ if (!label) throw invalid('Name the block to edit, such as user, persona, or notes.')
73
+ let edit: blocks.Edit
74
+ if (call === 'memory.append') edit = { kind: 'append', text: rawParam(params, 'text') }
75
+ else if (call === 'memory.replace') edit = { kind: 'replace', old: rawParam(params, 'old'), new: rawParam(params, 'new') }
76
+ else if (call === 'memory.rethink') edit = { kind: 'rethink', text: rawParam(params, 'body') }
77
+ else throw new HarnessError('unknown_method', `Unknown memory call ${call}.`)
78
+ const block = blocks.edit(env.root, caller, label, edit)
79
+ record(env.root, context, 'edit', [[block.id, 'block']])
80
+ return `Updated the ${block.front.label} block (${charCount(block.body)} of ${block.front.limit} characters). It is in your context from the next turn.`
81
+ }
82
+
83
+ // ---- Retrieval ------------------------------------------------------------------------------
84
+
85
+ function ownBlocksPrefix(env: HarnessEnv, context: CallerContext): string | null {
86
+ try {
87
+ const caller = blockCaller(env, context)
88
+ return caller.kind === 'agent' ? `blocks/agents/${caller.id}/` : null
89
+ } catch {
90
+ return null
91
+ }
92
+ }
93
+
94
+ /** A fact is stored and indexed under the kind `memory`. A model says `fact`; `memory` is still understood. */
95
+ const storedKind = (kind: string): string => (kind === 'fact' ? 'memory' : kind)
96
+ const shownKind = (kind: string): string => (kind === 'memory' ? 'fact' : kind)
97
+
98
+ export function search(env: HarnessEnv, context: CallerContext, params: Params): string {
99
+ const { root } = env
100
+ const query = textParam(params, 'query')
101
+ if (!query) return 'Give a query.'
102
+ const kinds = (Array.isArray(params?.kinds) ? params.kinds.filter((kind): kind is string => typeof kind === 'string') : []).map(storedKind)
103
+ const limit = countParam(params, 'limit') ?? 8
104
+ // Another agent's blocks never appear; a block of the caller's own does.
105
+ const own = ownBlocksPrefix(env, context)
106
+ const hits = searchEntries(root, query, { kinds, namespace: textParam(params, 'namespace') || null, projectId: context.projectId, limit: limit + 6 })
107
+ .filter((hit) => !(hit.kind === 'block' && hit.type === 'agent') || (own !== null && hit.id.startsWith(own)))
108
+ .slice(0, limit)
109
+ if (!hits.length) return 'No matching Wiki entries.'
110
+ const wiki = store.wikiDir(root)
111
+ record(root, context, 'search', hits.map((hit) => [hit.id, hit.kind]))
112
+ return `${hits.map((hit, index) => `${index + 1}. [${shownKind(hit.kind)}] ${hit.title} — ${hit.path} ${cite(wiki, hit.id)}\n ${hit.snippet}`).join('\n')}\n\nRead an entry with wiki_read and its id or path.`
113
+ }
114
+
115
+ export function read(env: HarnessEnv, context: CallerContext, params: Params): string {
116
+ const { root } = env
117
+ const key = textParam(params, 'id') || textParam(params, 'path') || textParam(params, 'idOrPath')
118
+ if (!key) throw invalid('Give an id or Wiki path.')
119
+ if (typeof params?.revision === 'string') {
120
+ const text = references.readPinned(root, context.runId, key, params.revision, countParam(params, 'offset') ?? 0)
121
+ recordSources(root, context, 'read', [{ id: key, kind: key.startsWith('skills/') ? 'skill' : 'page', revision: params.revision }])
122
+ return text
123
+ }
124
+ const wiki = store.wikiDir(root)
125
+ const entry = resolveEntry(root, key)
126
+ // An agent's own blocks are not readable by other agents or by sessions.
127
+ if (entry.kind === 'block' && entry.block.scope === 'agent') {
128
+ const own = ownBlocksPrefix(env, context)
129
+ if (own === null || `blocks/agents/${entry.block.owner}/` !== own) throw notFound('Wiki entry')
130
+ }
131
+ const linking = backlinks(root, entryId(entry))
132
+ const linked = linking.length ? linking.map((link) => `${link.fromId} (${link.type})`).join(', ') : 'none'
133
+ let kind: string
134
+ let text: string
135
+ if (entry.kind === 'page') {
136
+ const { frontmatter: front, location, body } = entry.page
137
+ kind = 'page'
138
+ text = `# ${front.title} ${cite(wiki, front.id)}\nPath: ${location.path} · namespace ${location.namespace} · type ${front.type} · updated ${front.updatedAt}\nSources: ${front.sourceIds.length ? front.sourceIds.join(', ') : 'none (unresolved)'}\nLinks: ${front.links.length ? front.links.map((link) => `${link.type} ${link.toId}`).join(', ') : 'none'}\nBacklinks: ${linked}\n\n${clip(body, READ_LIMIT)}`
139
+ } else if (entry.kind === 'memory') {
140
+ const { front, body } = entry.memory
141
+ kind = 'memory'
142
+ text = `# ${front.title} ${cite(wiki, front.id)}\nFact · ${front.kind} · namespace ${front.namespace} · ${front.confidence} confidence · ${front.status}${front.supersedes ? ` · supersedes ${front.supersedes}` : ''}\nSources: ${front.sourceIds.length ? front.sourceIds.join(', ') : 'none'}\nBacklinks: ${linked}\n\n${body}`
143
+ } else if (entry.kind === 'block') {
144
+ const { block } = entry
145
+ kind = 'block'
146
+ text = `# ${block.front.label} ${cite(wiki, block.id)}\nMemory block · ${block.scope} · ${charCount(block.body)} of ${block.front.limit} characters\n${block.front.description}\n\n${block.body}`
147
+ } else {
148
+ const { item } = entry
149
+ kind = registryEntryKind(item.kind) ?? 'skill'
150
+ text = `# ${item.name} ${cite(wiki, item.id)}\nRegistry ${item.id} · file ${item.path}\nBacklinks: ${linked}\n\n${clip(registry.itemBody(item), READ_LIMIT)}`
151
+ }
152
+ record(root, context, 'read', [[entryId(entry), kind]])
153
+ return text
154
+ }
155
+
156
+ export function list(env: HarnessEnv, params: Params): string {
157
+ const { root } = env
158
+ const namespace = textParam(params, 'namespace')
159
+ const kind = storedKind(textParam(params, 'kind'))
160
+ const wiki = store.wikiDir(root)
161
+ if (kind === 'memory') {
162
+ const memories = facts.loadMemories(wiki)
163
+ if (!memories.length) return 'No facts yet.'
164
+ return memories
165
+ .filter((memory) => !namespace || memory.front.namespace === namespace)
166
+ .map((memory) => `- ${memory.front.title} (${memory.front.id}) [${memory.front.kind}] ${memory.front.namespace} · ${memory.front.status}`)
167
+ .join('\n')
168
+ }
169
+ if (kind === 'skill' || kind === 'rule' || kind === 'prompt') {
170
+ const plural = `${kind}s`
171
+ const items = registry.list(root).filter((item) => item.kind === plural).map((item) => `- ${item.name} (${item.id}) — ${item.description}`)
172
+ return items.length ? items.join('\n') : `No ${plural} yet.`
173
+ }
174
+ let text = ''
175
+ if (!namespace) text += `Namespaces: / (Home), /library${namespaces(root).map((entry) => `, ${entry.namespace}`).join('')}\n\n`
176
+ const prefix = `${namespace.replace(/\/+$/, '')}/`
177
+ const pages = store
178
+ .loadPages(wiki)
179
+ .filter((page) => !namespace || page.location.namespace === namespace || page.location.path.startsWith(prefix))
180
+ .slice(0, 200)
181
+ .map((page) => `- ${page.location.path} — ${page.frontmatter.title} (${page.frontmatter.type}) [${page.frontmatter.id}]`)
182
+ return text + (pages.length ? pages.join('\n') : 'No pages here.')
183
+ }
184
+
185
+ /** One skill's instructions by name or id, or the skills index when no name is given. */
186
+ export function skill(env: HarnessEnv, context: CallerContext, params: Params): string {
187
+ const { root } = env
188
+ const name = textParam(params, 'name').toLowerCase()
189
+ const items = registry.list(root).filter((item) => item.kind === 'skills')
190
+ if (!name) {
191
+ if (!items.length) return 'No skills yet.'
192
+ const active = registry.enabledFor(root, context.projectId ?? null, context.agentId ?? null).map((item) => item.id)
193
+ return items.map((item) => `- ${item.name} (${item.id})${active.includes(item.id) ? ' · active' : ''} — ${item.description}`).join('\n')
194
+ }
195
+ const found = items.find((item) => item.id.toLowerCase() === name || item.id.replace(/^skills\//, '').toLowerCase() === name || item.name.toLowerCase() === name)
196
+ if (!found) return `There is no skill called "${name}". Call skill_load without a name to list them.`
197
+ record(root, context, 'skill', [[found.id, 'skill']])
198
+ const folder = found.path.endsWith('SKILL.md') ? `\nSupporting files are in ${dirname(found.path)}.` : ''
199
+ return `# ${found.name} ${cite(store.wikiDir(root), found.id)}\n${found.description}${folder}\n\n${clip(registry.itemBody(found), READ_LIMIT)}`
200
+ }
201
+
202
+ /** ", volatile, observed 12 days ago" for a fact that describes current state. */
203
+ export function volatileNote(front: facts.MemoryFront): string {
204
+ if (!front.volatile) return ''
205
+ const days = facts.ageDays(front)
206
+ return days === null ? ', volatile' : `, volatile, observed ${days} day${days === 1 ? '' : 's'} ago`
207
+ }
208
+
209
+ export function memories(env: HarnessEnv, context: CallerContext, params: Params): string {
210
+ const { root } = env
211
+ const wiki = store.wikiDir(root)
212
+ const query = textParam(params, 'query')
213
+ const namespace = textParam(params, 'namespace')
214
+ let selected: facts.ParsedMemory[]
215
+ if (!query) {
216
+ selected = applicableMemories(wiki, namespace || (projectOf(root, context)?.wikiNamespace ?? null))
217
+ } else {
218
+ selected = searchEntries(root, query, { kinds: ['memory'], namespace: namespace || null, projectId: context.projectId, limit: 20 }).flatMap((hit) => {
219
+ try {
220
+ return [facts.findMemory(wiki, hit.id)]
221
+ } catch {
222
+ return []
223
+ }
224
+ })
225
+ }
226
+ if (!selected.length) return 'No facts match.'
227
+ const shown = selected.slice(0, 30)
228
+ record(root, context, 'memory', shown.map((memory) => [memory.front.id, 'memory']))
229
+ return shown
230
+ .map((memory) => `- [${memory.front.kind}] ${memory.front.title} ${cite(wiki, memory.front.id)} (${memory.front.namespace}, ${memory.front.confidence} confidence${memory.front.status === 'active' ? '' : `, ${memory.front.status}`}${volatileNote(memory.front)})\n ${memory.body.trim().replaceAll('\n', '\n ')}`)
231
+ .join('\n')
232
+ }
233
+
234
+ // ---- Proposals -------------------------------------------------------------------------------
235
+
236
+ function sourceRefs(params: Params, context: CallerContext): { kind: string; ref: string; excerpt?: string }[] {
237
+ if (!Array.isArray(params?.sourceRefs)) return []
238
+ return params.sourceRefs.flatMap((reference: { kind?: unknown; ref?: unknown; excerpt?: unknown } | null) => {
239
+ if (!reference || typeof reference.kind !== 'string') return []
240
+ const given = typeof reference.ref === 'string' ? reference.ref : ''
241
+ // "current" (or nothing) names the caller's own run.
242
+ const own = reference.kind === 'run' && (!given || given === 'current')
243
+ if (own && !context.runId) return []
244
+ return [{ kind: reference.kind, ref: own ? context.runId! : given, ...(typeof reference.excerpt === 'string' ? { excerpt: reference.excerpt } : {}) }]
245
+ })
246
+ }
247
+
248
+ /** A `links` argument: usable only when every entry names a target and a type. */
249
+ function links(params: Params): { toId: string; type: string }[] {
250
+ const given = params?.links
251
+ if (!Array.isArray(given) || !given.every((link) => link && typeof link.toId === 'string' && typeof link.type === 'string')) return []
252
+ return given.map((link: { toId: string; type: string }) => ({ toId: link.toId, type: link.type }))
253
+ }
254
+
255
+ const optional = (params: Params, key: string): string | null => textParam(params, key) || null
256
+
257
+ /** Validates and applies (or parks) one proposal. */
258
+ export function propose(env: HarnessEnv, context: CallerContext, params: Params): string {
259
+ const { root } = env
260
+ const wiki = store.wikiDir(root)
261
+ const kind = storedKind(textParam(params, 'kind'))
262
+ const trigger: Trigger = { kind: 'agent', runId: context.runId ?? null, actor: actorOf(context) }
263
+ const defaultNamespace = projectOf(root, context)?.wikiNamespace ?? store.LIBRARY_NAMESPACE
264
+ const title = textParam(params, 'title')
265
+ const body = rawParam(params, 'body')
266
+ let proposals: unknown
267
+ if (kind === 'page') {
268
+ proposals = { pages: [{ id: optional(params, 'id'), title, type: optional(params, 'type') ?? 'concept', body, namespace: optional(params, 'namespace'), confidence: optional(params, 'confidence'), sourceRefs: sourceRefs(params, context), links: links(params) }] }
269
+ } else if (kind === 'memory') {
270
+ proposals = {
271
+ memories: [{ id: optional(params, 'id'), title, kind: optional(params, 'type') ?? 'fact', body, namespace: optional(params, 'namespace'), confidence: optional(params, 'confidence'), supersedes: optional(params, 'supersedes'), volatile: typeof params?.volatile === 'boolean' ? params.volatile : null, sourceRefs: sourceRefs(params, context), links: links(params) }]
272
+ }
273
+ } else if (kind === 'skill' || kind === 'rule' || kind === 'prompt') {
274
+ const id = parkRegistryProposal(wiki, trigger, { kind, name: optional(params, 'id') ?? title, description: textParam(params, 'description'), body, reason: textParam(params, 'reason') })
275
+ return `The ${kind} change (${id}) is waiting in the Wiki Inbox. Skills, prompts, and rules change only after the user accepts them.`
276
+ } else {
277
+ throw invalid('kind must be page, fact, skill, rule, or prompt.')
278
+ }
279
+ // The caller's own run is evidence only for what it can quote from it.
280
+ const outcome = apply(wiki, defaultNamespace, trigger, parseProposalSet(proposals), captureHashes(wiki), { runText: context.runId ? evidenceText(root, context.runId) : null })
281
+ let text = outcome.notes.join('\n')
282
+ if (outcome.operation) {
283
+ text += `\nApplied as operation ${outcome.operation.id.slice(0, 8)}; the user can undo it from the Wiki Inbox.`
284
+ record(root, context, 'edit', outcome.operation.pages.map((page) => [page.pageId, kind]))
285
+ }
286
+ return text
287
+ }
@@ -0,0 +1,78 @@
1
+ import { expect, test } from 'vitest'
2
+
3
+ import { rootWithProject } from '../../tests/helpers'
4
+ import { applyEdit, attached, DEFAULT_LIMIT, edit, PROJECT_LIMIT, render, saveAsUser, undoLast, type BlockInput, type Caller } from './blocks'
5
+ import { readManifest, wikiDir } from './store'
6
+
7
+ const agent = (id: string): Caller => ({ kind: 'agent', id, name: 'Nora' })
8
+ const append = (text: string) => ({ kind: 'append' as const, text })
9
+
10
+ test('edits apply with limits and exact replacement', () => {
11
+ expect(applyEdit('', append('Likes pnpm.'))).toBe('Likes pnpm.')
12
+ expect(applyEdit('A', append(' B '))).toBe('A\nB')
13
+ expect(applyEdit('Use npm.', { kind: 'replace', old: 'npm', new: 'pnpm' })).toBe('Use pnpm.')
14
+ expect(() => applyEdit('a a', { kind: 'replace', old: 'a', new: 'b' })).toThrow('2 times')
15
+ expect(() => applyEdit('a', { kind: 'replace', old: 'z', new: 'b' })).toThrow()
16
+ expect(() => applyEdit('a', append(' '))).toThrow()
17
+ expect(applyEdit('old', { kind: 'rethink', text: ' new ' })).toBe('new')
18
+ })
19
+
20
+ test('an agent gets its blocks, the user block and work project briefs', () => {
21
+ const root = rootWithProject()
22
+ const blocks = attached(root, agent('a1'), ['app', 'missing'])
23
+ expect(blocks.map((block) => block.front.label)).toEqual(['persona', 'notes', 'user', 'project/app'])
24
+ expect(blocks[0].id).toBe('blocks/agents/a1/persona')
25
+ expect(blocks[3].front.limit).toBe(PROJECT_LIMIT)
26
+ expect(attached(root, { kind: 'session', projectSlug: 'app' }, []).map((block) => block.front.label)).toEqual(['user', 'project/app'])
27
+ })
28
+
29
+ test('edits are revisioned, limited and undoable', () => {
30
+ const root = rootWithProject()
31
+ const wiki = wikiDir(root)
32
+ const nora = agent('a1')
33
+ expect(edit(root, nora, 'user', append('Prefers short answers.')).body).toBe('Prefers short answers.')
34
+ const revisions = readManifest(wiki, 'blocks/shared/user')
35
+ expect(revisions).toHaveLength(2)
36
+ expect(revisions[1].actor).toBe('Nora')
37
+ edit(root, nora, 'user', append('Works in Bangkok time.'))
38
+ expect(readManifest(wiki, 'blocks/shared/user')).toHaveLength(3)
39
+ expect(undoLast(wiki, 'blocks/shared/user').body).toBe('Prefers short answers.')
40
+ expect(undoLast(wiki, 'blocks/shared/user').body).toBe('')
41
+ expect(() => undoLast(wiki, 'blocks/shared/user')).toThrow()
42
+
43
+ expect(() => edit(root, nora, 'notes', { kind: 'rethink', text: 'x'.repeat(DEFAULT_LIMIT + 1) })).toThrow('memory_rethink')
44
+ expect(() => edit(root, nora, 'notes', append('key sk-ant-abc'))).toThrow('credential')
45
+ expect(() => edit(root, nora, 'nope', append('x'))).toThrow()
46
+ })
47
+
48
+ test('sessions edit only their project brief', () => {
49
+ const root = rootWithProject()
50
+ const session: Caller = { kind: 'session', projectSlug: 'app' }
51
+ expect(edit(root, session, 'project', append('Tauri 2 app.')).id).toBe('blocks/projects/app/brief')
52
+ expect(() => edit(root, session, 'user', append('x'))).toThrow('cannot edit')
53
+ expect(() => edit(root, session, 'persona', append('x'))).toThrow()
54
+ attached(root, agent('a1'), [])
55
+ expect(() => edit(root, session, 'blocks/agents/a1/persona', append('x'))).toThrow()
56
+ expect(() => edit(root, { kind: 'session', projectSlug: null }, 'project/app', append('x'))).toThrow()
57
+ // An agent may edit any project's brief, and its own blocks, but not another agent's.
58
+ expect(edit(root, agent('a1'), 'project/app', append('Uses pnpm.')).body).toContain('Uses pnpm.')
59
+ expect(() => edit(root, agent('a2'), 'blocks/agents/a1/persona', append('x'))).toThrow()
60
+ })
61
+
62
+ test('the user creates and restricts shared blocks', () => {
63
+ const root = rootWithProject()
64
+ const wiki = wikiDir(root)
65
+ const input = (id: string | null, label: string | null, agents: string[], readOnly: boolean): BlockInput => ({ id, label, body: 'We ship on Fridays.', description: 'Team habits.', limit: 500, readOnly, agents })
66
+ const created = saveAsUser(wiki, input(null, 'Team habits', ['a1'], false))
67
+ expect(created.id).toBe('blocks/shared/team-habits')
68
+ expect(() => saveAsUser(wiki, input(null, 'Team habits', [], false))).toThrow()
69
+ expect(() => saveAsUser(wiki, input(null, 'user', [], false))).toThrow()
70
+ expect(attached(root, agent('a1'), []).some((block) => block.id === created.id)).toBe(true)
71
+ expect(attached(root, agent('a2'), []).some((block) => block.id === created.id)).toBe(false)
72
+ expect(() => edit(root, agent('a2'), 'team-habits', append('x'))).toThrow()
73
+ saveAsUser(wiki, input(created.id, null, [], true))
74
+ expect(() => edit(root, agent('a1'), 'team-habits', append('x'))).toThrow('read-only')
75
+ const rendered = render(attached(root, agent('a1'), []), agent('a1'), false)
76
+ expect(rendered).toContain('<block label="team-habits" chars="19/500" read-only="true">')
77
+ expect(rendered).toContain('<block label="persona" chars="0/2000">')
78
+ })