@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.
- package/README.md +269 -2
- package/dist/cli.js +11510 -0
- package/dist/terminal-pi.js +326 -0
- package/package.json +41 -4
- package/src/accounts/accounts.test.ts +223 -0
- package/src/accounts/accounts.ts +522 -0
- package/src/accounts/env.test.ts +95 -0
- package/src/accounts/env.ts +338 -0
- package/src/accounts/secrets.ts +50 -0
- package/src/accounts/store.ts +338 -0
- package/src/capabilities/api.ts +75 -0
- package/src/capabilities/runSnapshot.ts +59 -0
- package/src/capabilities/types.ts +34 -0
- package/src/cli.ts +110 -0
- package/src/core/bus.ts +34 -0
- package/src/core/changes.ts +33 -0
- package/src/core/env.ts +27 -0
- package/src/core/errors.ts +32 -0
- package/src/core/frontmatter.ts +47 -0
- package/src/core/fs.test.ts +38 -0
- package/src/core/fs.ts +92 -0
- package/src/core/index.ts +193 -0
- package/src/core/path.ts +45 -0
- package/src/core/projects.ts +90 -0
- package/src/core/text.ts +39 -0
- package/src/harness.ts +299 -0
- package/src/index.ts +70 -0
- package/src/mcp/connections.ts +614 -0
- package/src/mcp/manager.ts +130 -0
- package/src/mcp/oauth.ts +195 -0
- package/src/mcp/scopeMigration.ts +204 -0
- package/src/mcp/service.test.ts +376 -0
- package/src/mcp/service.ts +549 -0
- package/src/mcp/types.ts +108 -0
- package/src/memory/api.test.ts +82 -0
- package/src/memory/api.ts +289 -0
- package/src/memory/blocks.test.ts +78 -0
- package/src/memory/blocks.ts +406 -0
- package/src/memory/caller.ts +57 -0
- package/src/memory/dispatch.ts +59 -0
- package/src/memory/facts.test.ts +41 -0
- package/src/memory/facts.ts +248 -0
- package/src/memory/graph.ts +103 -0
- package/src/memory/health.test.ts +130 -0
- package/src/memory/health.ts +74 -0
- package/src/memory/maintainer.ts +272 -0
- package/src/memory/maintenance.test.ts +176 -0
- package/src/memory/maintenance.ts +330 -0
- package/src/memory/migrate.ts +79 -0
- package/src/memory/operations.ts +138 -0
- package/src/memory/proposals.test.ts +168 -0
- package/src/memory/proposals.ts +576 -0
- package/src/memory/recall.test.ts +104 -0
- package/src/memory/recall.ts +271 -0
- package/src/memory/references.ts +77 -0
- package/src/memory/runContext.ts +93 -0
- package/src/memory/secrets.ts +6 -0
- package/src/memory/store.test.ts +66 -0
- package/src/memory/store.ts +385 -0
- package/src/memory/wiki.test.ts +47 -0
- package/src/memory/wiki.ts +422 -0
- package/src/memory/wikiIndex.test.ts +88 -0
- package/src/memory/wikiIndex.ts +116 -0
- package/src/plugins/api.ts +181 -0
- package/src/plugins/attachments.test.ts +84 -0
- package/src/plugins/attachments.ts +141 -0
- package/src/plugins/builtin.ts +82 -0
- package/src/plugins/catalogue.test.ts +254 -0
- package/src/plugins/catalogue.ts +251 -0
- package/src/plugins/cli.ts +184 -0
- package/src/plugins/discover.ts +151 -0
- package/src/plugins/firstParty.test.ts +143 -0
- package/src/plugins/firstParty.ts +129 -0
- package/src/plugins/hooks.test.ts +250 -0
- package/src/plugins/hooks.ts +239 -0
- package/src/plugins/identity.ts +37 -0
- package/src/plugins/install.test.ts +107 -0
- package/src/plugins/install.ts +106 -0
- package/src/plugins/inventory.ts +123 -0
- package/src/plugins/manifest.ts +76 -0
- package/src/plugins/plugins.test.ts +205 -0
- package/src/plugins/plugins.ts +189 -0
- package/src/plugins/proposal.ts +121 -0
- package/src/plugins/runtime.test.ts +40 -0
- package/src/plugins/runtime.ts +82 -0
- package/src/plugins/trust.ts +142 -0
- package/src/plugins/types.ts +149 -0
- package/src/prompt/compile.test.ts +261 -0
- package/src/prompt/compile.ts +732 -0
- package/src/server/methods.ts +314 -0
- package/src/server/remote.ts +77 -0
- package/src/server/rpc.ts +116 -0
- package/src/server/serve.ts +28 -0
- package/src/server/server.test.ts +174 -0
- package/src/sessions/approvals.ts +118 -0
- package/src/sessions/contracts.ts +299 -0
- package/src/sessions/conversation.test.ts +150 -0
- package/src/sessions/extensionSession.test.ts +74 -0
- package/src/sessions/extensionUi.test.ts +65 -0
- package/src/sessions/extensionUi.ts +102 -0
- package/src/sessions/history.ts +51 -0
- package/src/sessions/lineageJoin.ts +75 -0
- package/src/sessions/policy.ts +32 -0
- package/src/sessions/pricing.ts +60 -0
- package/src/sessions/reasoningSummaries.ts +59 -0
- package/src/sessions/registry.ts +294 -0
- package/src/sessions/runIndex.ts +99 -0
- package/src/sessions/runStore.test.ts +152 -0
- package/src/sessions/runStore.ts +332 -0
- package/src/sessions/runner.ts +556 -0
- package/src/sessions/runtimeResources.test.ts +61 -0
- package/src/sessions/sessions.test.ts +434 -0
- package/src/sessions/sessions.ts +295 -0
- package/src/skills/registry.test.ts +104 -0
- package/src/skills/registry.ts +407 -0
- package/src/terminal/cli.ts +149 -0
- package/src/terminal/client.ts +74 -0
- package/src/terminal/controller.ts +227 -0
- package/src/terminal/piExtension.ts +208 -0
- package/src/terminal/pluginControl.test.ts +146 -0
- package/src/terminal/pluginMenu.ts +112 -0
- package/src/terminal/socket.ts +182 -0
- package/src/terminal/standalone.test.ts +115 -0
- package/src/terminal/standalone.ts +126 -0
- package/src/terminal/terminal.test.ts +302 -0
- package/src/terminal/terminals.ts +429 -0
- package/src/tools/askUser.ts +45 -0
- package/src/tools/definitions.ts +187 -0
- package/src/tools/memoryTools.ts +22 -0
- package/src/tools/pluginPropose.ts +41 -0
- package/src/tools/types.ts +36 -0
- package/src/web/safeRequest.ts +171 -0
- package/src/web/text.ts +53 -0
- package/src/web/tools.ts +134 -0
- 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
|
+
}
|