@xingular/harness 2.1.2 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/NOTICE.md +22 -0
- package/README.md +67 -36
- package/dist/cli.js +6023 -6621
- package/dist/terminal-pi.js +19 -24
- package/docs/terminal-tui.md +27 -7
- package/package.json +2 -2
- package/src/accounts/accounts.ts +4 -3
- package/src/capabilities/api.ts +1 -1
- package/src/cli.ts +7 -3
- package/src/core/caller.ts +34 -0
- package/src/core/fs.ts +17 -1
- package/src/harness.ts +55 -66
- package/src/{memory → history}/recall.test.ts +1 -1
- package/src/{memory → history}/recall.ts +1 -1
- package/src/{memory → history}/trajectory.test.ts +4 -4
- package/src/{memory → history}/trajectory.ts +1 -1
- package/src/hub/hub.test.ts +4 -5
- package/src/hub/hub.ts +2 -2
- package/src/index.ts +28 -14
- package/src/mcp/connections.ts +1 -1
- package/src/mcp/service.ts +1 -1
- package/src/pi/proxy.test.ts +68 -0
- package/src/pi/runtime.test.ts +61 -0
- package/src/pi/runtime.ts +50 -0
- package/src/plugins/api.ts +11 -9
- package/src/plugins/builtin.ts +22 -40
- package/src/plugins/catalogue.test.ts +13 -41
- package/src/plugins/catalogue.ts +3 -32
- package/src/plugins/discover.ts +2 -1
- package/src/plugins/firstParty.test.ts +1 -1
- package/src/plugins/hooks.test.ts +21 -71
- package/src/plugins/hooks.ts +6 -7
- package/src/plugins/identity.ts +16 -2
- package/src/plugins/install.test.ts +1 -1
- package/src/plugins/install.ts +2 -1
- package/src/plugins/inventory.ts +19 -18
- package/src/plugins/plugins.ts +38 -2
- package/src/prompt/compile.test.ts +82 -193
- package/src/prompt/compile.ts +98 -363
- package/src/refine/cli.ts +114 -0
- package/src/refine/demo.ts +124 -0
- package/src/refine/evidence.ts +349 -0
- package/src/refine/host.ts +102 -0
- package/src/refine/ledger.ts +104 -0
- package/src/refine/library.ts +192 -0
- package/src/refine/pi.test.ts +90 -0
- package/src/refine/pi.ts +218 -0
- package/src/refine/prompts.ts +430 -0
- package/src/refine/recursion.ts +353 -0
- package/src/refine/refine.test.ts +140 -0
- package/src/refine/runner.ts +205 -0
- package/src/refine/selection.ts +291 -0
- package/src/refine/service.ts +194 -0
- package/src/refine/trajectory.ts +71 -0
- package/src/refine/types.ts +91 -0
- package/src/server/methods.ts +137 -123
- package/src/server/server.test.ts +37 -30
- package/src/sessions/contracts.ts +6 -1
- package/src/sessions/conversation.test.ts +2 -27
- package/src/sessions/policy.ts +1 -1
- package/src/sessions/pricing.ts +3 -1
- package/src/sessions/registry.ts +5 -1
- package/src/sessions/runStore.ts +3 -1
- package/src/sessions/runner.ts +10 -5
- package/src/sessions/sessions.test.ts +45 -6
- package/src/sessions/sessions.ts +40 -17
- package/src/sessions/subagentService.test.ts +22 -1
- package/src/sessions/subagentService.ts +14 -1
- package/src/sessions/subagents.ts +2 -0
- package/src/terminal/cli.ts +24 -48
- package/src/terminal/pluginControl.test.ts +7 -8
- package/src/terminal/socket.ts +3 -3
- package/src/terminal/terminal.test.ts +21 -39
- package/src/terminal/terminals.ts +20 -13
- package/src/terminal/tui/application.test.ts +58 -0
- package/src/terminal/tui/application.ts +10 -4
- package/src/terminal/tui/clipboardFiles.test.ts +94 -0
- package/src/terminal/tui/clipboardFiles.ts +86 -0
- package/src/terminal/tui/piAdapter.ts +7 -4
- package/src/terminal/tui/runtime.ts +2 -0
- package/src/tools/definitions.ts +49 -132
- package/src/tools/dispatch.ts +78 -0
- package/src/tools/harnessTools.ts +23 -0
- package/src/tools/spawnSubagent.ts +3 -3
- package/src/tools/types.ts +1 -1
- package/src/tree/components.ts +221 -0
- package/src/tree/materialise.ts +122 -0
- package/src/tree/seed.ts +123 -0
- package/src/tree/store.ts +193 -0
- package/src/tree/surface.ts +135 -0
- package/src/tree/tree.test.ts +126 -0
- package/src/tree/tree.ts +346 -0
- package/src/tree/trust.ts +86 -0
- package/src/wiki/notes.ts +313 -0
- package/src/memory/api.test.ts +0 -82
- package/src/memory/api.ts +0 -303
- package/src/memory/blocks.test.ts +0 -78
- package/src/memory/blocks.ts +0 -408
- package/src/memory/caller.ts +0 -69
- package/src/memory/curation.test.ts +0 -206
- package/src/memory/curation.ts +0 -64
- package/src/memory/dispatch.ts +0 -64
- package/src/memory/facts.test.ts +0 -41
- package/src/memory/facts.ts +0 -248
- package/src/memory/graph.ts +0 -103
- package/src/memory/health.test.ts +0 -130
- package/src/memory/health.ts +0 -74
- package/src/memory/maintainer.ts +0 -272
- package/src/memory/maintenance.test.ts +0 -192
- package/src/memory/maintenance.ts +0 -679
- package/src/memory/migrate.ts +0 -79
- package/src/memory/operations.ts +0 -150
- package/src/memory/proposals.test.ts +0 -168
- package/src/memory/proposals.ts +0 -584
- package/src/memory/references.ts +0 -77
- package/src/memory/runContext.ts +0 -93
- package/src/memory/store.test.ts +0 -66
- package/src/memory/store.ts +0 -385
- package/src/memory/wiki.test.ts +0 -47
- package/src/memory/wiki.ts +0 -423
- package/src/memory/wikiIndex.test.ts +0 -88
- package/src/memory/wikiIndex.ts +0 -117
- package/src/plugins/proposal.ts +0 -121
- package/src/skills/registry.test.ts +0 -104
- package/src/skills/registry.ts +0 -413
- package/src/tools/maintenance.ts +0 -29
- package/src/tools/memoryTools.ts +0 -22
- package/src/tools/pluginPropose.ts +0 -41
- package/src/tools/registryWrite.test.ts +0 -101
- package/src/tools/registryWrite.ts +0 -182
- /package/src/{memory → core}/secrets.ts +0 -0
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
// The Wiki: plain Markdown notes about projects, linked to each other. It is reference material
|
|
2
|
+
// a person and agents read and write; it is not what shapes an agent (that is the harness tree).
|
|
3
|
+
//
|
|
4
|
+
// <data>/wiki/home.md
|
|
5
|
+
// <data>/wiki/library/**.md notes that belong to no project
|
|
6
|
+
// <data>/wiki/projects/<slug>/**.md a project's notes
|
|
7
|
+
// <data>/wiki-history/<path>/<time>.md earlier versions, one file per save
|
|
8
|
+
//
|
|
9
|
+
// A note's path is its file path without `.md`. Links are `[[path]]`, `[[path|label]]`, or a
|
|
10
|
+
// Markdown link to a relative `.md` file.
|
|
11
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync } from 'node:fs'
|
|
12
|
+
import { dirname, join, relative, sep } from 'node:path'
|
|
13
|
+
|
|
14
|
+
import { noteChange } from '../core/changes'
|
|
15
|
+
import { HarnessError, invalid, notFound } from '../core/errors'
|
|
16
|
+
import * as frontmatter from '../core/frontmatter'
|
|
17
|
+
import { atomicWrite, now } from '../core/fs'
|
|
18
|
+
import { projectsOrNone } from '../core/projects'
|
|
19
|
+
import { looksSecret } from '../core/secrets'
|
|
20
|
+
|
|
21
|
+
/** Folders an earlier Wiki kept beside its pages. They are moved aside, never read as notes. */
|
|
22
|
+
export const LEGACY_FOLDERS = ['blocks', 'facts', 'raw', 'revisions', 'inbox', 'operations', 'trash'] as const
|
|
23
|
+
const MAX_NOTE = 400_000
|
|
24
|
+
|
|
25
|
+
export function wikiDir(root: string): string {
|
|
26
|
+
return join(root, 'wiki')
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function historyDir(root: string): string {
|
|
30
|
+
return join(root, 'wiki-history')
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface NoteSummary {
|
|
34
|
+
path: string
|
|
35
|
+
title: string
|
|
36
|
+
/** The project slug, or null for the Library and Home. */
|
|
37
|
+
project: string | null
|
|
38
|
+
updatedAt: string
|
|
39
|
+
size: number
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface Note extends NoteSummary {
|
|
43
|
+
body: string
|
|
44
|
+
links: string[]
|
|
45
|
+
backlinks: string[]
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Accepts a note path such as `projects/app/decisions` or `library/glossary`. */
|
|
49
|
+
export function cleanPath(path: string): string {
|
|
50
|
+
const clean = String(path ?? '').trim().replace(/\\/g, '/').replace(/^\/+|\/+$/g, '').replace(/\.md$/i, '')
|
|
51
|
+
const parts = clean.split('/')
|
|
52
|
+
if (!clean || parts.some((part) => !part || part === '.' || part === '..' || part.startsWith('.'))) throw invalid('A note path is a relative path such as projects/<slug>/<name>.')
|
|
53
|
+
if (parts.length === 1 && clean !== 'home') throw invalid('A note lives under library/ or projects/<slug>/.')
|
|
54
|
+
if (parts.length > 1 && parts[0] !== 'library' && parts[0] !== 'projects') throw invalid('A note lives under library/ or projects/<slug>/.')
|
|
55
|
+
if (parts[0] === 'projects' && parts.length < 3) throw invalid('A project note is projects/<slug>/<name>.')
|
|
56
|
+
if ((LEGACY_FOLDERS as readonly string[]).includes(parts[0]!)) throw invalid('That folder is not part of the Wiki.')
|
|
57
|
+
return clean
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function fileOf(root: string, path: string): string {
|
|
61
|
+
return join(wikiDir(root), `${cleanPath(path)}.md`)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function projectOfPath(path: string): string | null {
|
|
65
|
+
const parts = path.split('/')
|
|
66
|
+
return parts[0] === 'projects' ? parts[1] ?? null : null
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function titleOf(path: string, text: string): string {
|
|
70
|
+
const parsed = safeParse(text)
|
|
71
|
+
const declared = parsed.front?.title
|
|
72
|
+
if (typeof declared === 'string' && declared.trim()) return declared.trim()
|
|
73
|
+
const heading = /^#\s+(.+)$/m.exec(parsed.body)?.[1]?.trim()
|
|
74
|
+
return heading || path.split('/').pop()!.replace(/[-_]/g, ' ')
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function safeParse(text: string): { front: Record<string, unknown> | null; body: string } {
|
|
78
|
+
try {
|
|
79
|
+
const parsed = frontmatter.parse(text)
|
|
80
|
+
return parsed ? { front: parsed.front, body: parsed.body } : { front: null, body: text }
|
|
81
|
+
} catch {
|
|
82
|
+
return { front: null, body: text }
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function walk(dir: string, into: string[]): void {
|
|
87
|
+
let names: string[]
|
|
88
|
+
try {
|
|
89
|
+
names = readdirSync(dir)
|
|
90
|
+
} catch {
|
|
91
|
+
return
|
|
92
|
+
}
|
|
93
|
+
for (const name of names.sort()) {
|
|
94
|
+
if (name.startsWith('.')) continue
|
|
95
|
+
const path = join(dir, name)
|
|
96
|
+
let isDir = false
|
|
97
|
+
try {
|
|
98
|
+
isDir = statSync(path).isDirectory()
|
|
99
|
+
} catch {
|
|
100
|
+
continue
|
|
101
|
+
}
|
|
102
|
+
if (isDir) walk(path, into)
|
|
103
|
+
else if (name.endsWith('.md')) into.push(path)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Every note path, Home first, then the Library, then projects. */
|
|
108
|
+
export function paths(root: string): string[] {
|
|
109
|
+
const wiki = wikiDir(root)
|
|
110
|
+
const files: string[] = []
|
|
111
|
+
if (existsSync(join(wiki, 'home.md'))) files.push(join(wiki, 'home.md'))
|
|
112
|
+
walk(join(wiki, 'library'), files)
|
|
113
|
+
walk(join(wiki, 'projects'), files)
|
|
114
|
+
return files.map((file) => relative(wiki, file).split(sep).join('/').replace(/\.md$/, ''))
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function summary(root: string, path: string): NoteSummary {
|
|
118
|
+
const file = fileOf(root, path)
|
|
119
|
+
const text = readFileSync(file, 'utf8')
|
|
120
|
+
return { path, title: titleOf(path, text), project: projectOfPath(path), updatedAt: statSync(file).mtime.toISOString(), size: text.length }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function list(root: string, options: { project?: string | null } = {}): NoteSummary[] {
|
|
124
|
+
return paths(root)
|
|
125
|
+
.filter((path) => options.project === undefined || projectOfPath(path) === options.project)
|
|
126
|
+
.map((path) => summary(root, path))
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const WIKI_LINK = /\[\[([^\]|#]+)(?:#[^\]|]*)?(?:\|[^\]]*)?\]\]/g
|
|
130
|
+
const MD_LINK = /\[[^\]]*\]\(([^)\s]+\.md)(?:#[^)]*)?\)/g
|
|
131
|
+
|
|
132
|
+
/** Resolves a link written in `from` to a note path, or null when it points nowhere known. */
|
|
133
|
+
export function resolveLink(target: string, from: string, known: ReadonlySet<string>, titles: ReadonlyMap<string, string>): string | null {
|
|
134
|
+
const raw = target.trim().replace(/\.md$/i, '')
|
|
135
|
+
if (!raw || /^[a-z]+:/i.test(raw)) return null
|
|
136
|
+
const candidates: string[] = [raw.replace(/^\/+/, '')]
|
|
137
|
+
if (!raw.startsWith('/')) {
|
|
138
|
+
const base = from.split('/').slice(0, -1)
|
|
139
|
+
const parts = [...base]
|
|
140
|
+
for (const part of raw.split('/')) {
|
|
141
|
+
if (part === '..') parts.pop()
|
|
142
|
+
else if (part && part !== '.') parts.push(part)
|
|
143
|
+
}
|
|
144
|
+
candidates.unshift(parts.join('/'))
|
|
145
|
+
const project = projectOfPath(from)
|
|
146
|
+
if (project) candidates.push(`projects/${project}/${raw}`)
|
|
147
|
+
candidates.push(`library/${raw}`)
|
|
148
|
+
}
|
|
149
|
+
for (const candidate of candidates) if (known.has(candidate)) return candidate
|
|
150
|
+
return titles.get(raw.toLowerCase()) ?? null
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function linksIn(body: string, from: string, known: ReadonlySet<string>, titles: ReadonlyMap<string, string>): string[] {
|
|
154
|
+
const out = new Set<string>()
|
|
155
|
+
for (const match of body.matchAll(WIKI_LINK)) {
|
|
156
|
+
const found = resolveLink(match[1]!, from, known, titles)
|
|
157
|
+
if (found && found !== from) out.add(found)
|
|
158
|
+
}
|
|
159
|
+
for (const match of body.matchAll(MD_LINK)) {
|
|
160
|
+
const found = resolveLink(match[1]!, from, known, titles)
|
|
161
|
+
if (found && found !== from) out.add(found)
|
|
162
|
+
}
|
|
163
|
+
return [...out].sort()
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export interface Graph {
|
|
167
|
+
nodes: NoteSummary[]
|
|
168
|
+
edges: { from: string; to: string }[]
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Every note and every resolved link between notes. */
|
|
172
|
+
export function graph(root: string): Graph {
|
|
173
|
+
const nodes = list(root)
|
|
174
|
+
const known = new Set(nodes.map((node) => node.path))
|
|
175
|
+
const titles = new Map(nodes.map((node) => [node.title.toLowerCase(), node.path]))
|
|
176
|
+
const edges: Graph['edges'] = []
|
|
177
|
+
for (const node of nodes) {
|
|
178
|
+
const body = readFileSync(fileOf(root, node.path), 'utf8')
|
|
179
|
+
for (const to of linksIn(body, node.path, known, titles)) edges.push({ from: node.path, to })
|
|
180
|
+
}
|
|
181
|
+
return { nodes, edges }
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function read(root: string, path: string): Note {
|
|
185
|
+
const clean = cleanPath(path)
|
|
186
|
+
const file = fileOf(root, clean)
|
|
187
|
+
if (!existsSync(file)) throw notFound(`The note ${clean}`)
|
|
188
|
+
const text = readFileSync(file, 'utf8')
|
|
189
|
+
const { nodes, edges } = graph(root)
|
|
190
|
+
const known = new Set(nodes.map((node) => node.path))
|
|
191
|
+
const titles = new Map(nodes.map((node) => [node.title.toLowerCase(), node.path]))
|
|
192
|
+
return {
|
|
193
|
+
...summary(root, clean),
|
|
194
|
+
body: text,
|
|
195
|
+
links: linksIn(text, clean, known, titles),
|
|
196
|
+
backlinks: edges.filter((edge) => edge.to === clean).map((edge) => edge.from).sort()
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Writes a note, keeping the earlier text as a version. Returns the note as saved. */
|
|
201
|
+
export function write(root: string, path: string, body: string, options: { expectedUpdatedAt?: string | null; actor?: string; reason?: string } = {}): Note {
|
|
202
|
+
const clean = cleanPath(path)
|
|
203
|
+
if (typeof body !== 'string') throw invalid('A note needs text.')
|
|
204
|
+
if (body.length > MAX_NOTE) throw invalid(`A note may hold at most ${MAX_NOTE.toLocaleString('en-US')} characters.`)
|
|
205
|
+
if (looksSecret(body)) throw new HarnessError('secret_refused', 'That text looks like it contains a credential, so it was not saved.')
|
|
206
|
+
const project = projectOfPath(clean)
|
|
207
|
+
if (project && !projectsOrNone(root).some((known) => known.slug === project)) throw invalid(`There is no project with the slug ${project}.`)
|
|
208
|
+
const file = fileOf(root, clean)
|
|
209
|
+
if (existsSync(file)) {
|
|
210
|
+
const current = statSync(file).mtime.toISOString()
|
|
211
|
+
if (options.expectedUpdatedAt && options.expectedUpdatedAt !== current) throw new HarnessError('conflict', 'The note changed since it was opened. Reload it and try again.')
|
|
212
|
+
const previous = readFileSync(file, 'utf8')
|
|
213
|
+
if (previous === body) return read(root, clean)
|
|
214
|
+
atomicWrite(join(historyDir(root), clean, `${now().replace(/[:.]/g, '-')}.md`), previous)
|
|
215
|
+
}
|
|
216
|
+
mkdirSync(dirname(file), { recursive: true })
|
|
217
|
+
atomicWrite(file, body)
|
|
218
|
+
noteChange({ wiki: wikiDir(root), id: clean, path: file, text: body, actor: options.actor ?? 'user', reason: options.reason ?? 'Saved' })
|
|
219
|
+
return read(root, clean)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Removes a note; its last text is kept as a version. */
|
|
223
|
+
export function remove(root: string, path: string): void {
|
|
224
|
+
const clean = cleanPath(path)
|
|
225
|
+
const file = fileOf(root, clean)
|
|
226
|
+
if (!existsSync(file)) throw notFound(`The note ${clean}`)
|
|
227
|
+
atomicWrite(join(historyDir(root), clean, `${now().replace(/[:.]/g, '-')}.md`), readFileSync(file, 'utf8'))
|
|
228
|
+
rmSync(file)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export interface Version {
|
|
232
|
+
id: string
|
|
233
|
+
savedAt: string
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export function versions(root: string, path: string): Version[] {
|
|
237
|
+
const dir = join(historyDir(root), cleanPath(path))
|
|
238
|
+
try {
|
|
239
|
+
return readdirSync(dir).filter((name) => name.endsWith('.md')).sort().reverse().map((name) => ({ id: name.replace(/\.md$/, ''), savedAt: statSync(join(dir, name)).mtime.toISOString() }))
|
|
240
|
+
} catch {
|
|
241
|
+
return []
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export function version(root: string, path: string, id: string): string {
|
|
246
|
+
if (!/^[0-9A-Za-z-]+$/.test(id)) throw invalid('Invalid version.')
|
|
247
|
+
const file = join(historyDir(root), cleanPath(path), `${id}.md`)
|
|
248
|
+
if (!existsSync(file)) throw notFound('That version')
|
|
249
|
+
return readFileSync(file, 'utf8')
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export interface Hit {
|
|
253
|
+
path: string
|
|
254
|
+
title: string
|
|
255
|
+
project: string | null
|
|
256
|
+
excerpt: string
|
|
257
|
+
score: number
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Notes that contain the query's words, best first. The current project's notes rank higher. */
|
|
261
|
+
export function search(root: string, query: string, options: { project?: string | null; limit?: number } = {}): Hit[] {
|
|
262
|
+
const words = [...new Set(String(query ?? '').toLowerCase().split(/[^\p{L}\p{N}]+/u).filter((word) => word.length > 1))].slice(0, 16)
|
|
263
|
+
if (!words.length) return []
|
|
264
|
+
const hits: Hit[] = []
|
|
265
|
+
for (const path of paths(root)) {
|
|
266
|
+
const text = readFileSync(fileOf(root, path), 'utf8')
|
|
267
|
+
const lower = text.toLowerCase()
|
|
268
|
+
const title = titleOf(path, text)
|
|
269
|
+
let score = 0
|
|
270
|
+
for (const word of words) {
|
|
271
|
+
if (title.toLowerCase().includes(word)) score += 5
|
|
272
|
+
if (path.toLowerCase().includes(word)) score += 2
|
|
273
|
+
let at = lower.indexOf(word)
|
|
274
|
+
let count = 0
|
|
275
|
+
while (at >= 0 && count < 20) {
|
|
276
|
+
count++
|
|
277
|
+
at = lower.indexOf(word, at + word.length)
|
|
278
|
+
}
|
|
279
|
+
score += count
|
|
280
|
+
}
|
|
281
|
+
if (!score) continue
|
|
282
|
+
const project = projectOfPath(path)
|
|
283
|
+
if (options.project && project === options.project) score *= 1.5
|
|
284
|
+
const first = Math.max(0, Math.min(...words.map((word) => lower.indexOf(word)).filter((at) => at >= 0)))
|
|
285
|
+
const excerpt = text.slice(Math.max(0, first - 80), first + 160).replace(/\s+/g, ' ').trim()
|
|
286
|
+
hits.push({ path, title, project, excerpt, score })
|
|
287
|
+
}
|
|
288
|
+
return hits.sort((a, b) => b.score - a.score || a.path.localeCompare(b.path)).slice(0, Math.min(Math.max(options.limit ?? 10, 1), 50))
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Creates the Wiki's folders and Home note. Existing files are never replaced. */
|
|
292
|
+
export function ensureLayout(root: string, productName: string): void {
|
|
293
|
+
const wiki = wikiDir(root)
|
|
294
|
+
mkdirSync(join(wiki, 'library'), { recursive: true })
|
|
295
|
+
mkdirSync(join(wiki, 'projects'), { recursive: true })
|
|
296
|
+
if (!existsSync(join(wiki, 'home.md'))) atomicWrite(join(wiki, 'home.md'), `# Home\n\nNotes about your projects for ${productName}. Link notes with [[library/name]] or [[projects/<slug>/name]].\n`)
|
|
297
|
+
for (const project of projectsOrNone(root)) mkdirSync(join(wiki, 'projects', project.slug), { recursive: true })
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Moves what an earlier Wiki kept beside its notes (memory blocks, facts, sources, revisions,
|
|
302
|
+
* the Inbox) to `<data>/legacy/wiki-<time>/`, once. Nothing is deleted. Returns where it went.
|
|
303
|
+
*/
|
|
304
|
+
export function moveLegacy(root: string): string | null {
|
|
305
|
+
const wiki = wikiDir(root)
|
|
306
|
+
const found = LEGACY_FOLDERS.filter((name) => existsSync(join(wiki, name)))
|
|
307
|
+
const loose = ['index.md', 'log.md', 'schema.md'].filter((name) => existsSync(join(wiki, name)))
|
|
308
|
+
if (!found.length && !loose.length) return null
|
|
309
|
+
const dest = join(root, 'legacy', `wiki-${now().replace(/[:.]/g, '-')}`)
|
|
310
|
+
mkdirSync(dest, { recursive: true })
|
|
311
|
+
for (const name of [...found, ...loose]) renameSync(join(wiki, name), join(dest, name))
|
|
312
|
+
return dest
|
|
313
|
+
}
|
package/src/memory/api.test.ts
DELETED
|
@@ -1,82 +0,0 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, writeFileSync } from 'node:fs'
|
|
2
|
-
import { join } from 'node:path'
|
|
3
|
-
import { afterEach, expect, test } from 'vitest'
|
|
4
|
-
|
|
5
|
-
import { dataRoot, writeFile } from '../../tests/helpers'
|
|
6
|
-
import { createEnv, type HarnessEnv } from '../core/env'
|
|
7
|
-
import { writeJson } from '../core/fs'
|
|
8
|
-
import { closeIndex } from '../core/index'
|
|
9
|
-
import { launchText } from '../prompt/compile'
|
|
10
|
-
import { list, memories, memoryEdit, propose, read, search, skill } from './api'
|
|
11
|
-
import * as blocks from './blocks'
|
|
12
|
-
import type { CallerContext } from './caller'
|
|
13
|
-
import { findMemory } from './facts'
|
|
14
|
-
import { readRunContext } from './runContext'
|
|
15
|
-
import { ensureProjectNamespace, wikiDir } from './store'
|
|
16
|
-
import { reindexWiki } from './wikiIndex'
|
|
17
|
-
|
|
18
|
-
const roots: string[] = []
|
|
19
|
-
afterEach(() => roots.splice(0).forEach(closeIndex))
|
|
20
|
-
|
|
21
|
-
function setup(): HarnessEnv {
|
|
22
|
-
const root = dataRoot()
|
|
23
|
-
roots.push(root)
|
|
24
|
-
writeJson(join(root, 'projects.json'), [
|
|
25
|
-
{ id: 'p1', name: 'Xingular Agent', rootPath: '/src/xingular-agent', autoMaintenance: true, createdAt: 't', slug: 'xingular-agent' },
|
|
26
|
-
{ id: 'p2', name: 'Site', rootPath: '/src/site', autoMaintenance: true, createdAt: 't', slug: 'site' }
|
|
27
|
-
])
|
|
28
|
-
ensureProjectNamespace(root, 'xingular-agent', 'Xingular Agent')
|
|
29
|
-
ensureProjectNamespace(root, 'site', 'Site')
|
|
30
|
-
const wiki = wikiDir(root)
|
|
31
|
-
writeFileSync(join(wiki, 'projects/site/deploy.md'), '---\nid: deploy\ntitle: Deploying the site\ntype: decision\nsourceIds: [run:r0]\n---\n\nThe site deploys with wrangler.\n')
|
|
32
|
-
writeFileSync(join(wiki, 'facts/pnpm.md'), '---\nid: pnpm\ntitle: Use pnpm\nkind: preference\nnamespace: /\nconfidence: high\n---\n\nUse pnpm, not npm.\n')
|
|
33
|
-
writeFileSync(join(wiki, 'facts/site-only.md'), '---\nid: site-only\ntitle: Site uses Astro\nkind: fact\nnamespace: /projects/site\n---\n\nAstro.\n')
|
|
34
|
-
writeFile(join(root, 'registry/rules/tests.md'), '---\nname: Run tests\n---\n\nAlways run the tests.\n')
|
|
35
|
-
writeFile(join(root, 'registry/skills/review/SKILL.md'), '---\nname: review\ndescription: Review a change\n---\n\nRead the diff first.\n')
|
|
36
|
-
writeJson(join(root, 'config/registry-enabled.json'), { global: ['rules/tests'], p1: ['skills/review'] })
|
|
37
|
-
mkdirSync(join(root, 'runs/run-1'), { recursive: true })
|
|
38
|
-
writeJson(join(root, 'runs/run-1/run.json'), { id: 'run-1', projectId: 'p1', mode: 'code', harness: 'pi', sessionType: 'structured', sessionId: 's1', nativeSessionId: null, workingDirectory: null, worktreeId: null, goal: 'Please keep commits small from now on.', startedAt: 't', endedAt: null, status: 'running' })
|
|
39
|
-
reindexWiki(root)
|
|
40
|
-
return createEnv(root)
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
const context = (project: string): CallerContext => ({ runId: 'run-1', projectId: project, actor: 'Code' })
|
|
44
|
-
|
|
45
|
-
test('sessions edit their project brief through memory calls', () => {
|
|
46
|
-
const env = setup()
|
|
47
|
-
expect(memoryEdit(env, context('p1'), 'memory.append', { block: 'project', text: 'Tauri 2 desktop app.' })).toContain('project/xingular-agent')
|
|
48
|
-
expect(launchText(env, context('p1'))).toContain('Tauri 2 desktop app.')
|
|
49
|
-
expect(() => memoryEdit(env, context('p1'), 'memory.append', { block: 'user', text: 'x' })).toThrow()
|
|
50
|
-
// A session reads its project's brief by id, but never an agent's blocks.
|
|
51
|
-
expect(read(env, context('p1'), { id: 'blocks/projects/xingular-agent/brief' })).toContain('Tauri 2 desktop app.')
|
|
52
|
-
blocks.ensureAgent(wikiDir(env.root), 'a1')
|
|
53
|
-
expect(() => read(env, context('p1'), { id: 'blocks/agents/a1/persona' })).toThrow()
|
|
54
|
-
expect(() => memoryEdit(env, { actor: 'Chat' }, 'memory.append', { block: 'user', text: 'x' })).toThrow()
|
|
55
|
-
expect(readRunContext(env.root, 'run-1').some((entry) => entry.id === 'blocks/projects/xingular-agent/brief' && entry.via === 'edit')).toBe(true)
|
|
56
|
-
})
|
|
57
|
-
|
|
58
|
-
test('agents read across project namespaces', () => {
|
|
59
|
-
const env = setup()
|
|
60
|
-
const hits = search(env, context('p1'), { query: 'wrangler' })
|
|
61
|
-
expect(hits).toContain('Deploying the site')
|
|
62
|
-
expect(hits).toContain('/projects/site/deploy')
|
|
63
|
-
const page = read(env, context('p1'), { id: '/projects/site/deploy' })
|
|
64
|
-
expect(page).toContain('The site deploys with wrangler.')
|
|
65
|
-
expect(page).toContain('Sources: run:r0')
|
|
66
|
-
expect(skill(env, context('p2'), { name: 'review' })).toContain('Read the diff first.')
|
|
67
|
-
expect(memories(env, context('p1'), { namespace: '/projects/site' })).toContain('Astro')
|
|
68
|
-
expect(list(env, {})).toContain('/projects/site')
|
|
69
|
-
expect(readRunContext(env.root, 'run-1').some((entry) => entry.id === 'deploy' && entry.via === 'read')).toBe(true)
|
|
70
|
-
})
|
|
71
|
-
|
|
72
|
-
test('proposals apply in the project namespace or wait in the inbox', () => {
|
|
73
|
-
const env = setup()
|
|
74
|
-
const applied = propose(env, context('p1'), { kind: 'memory', title: 'Small commits', type: 'preference', body: 'Keep commits small.', confidence: 'high', sourceRefs: [{ kind: 'run', ref: 'current', excerpt: 'please keep commits small' }] })
|
|
75
|
-
expect(applied).toContain('saved')
|
|
76
|
-
expect(findMemory(wikiDir(env.root), 'small-commits').front.namespace).toBe('/projects/xingular-agent')
|
|
77
|
-
expect(propose(env, context('p1'), { kind: 'page', title: 'Rumor', body: 'Maybe.' })).toContain('Inbox')
|
|
78
|
-
// The caller's own run without a quote from it is not evidence.
|
|
79
|
-
expect(propose(env, context('p1'), { kind: 'memory', title: 'Bare', body: 'Unquoted.', confidence: 'high', sourceRefs: [{ kind: 'run', ref: 'current' }] })).toContain('Inbox')
|
|
80
|
-
expect(propose(env, context('p1'), { kind: 'skill', title: 'Deploy', body: 'Steps.' })).toContain('waiting in the Wiki Inbox')
|
|
81
|
-
expect(existsSync(join(env.root, 'registry/skills/deploy'))).toBe(false)
|
|
82
|
-
})
|