@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,193 @@
|
|
|
1
|
+
// Derived SQLite index at `<data root>/index.sqlite`: full-text search over every Wiki entry
|
|
2
|
+
// (pages, facts, blocks, registry skills/prompts/rules), links, sources, revisions, run
|
|
3
|
+
// metadata, and recall passages. Markdown, raw sources, revision manifests, registry files, and
|
|
4
|
+
// run files stay canonical; the index can always be deleted and rebuilt from them.
|
|
5
|
+
import { copyFileSync, existsSync, readdirSync, rmSync } from 'node:fs'
|
|
6
|
+
import { join } from 'node:path'
|
|
7
|
+
import { DatabaseSync, type SQLInputValue } from 'node:sqlite'
|
|
8
|
+
|
|
9
|
+
import { HarnessError } from './errors'
|
|
10
|
+
|
|
11
|
+
/** Index migration 4: recall, the searchable record of conversations and runs. */
|
|
12
|
+
const RECALL_SCHEMA = `CREATE TABLE recall_runs (run_id TEXT PRIMARY KEY, passages INTEGER NOT NULL, status TEXT NOT NULL);
|
|
13
|
+
CREATE VIRTUAL TABLE recall_fts USING fts5(run_id UNINDEXED, seq UNINDEXED, role UNINDEXED, at UNINDEXED,
|
|
14
|
+
mode UNINDEXED, session_id UNINDEXED, project_id UNINDEXED, text, tokenize = 'unicode61 remove_diacritics 2');`
|
|
15
|
+
|
|
16
|
+
/** Ordered, append-only migrations. Each runs in one transaction; the file is backed up first. */
|
|
17
|
+
export const MIGRATIONS: readonly string[] = [
|
|
18
|
+
// 1: initial schema
|
|
19
|
+
`CREATE TABLE pages (scope TEXT NOT NULL, id TEXT NOT NULL, title TEXT NOT NULL, type TEXT NOT NULL,
|
|
20
|
+
updated_at TEXT, path TEXT NOT NULL, source_ids TEXT NOT NULL, PRIMARY KEY (scope, id));
|
|
21
|
+
CREATE VIRTUAL TABLE pages_fts USING fts5(scope UNINDEXED, id UNINDEXED, title, body, links, sources,
|
|
22
|
+
tokenize = 'unicode61 remove_diacritics 2');
|
|
23
|
+
CREATE TABLE links (scope TEXT NOT NULL, from_id TEXT NOT NULL, to_id TEXT NOT NULL, type TEXT NOT NULL);
|
|
24
|
+
CREATE INDEX links_to ON links (scope, to_id);
|
|
25
|
+
CREATE TABLE sources (scope TEXT NOT NULL, id TEXT NOT NULL, name TEXT NOT NULL, imported_at TEXT, version INTEGER,
|
|
26
|
+
PRIMARY KEY (scope, id));
|
|
27
|
+
CREATE TABLE revisions (scope TEXT NOT NULL, page_id TEXT NOT NULL, id TEXT NOT NULL, created_at TEXT, actor TEXT,
|
|
28
|
+
PRIMARY KEY (scope, id));
|
|
29
|
+
CREATE TABLE runs (id TEXT PRIMARY KEY, project_id TEXT, mode TEXT, harness TEXT, session_type TEXT,
|
|
30
|
+
session_id TEXT, status TEXT, started_at TEXT, ended_at TEXT);
|
|
31
|
+
CREATE INDEX runs_session ON runs (session_id, started_at);`,
|
|
32
|
+
// 2: accounts and token usage per run
|
|
33
|
+
`ALTER TABLE runs ADD COLUMN account_id TEXT;
|
|
34
|
+
CREATE INDEX runs_account ON runs (account_id, started_at);
|
|
35
|
+
CREATE TABLE usage (run_id TEXT PRIMARY KEY, account_id TEXT, harness TEXT, model TEXT, day TEXT NOT NULL,
|
|
36
|
+
input_tokens INTEGER NOT NULL DEFAULT 0, output_tokens INTEGER NOT NULL DEFAULT 0);
|
|
37
|
+
CREATE INDEX usage_account ON usage (account_id, day);`,
|
|
38
|
+
// 3: one global Wiki; entries replace per-scope pages and include facts and registry items
|
|
39
|
+
`DROP TABLE IF EXISTS pages_fts; DROP TABLE IF EXISTS pages; DROP TABLE IF EXISTS links;
|
|
40
|
+
DROP TABLE IF EXISTS sources; DROP TABLE IF EXISTS revisions;
|
|
41
|
+
CREATE TABLE entries (id TEXT PRIMARY KEY, kind TEXT NOT NULL, title TEXT NOT NULL, type TEXT, path TEXT NOT NULL,
|
|
42
|
+
namespace TEXT NOT NULL, file TEXT NOT NULL, updated_at TEXT, source_ids TEXT NOT NULL);
|
|
43
|
+
CREATE VIRTUAL TABLE entries_fts USING fts5(id UNINDEXED, kind UNINDEXED, namespace UNINDEXED, title, body, links, sources,
|
|
44
|
+
tokenize = 'unicode61 remove_diacritics 2');
|
|
45
|
+
CREATE TABLE links (from_id TEXT NOT NULL, to_id TEXT NOT NULL, type TEXT NOT NULL);
|
|
46
|
+
CREATE INDEX links_to ON links (to_id);
|
|
47
|
+
CREATE TABLE sources (id TEXT PRIMARY KEY, name TEXT NOT NULL, imported_at TEXT, version INTEGER);
|
|
48
|
+
CREATE TABLE revisions (entry_id TEXT NOT NULL, id TEXT PRIMARY KEY, created_at TEXT, actor TEXT);
|
|
49
|
+
CREATE INDEX revisions_entry ON revisions (entry_id, created_at);`,
|
|
50
|
+
RECALL_SCHEMA,
|
|
51
|
+
// 5: a per-run summary cache for the embedding application's analytics
|
|
52
|
+
`CREATE TABLE run_stats (run_id TEXT PRIMARY KEY, session_id TEXT NOT NULL, version INTEGER NOT NULL, stats TEXT NOT NULL);
|
|
53
|
+
CREATE INDEX run_stats_session ON run_stats (session_id, version);`,
|
|
54
|
+
// 6: the agent that replied, so one agent's runs can be read across its sessions
|
|
55
|
+
`ALTER TABLE runs ADD COLUMN agent_id TEXT;
|
|
56
|
+
CREATE INDEX runs_agent ON runs (agent_id, started_at);
|
|
57
|
+
CREATE INDEX runs_started ON runs (started_at);`,
|
|
58
|
+
// 7: the per-account usage table goes
|
|
59
|
+
'DROP TABLE IF EXISTS usage;'
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
const connections = new Map<string, DatabaseSync>()
|
|
63
|
+
|
|
64
|
+
export function dbPath(root: string): string {
|
|
65
|
+
return join(root, 'index.sqlite')
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function sqlError(error: unknown): HarnessError {
|
|
69
|
+
return new HarnessError('index_error', error instanceof Error ? error.message : String(error), 'Settings → Projects and Storage → Repair index.')
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function schemaVersion(database: DatabaseSync): number {
|
|
73
|
+
database.exec('CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);')
|
|
74
|
+
const row = database.prepare("SELECT value FROM meta WHERE key = 'schema_version'").get() as { value?: string } | undefined
|
|
75
|
+
const version = Number.parseInt(row?.value ?? '', 10)
|
|
76
|
+
return Number.isInteger(version) && version >= 0 ? version : 0
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Runs `work` inside one transaction, rolling back if it throws. */
|
|
80
|
+
export function transaction<T>(database: DatabaseSync, work: () => T): T {
|
|
81
|
+
database.exec('BEGIN')
|
|
82
|
+
try {
|
|
83
|
+
const result = work()
|
|
84
|
+
database.exec('COMMIT')
|
|
85
|
+
return result
|
|
86
|
+
} catch (error) {
|
|
87
|
+
try {
|
|
88
|
+
database.exec('ROLLBACK')
|
|
89
|
+
} catch {
|
|
90
|
+
// The transaction is already gone.
|
|
91
|
+
}
|
|
92
|
+
throw error
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Applies pending migrations. The database file is copied first; if any migration fails, the
|
|
98
|
+
* copy is restored and the error is reported.
|
|
99
|
+
*/
|
|
100
|
+
export function migrate(path: string, migrations: readonly string[]): DatabaseSync {
|
|
101
|
+
let database: DatabaseSync
|
|
102
|
+
let current: number
|
|
103
|
+
try {
|
|
104
|
+
database = new DatabaseSync(path)
|
|
105
|
+
current = schemaVersion(database)
|
|
106
|
+
} catch (error) {
|
|
107
|
+
throw sqlError(error)
|
|
108
|
+
}
|
|
109
|
+
if (current >= migrations.length) return database
|
|
110
|
+
const backup = `${path.replace(/\.sqlite$/, '')}.sqlite.bak-v${current}`
|
|
111
|
+
const hadFile = existsSync(path) && current > 0
|
|
112
|
+
if (hadFile) {
|
|
113
|
+
try {
|
|
114
|
+
database.exec('PRAGMA wal_checkpoint(TRUNCATE);')
|
|
115
|
+
} catch {
|
|
116
|
+
// A checkpoint that cannot run leaves the copy slightly behind, which a rebuild repairs.
|
|
117
|
+
}
|
|
118
|
+
copyFileSync(path, backup)
|
|
119
|
+
}
|
|
120
|
+
for (let index = current; index < migrations.length; index += 1) {
|
|
121
|
+
try {
|
|
122
|
+
transaction(database, () => {
|
|
123
|
+
database.exec(migrations[index])
|
|
124
|
+
database.prepare("INSERT INTO meta (key, value) VALUES ('schema_version', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value").run(String(index + 1))
|
|
125
|
+
})
|
|
126
|
+
} catch (error) {
|
|
127
|
+
database.close()
|
|
128
|
+
if (hadFile) copyFileSync(backup, path)
|
|
129
|
+
else rmSync(path, { force: true })
|
|
130
|
+
throw new HarnessError('migration_failed', `Index migration ${index + 1} failed and the previous index was restored: ${error instanceof Error ? error.message : String(error)}`)
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return database
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Runs `work` with the shared connection for this data root, opening and migrating it on first use. */
|
|
137
|
+
export function withIndex<T>(root: string, work: (database: DatabaseSync) => T): T {
|
|
138
|
+
const path = dbPath(root)
|
|
139
|
+
let database = connections.get(path)
|
|
140
|
+
if (!database) {
|
|
141
|
+
database = migrate(path, MIGRATIONS)
|
|
142
|
+
try {
|
|
143
|
+
database.exec('PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON; PRAGMA busy_timeout = 5000;')
|
|
144
|
+
} catch (error) {
|
|
145
|
+
database.close()
|
|
146
|
+
throw sqlError(error)
|
|
147
|
+
}
|
|
148
|
+
connections.set(path, database)
|
|
149
|
+
}
|
|
150
|
+
try {
|
|
151
|
+
return work(database)
|
|
152
|
+
} catch (error) {
|
|
153
|
+
throw error instanceof HarnessError ? error : sqlError(error)
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Closes this data root's connection, if one is open. */
|
|
158
|
+
export function closeIndex(root: string): void {
|
|
159
|
+
const path = dbPath(root)
|
|
160
|
+
connections.get(path)?.close()
|
|
161
|
+
connections.delete(path)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Closes the connection and removes the index files; the next use rebuilds the schema. */
|
|
165
|
+
export function deleteIndex(root: string): void {
|
|
166
|
+
closeIndex(root)
|
|
167
|
+
for (const suffix of ['', '-wal', '-shm']) rmSync(`${dbPath(root)}${suffix}`, { force: true })
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export type Param = SQLInputValue
|
|
171
|
+
|
|
172
|
+
export function all<T>(database: DatabaseSync, sql: string, ...params: Param[]): T[] {
|
|
173
|
+
return database.prepare(sql).all(...params) as T[]
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function run(database: DatabaseSync, sql: string, ...params: Param[]): void {
|
|
177
|
+
database.prepare(sql).run(...params)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Folder names under `<data root>/runs`. */
|
|
181
|
+
export function runFolders(root: string): string[] {
|
|
182
|
+
try {
|
|
183
|
+
return readdirSync(join(root, 'runs'))
|
|
184
|
+
} catch {
|
|
185
|
+
return []
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Turns free text into an FTS5 query: each word becomes a quoted prefix term. */
|
|
190
|
+
export function ftsQuery(query: string): string | null {
|
|
191
|
+
const terms = query.split(/[^\p{Alphabetic}\p{N}]+/u).filter(Boolean).map((term) => `"${term.replaceAll('"', '')}"*`)
|
|
192
|
+
return terms.length ? terms.join(' ') : null
|
|
193
|
+
}
|
package/src/core/path.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Where executables are found. A desktop application starts with a minimal PATH, so the login
|
|
2
|
+
// shell's PATH is resolved once and used for everything the harness launches.
|
|
3
|
+
import { execFileSync } from 'node:child_process'
|
|
4
|
+
import { accessSync, constants, statSync } from 'node:fs'
|
|
5
|
+
import { homedir } from 'node:os'
|
|
6
|
+
import { delimiter, isAbsolute, join } from 'node:path'
|
|
7
|
+
|
|
8
|
+
let resolved: string | null = null
|
|
9
|
+
|
|
10
|
+
export function searchPath(): string {
|
|
11
|
+
if (resolved !== null) return resolved
|
|
12
|
+
const entries: string[] = []
|
|
13
|
+
try {
|
|
14
|
+
const output = execFileSync(process.env.SHELL || '/bin/zsh', ['-ilc', 'printf %s "$PATH"'], { encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'] })
|
|
15
|
+
// Interactive shells may print banners; the PATH is the last line.
|
|
16
|
+
entries.push(...(output.split('\n').filter(Boolean).at(-1) ?? '').split(delimiter))
|
|
17
|
+
} catch {
|
|
18
|
+
// The inherited PATH and the usual folders still apply.
|
|
19
|
+
}
|
|
20
|
+
entries.push(...(process.env.PATH ?? '').split(delimiter))
|
|
21
|
+
const home = homedir()
|
|
22
|
+
if (home) entries.push(...['.local/bin', '.bun/bin', '.npm-global/bin', '.cargo/bin'].map((folder) => join(home, folder)))
|
|
23
|
+
entries.push('/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', '/bin')
|
|
24
|
+
resolved = [...new Set(entries.filter(Boolean))].join(delimiter)
|
|
25
|
+
return resolved
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function isExecutable(path: string): boolean {
|
|
29
|
+
try {
|
|
30
|
+
accessSync(path, constants.X_OK)
|
|
31
|
+
return statSync(path).isFile()
|
|
32
|
+
} catch {
|
|
33
|
+
return false
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The executable `name` resolves to: itself when absolute, else the first match on the search path. */
|
|
38
|
+
export function findBinary(name: string): string | null {
|
|
39
|
+
if (isAbsolute(name)) return isExecutable(name) ? name : null
|
|
40
|
+
for (const directory of searchPath().split(delimiter)) {
|
|
41
|
+
const candidate = join(directory, name)
|
|
42
|
+
if (isExecutable(candidate)) return candidate
|
|
43
|
+
}
|
|
44
|
+
return null
|
|
45
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// Projects: named working folders, each with a Wiki namespace (`/projects/<slug>`). The harness
|
|
2
|
+
// reads `<data root>/projects.json`; the embedding application adds, renames, and removes them.
|
|
3
|
+
import { existsSync } from 'node:fs'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
|
|
6
|
+
import { notFound } from './errors'
|
|
7
|
+
import { readJson } from './fs'
|
|
8
|
+
import { newId } from './fs'
|
|
9
|
+
|
|
10
|
+
interface ProjectRecord {
|
|
11
|
+
id: string
|
|
12
|
+
name: string
|
|
13
|
+
rootPath: string
|
|
14
|
+
autoMaintenance: boolean
|
|
15
|
+
createdAt: string
|
|
16
|
+
/** Stable Wiki namespace segment; kept when the project is renamed. */
|
|
17
|
+
slug?: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface Project {
|
|
21
|
+
id: string
|
|
22
|
+
name: string
|
|
23
|
+
rootPath: string
|
|
24
|
+
isGitRepository: boolean
|
|
25
|
+
autoMaintenance: boolean
|
|
26
|
+
createdAt: string
|
|
27
|
+
slug: string
|
|
28
|
+
/** `/projects/<slug>`: where this project's pages live in the Wiki. */
|
|
29
|
+
wikiNamespace: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Same rule as the Wiki's page slugs, kept here so `core` does not depend on `memory`. */
|
|
33
|
+
export function slugify(text: string): string {
|
|
34
|
+
let value = ''
|
|
35
|
+
for (const character of text.toLowerCase()) {
|
|
36
|
+
if (/^[a-z0-9]$/.test(character)) value += character
|
|
37
|
+
else if (value && !value.endsWith('-')) value += '-'
|
|
38
|
+
}
|
|
39
|
+
value = value.replace(/^-+|-+$/g, '').slice(0, 60)
|
|
40
|
+
return value || `page-${newId().slice(0, 8)}`
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function uniqueSlug(name: string, taken: readonly string[]): string {
|
|
44
|
+
const slug = slugify(name)
|
|
45
|
+
const base = slug.startsWith('page-') ? 'project' : slug
|
|
46
|
+
let candidate = base
|
|
47
|
+
for (let counter = 2; taken.includes(candidate); counter += 1) candidate = `${base}-${counter}`
|
|
48
|
+
return candidate
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function load(root: string): ProjectRecord[] {
|
|
52
|
+
const records = readJson<ProjectRecord[]>(join(root, 'projects.json')) ?? []
|
|
53
|
+
// Records without a slug get a unique one derived from the name, in list order, so the result
|
|
54
|
+
// is the same on every read until the application persists it.
|
|
55
|
+
const taken = records.map((record) => record.slug ?? '').filter(Boolean)
|
|
56
|
+
for (const record of records) {
|
|
57
|
+
if (record.slug) continue
|
|
58
|
+
record.slug = uniqueSlug(record.name, taken)
|
|
59
|
+
taken.push(record.slug)
|
|
60
|
+
}
|
|
61
|
+
return records
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function listProjects(root: string): Project[] {
|
|
65
|
+
return load(root).map((record) => ({
|
|
66
|
+
id: record.id,
|
|
67
|
+
name: record.name,
|
|
68
|
+
rootPath: record.rootPath,
|
|
69
|
+
isGitRepository: existsSync(join(record.rootPath, '.git')),
|
|
70
|
+
autoMaintenance: record.autoMaintenance,
|
|
71
|
+
createdAt: record.createdAt,
|
|
72
|
+
slug: record.slug!,
|
|
73
|
+
wikiNamespace: `/projects/${record.slug}`
|
|
74
|
+
}))
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** `listProjects` for callers that treat an unreadable registry like an empty one. */
|
|
78
|
+
export function projectsOrNone(root: string): Project[] {
|
|
79
|
+
try {
|
|
80
|
+
return listProjects(root)
|
|
81
|
+
} catch {
|
|
82
|
+
return []
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function findProject(root: string, projectId: string): Project {
|
|
87
|
+
const project = listProjects(root).find((entry) => entry.id === projectId)
|
|
88
|
+
if (!project) throw notFound('Project')
|
|
89
|
+
return project
|
|
90
|
+
}
|
package/src/core/text.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Text helpers that count Unicode characters, not UTF-16 units, so budgets and cuts agree across languages.
|
|
2
|
+
|
|
3
|
+
export function charCount(text: string): number {
|
|
4
|
+
let count = 0
|
|
5
|
+
for (const _ of text) count += 1
|
|
6
|
+
return count
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function takeChars(text: string, count: number): string {
|
|
10
|
+
if (text.length <= count) return text
|
|
11
|
+
let taken = 0
|
|
12
|
+
let end = 0
|
|
13
|
+
for (const character of text) {
|
|
14
|
+
if (taken >= count) break
|
|
15
|
+
taken += 1
|
|
16
|
+
end += character.length
|
|
17
|
+
}
|
|
18
|
+
return text.slice(0, end)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Lines without their terminators; a final newline does not add an empty last line. */
|
|
22
|
+
export function lines(text: string): string[] {
|
|
23
|
+
if (!text) return []
|
|
24
|
+
const parts = text.split('\n')
|
|
25
|
+
if (parts[parts.length - 1] === '') parts.pop()
|
|
26
|
+
return parts.map((line) => (line.endsWith('\r') ? line.slice(0, -1) : line))
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Orders file paths segment by segment, so a folder sorts before a sibling whose name extends it. */
|
|
30
|
+
export function comparePaths(left: string, right: string): number {
|
|
31
|
+
const a = left.split('/')
|
|
32
|
+
const b = right.split('/')
|
|
33
|
+
for (let index = 0; index < Math.min(a.length, b.length); index += 1) {
|
|
34
|
+
if (a[index] !== b[index]) return a[index] < b[index] ? -1 : 1
|
|
35
|
+
}
|
|
36
|
+
return a.length - b.length
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const compareText = (left: string, right: string): number => (left < right ? -1 : left > right ? 1 : 0)
|
package/src/harness.ts
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
// The harness, assembled: Pi sessions on isolated accounts, a compiled prompt, a memory
|
|
2
|
+
// lifecycle, tools, connected apps, and skills. An application embeds one of these and adds
|
|
3
|
+
// its own prompt sections, tools, and policy; nothing here knows what that application is.
|
|
4
|
+
import { mkdirSync } from 'node:fs'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
|
|
7
|
+
import { Accounts } from './accounts/accounts'
|
|
8
|
+
import * as capabilities from './capabilities/api'
|
|
9
|
+
import { ensureSharedDir, isolateProcess } from './accounts/env'
|
|
10
|
+
import { fileSecretStore, type SecretStore } from './accounts/secrets'
|
|
11
|
+
import { Bus, log } from './core/bus'
|
|
12
|
+
import { DEFAULT_PRODUCT_NAME, EMPTY_DIRECTORY, type Directory, type HarnessEnv } from './core/env'
|
|
13
|
+
import { HarnessError } from './core/errors'
|
|
14
|
+
import { readJsonOr } from './core/fs'
|
|
15
|
+
import { closeIndex } from './core/index'
|
|
16
|
+
import { projectsOrNone } from './core/projects'
|
|
17
|
+
import { ConnectedApps } from './mcp/service'
|
|
18
|
+
import type { MaintainerFactory } from './memory/maintainer'
|
|
19
|
+
import { Maintenance } from './memory/maintenance'
|
|
20
|
+
import * as migrate from './memory/migrate'
|
|
21
|
+
import { writeIndex } from './memory/proposals'
|
|
22
|
+
import * as recall from './memory/recall'
|
|
23
|
+
import * as store from './memory/store'
|
|
24
|
+
import { countPages } from './memory/wiki'
|
|
25
|
+
import * as wikiIndex from './memory/wikiIndex'
|
|
26
|
+
import * as pluginApi from './plugins/api'
|
|
27
|
+
import * as attachments from './plugins/attachments'
|
|
28
|
+
import { HARNESS_PLUGINS } from './plugins/builtin'
|
|
29
|
+
import type { Resolver } from './plugins/discover'
|
|
30
|
+
import { Hooks } from './plugins/hooks'
|
|
31
|
+
import { packageFolders, Plugins } from './plugins/plugins'
|
|
32
|
+
import type { PluginDescriptor } from './plugins/types'
|
|
33
|
+
import { approvalKey, Approvals } from './sessions/approvals'
|
|
34
|
+
import type { ToolPolicy } from './sessions/policy'
|
|
35
|
+
import { RunRegistry } from './sessions/registry'
|
|
36
|
+
import { markInterruptedRuns } from './sessions/runStore'
|
|
37
|
+
import type { SessionFactory } from './sessions/runner'
|
|
38
|
+
import { Sessions } from './sessions/sessions'
|
|
39
|
+
import * as registry from './skills/registry'
|
|
40
|
+
import { Terminals, type TerminalAssets } from './terminal/terminals'
|
|
41
|
+
|
|
42
|
+
export interface HarnessOptions {
|
|
43
|
+
/** Where knowledge and history live. May be set later with `open`. */
|
|
44
|
+
dataRoot?: string | null
|
|
45
|
+
/** Device-local state that never belongs in the data root: accounts, approvals, connections, sockets. */
|
|
46
|
+
stateDir: string
|
|
47
|
+
/** Where credentials are kept. Defaults to an owner-only file in the state directory. */
|
|
48
|
+
secrets?: SecretStore
|
|
49
|
+
/** How the harness names itself to a model. */
|
|
50
|
+
productName?: string
|
|
51
|
+
directory?: Directory
|
|
52
|
+
policy?: ToolPolicy
|
|
53
|
+
/** Replaces Pi, for tests. */
|
|
54
|
+
sessionFactory?: SessionFactory
|
|
55
|
+
maintainerFactory?: MaintainerFactory
|
|
56
|
+
/** How long the maintenance queue stays empty before full memory blocks are condensed. */
|
|
57
|
+
idleMs?: number
|
|
58
|
+
/** Where the built commands a terminal session runs are, when they are not beside this package. */
|
|
59
|
+
assets?: TerminalAssets
|
|
60
|
+
/** The `pi` executable a terminal session runs. Defaults to the one on the search path. */
|
|
61
|
+
piProgram?: () => string | null
|
|
62
|
+
/** Folders of built-in skills, prompts, and rules, each laid out like `registry/`. */
|
|
63
|
+
builtins?: string[]
|
|
64
|
+
/**
|
|
65
|
+
* The application's own parts, described as plugins: which tools, prompt sections and tool
|
|
66
|
+
* groups each one provides. Data only; it changes what is listed, never what runs.
|
|
67
|
+
*/
|
|
68
|
+
plugins?: PluginDescriptor[]
|
|
69
|
+
/** Replaces Pi's own search for extensions, skills, prompts and themes, for tests. */
|
|
70
|
+
resolver?: Resolver
|
|
71
|
+
/** Replaces how a plugin's hooks file is read as a module, for tests. */
|
|
72
|
+
hookImporter?: (address: string) => Promise<unknown>
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** What starting on a data root found and repaired. */
|
|
76
|
+
export interface Recovery {
|
|
77
|
+
interruptedRuns: number
|
|
78
|
+
reindexedPages: number
|
|
79
|
+
/** Set when the storage migration failed; the old data is left as it was. */
|
|
80
|
+
migrationError: string | null
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
class Env implements HarnessEnv {
|
|
84
|
+
dataRoot: string | null
|
|
85
|
+
readonly bus: Bus
|
|
86
|
+
|
|
87
|
+
constructor(
|
|
88
|
+
dataRoot: string | null,
|
|
89
|
+
readonly directory: Directory,
|
|
90
|
+
readonly productName: string,
|
|
91
|
+
bus: Bus
|
|
92
|
+
) {
|
|
93
|
+
this.dataRoot = dataRoot
|
|
94
|
+
this.bus = bus
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
get root(): string {
|
|
98
|
+
if (!this.dataRoot) throw new HarnessError('data_root_missing', 'Choose a data location first.', 'Open Settings → Projects and Storage.')
|
|
99
|
+
return this.dataRoot
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export class Harness {
|
|
104
|
+
readonly bus = new Bus()
|
|
105
|
+
readonly env: Env
|
|
106
|
+
readonly stateDir: string
|
|
107
|
+
readonly policy: ToolPolicy
|
|
108
|
+
readonly accounts: Accounts
|
|
109
|
+
readonly approvals: Approvals
|
|
110
|
+
readonly runs: RunRegistry
|
|
111
|
+
readonly apps: ConnectedApps
|
|
112
|
+
readonly plugins: Plugins
|
|
113
|
+
/** Code plugins gave the harness to run after memory, the Wiki or history changed. */
|
|
114
|
+
readonly hooks: Hooks
|
|
115
|
+
/** Every part described as a plugin: the harness's own, then the application's. */
|
|
116
|
+
descriptors: readonly PluginDescriptor[] = HARNESS_PLUGINS
|
|
117
|
+
applicationPlugins: ReadonlySet<string> = new Set()
|
|
118
|
+
readonly sessions: Sessions
|
|
119
|
+
readonly maintenance: Maintenance
|
|
120
|
+
readonly terminals: Terminals
|
|
121
|
+
/** Resolves when what was loaded in the background at construction is in place. */
|
|
122
|
+
readonly ready: Promise<void>
|
|
123
|
+
recovery: Recovery = { interruptedRuns: 0, reindexedPages: 0, migrationError: null }
|
|
124
|
+
|
|
125
|
+
constructor(options: HarnessOptions) {
|
|
126
|
+
this.stateDir = options.stateDir
|
|
127
|
+
mkdirSync(this.stateDir, { recursive: true })
|
|
128
|
+
// Before anything can start a session: no inherited credential, no `~/.pi`.
|
|
129
|
+
isolateProcess(join(this.stateDir, 'accounts'))
|
|
130
|
+
this.env = new Env(options.dataRoot || null, options.directory ?? EMPTY_DIRECTORY, options.productName ?? DEFAULT_PRODUCT_NAME, this.bus)
|
|
131
|
+
this.policy = options.policy ?? {}
|
|
132
|
+
registry.configureBuiltins(options.builtins ?? [])
|
|
133
|
+
const secrets = options.secrets ?? fileSecretStore(join(this.stateDir, 'secrets.json'))
|
|
134
|
+
this.accounts = new Accounts({ dir: join(this.stateDir, 'accounts'), secrets, bus: this.bus })
|
|
135
|
+
this.approvals = new Approvals(this.stateDir, this.bus)
|
|
136
|
+
this.runs = new RunRegistry({
|
|
137
|
+
root: () => this.env.root,
|
|
138
|
+
automatic: async (run, target, operation, standing) => {
|
|
139
|
+
if (standing === 'switch') return this.approvals.isAuto(approvalKey(run)) ? 'always' : null
|
|
140
|
+
return this.approvals.automatic(run, target, (await this.policy.approval?.({ run, target, operation })) ?? {})
|
|
141
|
+
},
|
|
142
|
+
redact: (event) => this.apps.connections.redactEvent(event),
|
|
143
|
+
ended: (runId, sessionId) => this.apps.revokeRun(runId, sessionId)
|
|
144
|
+
})
|
|
145
|
+
this.apps = new ConnectedApps({ env: this.env, stateDir: this.stateDir, secrets, bus: this.bus, runs: this.runs, policy: this.policy })
|
|
146
|
+
this.plugins = new Plugins({ stateDir: this.stateDir, bus: this.bus, resolver: options.resolver, attachments: () => (this.env.dataRoot ? attachments.load(this.env.dataRoot) : null), packagesDir: () => (this.env.dataRoot ? join(this.env.dataRoot, 'plugins') : null) })
|
|
147
|
+
this.hooks = new Hooks({
|
|
148
|
+
stateDir: this.stateDir,
|
|
149
|
+
bus: this.bus,
|
|
150
|
+
// A hook runs for everyone or not at all, so only what is on everywhere and trusted counts.
|
|
151
|
+
// The shared folder stands in for an account: packages are the same for all of them.
|
|
152
|
+
wanted: async () => {
|
|
153
|
+
if (!this.env.dataRoot) return []
|
|
154
|
+
const shared = ensureSharedDir(join(this.stateDir, 'accounts'))
|
|
155
|
+
// A hook comes only with a package: one kept beside the data, or one the shared settings
|
|
156
|
+
// name. With neither there is nothing to look for, and Pi is not loaded to find that out.
|
|
157
|
+
const named = readJsonOr<{ packages?: unknown }>(join(shared, 'settings.json'), {}).packages
|
|
158
|
+
if (!packageFolders(join(this.env.dataRoot, 'plugins')).length && !(Array.isArray(named) && named.length)) return []
|
|
159
|
+
return (await this.plugins.loading({ cwd: this.stateDir, agentDir: shared, caller: {} })).hooks
|
|
160
|
+
},
|
|
161
|
+
call: (connection, tool, args) => this.apps.callUnattended(connection, tool, args ?? {}),
|
|
162
|
+
importFile: options.hookImporter
|
|
163
|
+
})
|
|
164
|
+
let reloadTimer: ReturnType<typeof setTimeout> | null = null
|
|
165
|
+
this.bus.on((notice) => {
|
|
166
|
+
if (notice.type !== 'plugins.changed' || reloadTimer) return
|
|
167
|
+
// Several changes arrive together when a plugin is installed or switched; one reload covers them.
|
|
168
|
+
reloadTimer = setTimeout(() => {
|
|
169
|
+
reloadTimer = null
|
|
170
|
+
void this.hooks.reload()
|
|
171
|
+
}, 50)
|
|
172
|
+
reloadTimer.unref?.()
|
|
173
|
+
})
|
|
174
|
+
this.describe(options.plugins ?? [])
|
|
175
|
+
this.sessions = new Sessions({
|
|
176
|
+
env: this.env,
|
|
177
|
+
accounts: this.accounts,
|
|
178
|
+
runs: this.runs,
|
|
179
|
+
policy: this.policy,
|
|
180
|
+
broker: this.apps,
|
|
181
|
+
plugins: this.plugins,
|
|
182
|
+
resolveCapabilities: (caller, tools) => capabilities.resolve(this, { agentId: caller.agentId ?? null, projectId: caller.projectId ?? null, tools }),
|
|
183
|
+
factory: options.sessionFactory
|
|
184
|
+
})
|
|
185
|
+
this.maintenance = new Maintenance({ env: this.env, bus: this.bus, accounts: this.accounts, factory: options.maintainerFactory, idleMs: options.idleMs })
|
|
186
|
+
this.terminals = new Terminals({ env: this.env, stateDir: this.stateDir, accounts: this.accounts, runs: this.runs, apps: this.apps, policy: this.policy, bus: this.bus, plugins: this.plugins, pluginControl: { list: (scope) => pluginApi.list(this, scope), attach: (params, on) => pluginApi.attach(this, params, on), trust: (scope) => pluginApi.trust(this, scope), untrust: (scope) => pluginApi.untrust(this, scope) }, assets: options.assets, program: options.piProgram })
|
|
187
|
+
this.ready = this.apps.start().catch((error) => log('connected apps', error instanceof Error ? error.message : String(error)))
|
|
188
|
+
this.runs.listen((event, run) => {
|
|
189
|
+
this.accounts.onRunEvent(event, run)
|
|
190
|
+
// Each run is indexed when it ends, so the next turn can already recall it.
|
|
191
|
+
if (event.type === 'completed' || event.type === 'failed') recall.indexRun(this.env.root, event.runId)
|
|
192
|
+
if ((event.type === 'completed' || event.type === 'failed') && run) {
|
|
193
|
+
this.hooks.emit({ type: 'history.run.ended', runId: run.id, sessionId: run.sessionId, status: run.status, goal: run.goal ?? null, agentId: run.agentId ?? null, projectId: run.projectId ?? null, startedAt: run.startedAt, endedAt: run.endedAt ?? null, at: event.timestamp })
|
|
194
|
+
}
|
|
195
|
+
this.maintenance.onRunEvent(event)
|
|
196
|
+
})
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
get dataRoot(): string | null {
|
|
200
|
+
return this.env.dataRoot
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Sets the application's own parts, described as plugins. One with a harness plugin's id is ignored. */
|
|
204
|
+
describe(plugins: readonly PluginDescriptor[]): void {
|
|
205
|
+
const own = new Set(HARNESS_PLUGINS.map((plugin) => plugin.id))
|
|
206
|
+
const supplied = plugins.filter((plugin) => plugin && typeof plugin.id === 'string' && !own.has(plugin.id))
|
|
207
|
+
this.descriptors = [...HARNESS_PLUGINS, ...supplied]
|
|
208
|
+
this.applicationPlugins = new Set(supplied.map((plugin) => plugin.id))
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Opens a data root: brings its Wiki to the current layout, settles runs that never ended,
|
|
213
|
+
* and refreshes the index. `agentIds` are the agents that get their own memory blocks when an
|
|
214
|
+
* older Wiki is migrated.
|
|
215
|
+
*/
|
|
216
|
+
open(dataRoot: string, agentIds: readonly string[] = []): Recovery {
|
|
217
|
+
if (this.env.dataRoot && this.env.dataRoot !== dataRoot) closeIndex(this.env.dataRoot)
|
|
218
|
+
this.env.dataRoot = dataRoot
|
|
219
|
+
const root = dataRoot
|
|
220
|
+
this.hooks.watch(store.wikiDir(root))
|
|
221
|
+
void this.hooks.reload()
|
|
222
|
+
for (const folder of ['registry/skills', 'registry/prompts', 'registry/rules', 'config', 'runs']) mkdirSync(join(root, folder), { recursive: true })
|
|
223
|
+
let migrationError: string | null = null
|
|
224
|
+
try {
|
|
225
|
+
if (migrate.run(this.env, agentIds)) log(`started Wiki storage version ${migrate.WIKI_VERSION}; the earlier Wiki is kept as a backup in the data root`)
|
|
226
|
+
} catch (error) {
|
|
227
|
+
migrationError = error instanceof Error ? error.message : String(error)
|
|
228
|
+
log('Wiki migration failed', migrationError)
|
|
229
|
+
}
|
|
230
|
+
if (!migrationError) {
|
|
231
|
+
try {
|
|
232
|
+
store.ensureLayout(root, this.env.productName)
|
|
233
|
+
// Every project has its fixed pages, and the generated indexes match the files.
|
|
234
|
+
for (const project of projectsOrNone(root)) store.ensureProjectNamespace(root, project.slug, project.name)
|
|
235
|
+
writeIndex(store.wikiDir(root))
|
|
236
|
+
} catch (error) {
|
|
237
|
+
log('Wiki layout', error instanceof Error ? error.message : String(error))
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
let interruptedRuns = 0
|
|
241
|
+
try {
|
|
242
|
+
interruptedRuns = markInterruptedRuns(root, `${this.env.productName} stopped before the run settled.`)
|
|
243
|
+
} catch (error) {
|
|
244
|
+
log('run recovery', error instanceof Error ? error.message : String(error))
|
|
245
|
+
}
|
|
246
|
+
let reindexedPages: number
|
|
247
|
+
try {
|
|
248
|
+
reindexedPages = wikiIndex.ensure(root)
|
|
249
|
+
} catch (error) {
|
|
250
|
+
log('index', error instanceof Error ? error.message : String(error))
|
|
251
|
+
reindexedPages = countPages(root)
|
|
252
|
+
}
|
|
253
|
+
try {
|
|
254
|
+
// Runs recorded while nothing was running, or before recall existed, become searchable.
|
|
255
|
+
recall.sync(root)
|
|
256
|
+
} catch (error) {
|
|
257
|
+
log('recall', error instanceof Error ? error.message : String(error))
|
|
258
|
+
}
|
|
259
|
+
this.recovery = { interruptedRuns, reindexedPages, migrationError }
|
|
260
|
+
this.maintenance.start()
|
|
261
|
+
return this.recovery
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Deletes the index and rebuilds it from the files. Returns the pages indexed. */
|
|
265
|
+
rebuildIndex(): number {
|
|
266
|
+
const pages = wikiIndex.rebuild(this.env.root)
|
|
267
|
+
recall.sync(this.env.root)
|
|
268
|
+
this.recovery = { ...this.recovery, reindexedPages: pages }
|
|
269
|
+
return pages
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Stops live runs and releases the index. */
|
|
273
|
+
async close(): Promise<void> {
|
|
274
|
+
this.maintenance.stop()
|
|
275
|
+
await this.terminals.close()
|
|
276
|
+
await this.runs.interruptAll()
|
|
277
|
+
await this.maintenance.settled()
|
|
278
|
+
await this.hooks.settled().catch(() => undefined)
|
|
279
|
+
this.hooks.close()
|
|
280
|
+
await this.apps.close()
|
|
281
|
+
if (this.env.dataRoot) closeIndex(this.env.dataRoot)
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export function createHarness(options: HarnessOptions): Harness {
|
|
286
|
+
const harness = new Harness(options)
|
|
287
|
+
if (options.dataRoot) harness.open(options.dataRoot)
|
|
288
|
+
return harness
|
|
289
|
+
}
|