@xingular/harness 0.0.0-stage → 0.1.1

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 (135) hide show
  1. package/README.md +269 -2
  2. package/dist/cli.js +11510 -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.test.ts +95 -0
  8. package/src/accounts/env.ts +338 -0
  9. package/src/accounts/secrets.ts +50 -0
  10. package/src/accounts/store.ts +338 -0
  11. package/src/capabilities/api.ts +75 -0
  12. package/src/capabilities/runSnapshot.ts +59 -0
  13. package/src/capabilities/types.ts +34 -0
  14. package/src/cli.ts +110 -0
  15. package/src/core/bus.ts +34 -0
  16. package/src/core/changes.ts +33 -0
  17. package/src/core/env.ts +27 -0
  18. package/src/core/errors.ts +32 -0
  19. package/src/core/frontmatter.ts +47 -0
  20. package/src/core/fs.test.ts +38 -0
  21. package/src/core/fs.ts +92 -0
  22. package/src/core/index.ts +193 -0
  23. package/src/core/path.ts +45 -0
  24. package/src/core/projects.ts +90 -0
  25. package/src/core/text.ts +39 -0
  26. package/src/harness.ts +299 -0
  27. package/src/index.ts +70 -0
  28. package/src/mcp/connections.ts +614 -0
  29. package/src/mcp/manager.ts +130 -0
  30. package/src/mcp/oauth.ts +195 -0
  31. package/src/mcp/scopeMigration.ts +204 -0
  32. package/src/mcp/service.test.ts +376 -0
  33. package/src/mcp/service.ts +549 -0
  34. package/src/mcp/types.ts +108 -0
  35. package/src/memory/api.test.ts +82 -0
  36. package/src/memory/api.ts +289 -0
  37. package/src/memory/blocks.test.ts +78 -0
  38. package/src/memory/blocks.ts +406 -0
  39. package/src/memory/caller.ts +57 -0
  40. package/src/memory/dispatch.ts +59 -0
  41. package/src/memory/facts.test.ts +41 -0
  42. package/src/memory/facts.ts +248 -0
  43. package/src/memory/graph.ts +103 -0
  44. package/src/memory/health.test.ts +130 -0
  45. package/src/memory/health.ts +74 -0
  46. package/src/memory/maintainer.ts +272 -0
  47. package/src/memory/maintenance.test.ts +176 -0
  48. package/src/memory/maintenance.ts +330 -0
  49. package/src/memory/migrate.ts +79 -0
  50. package/src/memory/operations.ts +138 -0
  51. package/src/memory/proposals.test.ts +168 -0
  52. package/src/memory/proposals.ts +576 -0
  53. package/src/memory/recall.test.ts +104 -0
  54. package/src/memory/recall.ts +271 -0
  55. package/src/memory/references.ts +77 -0
  56. package/src/memory/runContext.ts +93 -0
  57. package/src/memory/secrets.ts +6 -0
  58. package/src/memory/store.test.ts +66 -0
  59. package/src/memory/store.ts +385 -0
  60. package/src/memory/wiki.test.ts +47 -0
  61. package/src/memory/wiki.ts +422 -0
  62. package/src/memory/wikiIndex.test.ts +88 -0
  63. package/src/memory/wikiIndex.ts +116 -0
  64. package/src/plugins/api.ts +181 -0
  65. package/src/plugins/attachments.test.ts +84 -0
  66. package/src/plugins/attachments.ts +141 -0
  67. package/src/plugins/builtin.ts +82 -0
  68. package/src/plugins/catalogue.test.ts +254 -0
  69. package/src/plugins/catalogue.ts +251 -0
  70. package/src/plugins/cli.ts +184 -0
  71. package/src/plugins/discover.ts +151 -0
  72. package/src/plugins/firstParty.test.ts +143 -0
  73. package/src/plugins/firstParty.ts +129 -0
  74. package/src/plugins/hooks.test.ts +250 -0
  75. package/src/plugins/hooks.ts +239 -0
  76. package/src/plugins/identity.ts +37 -0
  77. package/src/plugins/install.test.ts +107 -0
  78. package/src/plugins/install.ts +106 -0
  79. package/src/plugins/inventory.ts +123 -0
  80. package/src/plugins/manifest.ts +76 -0
  81. package/src/plugins/plugins.test.ts +205 -0
  82. package/src/plugins/plugins.ts +189 -0
  83. package/src/plugins/proposal.ts +121 -0
  84. package/src/plugins/runtime.test.ts +40 -0
  85. package/src/plugins/runtime.ts +82 -0
  86. package/src/plugins/trust.ts +142 -0
  87. package/src/plugins/types.ts +149 -0
  88. package/src/prompt/compile.test.ts +261 -0
  89. package/src/prompt/compile.ts +732 -0
  90. package/src/server/methods.ts +314 -0
  91. package/src/server/remote.ts +77 -0
  92. package/src/server/rpc.ts +116 -0
  93. package/src/server/serve.ts +28 -0
  94. package/src/server/server.test.ts +174 -0
  95. package/src/sessions/approvals.ts +118 -0
  96. package/src/sessions/contracts.ts +299 -0
  97. package/src/sessions/conversation.test.ts +150 -0
  98. package/src/sessions/extensionSession.test.ts +74 -0
  99. package/src/sessions/extensionUi.test.ts +65 -0
  100. package/src/sessions/extensionUi.ts +102 -0
  101. package/src/sessions/history.ts +51 -0
  102. package/src/sessions/lineageJoin.ts +75 -0
  103. package/src/sessions/policy.ts +32 -0
  104. package/src/sessions/pricing.ts +60 -0
  105. package/src/sessions/reasoningSummaries.ts +59 -0
  106. package/src/sessions/registry.ts +294 -0
  107. package/src/sessions/runIndex.ts +99 -0
  108. package/src/sessions/runStore.test.ts +152 -0
  109. package/src/sessions/runStore.ts +332 -0
  110. package/src/sessions/runner.ts +556 -0
  111. package/src/sessions/runtimeResources.test.ts +61 -0
  112. package/src/sessions/sessions.test.ts +434 -0
  113. package/src/sessions/sessions.ts +295 -0
  114. package/src/skills/registry.test.ts +104 -0
  115. package/src/skills/registry.ts +407 -0
  116. package/src/terminal/cli.ts +149 -0
  117. package/src/terminal/client.ts +74 -0
  118. package/src/terminal/controller.ts +227 -0
  119. package/src/terminal/piExtension.ts +208 -0
  120. package/src/terminal/pluginControl.test.ts +146 -0
  121. package/src/terminal/pluginMenu.ts +112 -0
  122. package/src/terminal/socket.ts +182 -0
  123. package/src/terminal/standalone.test.ts +115 -0
  124. package/src/terminal/standalone.ts +126 -0
  125. package/src/terminal/terminal.test.ts +302 -0
  126. package/src/terminal/terminals.ts +429 -0
  127. package/src/tools/askUser.ts +45 -0
  128. package/src/tools/definitions.ts +187 -0
  129. package/src/tools/memoryTools.ts +22 -0
  130. package/src/tools/pluginPropose.ts +41 -0
  131. package/src/tools/types.ts +36 -0
  132. package/src/web/safeRequest.ts +171 -0
  133. package/src/web/text.ts +53 -0
  134. package/src/web/tools.ts +134 -0
  135. package/src/web/web.test.ts +141 -0
@@ -0,0 +1,272 @@
1
+ // Sleep-time consolidation: a Pi session that reads new evidence (an imported source or a
2
+ // settled run) or a set of full memory blocks, and proposes changes. It has no file or shell
3
+ // tools, only proposal tools, so every change goes back to the proposal check, which looks each
4
+ // quoted excerpt up in the evidence before anything is written.
5
+ import type { ModelRuntime } from '@earendil-works/pi-coding-agent'
6
+
7
+ import type { AccountContext } from '../accounts/env'
8
+ import { HarnessError } from '../core/errors'
9
+ import { MEMORY_KINDS } from './facts'
10
+ import { LINK_TYPES, PAGE_TYPES } from './store'
11
+
12
+ export interface ExistingPage {
13
+ id: string
14
+ title: string
15
+ type: string
16
+ path?: string
17
+ summary: string
18
+ /** The page's current text, for the few pages closest to the evidence. */
19
+ body?: string
20
+ }
21
+
22
+ export interface ExistingMemory {
23
+ id: string
24
+ title: string
25
+ kind: string
26
+ namespace: string
27
+ body: string
28
+ }
29
+
30
+ /** A memory block attached to the run, in full. */
31
+ export interface ExistingBlock {
32
+ id: string
33
+ label: string
34
+ description: string
35
+ limit: number
36
+ body: string
37
+ }
38
+
39
+ export type MaintenanceTrigger = { kind: 'ingest'; sourceId: string; sourceName: string; text: string } | { kind: 'run'; runId: string; transcript: string } | { kind: 'consolidate' }
40
+
41
+ export interface MaintenanceRequest {
42
+ jobId: string
43
+ /** Where new pages go by default: /library or /projects/<slug>. */
44
+ namespace: string
45
+ trigger: MaintenanceTrigger
46
+ pages: ExistingPage[]
47
+ memories?: ExistingMemory[]
48
+ blocks?: ExistingBlock[]
49
+ namespaces?: { namespace: string; label: string }[]
50
+ index: string
51
+ }
52
+
53
+ /** What the session proposed, as its tools received it. Nothing here has been checked yet. */
54
+ export interface MaintenanceResult {
55
+ pages: unknown[]
56
+ memories: unknown[]
57
+ blocks: unknown[]
58
+ flags: unknown[]
59
+ }
60
+
61
+ const EVIDENCE_RULE = `Every change must carry sourceRefs with an excerpt: a short passage (at least a few words) copied
62
+ exactly from the evidence. The host looks the excerpt up in the evidence and discards any change whose
63
+ excerpt it cannot find, so never paraphrase an excerpt and never cite something the evidence does not say.`
64
+
65
+ const runInstructions = (product: string): string => `You consolidate memory for ${product} after a run has finished ("sleep-time" work).
66
+ You are given the run's transcript, the memory blocks that were in its context, the facts that apply,
67
+ and related Wiki pages. Decide what is worth keeping for future work, then:
68
+ - rethink_block: rewrite a memory block when the run taught something that belongs in it (see each
69
+ block's description). Give the complete new content, merged with what is already there. Remove
70
+ what the run showed to be wrong or finished. Stay within the block's limit.
71
+ - propose_memory: record a short standing fact, preference, decision, or piece of feedback. Update
72
+ an existing fact by id, or replace one with supersedes. Use low confidence when unsure.
73
+ - propose_page: update a page that ALREADY EXISTS, by its id, with its complete new body (merge with
74
+ the current text shown to you; never drop established content unless the evidence supersedes it).
75
+ You cannot create pages: one run is an episode, not a page. What a run merely observed (a tool
76
+ result, a status, a list that will change) is not durable knowledge; leave it out.
77
+ - flag_claim: send an uncertain, contradictory, or sensitive claim to the review inbox instead.
78
+ ${EVIDENCE_RULE}
79
+ Use {"kind":"run","ref":"<the run id>","excerpt":"…"}.
80
+ Skip small talk and transient details. Most runs need few changes; it is fine to change nothing.`
81
+
82
+ const ingestInstructions = (product: string): string => `You maintain ${product}'s global knowledge Wiki (Karpathy-style LLM Wiki).
83
+ Raw sources are immutable evidence; Wiki pages are durable, interlinked Markdown syntheses, one per
84
+ entity, concept, or decision, updated in place. The Wiki is one corpus: the Library (/library) and one
85
+ namespace per project (/projects/<name>). Facts are short typed statements.
86
+ Read the imported source and decide what durable knowledge it adds. Then:
87
+ - propose_page once per page to create or update. To update, pass the existing page id and the
88
+ complete new body (merge with what the page already says; never drop established content unless
89
+ the source supersedes it, and say so). Prefer updating an existing page over creating a near
90
+ duplicate. New pages go to the job's namespace unless they clearly belong elsewhere.
91
+ - propose_memory for a short standing fact, preference, decision, or piece of feedback. Use namespace
92
+ "/" when it applies everywhere.
93
+ - Link related pages with typed links (references, depends_on, supersedes, contradicts,
94
+ produced_by, uses) using existing page ids or ids you propose.
95
+ - flag_claim for a claim that is uncertain, contradicts an existing page, or contains secrets or
96
+ personal data that should not be stored.
97
+ ${EVIDENCE_RULE}
98
+ Use {"kind":"source","ref":"<the source id>","excerpt":"…"}.
99
+ Prefer few, well-structured pages. Page ids are short kebab-case slugs. It is fine to propose nothing.`
100
+
101
+ const consolidateInstructions = (product: string): string => `You tidy ${product}'s memory blocks. Each block below is close to its
102
+ character limit. For each one, call rethink_block with a shorter version that keeps everything still
103
+ true and useful: merge repeats, drop what is finished or superseded, and tighten the wording. Do not
104
+ add anything that is not already in the block. The new content must be shorter than the current one.`
105
+
106
+ export function instructions(kind: MaintenanceTrigger['kind'], product: string): string {
107
+ return kind === 'run' ? runInstructions(product) : kind === 'ingest' ? ingestInstructions(product) : consolidateInstructions(product)
108
+ }
109
+
110
+ /** The proposal tools each kind of job gets. */
111
+ export function toolNames(kind: MaintenanceTrigger['kind']): string[] {
112
+ if (kind === 'consolidate') return ['rethink_block']
113
+ if (kind === 'run') return ['rethink_block', 'propose_memory', 'propose_page', 'flag_claim']
114
+ return ['propose_page', 'propose_memory', 'flag_claim']
115
+ }
116
+
117
+ export function prompt(request: MaintenanceRequest): string {
118
+ const blocks = request.blocks?.length
119
+ ? request.blocks.map((block) => `<block id="${block.id}" label="${block.label}" chars="${Array.from(block.body).length}/${block.limit}">\n${block.description}\n---\n${block.body.trim() || '(empty)'}\n</block>`).join('\n\n')
120
+ : '(none)'
121
+ if (request.trigger.kind === 'consolidate') return `Memory blocks to condense:\n\n${blocks}`
122
+ const existing = request.pages.length ? request.pages.map((page) => `- ${page.id} (${page.type}${page.path ? `, ${page.path}` : ''}): ${page.title} — ${page.summary}`).join('\n') : '(no pages yet)'
123
+ const bodies = request.pages.filter((page) => page.body?.trim()).map((page) => `<page id="${page.id}" title="${page.title}">\n${page.body}\n</page>`).join('\n\n')
124
+ const memories = request.memories?.length ? request.memories.map((memory) => `- ${memory.id} [${memory.kind}, ${memory.namespace}] ${memory.title}: ${memory.body}`).join('\n') : '(none)'
125
+ const namespaces = ['/library', ...(request.namespaces ?? []).map((namespace) => `${namespace.namespace} (${namespace.label})`)].join(', ')
126
+ const evidence = request.trigger.kind === 'ingest' ? `Imported source ${request.trigger.sourceId} ("${request.trigger.sourceName}"):\n\n${request.trigger.text}` : `Settled run ${request.trigger.runId} transcript:\n\n${request.trigger.transcript}`
127
+ return [
128
+ `Namespace for this job: ${request.namespace}`,
129
+ `Namespaces: ${namespaces}`,
130
+ ...(request.trigger.kind === 'run' ? [`Memory blocks in this run's context:\n\n${blocks}`] : []),
131
+ `Current index:\n${request.index}`,
132
+ `Relevant pages:\n${existing}`,
133
+ ...(bodies ? [`Current text of the closest pages:\n\n${bodies}`] : []),
134
+ `Facts:\n${memories}`,
135
+ `New evidence:\n${evidence}`
136
+ ].join('\n\n')
137
+ }
138
+
139
+ export interface MaintainerSession {
140
+ prompt(text: string): Promise<void>
141
+ dispose(): void
142
+ /** Assistant error text, if the provider failed. */
143
+ error(): string | null
144
+ }
145
+
146
+ type Collector = { page: (proposal: unknown) => string; memory: (proposal: unknown) => string; block: (proposal: unknown) => string; flag: (flag: unknown) => string }
147
+
148
+ export type MaintainerFactory = (input: {
149
+ cwd: string
150
+ /** Which job this is; it decides the instructions and the tools. */
151
+ kind: MaintenanceTrigger['kind']
152
+ product: string
153
+ account: AccountContext
154
+ runtime: () => Promise<ModelRuntime>
155
+ collect: Collector
156
+ }) => Promise<MaintainerSession>
157
+
158
+ export const piMaintainerFactory: MaintainerFactory = async ({ cwd, kind, product, account, runtime, collect }) => {
159
+ const pi = await import('@earendil-works/pi-coding-agent')
160
+ const { Type } = await import('typebox')
161
+ const agentDir = account.configDir
162
+ const resourceLoader = new pi.DefaultResourceLoader({ cwd, agentDir, noExtensions: true, noSkills: true, noPromptTemplates: true, noContextFiles: true, appendSystemPromptOverride: (base) => [...base, instructions(kind, product)] })
163
+ await resourceLoader.reload()
164
+ const sourceRefs = Type.Array(
165
+ Type.Object({
166
+ kind: Type.Union([Type.Literal('source'), Type.Literal('run'), Type.Literal('url')]),
167
+ ref: Type.String(),
168
+ excerpt: Type.Optional(Type.String({ description: 'A passage copied exactly from the evidence' }))
169
+ })
170
+ )
171
+ const reply = (text: string) => ({ content: [{ type: 'text' as const, text }], details: {} })
172
+ const tools = [
173
+ pi.defineTool({
174
+ name: 'rethink_block',
175
+ label: 'Rewrite block',
176
+ description: 'Rewrite one memory block with its complete new content. The host checks the excerpt and the block limit before anything is written.',
177
+ parameters: Type.Object({ block: Type.String({ description: 'The block id, as given in <block id="…">' }), body: Type.String({ description: 'The complete new content of the block' }), sourceRefs: Type.Optional(sourceRefs) }),
178
+ execute: async (_id, input) => reply(collect.block({ sourceRefs: [], ...(input as object) }))
179
+ }),
180
+ pi.defineTool({
181
+ name: 'propose_page',
182
+ label: 'Propose page',
183
+ description: 'Create or update one Wiki page. The host validates it before anything is written.',
184
+ parameters: Type.Object({
185
+ id: Type.Optional(Type.String({ description: 'Existing page id to update, or a new kebab-case id' })),
186
+ title: Type.String(),
187
+ type: Type.Union(PAGE_TYPES.map((value) => Type.Literal(value))),
188
+ body: Type.String({ description: 'Complete Markdown body' }),
189
+ namespace: Type.Optional(Type.String({ description: 'For a new page: /library or /projects/<name>; defaults to the job namespace' })),
190
+ sourceRefs,
191
+ links: Type.Array(Type.Object({ toId: Type.String(), type: Type.Union(LINK_TYPES.map((value) => Type.Literal(value))) }))
192
+ }),
193
+ execute: async (_id, input) => reply(collect.page(input))
194
+ }),
195
+ pi.defineTool({
196
+ name: 'propose_memory',
197
+ label: 'Propose fact',
198
+ description: 'Create or update one fact: a short standing statement agents can look up. The host validates it before anything is written.',
199
+ parameters: Type.Object({
200
+ id: Type.Optional(Type.String({ description: 'Existing fact id to update, or a new kebab-case id' })),
201
+ title: Type.String(),
202
+ kind: Type.Union(MEMORY_KINDS.map((value) => Type.Literal(value))),
203
+ body: Type.String({ description: 'One to a few sentences' }),
204
+ namespace: Type.Optional(Type.String({ description: '/ for everywhere, or /projects/<name>' })),
205
+ confidence: Type.Optional(Type.Union([Type.Literal('high'), Type.Literal('medium'), Type.Literal('low')])),
206
+ supersedes: Type.Optional(Type.String({ description: 'Id of the fact this replaces' })),
207
+ sourceRefs
208
+ }),
209
+ execute: async (_id, input) => reply(collect.memory(input))
210
+ }),
211
+ pi.defineTool({
212
+ name: 'flag_claim',
213
+ label: 'Flag claim',
214
+ description: 'Send an uncited, contradictory, uncertain, or sensitive claim to the review inbox instead of the Wiki.',
215
+ parameters: Type.Object({ claim: Type.String(), kind: Type.Union([Type.Literal('uncited'), Type.Literal('contradiction'), Type.Literal('low_confidence'), Type.Literal('sensitive')]), reason: Type.String() }),
216
+ execute: async (_id, input) => reply(collect.flag(input))
217
+ })
218
+ ]
219
+ const allowed = toolNames(kind)
220
+ const { session } = await pi.createAgentSession({
221
+ cwd,
222
+ agentDir,
223
+ modelRuntime: await runtime(),
224
+ tools: allowed,
225
+ customTools: tools.filter((tool) => allowed.includes((tool as { name: string }).name)) as never,
226
+ resourceLoader,
227
+ sessionManager: pi.SessionManager.inMemory(cwd)
228
+ })
229
+ let lastError: string | null = null
230
+ session.subscribe((event) => {
231
+ if (event.type !== 'message_end') return
232
+ const message = event.message as { role?: string; stopReason?: string; errorMessage?: string }
233
+ if (message.role === 'assistant' && message.stopReason === 'error') lastError = message.errorMessage ?? 'Provider error'
234
+ })
235
+ return { prompt: (text) => session.prompt(text), dispose: () => session.dispose(), error: () => lastError }
236
+ }
237
+
238
+ /** Runs one job's session and returns what it proposed. Nothing is written here. */
239
+ export async function runMaintenance(request: MaintenanceRequest, input: Omit<Parameters<MaintainerFactory>[0], 'kind' | 'collect'>, factory: MaintainerFactory = piMaintainerFactory): Promise<MaintenanceResult> {
240
+ const result: MaintenanceResult = { pages: [], memories: [], blocks: [], flags: [] }
241
+ const titled = (proposal: unknown): string => String((proposal as { title?: unknown } | null)?.title ?? '')
242
+ const session = await factory({
243
+ ...input,
244
+ kind: request.trigger.kind,
245
+ collect: {
246
+ page: (proposal) => {
247
+ result.pages.push(proposal)
248
+ return `Proposed "${titled(proposal)}". The host will validate it.`
249
+ },
250
+ memory: (proposal) => {
251
+ result.memories.push(proposal)
252
+ return `Proposed memory "${titled(proposal)}". The host will validate it.`
253
+ },
254
+ block: (proposal) => {
255
+ result.blocks.push(proposal)
256
+ return `Proposed a rewrite of ${String((proposal as { block?: unknown } | null)?.block ?? '')}. The host will validate it.`
257
+ },
258
+ flag: (flag) => {
259
+ result.flags.push(flag)
260
+ return 'Sent to the review inbox.'
261
+ }
262
+ }
263
+ })
264
+ try {
265
+ await session.prompt(prompt(request))
266
+ const error = session.error()
267
+ if (error) throw new HarnessError('provider_error', error)
268
+ } finally {
269
+ session.dispose()
270
+ }
271
+ return result
272
+ }
@@ -0,0 +1,176 @@
1
+ import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
2
+ import { tmpdir } from 'node:os'
3
+ import { join } from 'node:path'
4
+ import { afterEach, describe, expect, it } from 'vitest'
5
+
6
+ import { memorySecretStore } from '../accounts/secrets'
7
+ import { writeJson } from '../core/fs'
8
+ import type { Notice } from '../core/bus'
9
+ import { createHarness, type Harness } from '../harness'
10
+ import type { PiSessionLike } from '../sessions/runner'
11
+ import * as blocks from './blocks'
12
+ import { findMemory } from './facts'
13
+ import { prompt, runMaintenance, toolNames, type MaintainerFactory, type MaintenanceRequest } from './maintainer'
14
+ import { chatUpdatesPaused, fullBlocks } from './maintenance'
15
+ import { listInbox } from './operations'
16
+ import { wikiDir } from './store'
17
+
18
+ const request: MaintenanceRequest = { jobId: 'job-1', namespace: '/library', trigger: { kind: 'ingest', sourceId: 'src-1', sourceName: 'notes.md', text: 'We chose Tauri.' }, pages: [], index: '# Index' }
19
+ const input = { cwd: '/tmp', product: 'Xingular Agent', account: { id: 'a', provider: 'pi' as const, label: 'A', env: {}, configDir: '/tmp', sharing: [] }, runtime: async () => ({}) as never }
20
+
21
+ describe('the maintainer session', () => {
22
+ it('collects proposals and flags without writing anything itself', async () => {
23
+ let prompted = ''
24
+ const factory: MaintainerFactory = async ({ collect }) => ({
25
+ prompt: async (text) => {
26
+ prompted = text
27
+ collect.page({ title: 'Use Tauri', type: 'decision', body: 'We use Tauri.', sourceRefs: [{ kind: 'source', ref: 'src-1' }], links: [] })
28
+ collect.memory({ title: 'Prefers Tauri', kind: 'preference', body: 'Use Tauri for desktop apps.', namespace: '/', sourceRefs: [{ kind: 'source', ref: 'src-1' }] })
29
+ collect.flag({ claim: 'Electron is slow', kind: 'uncited', reason: 'No source' })
30
+ },
31
+ dispose: () => undefined,
32
+ error: () => null
33
+ })
34
+ const result = await runMaintenance(request, input, factory)
35
+ expect(prompted).toContain('src-1')
36
+ expect(prompted).toContain('Namespace for this job: /library')
37
+ expect(result.pages).toHaveLength(1)
38
+ expect(result.memories[0]).toMatchObject({ kind: 'preference', namespace: '/' })
39
+ expect(result.flags[0]).toMatchObject({ kind: 'uncited' })
40
+ })
41
+
42
+ it('gives a run its blocks and page bodies, and each job only its tools', async () => {
43
+ const block = { id: 'blocks/shared/user', label: 'user', description: 'About the user.', limit: 2000, body: 'Likes pnpm.' }
44
+ const run: MaintenanceRequest = { ...request, trigger: { kind: 'run', runId: 'run-1', transcript: 'User: hi\nAssistant: hello' }, pages: [{ id: 'shell', title: 'Shell', type: 'decision', summary: 'Tauri', body: 'We use Tauri.' }], blocks: [block] }
45
+ const text = prompt(run)
46
+ expect(text).toContain('<block id="blocks/shared/user" label="user" chars="11/2000">')
47
+ expect(text).toContain('<page id="shell" title="Shell">\nWe use Tauri.')
48
+ expect(text).toContain('Settled run run-1 transcript')
49
+ expect(prompt({ ...request, trigger: { kind: 'consolidate' }, blocks: [block] })).toBe('Memory blocks to condense:\n\n<block id="blocks/shared/user" label="user" chars="11/2000">\nAbout the user.\n---\nLikes pnpm.\n</block>')
50
+ expect(toolNames('run')).toEqual(['rethink_block', 'propose_memory', 'propose_page', 'flag_claim'])
51
+ expect(toolNames('ingest')).not.toContain('rethink_block')
52
+ expect(toolNames('consolidate')).toEqual(['rethink_block'])
53
+ })
54
+
55
+ it('reports provider failures', async () => {
56
+ const factory: MaintainerFactory = async () => ({ prompt: async () => undefined, dispose: () => undefined, error: () => 'quota exceeded' })
57
+ await expect(runMaintenance(request, input, factory)).rejects.toMatchObject({ code: 'provider_error' })
58
+ })
59
+ })
60
+
61
+ const open: Harness[] = []
62
+ afterEach(async () => {
63
+ for (const harness of open.splice(0)) await harness.close()
64
+ })
65
+
66
+ function setup(factory: MaintainerFactory) {
67
+ const reply: PiSessionLike = {
68
+ sessionId: 'native',
69
+ subscribe: (listener) => {
70
+ emit = listener as never
71
+ return () => undefined
72
+ },
73
+ prompt: async () => emit({ type: 'message_end', message: { role: 'assistant', content: [{ type: 'text', text: 'We decided to use Tauri. I prefer small commits.' }] } }),
74
+ sendCustomMessage: async () => undefined,
75
+ abort: async () => undefined,
76
+ dispose: () => undefined
77
+ }
78
+ let emit: (event: unknown) => void = () => undefined
79
+ const harness = createHarness({ dataRoot: mkdtempSync(join(tmpdir(), 'harness-maint-data-')), stateDir: mkdtempSync(join(tmpdir(), 'harness-maint-state-')), secrets: memorySecretStore(), sessionFactory: async () => reply, maintainerFactory: factory })
80
+ open.push(harness)
81
+ harness.accounts.create('Main', 'apiKey')
82
+ const notices: Notice[] = []
83
+ harness.bus.on((notice) => void notices.push(notice))
84
+ const turn = (id: string) => harness.sessions.run({ run: { id, sessionId: 'nora' }, cwd: harness.env.root, sessionDir: join(harness.env.root, 'history'), text: 'Which shell do we use?', caller: { agentId: 'nora', agentName: 'Nora', actor: 'Chat' }, tools: { allow: [] } })
85
+ return { harness, notices, turn, wiki: wikiDir(harness.env.root) }
86
+ }
87
+
88
+ describe('the memory lifecycle', () => {
89
+ it('a settled run is consolidated: quoted changes apply, invented ones wait, and the session never writes', async () => {
90
+ let seen: { kind: string; text: string } | null = null
91
+ const { harness, notices, turn, wiki } = setup(async ({ kind, collect }) => ({
92
+ prompt: async (text) => {
93
+ seen = { kind, text }
94
+ collect.memory({ title: 'Small commits', kind: 'preference', body: 'Prefer small commits.', confidence: 'high', sourceRefs: [{ kind: 'run', ref: 'run-1', excerpt: 'I prefer small commits' }] })
95
+ collect.memory({ title: 'Invented', kind: 'fact', body: 'Never said.', confidence: 'high', sourceRefs: [{ kind: 'run', ref: 'run-1', excerpt: 'something never said at all' }] })
96
+ collect.block({ block: 'blocks/agents/nora/notes', body: 'The shell is Tauri.', sourceRefs: [{ kind: 'run', ref: 'run-1', excerpt: 'We decided to use Tauri' }] })
97
+ collect.page({ title: 'A new page', type: 'concept', body: 'From one run.', sourceRefs: [{ kind: 'run', ref: 'run-1', excerpt: 'We decided to use Tauri' }], links: [] })
98
+ },
99
+ dispose: () => undefined,
100
+ error: () => null
101
+ }))
102
+ await turn('run-1')
103
+ await new Promise((resolve) => setTimeout(resolve, 30))
104
+ await harness.maintenance.settled()
105
+ expect(seen!.kind).toBe('run')
106
+ expect(seen!.text).toContain('Settled run run-1 transcript:\n\nUser: Which shell do we use?\nAssistant: We decided to use Tauri.')
107
+ expect(seen!.text).toContain('<block id="blocks/agents/nora/notes" label="notes"')
108
+ expect(findMemory(wiki, 'small-commits').front).toMatchObject({ namespace: '/', sourceIds: ['run:run-1'] })
109
+ expect(() => findMemory(wiki, 'invented')).toThrow()
110
+ expect(blocks.find(wiki, 'blocks/agents/nora/notes').body).toBe('The shell is Tauri.')
111
+ // One run is an episode: it never becomes a page by itself.
112
+ expect(listInbox(wiki).map((item) => item.kind).sort()).toEqual(['applied', 'unresolved', 'unresolved'])
113
+ expect(notices.some((notice) => notice.type === 'wiki.maintenance' && String(notice.detail).includes('2 parked'))).toBe(true)
114
+ expect(readFileSync(join(wiki, 'log.md'), 'utf8')).toContain('Sleep-time · run run-1')
115
+ // A person can undo the whole consolidation as one operation.
116
+ harness.maintenance.undoOperation(listInbox(wiki).find((item) => item.operationId)!.operationId!)
117
+ expect(blocks.find(wiki, 'blocks/agents/nora/notes').body).toBe('')
118
+ })
119
+
120
+ it('skips a run that settings exclude, and a paused conversation', async () => {
121
+ let jobs = 0
122
+ const { harness, turn } = setup(async () => ({ prompt: async () => void (jobs += 1), dispose: () => undefined, error: () => null }))
123
+ writeJson(join(harness.env.root, 'config/settings.json'), { excludedSourceIds: ['run-1'] })
124
+ await turn('run-1')
125
+ await new Promise((resolve) => setTimeout(resolve, 30))
126
+ await harness.maintenance.settled()
127
+ writeJson(join(harness.env.root, 'config/settings.json'), { autoMaintenanceChat: false })
128
+ await turn('run-2')
129
+ await new Promise((resolve) => setTimeout(resolve, 30))
130
+ await harness.maintenance.settled()
131
+ expect(jobs).toBe(0)
132
+ expect([chatUpdatesPaused({}), chatUpdatesPaused({ autoMaintenanceChat: true }), chatUpdatesPaused({ autoMaintenanceChat: false })]).toEqual([false, false, true])
133
+ })
134
+
135
+ it('an imported source is read once and its proposals are checked against its text', async () => {
136
+ const { harness, wiki } = setup(async ({ kind, collect }) => ({
137
+ prompt: async () => {
138
+ expect(kind).toBe('ingest')
139
+ const source = (await import('./store')).listSources(wiki)[0]
140
+ collect.page({ id: 'shell', title: 'Shell', type: 'decision', body: 'We use Tauri.', sourceRefs: [{ kind: 'source', ref: source.id, excerpt: 'we chose Tauri for the shell' }], links: [] })
141
+ collect.page({ id: 'rumor', title: 'Rumor', type: 'concept', body: 'Not in the source.', sourceRefs: [{ kind: 'source', ref: source.id, excerpt: 'Electron is faster than everything' }], links: [] })
142
+ },
143
+ dispose: () => undefined,
144
+ error: () => null
145
+ }))
146
+ harness.maintenance.ingest('notes.md', Buffer.from('After the review we chose Tauri for the shell.'))
147
+ await harness.maintenance.settled()
148
+ expect(readFileSync(join(wiki, 'library/shell.md'), 'utf8')).toContain('We use Tauri.')
149
+ const parked = listInbox(wiki).find((item) => item.payload !== undefined)!
150
+ expect(parked.title).toContain('Rumor')
151
+ // The user is the authority when they accept what was parked.
152
+ expect(harness.maintenance.acceptInboxItem(parked.id)).toContain('created')
153
+ expect(readFileSync(join(wiki, 'library/rumor.md'), 'utf8')).toContain('user:accepted')
154
+ })
155
+
156
+ it('condenses a block that is close to its limit once nothing else is happening, and only to something shorter', async () => {
157
+ const harness = createHarness({
158
+ dataRoot: mkdtempSync(join(tmpdir(), 'harness-maint-data-')),
159
+ stateDir: mkdtempSync(join(tmpdir(), 'harness-maint-state-')),
160
+ secrets: memorySecretStore(),
161
+ idleMs: 20,
162
+ maintainerFactory: async ({ kind, collect }) => ({ prompt: async () => void (kind === 'consolidate' && collect.block({ block: 'blocks/shared/user', body: 'Uses pnpm.' })), dispose: () => undefined, error: () => null })
163
+ })
164
+ open.push(harness)
165
+ harness.accounts.create('Main', 'apiKey')
166
+ const wiki = wikiDir(harness.env.root)
167
+ const user = blocks.ensureUser(wiki)
168
+ writeFileSync(user.path, blocks.serializeBlock({ ...user.front, limit: 60 }, 'Likes pnpm. Likes pnpm a lot. Uses pnpm everywhere, always.'))
169
+ expect(fullBlocks(wiki, new Set())).toEqual(['blocks/shared/user'])
170
+ await new Promise((resolve) => setTimeout(resolve, 120))
171
+ await harness.maintenance.settled()
172
+ expect(blocks.find(wiki, 'blocks/shared/user').body).toBe('Uses pnpm.')
173
+ const tried = new Set<string>()
174
+ expect(fullBlocks(wiki, tried)).toEqual([])
175
+ })
176
+ })