@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,59 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { HarnessError, invalid } from '../core/errors'
|
|
5
|
+
import { readJson, safeId, writeJson } from '../core/fs'
|
|
6
|
+
import type { CapabilitySet } from './types'
|
|
7
|
+
|
|
8
|
+
export interface RunCapabilitySnapshot {
|
|
9
|
+
version: 1
|
|
10
|
+
caller: { agentId: string | null; projectId: string | null }
|
|
11
|
+
tools: string[]
|
|
12
|
+
connectors: string[]
|
|
13
|
+
contributions: string[]
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const path = (root: string, runId: string): string => join(root, 'runs', safeId(runId), 'capabilities.json')
|
|
17
|
+
|
|
18
|
+
function strings(value: unknown): string[] {
|
|
19
|
+
return Array.isArray(value) ? [...new Set(value.filter((item): item is string => typeof item === 'string' && item.length > 0))] : []
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function capabilitySet(value: unknown): CapabilitySet {
|
|
23
|
+
if (!value || typeof value !== 'object' || (value as { version?: unknown }).version !== 1) throw invalid('A capability set must use version 1.')
|
|
24
|
+
const record = value as Record<string, unknown>
|
|
25
|
+
return { version: 1, tools: strings(record.tools), connectors: strings(record.connectors), contributions: strings(record.contributions) }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Writes a run's capability set once. A resumed or duplicate launch cannot replace it. */
|
|
29
|
+
export function freezeRun(root: string, runId: string, caller: { agentId?: string | null; projectId?: string | null }, set: CapabilitySet): RunCapabilitySnapshot {
|
|
30
|
+
const target = path(root, runId)
|
|
31
|
+
const snapshot: RunCapabilitySnapshot = {
|
|
32
|
+
version: 1,
|
|
33
|
+
caller: { agentId: caller.agentId ?? null, projectId: caller.projectId ?? null },
|
|
34
|
+
tools: strings(set.tools),
|
|
35
|
+
connectors: strings(set.connectors),
|
|
36
|
+
contributions: strings(set.contributions)
|
|
37
|
+
}
|
|
38
|
+
const existing = existsSync(target) ? readJson<RunCapabilitySnapshot>(target) : null
|
|
39
|
+
if (existing) {
|
|
40
|
+
if (JSON.stringify(existing) !== JSON.stringify(snapshot)) throw new HarnessError('capabilities_frozen', 'This run already has a different frozen capability set.')
|
|
41
|
+
return existing
|
|
42
|
+
}
|
|
43
|
+
writeJson(target, snapshot)
|
|
44
|
+
return snapshot
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function loadRun(root: string, runId: string): RunCapabilitySnapshot | null {
|
|
48
|
+
const stored = readJson<unknown>(path(root, runId))
|
|
49
|
+
if (!stored || typeof stored !== 'object' || (stored as { version?: unknown }).version !== 1) return null
|
|
50
|
+
const record = stored as Record<string, unknown>
|
|
51
|
+
const caller = record.caller && typeof record.caller === 'object' ? record.caller as Record<string, unknown> : {}
|
|
52
|
+
return {
|
|
53
|
+
version: 1,
|
|
54
|
+
caller: { agentId: typeof caller.agentId === 'string' ? caller.agentId : null, projectId: typeof caller.projectId === 'string' ? caller.projectId : null },
|
|
55
|
+
tools: strings(record.tools),
|
|
56
|
+
connectors: strings(record.connectors),
|
|
57
|
+
contributions: strings(record.contributions)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { AttachScope, Attachment } from '../plugins/attachments'
|
|
2
|
+
import type { ContributionKind, ContributionState, PluginSource } from '../plugins/types'
|
|
3
|
+
|
|
4
|
+
export type CapabilityKind = ContributionKind | 'plugin'
|
|
5
|
+
|
|
6
|
+
/** One item the caller can switch on or off, or that a plugin makes available. */
|
|
7
|
+
export interface Capability {
|
|
8
|
+
id: string
|
|
9
|
+
pluginId: string
|
|
10
|
+
kind: CapabilityKind
|
|
11
|
+
name: string
|
|
12
|
+
description: string
|
|
13
|
+
source: PluginSource
|
|
14
|
+
state: ContributionState
|
|
15
|
+
attachment: Attachment | null
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface CapabilityCatalogue {
|
|
19
|
+
plugins: import('../plugins/types').Plugin[]
|
|
20
|
+
capabilities: Capability[]
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The callable capabilities a run starts with. Permission to run still goes through the gate. */
|
|
24
|
+
export interface CapabilitySet {
|
|
25
|
+
version: 1
|
|
26
|
+
tools: string[]
|
|
27
|
+
connectors: string[]
|
|
28
|
+
contributions: string[]
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface CapabilityScope extends AttachScope {
|
|
32
|
+
cwd?: string | null
|
|
33
|
+
accountId?: string | null
|
|
34
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// The `xingular-harness` command.
|
|
3
|
+
//
|
|
4
|
+
// xingular-harness serve --state <dir> [--data <dir>] [--name <product>] [--builtins <dir>]…
|
|
5
|
+
// [--host-secrets] [--host-policy] [--host-tools] [--host-directory]
|
|
6
|
+
// Serves the harness to a host process over stdin/stdout (newline-delimited JSON).
|
|
7
|
+
// xingular-harness wiki <command> Memory and Wiki, from inside a terminal session.
|
|
8
|
+
// xingular-harness mcp <command> Connected apps, from inside a terminal session.
|
|
9
|
+
// xingular-harness plugins <command> List, install, switch and trust plugins.
|
|
10
|
+
import { log } from './core/bus'
|
|
11
|
+
import { createHarness, type HarnessOptions } from './harness'
|
|
12
|
+
import { runPluginsCli, terminalAsk } from './plugins/cli'
|
|
13
|
+
import { remoteDirectory, remotePolicy, remoteSecrets, remoteTools } from './server/remote'
|
|
14
|
+
import { RpcPeer, stdioTransport } from './server/rpc'
|
|
15
|
+
import { attach } from './server/serve'
|
|
16
|
+
import { runAppsCli, runWikiCli } from './terminal/cli'
|
|
17
|
+
import { runStandaloneTerminal, standaloneArguments } from './terminal/standalone'
|
|
18
|
+
|
|
19
|
+
const USAGE = `Usage: xingular-harness [--data <dir>] [--state <dir>]
|
|
20
|
+
xingular-harness <terminal | serve | wiki | mcp | plugins> [arguments]
|
|
21
|
+
|
|
22
|
+
(no command) Start the interactive Pi terminal
|
|
23
|
+
terminal Start the interactive Pi terminal
|
|
24
|
+
serve --state <dir> [--data <dir>] [--name <product>] [--builtins <dir>]
|
|
25
|
+
[--host-secrets] [--host-policy] [--host-tools] [--host-directory]
|
|
26
|
+
Serve the harness over newline-delimited JSON
|
|
27
|
+
wiki <command> Memory and Wiki commands for a terminal session
|
|
28
|
+
mcp <command> Connected-app commands for a terminal session
|
|
29
|
+
plugins <command> List, install, switch and trust plugins (plugins help)
|
|
30
|
+
|
|
31
|
+
Standalone terminal options:
|
|
32
|
+
--data <dir> Data directory (default: ~/.xingular-harness/data)
|
|
33
|
+
--state <dir> State directory (default: ~/.xingular-harness/state)
|
|
34
|
+
`
|
|
35
|
+
|
|
36
|
+
function flags(argv: string[]): { values: Record<string, string[]>; switches: Set<string> } {
|
|
37
|
+
const values: Record<string, string[]> = {}
|
|
38
|
+
const switches = new Set<string>()
|
|
39
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
40
|
+
const name = argv[index]
|
|
41
|
+
if (!name.startsWith('--')) continue
|
|
42
|
+
const next = argv[index + 1]
|
|
43
|
+
if (next !== undefined && !next.startsWith('--')) {
|
|
44
|
+
;(values[name.slice(2)] ??= []).push(next)
|
|
45
|
+
index += 1
|
|
46
|
+
} else switches.add(name.slice(2))
|
|
47
|
+
}
|
|
48
|
+
return { values, switches }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function serveCommand(argv: string[]): Promise<number> {
|
|
52
|
+
const { values, switches } = flags(argv)
|
|
53
|
+
const stateDir = values.state?.[0]
|
|
54
|
+
if (!stateDir) {
|
|
55
|
+
process.stderr.write('serve needs --state <dir>.\n')
|
|
56
|
+
return Promise.resolve(2)
|
|
57
|
+
}
|
|
58
|
+
const peer = new RpcPeer(stdioTransport())
|
|
59
|
+
const options: HarnessOptions = {
|
|
60
|
+
stateDir,
|
|
61
|
+
dataRoot: values.data?.[0] ?? null,
|
|
62
|
+
productName: values.name?.[0],
|
|
63
|
+
builtins: values.builtins,
|
|
64
|
+
...(switches.has('host-secrets') ? { secrets: remoteSecrets(peer) } : {}),
|
|
65
|
+
...(switches.has('host-policy') ? { policy: remotePolicy(peer) } : {}),
|
|
66
|
+
...(switches.has('host-directory') ? { directory: remoteDirectory(peer) } : {})
|
|
67
|
+
}
|
|
68
|
+
const harness = createHarness(options)
|
|
69
|
+
if (switches.has('host-tools')) harness.sessions.provide(remoteTools(peer))
|
|
70
|
+
attach(harness, peer)
|
|
71
|
+
return new Promise<number>((resolve) => {
|
|
72
|
+
let closing = false
|
|
73
|
+
const shutdown = (): void => {
|
|
74
|
+
if (closing) return
|
|
75
|
+
closing = true
|
|
76
|
+
void Promise.race([harness.close(), new Promise((done) => setTimeout(done, 5000))]).then(() => resolve(0))
|
|
77
|
+
}
|
|
78
|
+
peer.handle('harness.shutdown', () => void setImmediate(shutdown))
|
|
79
|
+
process.stdin.on('end', shutdown)
|
|
80
|
+
process.on('SIGTERM', shutdown)
|
|
81
|
+
process.on('SIGINT', shutdown)
|
|
82
|
+
peer.send({ notice: { type: 'ready' } })
|
|
83
|
+
log('ready')
|
|
84
|
+
})
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function main(): Promise<number> {
|
|
88
|
+
const argv = process.argv.slice(2)
|
|
89
|
+
const [command, ...rest] = argv
|
|
90
|
+
if (argv.includes('--help') || argv.includes('-h')) {
|
|
91
|
+
process.stdout.write(USAGE)
|
|
92
|
+
return 0
|
|
93
|
+
}
|
|
94
|
+
const terminalArgs = standaloneArguments(argv)
|
|
95
|
+
if (terminalArgs !== null) return runStandaloneTerminal(terminalArgs)
|
|
96
|
+
if (command === 'serve') return serveCommand(rest)
|
|
97
|
+
if (command === 'wiki') return runWikiCli(rest)
|
|
98
|
+
if (command === 'mcp') return runAppsCli(rest)
|
|
99
|
+
if (command === 'plugins') return runPluginsCli(rest, { ask: terminalAsk() })
|
|
100
|
+
process.stdout.write(USAGE)
|
|
101
|
+
return command ? 2 : 0
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
void main().then(
|
|
105
|
+
(code) => process.exit(code),
|
|
106
|
+
(error) => {
|
|
107
|
+
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`)
|
|
108
|
+
process.exit(1)
|
|
109
|
+
}
|
|
110
|
+
)
|
package/src/core/bus.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Notices: what the harness tells whoever embeds it. Run events travel separately, through the
|
|
2
|
+
// run tracker; a notice says that some state changed and should be read again.
|
|
3
|
+
|
|
4
|
+
export interface Notice {
|
|
5
|
+
/** `accounts.changed`, `accounts.login`, `accounts.loginEvent`, `wiki.changed`, `wiki.maintenance`, … */
|
|
6
|
+
type: string
|
|
7
|
+
[key: string]: unknown
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export type NoticeListener = (notice: Notice) => void
|
|
11
|
+
|
|
12
|
+
export class Bus {
|
|
13
|
+
private readonly listeners = new Set<NoticeListener>()
|
|
14
|
+
|
|
15
|
+
on(listener: NoticeListener): () => void {
|
|
16
|
+
this.listeners.add(listener)
|
|
17
|
+
return () => this.listeners.delete(listener)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
emit(notice: Notice): void {
|
|
21
|
+
for (const listener of this.listeners) {
|
|
22
|
+
try {
|
|
23
|
+
listener(notice)
|
|
24
|
+
} catch (error) {
|
|
25
|
+
log('a notice listener failed', error instanceof Error ? error.message : String(error))
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Diagnostics go to stderr; stdout may be carrying a protocol. */
|
|
32
|
+
export function log(...parts: unknown[]): void {
|
|
33
|
+
process.stderr.write(`[harness] ${parts.map((part) => (typeof part === 'string' ? part : JSON.stringify(part))).join(' ')}\n`)
|
|
34
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// A note that something in a Wiki was written: a fact, a memory block, or a page. The memory
|
|
2
|
+
// code calls `noteChange` after the file is in place; whoever wants to react listens. Listeners
|
|
3
|
+
// run inline and must only take the note down: a write never waits for, or fails with, a listener.
|
|
4
|
+
export interface Change {
|
|
5
|
+
/** The Wiki folder the entry is in. */
|
|
6
|
+
wiki: string
|
|
7
|
+
id: string
|
|
8
|
+
/** The file that was written. */
|
|
9
|
+
path: string
|
|
10
|
+
/** Its new contents, front matter included. */
|
|
11
|
+
text: string
|
|
12
|
+
actor: string
|
|
13
|
+
reason: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
type Listener = (change: Change) => void
|
|
17
|
+
|
|
18
|
+
const listeners = new Set<Listener>()
|
|
19
|
+
|
|
20
|
+
export function noteChange(change: Change): void {
|
|
21
|
+
for (const listener of listeners) {
|
|
22
|
+
try {
|
|
23
|
+
listener(change)
|
|
24
|
+
} catch {
|
|
25
|
+
// A listener must never break the write it is told about.
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function onChange(listener: Listener): () => void {
|
|
31
|
+
listeners.add(listener)
|
|
32
|
+
return () => void listeners.delete(listener)
|
|
33
|
+
}
|
package/src/core/env.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// What every harness module is given: where the data lives, and what the embedding application
|
|
2
|
+
// knows that the harness does not.
|
|
3
|
+
|
|
4
|
+
/** Names the harness shows but does not own. Every lookup may answer `null`. */
|
|
5
|
+
export interface Directory {
|
|
6
|
+
/** An agent's display name. */
|
|
7
|
+
agentName(agentId: string): string | null
|
|
8
|
+
/** The agent a conversation belongs to, when the session id is not the agent's own id. */
|
|
9
|
+
sessionAgent(sessionId: string): string | null
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** The name used when an application gives none. */
|
|
13
|
+
export const DEFAULT_PRODUCT_NAME = 'Xingular Harness'
|
|
14
|
+
|
|
15
|
+
export const EMPTY_DIRECTORY: Directory = { agentName: () => null, sessionAgent: () => null }
|
|
16
|
+
|
|
17
|
+
export interface HarnessEnv {
|
|
18
|
+
/** The data root: `wiki/`, `registry/`, `runs/`, `config/`, `index.sqlite`. */
|
|
19
|
+
root: string
|
|
20
|
+
directory: Directory
|
|
21
|
+
/** How the harness names itself to a model and in the Wiki it creates. */
|
|
22
|
+
productName: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function createEnv(root: string, options: Partial<Omit<HarnessEnv, 'root'>> = {}): HarnessEnv {
|
|
26
|
+
return { root, directory: options.directory ?? EMPTY_DIRECTORY, productName: options.productName ?? DEFAULT_PRODUCT_NAME }
|
|
27
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/** A typed failure: a stable code, a message a person can read, and an optional hint on what to do next. */
|
|
2
|
+
export class HarnessError extends Error {
|
|
3
|
+
readonly code: string
|
|
4
|
+
readonly retryHint: string | null
|
|
5
|
+
|
|
6
|
+
constructor(code: string, message: string, retryHint: string | null = null) {
|
|
7
|
+
super(message)
|
|
8
|
+
this.name = 'HarnessError'
|
|
9
|
+
this.code = code
|
|
10
|
+
this.retryHint = retryHint
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
withHint(hint: string): HarnessError {
|
|
14
|
+
return new HarnessError(this.code, this.message, hint)
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const invalid = (message: string): HarnessError => new HarnessError('invalid_request', message)
|
|
19
|
+
export const notFound = (what: string): HarnessError => new HarnessError('not_found', `${what} was not found.`)
|
|
20
|
+
|
|
21
|
+
export interface ErrorShape {
|
|
22
|
+
code: string
|
|
23
|
+
message: string
|
|
24
|
+
retryHint?: string | null
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function toErrorShape(error: unknown, fallbackCode = 'harness_error'): ErrorShape {
|
|
28
|
+
if (error instanceof HarnessError) return { code: error.code, message: error.message, retryHint: error.retryHint }
|
|
29
|
+
const code = (error as { code?: unknown } | null)?.code
|
|
30
|
+
if (error instanceof Error && typeof code === 'string' && code.startsWith('E')) return { code: 'io_error', message: error.message }
|
|
31
|
+
return { code: fallbackCode, message: error instanceof Error ? error.message : String(error) }
|
|
32
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Markdown files with a YAML frontmatter block (`---` … `---`), used by Wiki pages, facts, blocks and registry items.
|
|
2
|
+
import { parse as parseYaml, stringify } from 'yaml'
|
|
3
|
+
|
|
4
|
+
import { HarnessError } from './errors'
|
|
5
|
+
|
|
6
|
+
export function split(text: string): { yaml: string; body: string } | null {
|
|
7
|
+
const rest = text.startsWith('---\n') ? text.slice(4) : text.startsWith('---\r\n') ? text.slice(5) : null
|
|
8
|
+
if (rest === null) return null
|
|
9
|
+
const end = rest.indexOf('\n---')
|
|
10
|
+
if (end < 0) return null
|
|
11
|
+
return { yaml: rest.slice(0, end), body: rest.slice(end + 4).replace(/^[\r\n]+/, '') }
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** The frontmatter as a plain object and the body, or `null` when the block is missing or is not a mapping. */
|
|
15
|
+
export function parse(text: string): { front: Record<string, unknown>; body: string } | null {
|
|
16
|
+
const parts = split(text)
|
|
17
|
+
if (!parts) return null
|
|
18
|
+
try {
|
|
19
|
+
const front = parseYaml(parts.yaml)
|
|
20
|
+
if (!front || typeof front !== 'object' || Array.isArray(front)) return null
|
|
21
|
+
return { front: front as Record<string, unknown>, body: parts.body }
|
|
22
|
+
} catch {
|
|
23
|
+
return null
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The text after a readable frontmatter block, or the whole text when there is none. */
|
|
28
|
+
export function bodyOf(text: string): string {
|
|
29
|
+
const parts = split(text)
|
|
30
|
+
if (!parts) return text
|
|
31
|
+
try {
|
|
32
|
+
parseYaml(parts.yaml)
|
|
33
|
+
return parts.body
|
|
34
|
+
} catch {
|
|
35
|
+
return text
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function serialize(front: Record<string, unknown>, body: string): string {
|
|
40
|
+
let yaml: string
|
|
41
|
+
try {
|
|
42
|
+
yaml = stringify(front, { lineWidth: 0 })
|
|
43
|
+
} catch (error) {
|
|
44
|
+
throw new HarnessError('invalid_data', error instanceof Error ? error.message : String(error))
|
|
45
|
+
}
|
|
46
|
+
return `---\n${yaml}---\n\n${body.trimEnd()}\n`
|
|
47
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { mkdtempSync, readdirSync } from 'node:fs'
|
|
2
|
+
import { tmpdir } from 'node:os'
|
|
3
|
+
import { dirname, join } from 'node:path'
|
|
4
|
+
import { expect, test } from 'vitest'
|
|
5
|
+
|
|
6
|
+
import { parse, serialize } from './frontmatter'
|
|
7
|
+
import { appendLine, readJson, readText, safeId, safeRelative, writeJson } from './fs'
|
|
8
|
+
|
|
9
|
+
test('atomic write replaces contents', () => {
|
|
10
|
+
const path = join(mkdtempSync(join(tmpdir(), 'harness-')), 'nested/value.json')
|
|
11
|
+
writeJson(path, { a: 1 })
|
|
12
|
+
writeJson(path, { a: 2 })
|
|
13
|
+
expect(readJson<{ a: number }>(path)?.a).toBe(2)
|
|
14
|
+
expect(readdirSync(dirname(path))).toHaveLength(1)
|
|
15
|
+
expect(readJson(join(dirname(path), 'missing.json'))).toBeNull()
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
test('rejects unsafe ids and paths', () => {
|
|
19
|
+
expect(safeId('abc-123')).toBe('abc-123')
|
|
20
|
+
expect(() => safeId('../x')).toThrow()
|
|
21
|
+
expect(safeRelative('src/main.rs')).toBe('src/main.rs')
|
|
22
|
+
expect(() => safeRelative('../secret')).toThrow()
|
|
23
|
+
expect(() => safeRelative('/etc/passwd')).toThrow()
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
test('appends whole lines', () => {
|
|
27
|
+
const path = join(mkdtempSync(join(tmpdir(), 'harness-')), 'log.md')
|
|
28
|
+
appendLine(path, 'one')
|
|
29
|
+
appendLine(path, 'two')
|
|
30
|
+
expect(readText(path)).toBe('one\ntwo\n')
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test('frontmatter round-trips', () => {
|
|
34
|
+
const text = serialize({ id: 'a', title: 'Has: a colon', tags: ['x'] }, 'Body\n\n')
|
|
35
|
+
expect(text.startsWith('---\n')).toBe(true)
|
|
36
|
+
expect(parse(text)).toEqual({ front: { id: 'a', title: 'Has: a colon', tags: ['x'] }, body: 'Body\n' })
|
|
37
|
+
expect(parse('no frontmatter')).toBeNull()
|
|
38
|
+
})
|
package/src/core/fs.ts
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
import { closeSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeSync } from 'node:fs'
|
|
3
|
+
import { basename, dirname, isAbsolute, join, normalize, sep } from 'node:path'
|
|
4
|
+
|
|
5
|
+
import { HarnessError, invalid } from './errors'
|
|
6
|
+
|
|
7
|
+
/** Writes through a temporary sibling file and renames it into place so readers never see a partial file. */
|
|
8
|
+
export function atomicWrite(path: string, contents: string | Uint8Array): void {
|
|
9
|
+
const parent = dirname(path)
|
|
10
|
+
mkdirSync(parent, { recursive: true })
|
|
11
|
+
const temporary = join(parent, `.${basename(path)}.${randomUUID()}.tmp`)
|
|
12
|
+
const file = openSync(temporary, 'w')
|
|
13
|
+
try {
|
|
14
|
+
writeSync(file, contents as string)
|
|
15
|
+
fsyncSync(file)
|
|
16
|
+
} finally {
|
|
17
|
+
closeSync(file)
|
|
18
|
+
}
|
|
19
|
+
try {
|
|
20
|
+
renameSync(temporary, path)
|
|
21
|
+
} catch (error) {
|
|
22
|
+
rmSync(temporary, { force: true })
|
|
23
|
+
throw error
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function writeJson(path: string, value: unknown): void {
|
|
28
|
+
atomicWrite(path, `${JSON.stringify(value, null, 2)}\n`)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** `null` when the file does not exist; a file that exists but does not parse is an error. */
|
|
32
|
+
export function readJson<T>(path: string): T | null {
|
|
33
|
+
let text: string
|
|
34
|
+
try {
|
|
35
|
+
text = readFileSync(path, 'utf8')
|
|
36
|
+
} catch (error) {
|
|
37
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null
|
|
38
|
+
throw error
|
|
39
|
+
}
|
|
40
|
+
try {
|
|
41
|
+
return JSON.parse(text) as T
|
|
42
|
+
} catch (error) {
|
|
43
|
+
throw new HarnessError('invalid_data', `Stored data could not be read: ${error instanceof Error ? error.message : String(error)}`)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** `readJson` for callers that treat an unreadable file like a missing one. */
|
|
48
|
+
export function readJsonOr<T>(path: string, fallback: T): T {
|
|
49
|
+
try {
|
|
50
|
+
return readJson<T>(path) ?? fallback
|
|
51
|
+
} catch {
|
|
52
|
+
return fallback
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function readText(path: string): string | null {
|
|
57
|
+
try {
|
|
58
|
+
return readFileSync(path, 'utf8')
|
|
59
|
+
} catch {
|
|
60
|
+
return null
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function appendLine(path: string, line: string): void {
|
|
65
|
+
let next = readText(path) ?? ''
|
|
66
|
+
if (next && !next.endsWith('\n')) next += '\n'
|
|
67
|
+
atomicWrite(path, `${next}${line}\n`)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Accepts only IDs that are safe as a single path segment. */
|
|
71
|
+
export function safeId(id: string): string {
|
|
72
|
+
if (id.length > 0 && id.length <= 128 && /^[A-Za-z0-9_-]+$/.test(id)) return id
|
|
73
|
+
throw invalid('Invalid identifier.')
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Accepts a relative path that stays inside its base directory. */
|
|
77
|
+
export function safeRelative(path: string): string {
|
|
78
|
+
const parts = path.split(/[\\/]/)
|
|
79
|
+
if (!path || isAbsolute(path) || parts.some((part) => part === '' || part === '.' || part === '..')) {
|
|
80
|
+
throw invalid('Path must stay inside the working directory.')
|
|
81
|
+
}
|
|
82
|
+
return normalize(path).split(sep).join('/')
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Millisecond UTC timestamp, the format every record uses. */
|
|
86
|
+
export function now(): string {
|
|
87
|
+
return new Date().toISOString()
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function newId(): string {
|
|
91
|
+
return randomUUID()
|
|
92
|
+
}
|