@xingular/harness 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +269 -2
- package/dist/cli.js +11046 -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.ts +204 -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 +289 -0
- package/src/index.ts +69 -0
- package/src/mcp/connections.ts +441 -0
- package/src/mcp/manager.ts +130 -0
- package/src/mcp/oauth.ts +195 -0
- package/src/mcp/service.test.ts +376 -0
- package/src/mcp/service.ts +567 -0
- package/src/mcp/types.ts +105 -0
- package/src/memory/api.test.ts +82 -0
- package/src/memory/api.ts +287 -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 +128 -0
- package/src/plugins/builtin.ts +82 -0
- package/src/plugins/catalogue.test.ts +254 -0
- package/src/plugins/catalogue.ts +250 -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 +44 -0
- package/src/plugins/plugins.test.ts +205 -0
- package/src/plugins/plugins.ts +184 -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 +130 -0
- package/src/prompt/compile.test.ts +261 -0
- package/src/prompt/compile.ts +732 -0
- package/src/server/methods.ts +308 -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 +394 -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,104 @@
|
|
|
1
|
+
import { mkdtempSync } from 'node:fs'
|
|
2
|
+
import { tmpdir } from 'node:os'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { afterEach, expect, test } from 'vitest'
|
|
5
|
+
|
|
6
|
+
import { writeFile } from '../../tests/helpers'
|
|
7
|
+
import { createEnv, type HarnessEnv } from '../core/env'
|
|
8
|
+
import { newId, writeJson } from '../core/fs'
|
|
9
|
+
import { closeIndex } from '../core/index'
|
|
10
|
+
import { runRecordSchema, type RunRecord } from '../sessions/contracts'
|
|
11
|
+
import { parseEvent } from '../sessions/runStore'
|
|
12
|
+
import type { CallerContext } from './caller'
|
|
13
|
+
import { passages, read, readTool, search, searchTool, sync } from './recall'
|
|
14
|
+
|
|
15
|
+
const run = (id: string, mode: string, session: string, project: string | null, goal: string): RunRecord =>
|
|
16
|
+
runRecordSchema.parse({ id, projectId: project, mode, harness: 'pi', sessionType: 'structured', sessionId: session, nativeSessionId: null, workingDirectory: null, worktreeId: null, goal, startedAt: '2026-10-01T10:00:00.000Z', endedAt: '2026-10-01T10:01:00.000Z', status: 'completed' })
|
|
17
|
+
|
|
18
|
+
const event = (runId: string, type: string, payload: unknown) => ({ id: newId(), runId, sessionId: 's', sequence: 1, timestamp: '2026-10-01T10:00:30.000Z', type, payload })
|
|
19
|
+
|
|
20
|
+
function save(root: string, record: RunRecord, events: unknown[]): void {
|
|
21
|
+
writeJson(join(root, 'runs', record.id, 'run.json'), record)
|
|
22
|
+
writeFile(join(root, 'runs', record.id, 'events.jsonl'), `${events.map((entry) => JSON.stringify(entry)).join('\n')}\n`)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const roots: string[] = []
|
|
26
|
+
afterEach(() => roots.splice(0).forEach(closeIndex))
|
|
27
|
+
|
|
28
|
+
function setup(): HarnessEnv {
|
|
29
|
+
const root = mkdtempSync(join(tmpdir(), 'harness-recall-'))
|
|
30
|
+
roots.push(root)
|
|
31
|
+
writeJson(join(root, 'projects.json'), [
|
|
32
|
+
{ id: 'p1', name: 'App', slug: 'app', rootPath: '/tmp/app', autoMaintenance: true, createdAt: 't' },
|
|
33
|
+
{ id: 'p2', name: 'Site', slug: 'site', rootPath: '/tmp/site', autoMaintenance: true, createdAt: 't' }
|
|
34
|
+
])
|
|
35
|
+
save(root, run('chat-1', 'chat', 'agent-a', null, 'Which package manager do we use?'), [
|
|
36
|
+
event('chat-1', 'tool.started', { toolCallId: 't1', name: 'read', summary: 'package.json' }),
|
|
37
|
+
event('chat-1', 'message.completed', { messageId: 'm1', role: 'assistant', text: 'We use pnpm for every workspace.' })
|
|
38
|
+
])
|
|
39
|
+
save(root, run('chat-2', 'chat', 'agent-b', null, 'Plan the launch'), [event('chat-2', 'message.completed', { messageId: 'm1', role: 'assistant', text: 'The launch is on Friday; pnpm build first.' })])
|
|
40
|
+
save(root, run('code-1', 'code', 'pane-1', 'p1', 'Fix the pnpm lockfile'), [event('code-1', 'message.completed', { messageId: 'm1', role: 'assistant', text: 'Regenerated the lockfile.' })])
|
|
41
|
+
save(root, run('code-2', 'code', 'pane-2', 'p2', 'Deploy the site with pnpm'), [])
|
|
42
|
+
return createEnv(root, { directory: { agentName: (id) => (id === 'agent-a' ? 'Nora' : id === 'agent-b' ? 'Kai' : null), sessionAgent: () => null } })
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const agent = (id: string): CallerContext => ({ agentId: id, actor: 'Chat' })
|
|
46
|
+
|
|
47
|
+
test('a run becomes its request, messages and tool summaries', () => {
|
|
48
|
+
const record = run('r', 'chat', 'a', null, '[App coordination event e1 / review — not a user message]')
|
|
49
|
+
const events = [event('r', 'tool.started', { toolCallId: 't1', name: 'grep', summary: 'TODO' }), event('r', 'message.completed', { messageId: 'm1', role: 'assistant', text: ' Done. ' }), event('r', 'message.delta', { messageId: 'm1', text: 'x' })].flatMap((entry) => parseEvent(entry) ?? [])
|
|
50
|
+
const found = passages(record, events)
|
|
51
|
+
expect(found.map((passage) => passage.role)).toEqual(['event', 'tool', 'assistant'])
|
|
52
|
+
expect(found[1].text).toBe('grep: TODO')
|
|
53
|
+
expect(found[2].text).toBe('Done.')
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
test('sync indexes missing runs once and reindexes changed ones', () => {
|
|
57
|
+
const { root } = setup()
|
|
58
|
+
expect(sync(root)).toBe(4)
|
|
59
|
+
expect(sync(root)).toBe(0)
|
|
60
|
+
save(root, { ...run('code-2', 'code', 'pane-2', 'p2', 'Deploy the site with pnpm'), status: 'failed' }, [event('code-2', 'message.completed', { messageId: 'm1', role: 'assistant', text: 'Wrangler failed pnpm.' })])
|
|
61
|
+
expect(sync(root)).toBe(1)
|
|
62
|
+
expect(read(root, 'code-2')).toHaveLength(2)
|
|
63
|
+
expect(search(root, 'pnpm', {}, 30)).toHaveLength(5)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
test('an agent recalls its own chats by default and everything on request', () => {
|
|
67
|
+
const env = setup()
|
|
68
|
+
sync(env.root)
|
|
69
|
+
const mine = searchTool(env, agent('agent-a'), { query: 'pnpm package manager' })
|
|
70
|
+
expect(mine).toContain('run chat-1')
|
|
71
|
+
expect(mine).toContain('chat with Nora')
|
|
72
|
+
expect(mine).not.toContain('chat-2')
|
|
73
|
+
expect(mine).not.toContain('code-1')
|
|
74
|
+
const all = searchTool(env, agent('agent-a'), { query: 'pnpm', scope: 'all' })
|
|
75
|
+
expect(all).toContain('chat-2')
|
|
76
|
+
expect(all).toContain('code-1')
|
|
77
|
+
expect(all).toContain('Harness session in App')
|
|
78
|
+
const project = searchTool(env, agent('agent-a'), { query: 'pnpm', project: 'site' })
|
|
79
|
+
expect(project).toContain('code-2')
|
|
80
|
+
expect(project).not.toContain('code-1')
|
|
81
|
+
expect(searchTool(env, agent('agent-a'), { query: 'zebra' })).toContain('scope "all"')
|
|
82
|
+
expect(searchTool(env, agent('agent-a'), { query: 'pnpm', since: '2027-01-01' })).toContain('Nothing')
|
|
83
|
+
const transcript = readTool(env, agent('agent-a'), { run: 'chat-2' })
|
|
84
|
+
expect(transcript).toContain('user: Plan the launch')
|
|
85
|
+
expect(transcript).toContain('assistant: The launch is on Friday')
|
|
86
|
+
// The run a caller is in is not offered back to it.
|
|
87
|
+
expect(searchTool(env, { ...agent('agent-a'), runId: 'chat-1' }, { query: 'pnpm' })).not.toContain('run chat-1')
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
test('a session recalls only session runs of its project', () => {
|
|
91
|
+
const env = setup()
|
|
92
|
+
sync(env.root)
|
|
93
|
+
const session: CallerContext = { sessionId: 'pane-9', projectId: 'p1', actor: 'Code' }
|
|
94
|
+
const hits = searchTool(env, session, { query: 'pnpm', scope: 'all' })
|
|
95
|
+
expect(hits).toContain('code-1')
|
|
96
|
+
for (const other of ['chat-1', 'chat-2', 'code-2']) expect(hits).not.toContain(other)
|
|
97
|
+
expect(readTool(env, session, { run: 'code-1' })).toContain('Run code-1')
|
|
98
|
+
expect(() => readTool(env, session, { run: 'chat-1' })).toThrow()
|
|
99
|
+
expect(() => readTool(env, session, { run: 'code-2' })).toThrow()
|
|
100
|
+
// Without a project, a session sees only its own runs.
|
|
101
|
+
const loose: CallerContext = { sessionId: 'pane-1', actor: 'Code' }
|
|
102
|
+
expect(searchTool(env, loose, { query: 'pnpm' })).toContain('code-1')
|
|
103
|
+
expect(() => readTool(env, loose, { run: 'code-2' })).toThrow()
|
|
104
|
+
})
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
// Recall: the searchable record of what happened in conversations and runs. Each run's request,
|
|
2
|
+
// assistant messages, and tool summaries are indexed as passages in `index.sqlite`. Run records
|
|
3
|
+
// and events stay canonical; the recall index is rebuilt from `runs/`.
|
|
4
|
+
import type { DatabaseSync } from 'node:sqlite'
|
|
5
|
+
|
|
6
|
+
import type { HarnessEnv } from '../core/env'
|
|
7
|
+
import { invalid, notFound } from '../core/errors'
|
|
8
|
+
import { all, run as execute, transaction, withIndex } from '../core/index'
|
|
9
|
+
import { findProject, listProjects } from '../core/projects'
|
|
10
|
+
import { charCount, takeChars } from '../core/text'
|
|
11
|
+
import type { RunEvent, RunRecord } from '../sessions/contracts'
|
|
12
|
+
import { readAllRuns } from '../sessions/runIndex'
|
|
13
|
+
import { findRun, readEvents } from '../sessions/runStore'
|
|
14
|
+
import { isSession, projectOf, type CallerContext } from './caller'
|
|
15
|
+
|
|
16
|
+
const PASSAGE_LIMIT = 6000
|
|
17
|
+
const READ_LIMIT = 12_000
|
|
18
|
+
const SEARCH_DEFAULT = 8
|
|
19
|
+
const SEARCH_MAX = 30
|
|
20
|
+
|
|
21
|
+
export interface Passage {
|
|
22
|
+
/** `user`, `event` (a turn the application started, not the user), `assistant`, or `tool`. */
|
|
23
|
+
role: 'user' | 'event' | 'assistant' | 'tool'
|
|
24
|
+
at: string
|
|
25
|
+
text: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** A run as recall sees it: what was asked, what the assistant said, and which tools it used. */
|
|
29
|
+
export function passages(run: RunRecord, events: readonly RunEvent[]): Passage[] {
|
|
30
|
+
const found: Passage[] = []
|
|
31
|
+
const goal = run.goal?.trim()
|
|
32
|
+
if (goal) found.push({ role: goal.startsWith('[App coordination event') ? 'event' : 'user', at: run.startedAt, text: takeChars(goal, PASSAGE_LIMIT) })
|
|
33
|
+
for (const event of events) {
|
|
34
|
+
const payload = event.payload as { role?: unknown; text?: unknown; name?: unknown; summary?: unknown }
|
|
35
|
+
if (event.type === 'message.completed' && payload.role === 'assistant') {
|
|
36
|
+
const text = typeof payload.text === 'string' ? payload.text.trim() : ''
|
|
37
|
+
if (text) found.push({ role: 'assistant', at: event.timestamp, text: takeChars(text, PASSAGE_LIMIT) })
|
|
38
|
+
} else if (event.type === 'tool.started') {
|
|
39
|
+
const summary = typeof payload.summary === 'string' ? payload.summary.trim() : ''
|
|
40
|
+
if (summary) found.push({ role: 'tool', at: event.timestamp, text: takeChars(`${typeof payload.name === 'string' ? payload.name : 'tool'}: ${summary}`, PASSAGE_LIMIT) })
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return found
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function writeRun(database: DatabaseSync, run: RunRecord, found: readonly Passage[]): void {
|
|
47
|
+
execute(database, 'DELETE FROM recall_fts WHERE run_id = ?1', run.id)
|
|
48
|
+
found.forEach((passage, seq) => {
|
|
49
|
+
execute(database, 'INSERT INTO recall_fts (run_id, seq, role, at, mode, session_id, project_id, text) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)', run.id, seq, passage.role, passage.at, run.mode, run.sessionId, run.projectId, passage.text)
|
|
50
|
+
})
|
|
51
|
+
execute(database, 'INSERT OR REPLACE INTO recall_runs (run_id, passages, status) VALUES (?1, ?2, ?3)', run.id, found.length, run.status)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function eventsOf(root: string, runId: string): RunEvent[] {
|
|
55
|
+
try {
|
|
56
|
+
return readEvents(root, runId)
|
|
57
|
+
} catch {
|
|
58
|
+
return []
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Indexes one run from its record and events. Index failures never block a run. */
|
|
63
|
+
export function indexRun(root: string, runId: string): void {
|
|
64
|
+
const run = findRun(root, runId)
|
|
65
|
+
if (!run) return
|
|
66
|
+
const found = passages(run, eventsOf(root, runId))
|
|
67
|
+
try {
|
|
68
|
+
withIndex(root, (database) => writeRun(database, run, found))
|
|
69
|
+
} catch (error) {
|
|
70
|
+
process.stderr.write(`[recall] run ${runId} was not indexed: ${error instanceof Error ? error.message : String(error)}\n`)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Indexes every run that is missing or whose status changed since it was indexed. Returns how
|
|
76
|
+
* many were (re)indexed. Called at startup, so earlier conversations are searchable.
|
|
77
|
+
*/
|
|
78
|
+
export function sync(root: string): number {
|
|
79
|
+
const known = new Map<string, string>()
|
|
80
|
+
withIndex(root, (database) => {
|
|
81
|
+
for (const row of all<{ run_id: string; status: string }>(database, 'SELECT run_id, status FROM recall_runs')) known.set(row.run_id, row.status)
|
|
82
|
+
})
|
|
83
|
+
const pending = readAllRuns(root)
|
|
84
|
+
.filter((run) => known.get(run.id) !== run.status)
|
|
85
|
+
.map((run) => ({ run, found: passages(run, eventsOf(root, run.id)) }))
|
|
86
|
+
if (pending.length) withIndex(root, (database) => transaction(database, () => pending.forEach(({ run, found }) => writeRun(database, run, found))))
|
|
87
|
+
return pending.length
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ---- Search ---------------------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* What a caller may recall. An agent searches its own conversations by default and everything
|
|
94
|
+
* when it asks; a session only sees session runs in its own project (or its own session when
|
|
95
|
+
* it has none), never an agent's conversation.
|
|
96
|
+
*/
|
|
97
|
+
export interface Scope {
|
|
98
|
+
/** Only runs of this session. */
|
|
99
|
+
sessionId?: string | null
|
|
100
|
+
/** Only runs of this mode (`code` for sessions). */
|
|
101
|
+
mode?: string | null
|
|
102
|
+
projectId?: string | null
|
|
103
|
+
/** ISO date or timestamp; only passages at or after it. */
|
|
104
|
+
since?: string | null
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface RecallHit {
|
|
108
|
+
runId: string
|
|
109
|
+
seq: number
|
|
110
|
+
role: string
|
|
111
|
+
at: string
|
|
112
|
+
mode: string
|
|
113
|
+
sessionId: string
|
|
114
|
+
projectId: string | null
|
|
115
|
+
snippet: string
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Any word may match; passages that match more of them, and rarer ones, rank first. */
|
|
119
|
+
function ftsAny(query: string): string | null {
|
|
120
|
+
const terms: string[] = []
|
|
121
|
+
for (const word of query.split(/[^\p{Alphabetic}\p{N}]+/u)) {
|
|
122
|
+
if (charCount(word) <= 1) continue
|
|
123
|
+
const term = `"${word.toLowerCase()}"*`
|
|
124
|
+
if (!terms.includes(term) && terms.length < 24) terms.push(term)
|
|
125
|
+
}
|
|
126
|
+
return terms.length ? terms.join(' OR ') : null
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
interface HitRow {
|
|
130
|
+
run_id: string
|
|
131
|
+
seq: number | string
|
|
132
|
+
role: string
|
|
133
|
+
at: string | null
|
|
134
|
+
mode: string | null
|
|
135
|
+
session_id: string | null
|
|
136
|
+
project_id: string | null
|
|
137
|
+
snippet: string
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function search(root: string, query: string, scope: Scope, limit: number): RecallHit[] {
|
|
141
|
+
const fts = ftsAny(query)
|
|
142
|
+
if (!fts) return []
|
|
143
|
+
return withIndex(root, (database) =>
|
|
144
|
+
all<HitRow>(
|
|
145
|
+
database,
|
|
146
|
+
`SELECT run_id, seq, role, at, mode, session_id, project_id, snippet(recall_fts, 7, '', '', '…', 40) AS snippet
|
|
147
|
+
FROM recall_fts WHERE recall_fts MATCH ?1
|
|
148
|
+
AND (?2 IS NULL OR session_id = ?2) AND (?3 IS NULL OR mode = ?3)
|
|
149
|
+
AND (?4 IS NULL OR project_id = ?4) AND (?5 IS NULL OR at >= ?5)
|
|
150
|
+
ORDER BY bm25(recall_fts), at DESC LIMIT ?6`,
|
|
151
|
+
fts,
|
|
152
|
+
scope.sessionId ?? null,
|
|
153
|
+
scope.mode ?? null,
|
|
154
|
+
scope.projectId ?? null,
|
|
155
|
+
scope.since ?? null,
|
|
156
|
+
Math.min(Math.max(limit, 1), SEARCH_MAX)
|
|
157
|
+
).map((row) => ({ runId: row.run_id, seq: Number(row.seq), role: row.role, at: row.at ?? '', mode: row.mode ?? '', sessionId: row.session_id ?? '', projectId: row.project_id, snippet: row.snippet }))
|
|
158
|
+
)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export interface RecallMessage {
|
|
162
|
+
seq: number
|
|
163
|
+
role: string
|
|
164
|
+
at: string
|
|
165
|
+
text: string
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function read(root: string, runId: string): RecallMessage[] {
|
|
169
|
+
return withIndex(root, (database) =>
|
|
170
|
+
all<{ seq: number | string; role: string; at: string | null; text: string }>(database, 'SELECT seq, role, at, text FROM recall_fts WHERE run_id = ?1 ORDER BY CAST(seq AS INTEGER)', runId).map((row) => ({ seq: Number(row.seq), role: row.role, at: row.at ?? '', text: row.text }))
|
|
171
|
+
)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ---- Tools ----------------------------------------------------------------------------------
|
|
175
|
+
|
|
176
|
+
type Params = Record<string, unknown> | null | undefined
|
|
177
|
+
|
|
178
|
+
function textParam(params: Params, key: string): string {
|
|
179
|
+
const value = params?.[key]
|
|
180
|
+
return typeof value === 'string' ? value.trim() : ''
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** The widest scope a caller may use, narrowed by what it asked for. */
|
|
184
|
+
function callerScope(root: string, context: CallerContext, params: Params): Scope {
|
|
185
|
+
const since = textParam(params, 'since') || null
|
|
186
|
+
if (isSession(context)) {
|
|
187
|
+
// A session never reads an agent's conversations: only session runs of its project, or its own.
|
|
188
|
+
const projectId = projectOf(root, context)?.id ?? null
|
|
189
|
+
if (!projectId && !context.sessionId) throw invalid('Recall needs a session.')
|
|
190
|
+
return { sessionId: projectId ? null : context.sessionId, mode: 'code', projectId, since }
|
|
191
|
+
}
|
|
192
|
+
if (!context.agentId) throw invalid('Recall needs an agent or a Harness session.')
|
|
193
|
+
const key = textParam(params, 'project')
|
|
194
|
+
let projectId: string | null = null
|
|
195
|
+
if (key) {
|
|
196
|
+
const project = listProjects(root).find((entry) => entry.id === key || entry.slug === key || entry.name.toLowerCase() === key.toLowerCase())
|
|
197
|
+
if (!project) throw invalid(`There is no project called ${key}.`)
|
|
198
|
+
projectId = project.id
|
|
199
|
+
}
|
|
200
|
+
const everything = textParam(params, 'scope') === 'all' || projectId !== null
|
|
201
|
+
return { sessionId: everything ? null : context.agentId, mode: null, projectId, since }
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function mayRead(scope: Scope, run: RunRecord): boolean {
|
|
205
|
+
if (!scope.mode) return true
|
|
206
|
+
return scope.mode === run.mode && (!scope.projectId || scope.projectId === run.projectId) && (!scope.sessionId || scope.sessionId === run.sessionId)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Who a run belonged to, for a person or a model reading a hit. */
|
|
210
|
+
export function sourceLabel(env: HarnessEnv, mode: string, sessionId: string, projectId: string | null): string {
|
|
211
|
+
if (mode === 'chat') {
|
|
212
|
+
// One of an agent's extra conversations has its own id.
|
|
213
|
+
const owner = env.directory.sessionAgent(sessionId)
|
|
214
|
+
const name = env.directory.agentName(sessionId) ?? (owner ? env.directory.agentName(owner) : null)
|
|
215
|
+
return name ? `chat with ${name}` : 'group or earlier chat'
|
|
216
|
+
}
|
|
217
|
+
try {
|
|
218
|
+
return projectId ? `Harness session in ${findProject(env.root, projectId).name}` : 'Harness session'
|
|
219
|
+
} catch {
|
|
220
|
+
return 'Harness session'
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** `recall.search` as text for a model. */
|
|
225
|
+
export function searchTool(env: HarnessEnv, context: CallerContext, params: Params): string {
|
|
226
|
+
const query = textParam(params, 'query')
|
|
227
|
+
if (!query) throw invalid('Give a query.')
|
|
228
|
+
const scope = callerScope(env.root, context, params)
|
|
229
|
+
const requested = params?.limit
|
|
230
|
+
const limit = typeof requested === 'number' && Number.isInteger(requested) && requested >= 0 ? requested : SEARCH_DEFAULT
|
|
231
|
+
// The caller's current run is what it is in the middle of, not something to recall.
|
|
232
|
+
const hits = search(env.root, query, scope, limit + 4).filter((hit) => hit.runId !== context.runId).slice(0, Math.min(Math.max(limit, 1), SEARCH_MAX))
|
|
233
|
+
if (!hits.length) {
|
|
234
|
+
return scope.sessionId && !isSession(context) ? 'Nothing in your own conversations matches. Pass scope "all" to search every conversation and Harness run.' : 'Nothing in the recorded history matches.'
|
|
235
|
+
}
|
|
236
|
+
return `${hits.map((hit) => `- [run ${hit.runId} · ${hit.at.slice(0, 10)} · ${sourceLabel(env, hit.mode, hit.sessionId, hit.projectId)}] ${hit.role}: ${hit.snippet.replaceAll('\n', ' ')}`).join('\n')}\n\nRead a whole run with history_read and its run id.`
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** `recall.read` as text for a model. */
|
|
240
|
+
export function readTool(env: HarnessEnv, context: CallerContext, params: Params): string {
|
|
241
|
+
const runId = textParam(params, 'run') || textParam(params, 'runId')
|
|
242
|
+
const run = findRun(env.root, runId)
|
|
243
|
+
if (!run) throw notFound('Run')
|
|
244
|
+
// Reading is as wide as an "all" search for an agent, and as narrow as a search for a session.
|
|
245
|
+
if (!mayRead(callerScope(env.root, context, { scope: 'all' }), run)) throw notFound('Run')
|
|
246
|
+
const messages = read(env.root, run.id)
|
|
247
|
+
if (!messages.length) return 'That run has no recorded messages.'
|
|
248
|
+
let text = `Run ${run.id} · ${run.startedAt.slice(0, 16)} · ${sourceLabel(env, run.mode, run.sessionId, run.projectId)} · ${run.status}\n`
|
|
249
|
+
for (const message of messages) {
|
|
250
|
+
const line = `\n${message.role}: ${message.text}\n`
|
|
251
|
+
if (charCount(text) + charCount(line) > READ_LIMIT) {
|
|
252
|
+
text += '\n(The rest of the run was left out.)\n'
|
|
253
|
+
break
|
|
254
|
+
}
|
|
255
|
+
text += line
|
|
256
|
+
}
|
|
257
|
+
return text
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export interface RecallFilter {
|
|
261
|
+
sessionId?: string | null
|
|
262
|
+
projectId?: string | null
|
|
263
|
+
since?: string | null
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export type RecallResult = RecallHit & { source: string }
|
|
267
|
+
|
|
268
|
+
/** A person's search: every conversation and run, narrowed only by the filter. */
|
|
269
|
+
export function searchAll(env: HarnessEnv, query: string, filter: RecallFilter = {}): RecallResult[] {
|
|
270
|
+
return search(env.root, query, { sessionId: filter.sessionId, mode: null, projectId: filter.projectId, since: filter.since || null }, SEARCH_MAX).map((hit) => ({ ...hit, source: sourceLabel(env, hit.mode, hit.sessionId, hit.projectId) }))
|
|
271
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// Selected references: Wiki pages and skills a person attached to one request. Each is pinned to
|
|
2
|
+
// a revision when it is selected, so what the model reads cannot change under it, and the rest
|
|
3
|
+
// of a long reference can be retrieved only at that revision.
|
|
4
|
+
import { readFileSync } from 'node:fs'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
|
|
7
|
+
import { invalid, notFound } from '../core/errors'
|
|
8
|
+
import * as frontmatter from '../core/frontmatter'
|
|
9
|
+
import { readJson, safeId } from '../core/fs'
|
|
10
|
+
import { charCount } from '../core/text'
|
|
11
|
+
import type { Source } from './runContext'
|
|
12
|
+
import * as store from './store'
|
|
13
|
+
import { entryFile, entryId, resolveEntry } from './wiki'
|
|
14
|
+
|
|
15
|
+
export interface PinnedReference {
|
|
16
|
+
kind: 'page' | 'skill'
|
|
17
|
+
id: string
|
|
18
|
+
label: string
|
|
19
|
+
revision: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Pins the entry's current content. The latest revision is reused when it already holds that
|
|
24
|
+
* content; otherwise a new one is written. The entry must be a page or a saved skill.
|
|
25
|
+
*/
|
|
26
|
+
export function pin(root: string, kind: 'page' | 'skill', id: string): string {
|
|
27
|
+
return pinAll(root, [{ kind, id }])[0]
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Pins several entries, resolving every one before any snapshot is written. */
|
|
31
|
+
export function pinAll(root: string, items: readonly { kind: 'page' | 'skill'; id: string }[]): string[] {
|
|
32
|
+
const wiki = store.wikiDir(root)
|
|
33
|
+
const contents = items.map(({ kind, id }) => {
|
|
34
|
+
const entry = resolveEntry(root, id)
|
|
35
|
+
const valid = kind === 'page' ? entry.kind === 'page' : entry.kind === 'registry' && entry.item.kind === 'skills'
|
|
36
|
+
if (!valid || entryId(entry) !== id) throw invalid('The selected reference is not a Wiki page or saved skill.')
|
|
37
|
+
return readFileSync(entryFile(entry), 'utf8')
|
|
38
|
+
})
|
|
39
|
+
return items.map(({ id }, index) => {
|
|
40
|
+
const current = store.currentRevision(wiki, id)
|
|
41
|
+
if (current) {
|
|
42
|
+
try {
|
|
43
|
+
if (store.revisionText(wiki, id, current) === contents[index]) return current
|
|
44
|
+
} catch {
|
|
45
|
+
// The manifest names a revision whose text is gone; pin a new one.
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return store.snapshot(wiki, id, contents[index], 'user', 'Pinned chat reference').id
|
|
49
|
+
})
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The pinned revision's body without its frontmatter. */
|
|
53
|
+
export function body(root: string, id: string, revision: string | null): string {
|
|
54
|
+
if (!revision) throw invalid('This reference has no pinned revision.')
|
|
55
|
+
const text = store.revisionText(store.wikiDir(root), id, revision)
|
|
56
|
+
return frontmatter.bodyOf(text)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const PAGE_CHARS = 12_000
|
|
60
|
+
|
|
61
|
+
/** A window of a reference that was selected for the caller's current run. */
|
|
62
|
+
export function readPinned(root: string, runId: string | null | undefined, id: string, revision: string, offset: number): string {
|
|
63
|
+
if (!runId) throw invalid('Pinned references require a current run.')
|
|
64
|
+
const pack = readJson<{ sections?: { id: string; sources?: Source[] }[] }>(join(root, 'runs', safeId(runId), 'context.json'))
|
|
65
|
+
if (!pack) throw notFound('Run context')
|
|
66
|
+
const selected = (pack.sections ?? [])
|
|
67
|
+
.filter((section) => section.id === 'references')
|
|
68
|
+
.flatMap((section) => section.sources ?? [])
|
|
69
|
+
.find((source) => source.id === id && source.revision === revision && (source.kind === 'page' || source.kind === 'skill'))
|
|
70
|
+
if (!selected) throw invalid('This revision was not selected for the current reply.')
|
|
71
|
+
const characters = Array.from(body(root, id, revision))
|
|
72
|
+
const count = characters.length
|
|
73
|
+
if (offset > count) throw invalid('The reference offset is past the end.')
|
|
74
|
+
const excerpt = characters.slice(offset, offset + PAGE_CHARS).join('')
|
|
75
|
+
const end = offset + charCount(excerpt)
|
|
76
|
+
return `[${id}@${revision}] · characters ${offset}–${end} of ${count} · nextOffset: ${end < count ? end : 'none'}\n\n${excerpt}`
|
|
77
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
const SECRET_MARKERS = ['-----BEGIN', 'sk-ant-', 'sk-proj-', 'ghp_', 'github_pat_', 'AKIA', 'xoxb-']
|
|
2
|
+
|
|
3
|
+
/** Text that carries a credential marker is never written to the Wiki. */
|
|
4
|
+
export function looksSecret(text: string): boolean {
|
|
5
|
+
return SECRET_MARKERS.some((marker) => text.includes(marker))
|
|
6
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
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
|
+
})
|