@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
package/src/memory/runContext.ts
DELETED
|
@@ -1,93 +0,0 @@
|
|
|
1
|
-
// What a run was given and what it read: every Wiki entry a run uses is recorded in
|
|
2
|
-
// `runs/<id>/wiki-context.json`, so a person can see what informed a reply.
|
|
3
|
-
import { join } from 'node:path'
|
|
4
|
-
|
|
5
|
-
import { now, readJsonOr, safeId, writeJson } from '../core/fs'
|
|
6
|
-
import { runsForSession } from '../sessions/runIndex'
|
|
7
|
-
import type { CallerContext } from './caller'
|
|
8
|
-
import * as store from './store'
|
|
9
|
-
|
|
10
|
-
/** An entry and the revision that was current when it was used. */
|
|
11
|
-
export interface Source {
|
|
12
|
-
id: string
|
|
13
|
-
kind: string
|
|
14
|
-
revision: string | null
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
export interface ContextEntry {
|
|
18
|
-
id: string
|
|
19
|
-
kind: string
|
|
20
|
-
revisionId: string | null
|
|
21
|
-
/** `entrypoint`, `search`, `read`, `skill`, `memory`, or `edit` (an entry the run changed). */
|
|
22
|
-
via: string
|
|
23
|
-
at: string
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
export function contextPath(root: string, runId: string): string {
|
|
27
|
-
return join(root, 'runs', safeId(runId), 'wiki-context.json')
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
export function readRunContext(root: string, runId: string): ContextEntry[] {
|
|
31
|
-
return readJsonOr<ContextEntry[]>(contextPath(root, runId), [])
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/** The blocks, facts, and pages these runs changed, without repeats: `[id, kind]`. */
|
|
35
|
-
export function editedBy(root: string, runIds: readonly string[]): [string, string][] {
|
|
36
|
-
const edited: [string, string][] = []
|
|
37
|
-
for (const runId of runIds) {
|
|
38
|
-
let entries: ContextEntry[]
|
|
39
|
-
try {
|
|
40
|
-
entries = readRunContext(root, runId)
|
|
41
|
-
} catch {
|
|
42
|
-
continue
|
|
43
|
-
}
|
|
44
|
-
for (const entry of entries) if (entry.via === 'edit' && !edited.some(([id]) => id === entry.id)) edited.push([entry.id, entry.kind])
|
|
45
|
-
}
|
|
46
|
-
return edited
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/** The run a call belongs to: the caller's run, else the session's latest run. */
|
|
50
|
-
function runOf(root: string, context: CallerContext): string | null {
|
|
51
|
-
if (context.runId) return context.runId
|
|
52
|
-
if (!context.sessionId) return null
|
|
53
|
-
try {
|
|
54
|
-
return runsForSession(root, context.sessionId, 1)[0] ?? null
|
|
55
|
-
} catch {
|
|
56
|
-
return null
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
export function recordSources(root: string, context: CallerContext, via: string, sources: readonly Source[]): void {
|
|
61
|
-
const runId = runOf(root, context)
|
|
62
|
-
if (!runId || !sources.length) return
|
|
63
|
-
let path: string
|
|
64
|
-
try {
|
|
65
|
-
path = contextPath(root, runId)
|
|
66
|
-
} catch {
|
|
67
|
-
return
|
|
68
|
-
}
|
|
69
|
-
if (!store.isDir(join(root, 'runs', runId))) return
|
|
70
|
-
const recorded = readRunContext(root, runId)
|
|
71
|
-
for (const source of sources) {
|
|
72
|
-
if (!recorded.some((entry) => entry.id === source.id && entry.revisionId === source.revision && entry.via === via)) {
|
|
73
|
-
recorded.push({ id: source.id, kind: source.kind, revisionId: source.revision, via, at: now() })
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
try {
|
|
77
|
-
writeJson(path, recorded)
|
|
78
|
-
} catch {
|
|
79
|
-
// A record that cannot be written never fails the call it describes.
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/** `recordSources` for entries at their current revision: `[id, kind]`. */
|
|
84
|
-
export function record(root: string, context: CallerContext, via: string, entries: readonly [string, string][]): void {
|
|
85
|
-
const wiki = store.wikiDir(root)
|
|
86
|
-
recordSources(root, context, via, entries.map(([id, kind]) => ({ id, kind, revision: store.currentRevision(wiki, id) })))
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
/** `[id@revision]`, the citation form every reply uses. */
|
|
90
|
-
export function cite(wiki: string, id: string): string {
|
|
91
|
-
const revision = store.currentRevision(wiki, id)
|
|
92
|
-
return revision ? `[${id}@${revision.slice(0, 8)}]` : `[${id}]`
|
|
93
|
-
}
|
package/src/memory/store.test.ts
DELETED
|
@@ -1,66 +0,0 @@
|
|
|
1
|
-
import { mkdtempSync, readFileSync, 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 { ensureLayout, ensureProjectNamespace, importSource, isSkeleton, listSources, locate, namespaceDir, parsePage, projectNamespaceOf, revisionsDir, serializePage, wikiDir } from './store'
|
|
7
|
-
|
|
8
|
-
const temp = () => mkdtempSync(join(tmpdir(), 'harness-store-'))
|
|
9
|
-
|
|
10
|
-
test('every project has a links page that starts as a map and is never replaced', () => {
|
|
11
|
-
const root = temp()
|
|
12
|
-
ensureLayout(root)
|
|
13
|
-
ensureProjectNamespace(root, 'app', 'App')
|
|
14
|
-
const links = join(wikiDir(root), 'projects/app/links.md')
|
|
15
|
-
const text = readFileSync(links, 'utf8')
|
|
16
|
-
const page = parsePage(text, wikiDir(root), links)!
|
|
17
|
-
expect([page.frontmatter.id, page.frontmatter.type]).toEqual(['app-links', 'reference'])
|
|
18
|
-
expect(page.body).toContain('one line each')
|
|
19
|
-
expect(page.body).toContain('do not copy')
|
|
20
|
-
expect(isSkeleton(page.location)).toBe(true)
|
|
21
|
-
// The agent's or user's entries survive the next start, which runs this for every project.
|
|
22
|
-
writeFileSync(links, text.replace('Nothing recorded yet.', '- docs/specs/product.md: what the app is for'))
|
|
23
|
-
ensureProjectNamespace(root, 'app', 'App')
|
|
24
|
-
expect(readFileSync(links, 'utf8')).toContain('docs/specs/product.md')
|
|
25
|
-
})
|
|
26
|
-
|
|
27
|
-
test('derives paths and namespaces from file locations', () => {
|
|
28
|
-
const wiki = '/data/wiki'
|
|
29
|
-
expect(locate(wiki, '/data/wiki/projects/xingular-agent/design/arch.md')).toEqual({ path: '/projects/xingular-agent/design/arch', namespace: '/projects/xingular-agent' })
|
|
30
|
-
expect(locate(wiki, '/data/wiki/library/tauri.md').namespace).toBe('/library')
|
|
31
|
-
expect(locate(wiki, '/data/wiki/home.md')).toEqual({ path: '/home', namespace: '/' })
|
|
32
|
-
expect(namespaceDir(wiki, '/projects/xingular-agent')).toBe('/data/wiki/projects/xingular-agent')
|
|
33
|
-
expect(namespaceDir(wiki, '/')).toBe('/data/wiki/library')
|
|
34
|
-
expect(namespaceDir(wiki, '/docs/guides')).toBe('/data/wiki/library/guides')
|
|
35
|
-
expect(() => namespaceDir(wiki, '/projects/../x')).toThrow()
|
|
36
|
-
expect(() => namespaceDir(wiki, '/facts')).toThrow()
|
|
37
|
-
expect(projectNamespaceOf('/projects/a/b')).toBe('/projects/a')
|
|
38
|
-
expect(projectNamespaceOf('/library')).toBeNull()
|
|
39
|
-
})
|
|
40
|
-
|
|
41
|
-
test('imports each source once and versions replacements', () => {
|
|
42
|
-
const root = temp()
|
|
43
|
-
ensureLayout(root)
|
|
44
|
-
const wiki = wikiDir(root)
|
|
45
|
-
const first = importSource(wiki, 'notes.md', Buffer.from('one'))
|
|
46
|
-
expect(importSource(wiki, 'notes.md', Buffer.from('one')).id).toBe(first.id)
|
|
47
|
-
const second = importSource(wiki, 'notes.md', Buffer.from('two'))
|
|
48
|
-
expect(second.version).toBe(2)
|
|
49
|
-
expect(second.previousId).toBe(first.id)
|
|
50
|
-
expect(listSources(wiki)).toHaveLength(2)
|
|
51
|
-
})
|
|
52
|
-
|
|
53
|
-
test('round-trips frontmatter and drops legacy scope', () => {
|
|
54
|
-
const text = '---\nid: a\ntitle: A\ntype: concept\nscope: personal\nsourceIds:\n- src-1\n---\n\nBody\n'
|
|
55
|
-
const page = parsePage(text, '/w', '/w/library/a.md')!
|
|
56
|
-
const serialized = serializePage(page.frontmatter, page.body)
|
|
57
|
-
expect(serialized).not.toContain('scope')
|
|
58
|
-
const reparsed = parsePage(serialized, '/w', '/w/library/a.md')!
|
|
59
|
-
expect(reparsed.frontmatter.sourceIds).toEqual(['src-1'])
|
|
60
|
-
expect(reparsed.body.trim()).toBe('Body')
|
|
61
|
-
})
|
|
62
|
-
|
|
63
|
-
test('registry ids get revision folders', () => {
|
|
64
|
-
expect(revisionsDir('/w', 'skills/review')).toBe('/w/revisions/skills--review')
|
|
65
|
-
expect(() => revisionsDir('/w', '../x')).toThrow()
|
|
66
|
-
})
|
package/src/memory/store.ts
DELETED
|
@@ -1,385 +0,0 @@
|
|
|
1
|
-
// Canonical Wiki files under `<data root>/wiki/`. One global corpus: `home.md`, `library/**`,
|
|
2
|
-
// and `projects/<slug>/**` hold pages; `blocks/` holds memory blocks and `facts/` holds short
|
|
3
|
-
// typed facts; `raw/`, `revisions/`, `inbox/`, `operations/`, and `trash/` are shared. A page's Wiki path and
|
|
4
|
-
// namespace come from where its file lives, so they are never stored twice.
|
|
5
|
-
import { createHash } from 'node:crypto'
|
|
6
|
-
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync } from 'node:fs'
|
|
7
|
-
import { join, relative } from 'node:path'
|
|
8
|
-
import { stringify } from 'yaml'
|
|
9
|
-
|
|
10
|
-
import { DEFAULT_PRODUCT_NAME } from '../core/env'
|
|
11
|
-
import { invalid, notFound } from '../core/errors'
|
|
12
|
-
import * as frontmatter from '../core/frontmatter'
|
|
13
|
-
import { appendLine, atomicWrite, newId, now, readJson, safeId, writeJson } from '../core/fs'
|
|
14
|
-
import { comparePaths, compareText, lines, takeChars } from '../core/text'
|
|
15
|
-
|
|
16
|
-
export const ROOT_NAMESPACE = '/'
|
|
17
|
-
/** Pages that belong to no project. `/docs` (its name before storage version 3) is accepted as input. */
|
|
18
|
-
export const LIBRARY_NAMESPACE = '/library'
|
|
19
|
-
export const LAYOUT = ['blocks', 'facts', 'library', 'projects', 'raw', 'revisions', 'inbox', 'operations'] as const
|
|
20
|
-
|
|
21
|
-
export function wikiDir(root: string): string {
|
|
22
|
-
return join(root, 'wiki')
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
function createIfMissing(path: string, contents: string): void {
|
|
26
|
-
if (!existsSync(path)) atomicWrite(path, contents)
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export const SCHEMA = `# Wiki schema
|
|
30
|
-
|
|
31
|
-
- One global Wiki. \`home.md\` is the Home page, \`library/\` holds pages that belong to no project, and \`projects/<slug>/\` is a project's namespace.
|
|
32
|
-
- Pages carry frontmatter \`id\`, \`title\`, \`type\`, \`updatedAt\`, \`sourceIds\`, \`links\`. The path and namespace come from the file's location.
|
|
33
|
-
- \`blocks/\` holds memory blocks that are always in an agent's or session's context: \`shared/\`, \`agents/<agent-id>/\`, and \`projects/<slug>/brief.md\`.
|
|
34
|
-
- \`facts/\` holds short typed facts with \`kind\`, \`namespace\`, \`confidence\`, \`status\`, and sources.
|
|
35
|
-
- Skills, prompts, and rules live in the registry (\`registry/\`); pages and facts link to them by id (\`skills/<name>\`).
|
|
36
|
-
- \`raw/\` holds immutable source snapshots; replacing a source creates a new version.
|
|
37
|
-
- Every change writes a snapshot and manifest entry in \`revisions/\`. Claims without verifiable evidence go to \`inbox/\`.
|
|
38
|
-
`
|
|
39
|
-
|
|
40
|
-
/** Creates the Wiki skeleton. Existing files are never replaced. */
|
|
41
|
-
export function ensureLayout(root: string, productName = DEFAULT_PRODUCT_NAME): void {
|
|
42
|
-
ensureLayoutAt(wikiDir(root), productName)
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/** The skeleton in any folder, so a migration can stage it before it replaces the Wiki. */
|
|
46
|
-
export function ensureLayoutAt(directory: string, productName = DEFAULT_PRODUCT_NAME): void {
|
|
47
|
-
for (const part of LAYOUT) mkdirSync(join(directory, part), { recursive: true })
|
|
48
|
-
createIfMissing(
|
|
49
|
-
join(directory, 'home.md'),
|
|
50
|
-
`---\nid: home\ntitle: Home\ntype: summary\nsourceIds: []\nlinks: []\n---\n\n# Welcome\n\nThis is the shared ${productName} Wiki: memory blocks, knowledge, and the playbook every agent and session works from.\n`
|
|
51
|
-
)
|
|
52
|
-
createIfMissing(join(directory, 'index.md'), '# Wiki index\n\nNo pages yet.\n')
|
|
53
|
-
createIfMissing(join(directory, 'log.md'), '# Maintenance log\n\n')
|
|
54
|
-
createIfMissing(join(directory, 'schema.md'), SCHEMA)
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
// What a project's Links page says until something is recorded: a map of where the project's
|
|
58
|
-
// documents and notes are, not a copy of them.
|
|
59
|
-
const LINKS_PLACEHOLDER = 'Nothing recorded yet. List the documents, notes, and sources that matter for this project, one line each: a link or path, then what it is for and why it matters. Link to them; do not copy them here.'
|
|
60
|
-
|
|
61
|
-
/** The fixed pages every project namespace starts with, beside `overview`: file name, title, type. */
|
|
62
|
-
export const PROJECT_SKELETON = [
|
|
63
|
-
['decisions', 'Decisions', 'decision'],
|
|
64
|
-
['state', 'Current state', 'summary'],
|
|
65
|
-
['open-questions', 'Open questions', 'question'],
|
|
66
|
-
['links', 'Links', 'reference']
|
|
67
|
-
] as const
|
|
68
|
-
|
|
69
|
-
function yamlText(text: string): string {
|
|
70
|
-
try {
|
|
71
|
-
return stringify(text, { lineWidth: 0 }).trimEnd()
|
|
72
|
-
} catch {
|
|
73
|
-
return 'Project'
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/** Where a project's namespace lives, creating its overview page the first time. */
|
|
78
|
-
export function ensureProjectNamespace(root: string, slug: string, name: string): void {
|
|
79
|
-
const directory = join(wikiDir(root), 'projects', safeId(slug))
|
|
80
|
-
mkdirSync(directory, { recursive: true })
|
|
81
|
-
createIfMissing(
|
|
82
|
-
join(directory, 'overview.md'),
|
|
83
|
-
`---\nid: project-${slug}\ntitle: ${yamlText(name)}\ntype: project\nsourceIds: []\nlinks: []\n---\n\nOverview of the ${name.replaceAll('\n', ' ')} project.\n`
|
|
84
|
-
)
|
|
85
|
-
// One page per aspect, updated in place, so knowledge has a place to go before it exists.
|
|
86
|
-
for (const [file, title, pageType] of PROJECT_SKELETON) {
|
|
87
|
-
createIfMissing(
|
|
88
|
-
join(directory, `${file}.md`),
|
|
89
|
-
`---\nid: ${slug}-${file}\ntitle: ${yamlText(`${name}: ${title}`)}\ntype: ${pageType}\nsourceIds: [user]\nlinks:\n- toId: project-${slug}\n type: references\n---\n\n${file === 'links' ? LINKS_PLACEHOLDER : 'Nothing recorded yet.'}\n`
|
|
90
|
-
)
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
// ---- Locations ----------------------------------------------------------------
|
|
95
|
-
|
|
96
|
-
/** A page's Wiki path (`/projects/xagent/architecture`) and namespace (`/projects/xagent`). */
|
|
97
|
-
export interface Location {
|
|
98
|
-
path: string
|
|
99
|
-
namespace: string
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
/** Whether a page is part of a project's fixed skeleton (it may stand alone and start empty). */
|
|
103
|
-
export function isSkeleton(location: Location): boolean {
|
|
104
|
-
return location.namespace.startsWith('/projects/') && PROJECT_SKELETON.some(([file]) => location.path === `${location.namespace}/${file}`)
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
export function locate(wiki: string, file: string): Location {
|
|
108
|
-
const inside = relative(wiki, file)
|
|
109
|
-
const parts = (inside.startsWith('..') ? file : inside).split('/').filter(Boolean)
|
|
110
|
-
if (parts.length) parts[parts.length - 1] = parts[parts.length - 1].replace(/(?<=.)\.[^.]*$/, '')
|
|
111
|
-
const namespace = parts[0] === 'projects' && parts.length > 2 ? `/projects/${parts[1]}` : parts[0] === 'library' ? LIBRARY_NAMESPACE : ROOT_NAMESPACE
|
|
112
|
-
return { path: `/${parts.join('/')}`, namespace }
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
/**
|
|
116
|
-
* Folder that new pages in `namespace` are written to. Accepts `/`, `/library[/…]`, and
|
|
117
|
-
* `/projects/<slug>[/…]`; every segment must be a safe identifier.
|
|
118
|
-
*/
|
|
119
|
-
export function namespaceDir(wiki: string, namespace: string): string {
|
|
120
|
-
const trimmed = namespace.trim().replace(/\/+$/, '')
|
|
121
|
-
if (!trimmed || trimmed === LIBRARY_NAMESPACE) return join(wiki, 'library')
|
|
122
|
-
const segments = trimmed.replace(/^\/+/, '').split('/')
|
|
123
|
-
if (segments[0] === 'docs') segments[0] = 'library'
|
|
124
|
-
for (const segment of segments) {
|
|
125
|
-
try {
|
|
126
|
-
safeId(segment)
|
|
127
|
-
} catch {
|
|
128
|
-
throw invalid(`${namespace} is not a valid Wiki namespace.`)
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
if (segments[0] === 'library' || (segments[0] === 'projects' && segments.length > 1)) return join(wiki, ...segments)
|
|
132
|
-
throw invalid(`${namespace} is not a Wiki namespace. Use /library or /projects/<project>.`)
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
/** `/projects/<slug>` for any path inside a project namespace. */
|
|
136
|
-
export function projectNamespaceOf(namespace: string): string | null {
|
|
137
|
-
const [first, slug] = namespace.replace(/^\/+/, '').split('/')
|
|
138
|
-
return first === 'projects' && slug ? `/projects/${slug}` : null
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
// ---- Pages ----------------------------------------------------------------------
|
|
142
|
-
|
|
143
|
-
export interface WikiLink {
|
|
144
|
-
fromId: string
|
|
145
|
-
toId: string
|
|
146
|
-
type: string
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
/** Page frontmatter. Older pages also carry `scope`; it is ignored and not written back. */
|
|
150
|
-
export interface Frontmatter {
|
|
151
|
-
id: string
|
|
152
|
-
title: string
|
|
153
|
-
type: string
|
|
154
|
-
updatedAt: string
|
|
155
|
-
sourceIds: string[]
|
|
156
|
-
links: WikiLink[]
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
export const PAGE_TYPES = ['project', 'person', 'concept', 'decision', 'summary', 'question', 'reference'] as const
|
|
160
|
-
export const LINK_TYPES = ['references', 'depends_on', 'supersedes', 'contradicts', 'produced_by', 'uses'] as const
|
|
161
|
-
|
|
162
|
-
export interface ParsedPage {
|
|
163
|
-
frontmatter: Frontmatter
|
|
164
|
-
body: string
|
|
165
|
-
path: string
|
|
166
|
-
location: Location
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
/** A string array field: absent is empty, anything else that is not a list of strings fails the parse. */
|
|
170
|
-
export function stringList(value: unknown): string[] | null {
|
|
171
|
-
if (value === undefined) return []
|
|
172
|
-
return Array.isArray(value) && value.every((item) => typeof item === 'string') ? (value as string[]) : null
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
/** A `links` field with the same rules: every entry needs `toId` and `type`. */
|
|
176
|
-
export function linkList(value: unknown, fromId: string): WikiLink[] | null {
|
|
177
|
-
if (value === undefined) return []
|
|
178
|
-
if (!Array.isArray(value)) return null
|
|
179
|
-
const links: WikiLink[] = []
|
|
180
|
-
for (const item of value) {
|
|
181
|
-
const link = item as { toId?: unknown; type?: unknown } | null
|
|
182
|
-
if (!link || typeof link !== 'object' || typeof link.toId !== 'string' || typeof link.type !== 'string') return null
|
|
183
|
-
if ((LINK_TYPES as readonly string[]).includes(link.type)) links.push({ fromId, toId: link.toId, type: link.type })
|
|
184
|
-
}
|
|
185
|
-
return links
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
export function parsePage(text: string, wiki: string, path: string): ParsedPage | null {
|
|
189
|
-
const parsed = frontmatter.parse(text)
|
|
190
|
-
if (!parsed) return null
|
|
191
|
-
const { id, title, type, updatedAt } = parsed.front
|
|
192
|
-
if (typeof id !== 'string' || typeof title !== 'string' || typeof type !== 'string') return null
|
|
193
|
-
if (updatedAt !== undefined && typeof updatedAt !== 'string') return null
|
|
194
|
-
if (!(PAGE_TYPES as readonly string[]).includes(type)) return null
|
|
195
|
-
const sourceIds = stringList(parsed.front.sourceIds)
|
|
196
|
-
const links = linkList(parsed.front.links, id)
|
|
197
|
-
if (!sourceIds || !links) return null
|
|
198
|
-
return { frontmatter: { id, title, type, updatedAt: updatedAt ?? '', sourceIds, links }, body: parsed.body, path, location: locate(wiki, path) }
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
export function serializePage(front: Frontmatter, body: string): string {
|
|
202
|
-
return frontmatter.serialize({ id: front.id, title: front.title, type: front.type, updatedAt: front.updatedAt, sourceIds: front.sourceIds, links: front.links }, body)
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
export function markdownFiles(directory: string, into: string[]): void {
|
|
206
|
-
let entries: string[]
|
|
207
|
-
try {
|
|
208
|
-
entries = readdirSync(directory)
|
|
209
|
-
} catch {
|
|
210
|
-
return
|
|
211
|
-
}
|
|
212
|
-
for (const name of entries) {
|
|
213
|
-
if (name.startsWith('.')) continue
|
|
214
|
-
const path = join(directory, name)
|
|
215
|
-
let directoryEntry = false
|
|
216
|
-
try {
|
|
217
|
-
directoryEntry = statSync(path).isDirectory()
|
|
218
|
-
} catch {
|
|
219
|
-
continue
|
|
220
|
-
}
|
|
221
|
-
if (directoryEntry) markdownFiles(path, into)
|
|
222
|
-
else if (name.endsWith('.md') && name.length > 3) into.push(path)
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
export function isFile(path: string): boolean {
|
|
227
|
-
try {
|
|
228
|
-
return statSync(path).isFile()
|
|
229
|
-
} catch {
|
|
230
|
-
return false
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
export function isDir(path: string): boolean {
|
|
235
|
-
try {
|
|
236
|
-
return statSync(path).isDirectory()
|
|
237
|
-
} catch {
|
|
238
|
-
return false
|
|
239
|
-
}
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
/** Every page file: Home, the Library, and project namespaces. */
|
|
243
|
-
export function pageFiles(wiki: string): string[] {
|
|
244
|
-
const files: string[] = []
|
|
245
|
-
if (isFile(join(wiki, 'home.md'))) files.push(join(wiki, 'home.md'))
|
|
246
|
-
markdownFiles(join(wiki, 'library'), files)
|
|
247
|
-
markdownFiles(join(wiki, 'projects'), files)
|
|
248
|
-
return files.sort(comparePaths)
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
export function loadPages(wiki: string): ParsedPage[] {
|
|
252
|
-
const pages: ParsedPage[] = []
|
|
253
|
-
for (const path of pageFiles(wiki)) {
|
|
254
|
-
let text: string
|
|
255
|
-
try {
|
|
256
|
-
text = readFileSync(path, 'utf8')
|
|
257
|
-
} catch {
|
|
258
|
-
continue
|
|
259
|
-
}
|
|
260
|
-
const page = parsePage(text, wiki, path)
|
|
261
|
-
if (page) pages.push(page)
|
|
262
|
-
}
|
|
263
|
-
return pages
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
export function firstParagraph(body: string): string {
|
|
267
|
-
const line = lines(body).map((entry) => entry.trim()).find((entry) => entry && !entry.startsWith('#')) ?? ''
|
|
268
|
-
return takeChars(line, 240)
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
export { slugify as slug } from '../core/projects'
|
|
272
|
-
|
|
273
|
-
// ---- Revisions --------------------------------------------------------------------
|
|
274
|
-
|
|
275
|
-
export interface WikiRevision {
|
|
276
|
-
id: string
|
|
277
|
-
pageId: string
|
|
278
|
-
createdAt: string
|
|
279
|
-
actor: string
|
|
280
|
-
reason: string
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
/** Revision folder for any entry: a page, a fact, a block (`blocks/shared/user`), or a registry item (`skills/<name>`). */
|
|
284
|
-
export function revisionsDir(wiki: string, entryId: string): string {
|
|
285
|
-
return join(wiki, 'revisions', safeId(entryId.replaceAll('/', '--')))
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
export function readManifest(wiki: string, entryId: string): WikiRevision[] {
|
|
289
|
-
return readJson<WikiRevision[]>(join(revisionsDir(wiki, entryId), 'manifest.json')) ?? []
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
export function currentRevision(wiki: string, entryId: string): string | null {
|
|
293
|
-
try {
|
|
294
|
-
return readManifest(wiki, entryId).at(-1)?.id ?? null
|
|
295
|
-
} catch {
|
|
296
|
-
return null
|
|
297
|
-
}
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
export function snapshot(wiki: string, entryId: string, contents: string, actor: string, reason: string): WikiRevision {
|
|
301
|
-
const directory = revisionsDir(wiki, entryId)
|
|
302
|
-
const revision: WikiRevision = { id: newId(), pageId: entryId, createdAt: now(), actor, reason }
|
|
303
|
-
atomicWrite(join(directory, `${revision.id}.md`), contents)
|
|
304
|
-
const manifest = readManifest(wiki, entryId)
|
|
305
|
-
manifest.push(revision)
|
|
306
|
-
writeJson(join(directory, 'manifest.json'), manifest)
|
|
307
|
-
return revision
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
export function revisionText(wiki: string, entryId: string, revisionId: string): string {
|
|
311
|
-
try {
|
|
312
|
-
return readFileSync(join(revisionsDir(wiki, entryId), `${safeId(revisionId)}.md`), 'utf8')
|
|
313
|
-
} catch {
|
|
314
|
-
throw notFound('Revision')
|
|
315
|
-
}
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
export function log(wiki: string, line: string): void {
|
|
319
|
-
appendLine(join(wiki, 'log.md'), line)
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
// ---- Sources ------------------------------------------------------------------------
|
|
323
|
-
|
|
324
|
-
export interface WikiSource {
|
|
325
|
-
id: string
|
|
326
|
-
name: string
|
|
327
|
-
importedAt: string
|
|
328
|
-
version: number
|
|
329
|
-
previousId: string | null
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
interface StoredSource extends WikiSource {
|
|
333
|
-
sha256: string
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
function storedSources(wiki: string): StoredSource[] {
|
|
337
|
-
let entries: string[]
|
|
338
|
-
try {
|
|
339
|
-
entries = readdirSync(join(wiki, 'raw'))
|
|
340
|
-
} catch {
|
|
341
|
-
return []
|
|
342
|
-
}
|
|
343
|
-
const sources: StoredSource[] = []
|
|
344
|
-
for (const name of entries) {
|
|
345
|
-
try {
|
|
346
|
-
const stored = readJson<StoredSource>(join(wiki, 'raw', name, 'meta.json'))
|
|
347
|
-
if (stored && typeof stored.id === 'string') sources.push(stored)
|
|
348
|
-
} catch {
|
|
349
|
-
// A source without readable metadata is not listed.
|
|
350
|
-
}
|
|
351
|
-
}
|
|
352
|
-
return sources.sort((left, right) => compareText(left.importedAt, right.importedAt))
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
const withoutHash = ({ sha256: _hash, ...source }: StoredSource): WikiSource => source
|
|
356
|
-
|
|
357
|
-
export function listSources(wiki: string): WikiSource[] {
|
|
358
|
-
return storedSources(wiki).map(withoutHash)
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
export function importSource(wiki: string, name: string, bytes: Uint8Array): WikiSource {
|
|
362
|
-
const hash = createHash('sha256').update(bytes).digest('hex')
|
|
363
|
-
const existing = storedSources(wiki)
|
|
364
|
-
const same = existing.find((source) => source.sha256 === hash)
|
|
365
|
-
if (same) return withoutHash(same)
|
|
366
|
-
const previous = existing.filter((source) => source.name === name).reduce<StoredSource | null>((best, source) => (!best || source.version >= best.version ? source : best), null)
|
|
367
|
-
const stored: StoredSource = {
|
|
368
|
-
id: `src-${hash.slice(0, 12)}`,
|
|
369
|
-
name,
|
|
370
|
-
importedAt: now(),
|
|
371
|
-
version: previous ? previous.version + 1 : 1,
|
|
372
|
-
previousId: previous?.id ?? null,
|
|
373
|
-
sha256: hash
|
|
374
|
-
}
|
|
375
|
-
const directory = join(wiki, 'raw', stored.id)
|
|
376
|
-
atomicWrite(join(directory, name), bytes)
|
|
377
|
-
writeJson(join(directory, 'meta.json'), stored)
|
|
378
|
-
log(wiki, `- ${stored.importedAt} · user · ingest · source \`${stored.id}\` (${stored.name}, v${stored.version})`)
|
|
379
|
-
return withoutHash(stored)
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
/** Evidence ids that need no local file: runs, URLs, and things the user accepted or wrote. */
|
|
383
|
-
export function isExternalEvidence(id: string): boolean {
|
|
384
|
-
return id.startsWith('run:') || id.startsWith('url:') || id.startsWith('user')
|
|
385
|
-
}
|
package/src/memory/wiki.test.ts
DELETED
|
@@ -1,47 +0,0 @@
|
|
|
1
|
-
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
2
|
-
import { join } from 'node:path'
|
|
3
|
-
import { afterEach, expect, test } from 'vitest'
|
|
4
|
-
|
|
5
|
-
import { dataRoot, writeWiki } from '../../tests/helpers'
|
|
6
|
-
import { closeIndex } from '../core/index'
|
|
7
|
-
import * as blocks from './blocks'
|
|
8
|
-
import { readManifest, snapshot, wikiDir } from './store'
|
|
9
|
-
import { resolveEntry, restore, scan } from './wiki'
|
|
10
|
-
|
|
11
|
-
const roots: string[] = []
|
|
12
|
-
afterEach(() => roots.splice(0).forEach(closeIndex))
|
|
13
|
-
const setup = (): string => {
|
|
14
|
-
const root = dataRoot()
|
|
15
|
-
roots.push(root)
|
|
16
|
-
return root
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
test('resolves pages, memories and registry items', () => {
|
|
20
|
-
const root = setup()
|
|
21
|
-
writeWiki(root, 'projects/app/arch.md', '---\nid: arch\ntitle: Architecture\ntype: concept\n---\n\nTauri.\n')
|
|
22
|
-
writeWiki(root, 'facts/pnpm.md', '---\nid: pnpm\ntitle: Use pnpm\nkind: preference\n---\n\nUse pnpm.\n')
|
|
23
|
-
mkdirSync(join(root, 'registry/skills/review'), { recursive: true })
|
|
24
|
-
writeFileSync(join(root, 'registry/skills/review/SKILL.md'), '---\nname: review\ndescription: Review code\n---\n\nSteps.\n')
|
|
25
|
-
expect(resolveEntry(root, 'arch').kind).toBe('page')
|
|
26
|
-
expect(resolveEntry(root, '/projects/app/arch').kind).toBe('page')
|
|
27
|
-
expect(resolveEntry(root, 'pnpm').kind).toBe('memory')
|
|
28
|
-
expect(resolveEntry(root, 'skills/review').kind).toBe('registry')
|
|
29
|
-
expect(() => resolveEntry(root, 'missing')).toThrow()
|
|
30
|
-
const kinds = scan(root).entries.map((entry) => entry.kind)
|
|
31
|
-
for (const kind of ['page', 'memory', 'skill']) expect(kinds).toContain(kind)
|
|
32
|
-
blocks.ensureUser(wikiDir(root))
|
|
33
|
-
expect(scan(root).entries.some((entry) => entry.kind === 'block' && entry.id === 'blocks/shared/user' && entry.entryType === 'shared')).toBe(true)
|
|
34
|
-
})
|
|
35
|
-
|
|
36
|
-
test('restores registry items through revisions', () => {
|
|
37
|
-
const root = setup()
|
|
38
|
-
mkdirSync(join(root, 'registry/rules'), { recursive: true })
|
|
39
|
-
const file = join(root, 'registry/rules/style.md')
|
|
40
|
-
writeFileSync(file, 'Use tabs.\n')
|
|
41
|
-
const wiki = wikiDir(root)
|
|
42
|
-
const first = snapshot(wiki, 'rules/style', 'Use tabs.\n', 'user', 'Original')
|
|
43
|
-
writeFileSync(file, 'Use spaces.\n')
|
|
44
|
-
restore(root, 'rules/style', first.id)
|
|
45
|
-
expect(readFileSync(file, 'utf8')).toBe('Use tabs.\n')
|
|
46
|
-
expect(readManifest(wiki, 'rules/style')).toHaveLength(3)
|
|
47
|
-
})
|