@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,248 @@
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/agent-x`. */
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
+ }
@@ -0,0 +1,103 @@
1
+ // One graph of the Wiki: pages, facts, memory blocks, registry skills/prompts/rules, and sources
2
+ // as nodes; frontmatter links, citations, and namespace membership as edges.
3
+ import type { HarnessEnv } from '../core/env'
4
+ import { charCount } from '../core/text'
5
+ import * as registry from '../skills/registry'
6
+ import * as blocks from './blocks'
7
+ import * as facts from './facts'
8
+ import * as store from './store'
9
+ import { registryEntryKind } from './wiki'
10
+
11
+ export interface GraphNode {
12
+ id: string
13
+ label: string
14
+ type: 'project' | 'page' | 'memory' | 'skill' | 'prompt' | 'rule' | 'block' | 'source'
15
+ pageType: string | null
16
+ namespace: string
17
+ summary: string
18
+ status: 'unresolved' | 'established' | 'stale'
19
+ provenance: string
20
+ }
21
+
22
+ export interface GraphEdge {
23
+ id: string
24
+ source: string
25
+ target: string
26
+ type: string
27
+ evidence: string | null
28
+ }
29
+
30
+ export interface GraphResponse {
31
+ nodes: GraphNode[]
32
+ edges: GraphEdge[]
33
+ }
34
+
35
+ const statusOf = (sourceIds: readonly string[]): 'unresolved' | 'established' => (sourceIds.length ? 'established' : 'unresolved')
36
+
37
+ export function build(env: HarnessEnv): GraphResponse {
38
+ const wiki = store.wikiDir(env.root)
39
+ const pages = store.loadPages(wiki)
40
+ const memories = facts.loadMemories(wiki)
41
+ const nodes: GraphNode[] = pages.map((page) => ({
42
+ id: page.frontmatter.id,
43
+ label: page.frontmatter.title,
44
+ type: page.frontmatter.type === 'project' ? 'project' : 'page',
45
+ pageType: page.frontmatter.type,
46
+ namespace: page.location.namespace,
47
+ summary: store.firstParagraph(page.body),
48
+ status: statusOf(page.frontmatter.sourceIds),
49
+ provenance: `Wiki page ${page.location.path} · ${page.frontmatter.sourceIds.length} source(s)`
50
+ }))
51
+ for (const memory of memories) {
52
+ nodes.push({
53
+ id: memory.front.id,
54
+ label: memory.front.title,
55
+ type: 'memory',
56
+ pageType: memory.front.kind,
57
+ namespace: memory.front.namespace,
58
+ summary: store.firstParagraph(memory.body),
59
+ status: memory.front.status !== 'active' ? 'stale' : statusOf(memory.front.sourceIds),
60
+ provenance: `Memory · ${memory.front.confidence} confidence · ${memory.front.sourceIds.length} source(s)`
61
+ })
62
+ }
63
+ for (const item of registry.list(env.root)) {
64
+ const type = registryEntryKind(item.kind)
65
+ if (type) nodes.push({ id: item.id, label: item.name, type, pageType: null, namespace: store.ROOT_NAMESPACE, summary: item.description, status: 'established', provenance: `Registry item ${item.id}` })
66
+ }
67
+ for (const block of blocks.loadAll(wiki)) {
68
+ nodes.push({
69
+ id: block.id,
70
+ label: block.scope === 'agent' && block.owner ? `${env.directory.agentName(block.owner) ?? 'Removed agent'} · ${block.front.label}` : block.front.label,
71
+ type: 'block',
72
+ pageType: block.scope,
73
+ namespace: block.scope === 'project' && block.owner ? `/projects/${block.owner}` : store.ROOT_NAMESPACE,
74
+ summary: block.body.trim() ? store.firstParagraph(block.body) : block.front.description,
75
+ status: 'established',
76
+ provenance: `Memory block · ${charCount(block.body)} of ${block.front.limit} characters`
77
+ })
78
+ }
79
+ for (const source of store.listSources(wiki)) {
80
+ nodes.push({ id: source.id, label: source.name, type: 'source', pageType: null, namespace: store.ROOT_NAMESPACE, summary: `Imported source, version ${source.version}`, status: 'established', provenance: `Imported ${source.importedAt}` })
81
+ }
82
+
83
+ const ids = new Set(nodes.map((node) => node.id))
84
+ const edges: GraphEdge[] = []
85
+ const pushLinks = (from: string, links: readonly store.WikiLink[], sourceIds: readonly string[]): void => {
86
+ for (const link of links) if (ids.has(link.toId)) edges.push({ id: `${from}->${link.toId}:${link.type}`, source: from, target: link.toId, type: link.type, evidence: null })
87
+ for (const sourceId of sourceIds) if (ids.has(sourceId)) edges.push({ id: `${from}->${sourceId}:cites`, source: from, target: sourceId, type: 'references', evidence: sourceId })
88
+ }
89
+ for (const page of pages) pushLinks(page.frontmatter.id, page.frontmatter.links, page.frontmatter.sourceIds)
90
+ for (const memory of memories) pushLinks(memory.front.id, memory.front.links, memory.front.sourceIds)
91
+ // Namespace membership: entries in a project namespace attach to that project's overview page.
92
+ const projectPages = pages.filter((page) => page.frontmatter.type === 'project' && page.location.namespace.startsWith('/projects/')).map((page) => ({ namespace: page.location.namespace, id: page.frontmatter.id }))
93
+ const members = [
94
+ ...pages.map((page) => ({ id: page.frontmatter.id, namespace: page.location.namespace })),
95
+ ...memories.map((memory) => ({ id: memory.front.id, namespace: memory.front.namespace })),
96
+ ...nodes.filter((node) => node.type === 'block').map((node) => ({ id: node.id, namespace: node.namespace }))
97
+ ]
98
+ for (const member of members) {
99
+ const project = projectPages.find((entry) => entry.namespace === member.namespace && entry.id !== member.id)
100
+ if (project) edges.push({ id: `${member.id}->${project.id}:part_of`, source: member.id, target: project.id, type: 'part_of', evidence: null })
101
+ }
102
+ return { nodes, edges }
103
+ }
@@ -0,0 +1,130 @@
1
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
2
+ import { tmpdir } from 'node:os'
3
+ import { join } from 'node:path'
4
+ import { expect, test } from 'vitest'
5
+
6
+ import { dataRoot, writeFile } from '../../tests/helpers'
7
+ import { createEnv } from '../core/env'
8
+ import { now, readJson, writeJson } from '../core/fs'
9
+ import * as blocks from './blocks'
10
+ import { loadMemories } from './facts'
11
+ import { build } from './graph'
12
+ import { check } from './health'
13
+ import { run, version, WIKI_VERSION } from './migrate'
14
+ import { ensureProjectNamespace, isDir, isFile, loadPages, wikiDir } from './store'
15
+
16
+ test('reports problems across the wiki', () => {
17
+ const root = dataRoot()
18
+ const wiki = wikiDir(root)
19
+ writeFileSync(join(wiki, 'library/tauri.md'), '---\nid: tauri\ntitle: Use Tauri\ntype: decision\nsourceIds: []\nlinks:\n - toId: xingular-agent\n type: references\n - toId: electron\n type: contradicts\n---\n\nWe use Tauri 2.\n')
20
+ writeFile(join(wiki, 'projects/xingular-agent/xingular-agent.md'), '---\nid: xingular-agent\ntitle: Xingular Agent\ntype: concept\n---\n\nThe app.\n')
21
+ writeFileSync(join(wiki, 'library/lonely.md'), '---\nid: lonely\ntitle: Lonely\ntype: concept\nsourceIds: [run:r1]\n---\n\nAlone.\n')
22
+ writeFileSync(join(wiki, 'facts/m.md'), '---\nid: m\ntitle: M\nkind: fact\nsourceIds: [src-missing]\n---\n\nFact.\n')
23
+ const report = check(root)
24
+ expect(report.brokenLinks).toHaveLength(1)
25
+ expect(report.contradictions).toHaveLength(1)
26
+ expect(report.orphanPages).toEqual(['lonely'])
27
+ expect([...report.missingEvidence].sort()).toEqual(['m', 'tauri', 'xingular-agent'])
28
+ expect([report.staleFacts, report.similarTitles, report.fullBlocks]).toEqual([[], [], []])
29
+ })
30
+
31
+ test('reports stale facts, near-duplicate titles and full blocks', () => {
32
+ const root = dataRoot()
33
+ ensureProjectNamespace(root, 'app', 'App')
34
+ const wiki = wikiDir(root)
35
+ for (const [file, title] of [['a', 'Linear MCP availability observed in run a3695ebe'], ['b', 'Linear MCP availability observed in run c54c128a'], ['c', 'Release plan']]) {
36
+ writeFileSync(join(wiki, `library/${file}.md`), `---\nid: ${file}\ntitle: ${title}\ntype: reference\nsourceIds: [user]\n---\n\nText.\n`)
37
+ }
38
+ writeFileSync(join(wiki, 'facts/old.md'), '---\nid: old\ntitle: Open issues\nkind: fact\nvolatile: true\nobservedAt: 2020-01-01T00:00:00Z\nsourceIds: [user]\n---\n\nThree open.\n')
39
+ writeFileSync(join(wiki, 'facts/new.md'), `---\nid: new\ntitle: On call\nkind: fact\nvolatile: true\nobservedAt: ${now()}\nsourceIds: [user]\n---\n\nNora.\n`)
40
+ const user = blocks.ensureUser(wiki)
41
+ writeFileSync(user.path, blocks.serializeBlock({ ...user.front, limit: 20 }, 'Nineteen characters'))
42
+ const report = check(root)
43
+ expect(report.staleFacts).toEqual(['old'])
44
+ expect(report.similarTitles).toEqual([['a', 'b']])
45
+ expect(report.fullBlocks).toEqual(['blocks/shared/user'])
46
+ // A project's skeleton pages start empty and unlinked without being reported.
47
+ expect(report.orphanPages).toEqual(['a', 'b', 'c'])
48
+ expect(report.missingEvidence).toEqual([])
49
+ })
50
+
51
+ test('graph spans namespaces, memories and registry', () => {
52
+ const root = dataRoot()
53
+ ensureProjectNamespace(root, 'app', 'App')
54
+ const wiki = wikiDir(root)
55
+ writeFileSync(join(wiki, 'projects/app/arch.md'), '---\nid: arch\ntitle: Arch\ntype: concept\nlinks:\n - toId: skills/review\n type: uses\n---\n\nBody.\n')
56
+ writeFileSync(join(wiki, 'facts/m.md'), '---\nid: m\ntitle: M\nkind: fact\nnamespace: /projects/app\n---\n\nFact.\n')
57
+ writeFile(join(root, 'registry/skills/review.md'), 'Review code.\n')
58
+ const graph = build(createEnv(root))
59
+ const types = new Set(graph.nodes.map((node) => node.type))
60
+ for (const type of ['project', 'memory', 'skill'] as const) expect(types.has(type)).toBe(true)
61
+ expect(graph.edges.some((edge) => edge.source === 'arch' && edge.target === 'skills/review')).toBe(true)
62
+ expect(graph.edges.some((edge) => edge.source === 'm' && edge.target === 'project-app' && edge.type === 'part_of')).toBe(true)
63
+ })
64
+
65
+ function v2Layout(): string {
66
+ const root = mkdtempSync(join(tmpdir(), 'harness-migrate-'))
67
+ writeJson(join(root, 'projects.json'), [{ id: 'p1', name: 'Xingular Agent', slug: 'xingular-agent', rootPath: '/tmp/x', autoMaintenance: true, createdAt: '2026-01-01T00:00:00Z' }])
68
+ writeFile(join(root, 'config/storage.json'), '{"wikiVersion":2}')
69
+ writeFile(join(root, 'wiki/docs/observed.md'), '---\nid: observed\ntitle: Observed in run\ntype: reference\n---\n\nA run.\n')
70
+ writeFile(join(root, 'wiki/memory/pnpm.md'), '---\nid: pnpm\ntitle: Use pnpm\nkind: preference\n---\n\nUse pnpm.\n')
71
+ writeFile(join(root, 'wiki/log.md'), '# Maintenance log\n\n- t · user · ingest\n')
72
+ writeFile(join(root, 'registry/rules/style.md'), 'Use spaces.\n')
73
+ return root
74
+ }
75
+
76
+ test('version 3 starts clean and keeps the old wiki as a backup', () => {
77
+ const root = v2Layout()
78
+ expect(version(root)).toBe(2)
79
+ expect(run(createEnv(root), ['a1'])).toBe(true)
80
+ const wiki = wikiDir(root)
81
+ expect(loadPages(wiki).every((page) => page.frontmatter.id !== 'observed')).toBe(true)
82
+ expect(loadMemories(wiki)).toEqual([])
83
+ for (const relative of ['blocks/shared/user.md', 'blocks/agents/a1/persona.md', 'blocks/agents/a1/notes.md', 'blocks/projects/xingular-agent/brief.md', 'projects/xingular-agent/overview.md']) expect(isFile(join(wiki, relative))).toBe(true)
84
+ expect(isDir(join(wiki, 'library')) && isDir(join(wiki, 'facts'))).toBe(true)
85
+ expect(isFile(join(root, 'wiki.pre-v3/docs/observed.md')) && isFile(join(root, 'wiki.pre-v3/memory/pnpm.md'))).toBe(true)
86
+ expect(readFileSync(join(root, 'registry/rules/style.md'), 'utf8')).toBe('Use spaces.\n')
87
+ expect(isDir(join(root, 'wiki.migrating'))).toBe(false)
88
+ expect(version(root)).toBe(WIKI_VERSION)
89
+ expect(run(createEnv(root))).toBe(false)
90
+ })
91
+
92
+ test('a version 2 wiki without a marker is still migrated', () => {
93
+ const root = v2Layout()
94
+ rmSync(join(root, 'config/storage.json'))
95
+ expect(version(root)).toBe(2)
96
+ expect(run(createEnv(root))).toBe(true)
97
+ expect(isFile(join(root, 'wiki.pre-v3/docs/observed.md'))).toBe(true)
98
+ })
99
+
100
+ test('version 1 knowledge is kept as a backup and personal items become global', () => {
101
+ const root = mkdtempSync(join(tmpdir(), 'harness-migrate-'))
102
+ writeJson(join(root, 'projects.json'), [{ id: 'p1', name: 'Xingular Agent', rootPath: '/tmp/x', autoMaintenance: true, createdAt: '2026-01-01T00:00:00Z' }])
103
+ writeFile(join(root, 'knowledge/personal/wiki/overview.md'), '---\nid: overview\ntitle: Mine\ntype: summary\n---\n\nMine.\n')
104
+ writeFile(join(root, 'config/registry-enabled.json'), '{"personal":["skills/a"],"p1":["rules/b"]}')
105
+ expect(version(root)).toBe(1)
106
+ expect(run(createEnv(root))).toBe(true)
107
+ expect(isDir(join(root, 'knowledge'))).toBe(false)
108
+ expect(isFile(join(root, 'knowledge.pre-v2/personal/wiki/overview.md'))).toBe(true)
109
+ expect(isFile(join(wikiDir(root), 'blocks/projects/xingular-agent/brief.md'))).toBe(true)
110
+ const enablement = readJson<Record<string, string[]>>(join(root, 'config/registry-enabled.json'))!
111
+ expect(enablement.global[0]).toBe('skills/a')
112
+ expect('personal' in enablement).toBe(false)
113
+ })
114
+
115
+ test('a failed migration leaves the wiki untouched', () => {
116
+ const root = v2Layout()
117
+ // An unreadable project registry makes staging fail.
118
+ writeFileSync(join(root, 'projects.json'), 'not json')
119
+ expect(() => run(createEnv(root))).toThrow()
120
+ expect(isFile(join(root, 'wiki/docs/observed.md'))).toBe(true)
121
+ expect(isDir(join(root, 'wiki.migrating')) || isDir(join(root, 'wiki.pre-v3'))).toBe(false)
122
+ expect(version(root)).toBe(2)
123
+ })
124
+
125
+ test('fresh data roots need no migration', () => {
126
+ const root = mkdtempSync(join(tmpdir(), 'harness-migrate-'))
127
+ mkdirSync(root, { recursive: true })
128
+ expect(version(root)).toBe(WIKI_VERSION)
129
+ expect(run(createEnv(root))).toBe(false)
130
+ })
@@ -0,0 +1,74 @@
1
+ // Health checks over the whole Wiki. They report problems with a route to the affected
2
+ // entries and never delete anything.
3
+ import { charCount } from '../core/text'
4
+ import * as registry from '../skills/registry'
5
+ import * as blocks from './blocks'
6
+ import * as facts from './facts'
7
+ import { lastOperationAt } from './operations'
8
+ import * as store from './store'
9
+
10
+ export interface HealthReport {
11
+ brokenLinks: { pageId: string; targetId: string }[]
12
+ orphanPages: string[]
13
+ missingEvidence: string[]
14
+ contradictions: { fromId: string; toId: string }[]
15
+ duplicateIds: string[]
16
+ /** Volatile facts that have not been observed for a while and may no longer be true. */
17
+ staleFacts: string[]
18
+ /** Groups of pages whose titles differ only by a run id, a number, or punctuation. */
19
+ similarTitles: string[][]
20
+ /** Memory blocks at 90% of their limit or more. */
21
+ fullBlocks: string[]
22
+ lastMaintenanceAt: string | null
23
+ }
24
+
25
+ const IGNORED_WORDS = new Set(['the', 'a', 'an', 'in', 'of', 'run'])
26
+
27
+ /** A title without the parts that make near-duplicates look different: case, punctuation, numbers, and id-like words. */
28
+ function titleKey(title: string): string {
29
+ return title.toLowerCase().split(/[^\p{Alphabetic}\p{N}]+/u).filter((word) => word && !/[0-9]/.test(word) && !IGNORED_WORDS.has(word)).join(' ')
30
+ }
31
+
32
+ export function check(root: string): HealthReport {
33
+ const wiki = store.wikiDir(root)
34
+ const pages = store.loadPages(wiki)
35
+ const memories = facts.loadMemories(wiki)
36
+ const sourceIds = new Set(store.listSources(wiki).map((source) => source.id))
37
+ const registryIds = registry.list(root).map((item) => item.id)
38
+
39
+ // Every page and fact; `standalone` ones need neither links nor sources.
40
+ const entries = [
41
+ ...pages.map((page) => ({ id: page.frontmatter.id, links: page.frontmatter.links, sources: page.frontmatter.sourceIds, standalone: page.frontmatter.id === 'home' || page.frontmatter.type === 'project' || store.isSkeleton(page.location) })),
42
+ ...memories.map((memory) => ({ id: memory.front.id, links: memory.front.links, sources: memory.front.sourceIds, standalone: true }))
43
+ ]
44
+ const seen = new Set<string>()
45
+ const duplicateIds = entries.filter((entry) => seen.size === seen.add(entry.id).size).map((entry) => entry.id)
46
+ const known = new Set([...entries.map((entry) => entry.id), ...sourceIds, ...registryIds])
47
+ const linked = new Set(entries.flatMap((entry) => entry.links.map((link) => link.toId)))
48
+ const validEvidence = (id: string): boolean => sourceIds.has(id) || store.isExternalEvidence(id)
49
+
50
+ // Pages in one namespace whose titles reduce to the same key, in namespace then key order.
51
+ const groups = new Map<string, { namespace: string; key: string; ids: string[] }>()
52
+ for (const page of pages) {
53
+ if (store.isSkeleton(page.location) || page.frontmatter.type === 'project') continue
54
+ const key = titleKey(page.frontmatter.title)
55
+ if (!key) continue
56
+ const name = `${page.location.namespace}\u0000${key}`
57
+ if (!groups.has(name)) groups.set(name, { namespace: page.location.namespace, key, ids: [] })
58
+ groups.get(name)!.ids.push(page.frontmatter.id)
59
+ }
60
+ const ordered = [...groups.values()].sort((left, right) => (left.namespace === right.namespace ? (left.key < right.key ? -1 : left.key > right.key ? 1 : 0) : left.namespace < right.namespace ? -1 : 1))
61
+
62
+ return {
63
+ brokenLinks: entries.flatMap((entry) => entry.links.filter((link) => !known.has(link.toId)).map((link) => ({ pageId: entry.id, targetId: link.toId }))),
64
+ // Home, project overviews, and facts stand on their own; other pages should be linked.
65
+ orphanPages: entries.filter((entry) => !entry.standalone && !entry.links.length && !linked.has(entry.id)).map((entry) => entry.id),
66
+ missingEvidence: entries.filter((entry) => (!entry.standalone && !entry.sources.length) || entry.sources.some((id) => !validEvidence(id))).map((entry) => entry.id),
67
+ contradictions: entries.flatMap((entry) => entry.links.filter((link) => link.type === 'contradicts').map((link) => ({ fromId: entry.id, toId: link.toId }))),
68
+ duplicateIds,
69
+ staleFacts: memories.filter((memory) => facts.isStale(memory.front)).map((memory) => memory.front.id),
70
+ similarTitles: ordered.filter((group) => group.ids.length > 1).map((group) => group.ids),
71
+ fullBlocks: blocks.loadAll(wiki).filter((block) => block.front.limit > 0 && charCount(block.body) * 100 >= block.front.limit * 90).map((block) => block.id),
72
+ lastMaintenanceAt: lastOperationAt(wiki)
73
+ }
74
+ }