throughline 0.6.3 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +101 -1
- package/README.md +86 -28
- package/bin/throughline.mjs +20 -0
- package/docs/00_overview.md +12 -0
- package/docs/02_clear_auto_handoff_plan.md +47 -13
- package/docs/04_public_release_plan.md +1 -0
- package/docs/14_observer_completed_turn_feed_plan.md +290 -0
- package/docs/BUGHUB_RUNTIME_ERROR_STORE_PLAN.md +9 -3
- package/docs/adr/0002-observer-claude-completion-receipt.md +42 -0
- package/docs/adr/0003-observer-completed-chain-cursor.md +34 -0
- package/docs/adr/0004-observer-db-pair-projection.md +71 -0
- package/docs/adr/0005-observer-read-pagination.md +51 -0
- package/docs/adr/0006-observer-page-offset-proof.md +33 -0
- package/docs/adr/0007-observer-read-cli-contract.md +61 -0
- package/docs/adr/0008-observer-wait-deadline-cancel.md +81 -0
- package/docs/adr/0009-observer-integration-regression-and-docs.md +37 -0
- package/docs/adr/0010-observer-o1-phase-acceptance.md +49 -0
- package/docs/adr/0011-observer-o1-control-lane-reconciliation.md +34 -0
- package/docs/adr/0012-claude-stop-transcript-flush-barrier.md +32 -0
- package/docs/adr/0013-observer-read-busy-writer-gate.md +46 -0
- package/docs/adr/0014-two-phase-handoff-ghost-baton.md +112 -0
- package/docs/adr/0015-l1-summarizer-model-effort-ratio.md +81 -0
- package/docs/adr/0016-push-pull-recall-injection.md +93 -0
- package/package.json +1 -1
- package/rag/01-hooks/hook-stdout-10k-persisted-output.md +65 -0
- package/rag/INDEX.md +4 -0
- package/src/auditor-context.mjs +92 -11
- package/src/auditor-context.test.mjs +116 -1
- package/src/baton.mjs +27 -7
- package/src/baton.test.mjs +44 -0
- package/src/body-digest.mjs +9 -0
- package/src/cli/auditor-context.test.mjs +1 -1
- package/src/cli/factory-diagnostics.mjs +1 -0
- package/src/cli/factory-diagnostics.test.mjs +6 -2
- package/src/cli/observer-read.mjs +73 -0
- package/src/cli/observer-read.test.mjs +93 -0
- package/src/cli/observer-wait.mjs +123 -0
- package/src/cli/observer-wait.test.mjs +167 -0
- package/src/cli/recall.mjs +279 -0
- package/src/cli/recall.test.mjs +269 -0
- package/src/codex-rollout-memory.mjs +13 -0
- package/src/codex-rollout-memory.test.mjs +27 -0
- package/src/codex-thread-index.mjs +1 -1
- package/src/codex-thread-index.test.mjs +18 -0
- package/src/completed-turn-receipts.mjs +374 -0
- package/src/completed-turn-receipts.test.mjs +186 -0
- package/src/db-schema.test.mjs +9 -2
- package/src/db.mjs +20 -1
- package/src/decision-log.mjs +24 -0
- package/src/haiku-summarizer.mjs +93 -16
- package/src/haiku-summarizer.test.mjs +118 -9
- package/src/handoff-executor.mjs +161 -0
- package/src/hook-entrypoints.test.mjs +192 -12
- package/src/observer-codex-projection.test.mjs +49 -0
- package/src/observer-turn-feed.mjs +392 -0
- package/src/observer-turn-feed.test.mjs +339 -0
- package/src/observer-turn-wait.mjs +102 -0
- package/src/observer-turn-wait.test.mjs +122 -0
- package/src/pending-handoff.mjs +96 -0
- package/src/pending-handoff.test.mjs +107 -0
- package/src/prompt-submit.mjs +46 -1
- package/src/resume-context.mjs +337 -60
- package/src/resume-context.test.mjs +228 -1
- package/src/runtime-error-store.mjs +2 -1
- package/src/runtime-error-store.test.mjs +1 -1
- package/src/session-start.mjs +70 -233
- package/src/transcript-reader.mjs +32 -0
- package/src/turn-backfill.mjs +3 -2
- package/src/turn-backfill.test.mjs +10 -4
- package/src/turn-processor.mjs +90 -1
- package/src/turn-processor.test.mjs +142 -0
- package/src/windows-acl-test-helper.mjs +2 -2
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import { mkdtemp, mkdir, rm } from 'node:fs/promises';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import test from 'node:test';
|
|
8
|
+
import { OBSERVER_READ_SCHEMA } from '../observer-turn-feed.mjs';
|
|
9
|
+
import { parseArgs, run } from './observer-read.mjs';
|
|
10
|
+
|
|
11
|
+
const REPO_ROOT = join(fileURLToPath(new URL('../..', import.meta.url)));
|
|
12
|
+
const BIN_PATH = join(REPO_ROOT, 'bin/throughline.mjs');
|
|
13
|
+
const FIXED = { schema: OBSERVER_READ_SCHEMA, status: 'snapshot', turns: [], page: { complete: true, nextToken: null } };
|
|
14
|
+
|
|
15
|
+
function streams() {
|
|
16
|
+
return { stdout: { text: '', write(value) { this.text += value; } }, stderr: { text: '', write(value) { this.text += value; } } };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
test('observer-read parses each supported option once and delegates validation to read library', () => {
|
|
20
|
+
assert.deepEqual(parseArgs(['--project', '/repo', '--after-cursor', 'after', '--through-cursor', 'through', '--page-token', 'page', '--limit', '100', '--json']), {
|
|
21
|
+
projectPath: '/repo', afterCursor: 'after', throughCursor: 'through', pageToken: 'page', limit: 100, json: true,
|
|
22
|
+
});
|
|
23
|
+
const io = streams(); let input;
|
|
24
|
+
assert.equal(run(['--project', '/repo', '--json'], { ...io, read(value) { input = value; return FIXED; } }), 0);
|
|
25
|
+
assert.deepEqual(input, { projectPath: '/repo' });
|
|
26
|
+
assert.deepEqual(JSON.parse(io.stdout.text), FIXED);
|
|
27
|
+
assert.equal(io.stderr.text, '');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('observer-read rejects duplicate, missing, positional, and invalid pagination arguments with fixed args error', () => {
|
|
31
|
+
for (const argv of [
|
|
32
|
+
['--project', '/repo', '--project', '/repo', '--json'], ['--project', '/repo', '--json', '--json'],
|
|
33
|
+
['--project', '/repo', '--page-token', 'p', '--json'], ['--project', '/repo', '--limit', '0', '--json'],
|
|
34
|
+
['--project', '/repo', 'extra', '--json'], ['--json'],
|
|
35
|
+
]) {
|
|
36
|
+
const io = streams();
|
|
37
|
+
assert.equal(run(argv, { ...io }), 1);
|
|
38
|
+
assert.equal(io.stdout.text, '');
|
|
39
|
+
assert.deepEqual(JSON.parse(io.stderr.text), { schema: OBSERVER_READ_SCHEMA, status: 'error', code: 'E_OBSERVER_READ_ARGS', message: 'invalid observer-read arguments' });
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('observer-read keeps known states successful and maps hard failures without leakage', () => {
|
|
44
|
+
for (const status of ['snapshot', 'delta', 'thread_switched', 'host_switched', 'resync_required', 'projection_pending', 'ambiguous_parent']) {
|
|
45
|
+
const io = streams();
|
|
46
|
+
assert.equal(run(['--project', '/repo', '--json'], { ...io, read: () => ({ ...FIXED, status }) }), 0);
|
|
47
|
+
assert.equal(JSON.parse(io.stdout.text).status, status);
|
|
48
|
+
assert.equal(io.stderr.text, '');
|
|
49
|
+
}
|
|
50
|
+
for (const [error, code] of [
|
|
51
|
+
[new TypeError('/private/cursor body'), 'E_OBSERVER_READ_INPUT'],
|
|
52
|
+
[Object.assign(new Error('schema'), { code: 'E_AUDITOR_CONTEXT_SCHEMA' }), 'E_OBSERVER_READ_DB_SCHEMA'],
|
|
53
|
+
[Object.assign(new Error('project'), { code: 'E_AUDITOR_CONTEXT_PROJECT' }), 'E_OBSERVER_READ_DB_PROJECT'],
|
|
54
|
+
[Object.assign(new Error('io'), { code: 'E_AUDITOR_CONTEXT_QUERY' }), 'E_OBSERVER_READ_DB_IO'],
|
|
55
|
+
[new Error('secret /private cursor'), 'E_OBSERVER_READ_INTERNAL'],
|
|
56
|
+
]) {
|
|
57
|
+
const io = streams();
|
|
58
|
+
assert.equal(run(['--project', '/repo', '--json'], { ...io, read: () => { throw error; } }), 1);
|
|
59
|
+
assert.equal(io.stdout.text, '');
|
|
60
|
+
assert.equal(JSON.parse(io.stderr.text).code, code);
|
|
61
|
+
assert.doesNotMatch(io.stderr.text, /private|cursor|secret/i);
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('observer-read bin dispatch and help advertise JSON-only command', () => {
|
|
66
|
+
const help = spawnSync(process.execPath, [BIN_PATH, '--help'], { cwd: REPO_ROOT, encoding: 'utf8' });
|
|
67
|
+
assert.equal(help.status, 0, help.stderr);
|
|
68
|
+
assert.match(help.stdout, /throughline observer-read --project <absolute-directory> --json/);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('observer-read bin dispatch returns an empty snapshot from an isolated environment', async () => {
|
|
72
|
+
const root = await mkdtemp(join(tmpdir(), 'throughline-observer-read-'));
|
|
73
|
+
const home = join(root, 'home');
|
|
74
|
+
const state = join(root, 'state');
|
|
75
|
+
const codexHome = join(root, 'codex');
|
|
76
|
+
const project = join(root, 'project');
|
|
77
|
+
try {
|
|
78
|
+
await Promise.all([mkdir(home), mkdir(state), mkdir(codexHome), mkdir(project)]);
|
|
79
|
+
const result = spawnSync(process.execPath, [BIN_PATH, 'observer-read', '--project', project, '--json'], {
|
|
80
|
+
cwd: REPO_ROOT,
|
|
81
|
+
encoding: 'utf8',
|
|
82
|
+
env: { ...process.env, HOME: home, USERPROFILE: home, XDG_STATE_HOME: state, CODEX_HOME: codexHome },
|
|
83
|
+
});
|
|
84
|
+
assert.equal(result.status, 0, result.stderr);
|
|
85
|
+
assert.equal(result.stderr, '');
|
|
86
|
+
const page = JSON.parse(result.stdout);
|
|
87
|
+
assert.equal(page.schema, OBSERVER_READ_SCHEMA);
|
|
88
|
+
assert.equal(page.status, 'snapshot');
|
|
89
|
+
assert.deepEqual(page.turns, []);
|
|
90
|
+
} finally {
|
|
91
|
+
await rm(root, { recursive: true, force: true });
|
|
92
|
+
}
|
|
93
|
+
});
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { statSync } from 'node:fs';
|
|
2
|
+
import { isAbsolute } from 'node:path';
|
|
3
|
+
import {
|
|
4
|
+
OBSERVER_WAIT_SCHEMA,
|
|
5
|
+
DEFAULT_OBSERVER_WAIT_TIMEOUT_SECONDS,
|
|
6
|
+
waitForObserverTurnChange,
|
|
7
|
+
} from '../observer-turn-wait.mjs';
|
|
8
|
+
|
|
9
|
+
const ERRORS = Object.freeze({
|
|
10
|
+
args: { schema: OBSERVER_WAIT_SCHEMA, status: 'error', code: 'E_OBSERVER_WAIT_ARGS', message: 'invalid observer-wait arguments' },
|
|
11
|
+
input: { schema: OBSERVER_WAIT_SCHEMA, status: 'error', code: 'E_OBSERVER_WAIT_INPUT', message: 'observer wait input is invalid' },
|
|
12
|
+
cancelled: { schema: OBSERVER_WAIT_SCHEMA, status: 'error', code: 'E_OBSERVER_WAIT_CANCELLED', message: 'observer wait was cancelled' },
|
|
13
|
+
internal: { schema: OBSERVER_WAIT_SCHEMA, status: 'error', code: 'E_OBSERVER_WAIT_INTERNAL', message: 'observer wait failed' },
|
|
14
|
+
});
|
|
15
|
+
const PARENT_WATCH_INTERVAL_MS = 1000;
|
|
16
|
+
|
|
17
|
+
export function parseArgs(argv = []) {
|
|
18
|
+
const out = { projectPath: null, afterCursor: null, timeoutSeconds: DEFAULT_OBSERVER_WAIT_TIMEOUT_SECONDS, json: false };
|
|
19
|
+
const seen = new Set();
|
|
20
|
+
for (let index = 0; index < argv.length; index++) {
|
|
21
|
+
const arg = argv[index];
|
|
22
|
+
if (arg === '--json') {
|
|
23
|
+
if (seen.has(arg)) throw new TypeError('duplicate option');
|
|
24
|
+
seen.add(arg); out.json = true; continue;
|
|
25
|
+
}
|
|
26
|
+
if (!['--project', '--after-cursor', '--timeout-seconds'].includes(arg) || seen.has(arg)) {
|
|
27
|
+
throw new TypeError('invalid option');
|
|
28
|
+
}
|
|
29
|
+
const value = argv[++index];
|
|
30
|
+
if (!value || value.startsWith('-')) throw new TypeError('missing option value');
|
|
31
|
+
seen.add(arg);
|
|
32
|
+
if (arg === '--project') out.projectPath = value;
|
|
33
|
+
else if (arg === '--after-cursor') out.afterCursor = value;
|
|
34
|
+
else out.timeoutSeconds = parseTimeout(value);
|
|
35
|
+
}
|
|
36
|
+
if (!out.json || !out.projectPath || !out.afterCursor) throw new TypeError('missing required option');
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function run(argv = [], {
|
|
41
|
+
wait = waitForObserverTurnChange,
|
|
42
|
+
validateProject = assertProjectDirectory,
|
|
43
|
+
stdout = process.stdout,
|
|
44
|
+
stderr = process.stderr,
|
|
45
|
+
processRef = process,
|
|
46
|
+
setIntervalFn = setInterval,
|
|
47
|
+
clearIntervalFn = clearInterval,
|
|
48
|
+
parentWatchIntervalMs = PARENT_WATCH_INTERVAL_MS,
|
|
49
|
+
} = {}) {
|
|
50
|
+
let args;
|
|
51
|
+
try { args = parseArgs(argv); } catch { writeJson(stderr, ERRORS.args); return 1; }
|
|
52
|
+
|
|
53
|
+
try { validateProject(args.projectPath); } catch { writeJson(stderr, ERRORS.input); return 1; }
|
|
54
|
+
|
|
55
|
+
const controller = new AbortController();
|
|
56
|
+
const cleanup = installCancellation({ controller, processRef, setIntervalFn, clearIntervalFn, parentWatchIntervalMs });
|
|
57
|
+
try {
|
|
58
|
+
const result = await wait({
|
|
59
|
+
projectPath: args.projectPath,
|
|
60
|
+
afterCursor: args.afterCursor,
|
|
61
|
+
timeoutSeconds: args.timeoutSeconds,
|
|
62
|
+
signal: controller.signal,
|
|
63
|
+
});
|
|
64
|
+
writeJson(stdout, result);
|
|
65
|
+
return 0;
|
|
66
|
+
} catch (error) {
|
|
67
|
+
writeJson(stderr, mapError(error));
|
|
68
|
+
return 1;
|
|
69
|
+
} finally {
|
|
70
|
+
cleanup();
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function parseTimeout(value) {
|
|
75
|
+
if (!/^\d+$/.test(value)) throw new TypeError('invalid timeout');
|
|
76
|
+
const timeout = Number(value);
|
|
77
|
+
if (!Number.isSafeInteger(timeout) || timeout < 1 || timeout > 3600) throw new TypeError('invalid timeout');
|
|
78
|
+
return timeout;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function assertProjectDirectory(projectPath) {
|
|
82
|
+
if (!isAbsolute(projectPath) || !statSync(projectPath).isDirectory()) {
|
|
83
|
+
throw new TypeError('project must be an absolute existing directory');
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function installCancellation({ controller, processRef, setIntervalFn, clearIntervalFn, parentWatchIntervalMs }) {
|
|
88
|
+
const abort = () => controller.abort();
|
|
89
|
+
const parentPid = processRef.ppid;
|
|
90
|
+
processRef.on('SIGINT', abort);
|
|
91
|
+
processRef.on('SIGTERM', abort);
|
|
92
|
+
processRef.on('disconnect', abort);
|
|
93
|
+
|
|
94
|
+
let parentTimer = null;
|
|
95
|
+
if (Number.isSafeInteger(parentPid) && parentPid > 1) {
|
|
96
|
+
const checkParent = () => {
|
|
97
|
+
if (processRef.ppid !== parentPid) return abort();
|
|
98
|
+
try {
|
|
99
|
+
processRef.kill(parentPid, 0);
|
|
100
|
+
} catch (error) {
|
|
101
|
+
if (error?.code === 'ESRCH') abort();
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
parentTimer = setIntervalFn(checkParent, parentWatchIntervalMs);
|
|
105
|
+
parentTimer?.unref?.();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return () => {
|
|
109
|
+
processRef.removeListener('SIGINT', abort);
|
|
110
|
+
processRef.removeListener('SIGTERM', abort);
|
|
111
|
+
processRef.removeListener('disconnect', abort);
|
|
112
|
+
if (parentTimer !== null) clearIntervalFn(parentTimer);
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function mapError(error) {
|
|
117
|
+
if (error?.code === 'E_OBSERVER_WAIT_CANCELLED') return ERRORS.cancelled;
|
|
118
|
+
return ERRORS.internal;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function writeJson(stream, value) { stream.write(`${JSON.stringify(value)}\n`); }
|
|
122
|
+
|
|
123
|
+
export const _internal = { assertProjectDirectory, installCancellation, PARENT_WATCH_INTERVAL_MS };
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import { EventEmitter, getEventListeners } from 'node:events';
|
|
4
|
+
import { mkdtemp, mkdir, rm } from 'node:fs/promises';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import test from 'node:test';
|
|
9
|
+
import { OBSERVER_WAIT_SCHEMA } from '../observer-turn-wait.mjs';
|
|
10
|
+
import { parseArgs, run } from './observer-wait.mjs';
|
|
11
|
+
|
|
12
|
+
const REPO_ROOT = join(fileURLToPath(new URL('../..', import.meta.url)));
|
|
13
|
+
const BIN_PATH = join(REPO_ROOT, 'bin/throughline.mjs');
|
|
14
|
+
const AFTER = 'tlc1.after';
|
|
15
|
+
|
|
16
|
+
function streams() {
|
|
17
|
+
return { stdout: { text: '', write(value) { this.text += value; } }, stderr: { text: '', write(value) { this.text += value; } } };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function fakeProcess({ ppid = 42, kill = () => {} } = {}) {
|
|
21
|
+
const result = new EventEmitter();
|
|
22
|
+
result.ppid = ppid;
|
|
23
|
+
result.kill = kill;
|
|
24
|
+
return result;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
test('observer-wait strictly parses its public arguments', async () => {
|
|
28
|
+
assert.deepEqual(parseArgs(['--project', '/repo', '--after-cursor', AFTER, '--timeout-seconds', '1', '--json']), {
|
|
29
|
+
projectPath: '/repo', afterCursor: AFTER, timeoutSeconds: 1, json: true,
|
|
30
|
+
});
|
|
31
|
+
assert.equal(parseArgs(['--project', '/repo', '--after-cursor', AFTER, '--json']).timeoutSeconds, 3600);
|
|
32
|
+
for (const argv of [
|
|
33
|
+
['--project', '/repo', '--project', '/repo', '--after-cursor', AFTER, '--json'],
|
|
34
|
+
['--project', '/repo', '--after-cursor', AFTER, '--json', '--json'],
|
|
35
|
+
['--project', '/repo', '--after-cursor', AFTER, '--timeout-seconds', '0', '--json'],
|
|
36
|
+
['--project', '/repo', '--after-cursor', AFTER, '--timeout-seconds', '3601', '--json'],
|
|
37
|
+
['--project', '/repo', '--after-cursor', AFTER, 'extra', '--json'],
|
|
38
|
+
['--project', '/repo', '--json'],
|
|
39
|
+
]) {
|
|
40
|
+
const io = streams();
|
|
41
|
+
assert.equal(await run(argv, { ...io }), 1);
|
|
42
|
+
assert.equal(io.stdout.text, '');
|
|
43
|
+
assert.deepEqual(JSON.parse(io.stderr.text), { schema: OBSERVER_WAIT_SCHEMA, status: 'error', code: 'E_OBSERVER_WAIT_ARGS', message: 'invalid observer-wait arguments' });
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('observer-wait returns each known core status as successful JSON', async () => {
|
|
48
|
+
for (const [status, throughCursor] of [['changed', 'tlc1.changed'], ['timeout', AFTER], ['resync_required', null], ['ambiguous_parent', null]]) {
|
|
49
|
+
const io = streams();
|
|
50
|
+
assert.equal(await run(['--project', '/repo', '--after-cursor', AFTER, '--json'], {
|
|
51
|
+
...io,
|
|
52
|
+
validateProject: () => {},
|
|
53
|
+
processRef: fakeProcess(),
|
|
54
|
+
setIntervalFn: () => ({ unref() {} }), clearIntervalFn: () => {},
|
|
55
|
+
wait: async () => ({ schema: OBSERVER_WAIT_SCHEMA, status, afterCursor: AFTER, throughCursor }),
|
|
56
|
+
}), 0);
|
|
57
|
+
assert.deepEqual(JSON.parse(io.stdout.text), { schema: OBSERVER_WAIT_SCHEMA, status, afterCursor: AFTER, throughCursor });
|
|
58
|
+
assert.equal(io.stderr.text, '');
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('observer-wait maps project, cancel, and internal failures to fixed errors without leakage', async () => {
|
|
63
|
+
for (const [dependencies, code] of [
|
|
64
|
+
[{ validateProject: () => { throw new Error('/private/project'); } }, 'E_OBSERVER_WAIT_INPUT'],
|
|
65
|
+
[{ wait: async () => { throw Object.assign(new Error('secret cursor'), { code: 'E_OBSERVER_WAIT_CANCELLED' }); } }, 'E_OBSERVER_WAIT_CANCELLED'],
|
|
66
|
+
[{ wait: async () => { throw new Error('secret /private/cursor'); } }, 'E_OBSERVER_WAIT_INTERNAL'],
|
|
67
|
+
]) {
|
|
68
|
+
const io = streams();
|
|
69
|
+
assert.equal(await run(['--project', '/repo', '--after-cursor', AFTER, '--json'], {
|
|
70
|
+
...io, validateProject: () => {}, processRef: fakeProcess(),
|
|
71
|
+
setIntervalFn: () => ({ unref() {} }), clearIntervalFn: () => {}, ...dependencies,
|
|
72
|
+
}), 1);
|
|
73
|
+
assert.equal(io.stdout.text, '');
|
|
74
|
+
assert.equal(JSON.parse(io.stderr.text).code, code);
|
|
75
|
+
assert.doesNotMatch(io.stderr.text, /private|secret|cursor/i);
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('observer-wait cancels on signals and removes listeners and parent watcher', async () => {
|
|
80
|
+
for (const event of ['SIGINT', 'SIGTERM', 'disconnect']) {
|
|
81
|
+
const io = streams();
|
|
82
|
+
const processRef = fakeProcess();
|
|
83
|
+
let checkParent;
|
|
84
|
+
let cleared = 0;
|
|
85
|
+
const pending = run(['--project', '/repo', '--after-cursor', AFTER, '--json'], {
|
|
86
|
+
...io, validateProject: () => {}, processRef,
|
|
87
|
+
setIntervalFn(callback) { checkParent = callback; return { unref() {} }; },
|
|
88
|
+
clearIntervalFn() { cleared++; },
|
|
89
|
+
wait: ({ signal }) => new Promise((resolve, reject) => signal.addEventListener('abort', () => reject(Object.assign(new Error('cancelled'), { code: 'E_OBSERVER_WAIT_CANCELLED' })), { once: true })),
|
|
90
|
+
});
|
|
91
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
92
|
+
assert.equal(getEventListeners(processRef, 'SIGINT').length, 1);
|
|
93
|
+
assert.equal(getEventListeners(processRef, 'SIGTERM').length, 1);
|
|
94
|
+
assert.equal(getEventListeners(processRef, 'disconnect').length, 1);
|
|
95
|
+
assert.equal(typeof checkParent, 'function');
|
|
96
|
+
processRef.emit(event);
|
|
97
|
+
assert.equal(await pending, 1);
|
|
98
|
+
assert.equal(JSON.parse(io.stderr.text).code, 'E_OBSERVER_WAIT_CANCELLED');
|
|
99
|
+
assert.equal(cleared, 1);
|
|
100
|
+
assert.equal(getEventListeners(processRef, 'SIGINT').length, 0);
|
|
101
|
+
assert.equal(getEventListeners(processRef, 'SIGTERM').length, 0);
|
|
102
|
+
assert.equal(getEventListeners(processRef, 'disconnect').length, 0);
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('observer-wait treats changed parent or ESRCH as cancellation but not EPERM', async () => {
|
|
107
|
+
for (const [mode, kill] of [
|
|
108
|
+
['ppid', () => {}],
|
|
109
|
+
['esrch', () => { throw Object.assign(new Error('gone'), { code: 'ESRCH' }); }],
|
|
110
|
+
]) {
|
|
111
|
+
const io = streams();
|
|
112
|
+
const processRef = fakeProcess({ kill });
|
|
113
|
+
let checkParent;
|
|
114
|
+
const pending = run(['--project', '/repo', '--after-cursor', AFTER, '--json'], {
|
|
115
|
+
...io, validateProject: () => {}, processRef,
|
|
116
|
+
setIntervalFn(callback) { checkParent = callback; return { unref() {} }; }, clearIntervalFn: () => {},
|
|
117
|
+
wait: ({ signal }) => new Promise((resolve, reject) => signal.addEventListener('abort', () => reject(Object.assign(new Error('cancelled'), { code: 'E_OBSERVER_WAIT_CANCELLED' })), { once: true })),
|
|
118
|
+
});
|
|
119
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
120
|
+
if (mode === 'ppid') processRef.ppid = 1;
|
|
121
|
+
checkParent();
|
|
122
|
+
assert.equal(await pending, 1);
|
|
123
|
+
assert.equal(JSON.parse(io.stderr.text).code, 'E_OBSERVER_WAIT_CANCELLED');
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const io = streams();
|
|
127
|
+
const processRef = fakeProcess({ kill: () => { throw Object.assign(new Error('denied'), { code: 'EPERM' }); } });
|
|
128
|
+
let checkParent;
|
|
129
|
+
const pending = run(['--project', '/repo', '--after-cursor', AFTER, '--json'], {
|
|
130
|
+
...io, validateProject: () => {}, processRef,
|
|
131
|
+
setIntervalFn(callback) { checkParent = callback; return { unref() {} }; }, clearIntervalFn: () => {},
|
|
132
|
+
wait: ({ signal }) => new Promise((resolve, reject) => signal.addEventListener('abort', () => reject(Object.assign(new Error('cancelled'), { code: 'E_OBSERVER_WAIT_CANCELLED' })), { once: true })),
|
|
133
|
+
});
|
|
134
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
135
|
+
checkParent();
|
|
136
|
+
assert.equal(io.stderr.text, '');
|
|
137
|
+
processRef.emit('disconnect');
|
|
138
|
+
assert.equal(await pending, 1);
|
|
139
|
+
assert.equal(JSON.parse(io.stderr.text).code, 'E_OBSERVER_WAIT_CANCELLED');
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test('observer-wait bin dispatch and help use the JSON-only contract', async () => {
|
|
143
|
+
const help = spawnSync(process.execPath, [BIN_PATH, '--help'], { cwd: REPO_ROOT, encoding: 'utf8' });
|
|
144
|
+
assert.equal(help.status, 0, help.stderr);
|
|
145
|
+
assert.match(help.stdout, /throughline observer-wait --project <absolute-directory> --after-cursor <opaque> --json/);
|
|
146
|
+
|
|
147
|
+
const root = await mkdtemp(join(tmpdir(), 'throughline-observer-wait-'));
|
|
148
|
+
const home = join(root, 'home');
|
|
149
|
+
const state = join(root, 'state');
|
|
150
|
+
const codexHome = join(root, 'codex');
|
|
151
|
+
const project = join(root, 'project');
|
|
152
|
+
try {
|
|
153
|
+
await Promise.all([mkdir(home), mkdir(state), mkdir(codexHome), mkdir(project)]);
|
|
154
|
+
const result = spawnSync(process.execPath, [BIN_PATH, 'observer-wait', '--project', project, '--after-cursor', 'invalid', '--json'], {
|
|
155
|
+
cwd: REPO_ROOT, encoding: 'utf8', timeout: 5000,
|
|
156
|
+
env: { ...process.env, HOME: home, USERPROFILE: home, XDG_STATE_HOME: state, CODEX_HOME: codexHome },
|
|
157
|
+
});
|
|
158
|
+
assert.equal(result.status, 0, result.stderr);
|
|
159
|
+
assert.equal(result.stderr, '');
|
|
160
|
+
const output = JSON.parse(result.stdout);
|
|
161
|
+
assert.equal(output.schema, OBSERVER_WAIT_SCHEMA);
|
|
162
|
+
assert.equal(output.status, 'resync_required');
|
|
163
|
+
assert.equal(output.throughCursor, null);
|
|
164
|
+
} finally {
|
|
165
|
+
await rm(root, { recursive: true, force: true });
|
|
166
|
+
}
|
|
167
|
+
});
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `throughline recall` — 注入案内から辿る pull 用の DB 直参照 CLI (read-only)
|
|
3
|
+
*
|
|
4
|
+
* 注入(push)は「現在地 + 入るだけの L2」だけを運び、残りの記憶は本コマンドで
|
|
5
|
+
* 必要な時だけ取得する(オーナー裁定 2026-07-18、ADR 0016):
|
|
6
|
+
* - `recall --l2 --session <id> --before <ISO日時> --last <N>`
|
|
7
|
+
* 境界(strict less-than, ms 比較)より古いターンを新しい側から N 件、
|
|
8
|
+
* L2 全文(注入と同じ行文法 + L3 参照 suffix)で出す。
|
|
9
|
+
* - `recall --l1 --session <id> --before <ISO日時> --skip <N>`
|
|
10
|
+
* 境界から N 件(--l2 の担当分)を飛ばした先の全ターン一覧。
|
|
11
|
+
* L1 要約があれば要約、無ければ「未要約」と明示して detail 誘導を出す。
|
|
12
|
+
*
|
|
13
|
+
* 契約:
|
|
14
|
+
* - 範囲・境界・session は注入時に案内コマンドへ焼き込まれた値だけで決まる。
|
|
15
|
+
* recall 側で「現在の 20 ターン窓」を再計算しない(新セッションのターン追記で
|
|
16
|
+
* 窓がスライドし、古い側が黙って欠落するため)。
|
|
17
|
+
* - DB は read-only で開く(create / migrate / write なし)。DB が無ければ
|
|
18
|
+
* explicit error で終了する。
|
|
19
|
+
* - 既定 session 解決は持たない。案内コマンドが常に `--session` を運ぶ。
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { existsSync } from 'node:fs';
|
|
23
|
+
import { homedir } from 'node:os';
|
|
24
|
+
import { join } from 'node:path';
|
|
25
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
26
|
+
import { formatTime } from '../handoff-record.mjs';
|
|
27
|
+
import { groupL3ByTurn, buildPartsSummary } from '../l3-summary.mjs';
|
|
28
|
+
|
|
29
|
+
export function defaultRecallDbPath() {
|
|
30
|
+
return join(homedir(), '.throughline', 'throughline.db');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const USAGE =
|
|
34
|
+
'usage: throughline recall (--l2 --last <N> | --l1 [--skip <N>] [--last <N>]) ' +
|
|
35
|
+
'--session <id> --before <ISO8601> [--db <path>]';
|
|
36
|
+
|
|
37
|
+
export function parseRecallArgs(argv) {
|
|
38
|
+
const opts = {
|
|
39
|
+
mode: null,
|
|
40
|
+
sessionId: null,
|
|
41
|
+
beforeMs: null,
|
|
42
|
+
last: null,
|
|
43
|
+
skip: 0,
|
|
44
|
+
dbPath: null,
|
|
45
|
+
};
|
|
46
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
47
|
+
const a = argv[i];
|
|
48
|
+
if (a === '--l2' || a === '--l1') {
|
|
49
|
+
if (opts.mode) throw new Error('recall: --l2 と --l1 は同時に指定できません');
|
|
50
|
+
opts.mode = a.slice(2);
|
|
51
|
+
} else if (a === '--session') {
|
|
52
|
+
opts.sessionId = argv[++i];
|
|
53
|
+
} else if (a === '--before') {
|
|
54
|
+
const raw = argv[++i];
|
|
55
|
+
const ms = Date.parse(raw ?? '');
|
|
56
|
+
if (!Number.isFinite(ms)) {
|
|
57
|
+
throw new Error(`recall: --before の日時を解釈できません: ${raw}(ISO 8601 を指定)`);
|
|
58
|
+
}
|
|
59
|
+
opts.beforeMs = ms;
|
|
60
|
+
} else if (a === '--last') {
|
|
61
|
+
const n = Number(argv[++i]);
|
|
62
|
+
if (!Number.isInteger(n) || n < 0) throw new Error('recall: --last は 0 以上の整数');
|
|
63
|
+
opts.last = n;
|
|
64
|
+
} else if (a === '--skip') {
|
|
65
|
+
const n = Number(argv[++i]);
|
|
66
|
+
if (!Number.isInteger(n) || n < 0) throw new Error('recall: --skip は 0 以上の整数');
|
|
67
|
+
opts.skip = n;
|
|
68
|
+
} else if (a === '--db') {
|
|
69
|
+
opts.dbPath = argv[++i];
|
|
70
|
+
} else {
|
|
71
|
+
throw new Error(`recall: 未知の引数: ${a}\n${USAGE}`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (!opts.mode) throw new Error(`recall: --l2 または --l1 を指定してください\n${USAGE}`);
|
|
75
|
+
if (!opts.sessionId) throw new Error(`recall: --session は必須です\n${USAGE}`);
|
|
76
|
+
if (opts.beforeMs == null) throw new Error(`recall: --before は必須です\n${USAGE}`);
|
|
77
|
+
if (opts.mode === 'l2' && opts.last == null) {
|
|
78
|
+
throw new Error(`recall: --l2 には --last <N> が必須です\n${USAGE}`);
|
|
79
|
+
}
|
|
80
|
+
return opts;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* 境界より古い側の distinct ターンを新しい順に列挙する。
|
|
85
|
+
* 各要素: { originSessionId, turnNumber, turnKey, minCreatedAt }
|
|
86
|
+
*/
|
|
87
|
+
function listTurnsBefore(db, { sessionId, beforeMs }) {
|
|
88
|
+
const rows = db
|
|
89
|
+
.prepare(
|
|
90
|
+
`SELECT origin_session_id, turn_number, MIN(created_at) AS min_ca, MAX(created_at) AS max_ca
|
|
91
|
+
FROM bodies
|
|
92
|
+
WHERE session_id = ? AND created_at < ?
|
|
93
|
+
GROUP BY origin_session_id, turn_number
|
|
94
|
+
ORDER BY min_ca DESC`,
|
|
95
|
+
)
|
|
96
|
+
.all(sessionId, beforeMs);
|
|
97
|
+
return rows.map((r) => ({
|
|
98
|
+
originSessionId: r.origin_session_id,
|
|
99
|
+
turnNumber: r.turn_number,
|
|
100
|
+
turnKey: `${r.origin_session_id}\x00${r.turn_number}`,
|
|
101
|
+
minCreatedAt: r.min_ca,
|
|
102
|
+
maxCreatedAt: r.max_ca,
|
|
103
|
+
}));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function loadL3ForTurns(db, sessionId, turns) {
|
|
107
|
+
if (turns.length === 0) return [];
|
|
108
|
+
const placeholders = turns.map(() => '(?, ?, ?)').join(', ');
|
|
109
|
+
const params = turns.flatMap((t) => [sessionId, t.originSessionId, Number(t.turnNumber)]);
|
|
110
|
+
return db
|
|
111
|
+
.prepare(
|
|
112
|
+
`SELECT kind, tool_name, origin_session_id, turn_number, created_at
|
|
113
|
+
FROM details
|
|
114
|
+
WHERE (session_id, origin_session_id, turn_number) IN (VALUES ${placeholders})
|
|
115
|
+
ORDER BY created_at ASC, id ASC`,
|
|
116
|
+
)
|
|
117
|
+
.all(...params)
|
|
118
|
+
.map((r) => ({
|
|
119
|
+
kind: r.kind,
|
|
120
|
+
toolName: r.tool_name,
|
|
121
|
+
originSessionId: r.origin_session_id,
|
|
122
|
+
turnNumber: r.turn_number,
|
|
123
|
+
createdAt: r.created_at,
|
|
124
|
+
}));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* --l2: 境界より古いターンを新しい側から last 件、古い順の L2 全文で描画する。
|
|
129
|
+
*/
|
|
130
|
+
export function renderRecallL2(db, { sessionId, beforeMs, last }) {
|
|
131
|
+
const turnsDesc = listTurnsBefore(db, { sessionId, beforeMs });
|
|
132
|
+
const selected = turnsDesc.slice(0, last).reverse(); // 古い順に戻す
|
|
133
|
+
const lines = [];
|
|
134
|
+
|
|
135
|
+
if (selected.length === 0) {
|
|
136
|
+
lines.push(`## Throughline recall (L2): 該当ターンなし(--before ${new Date(beforeMs).toISOString()} より古い L2 が DB にありません)`);
|
|
137
|
+
return { text: lines.join('\n'), turnCount: 0 };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const range = `${formatTime(selected[0].minCreatedAt)}〜${formatTime(selected[selected.length - 1].maxCreatedAt)}`;
|
|
141
|
+
lines.push(`## Throughline recall (L2): ${selected.length}ターン (${range}, 古い順)`);
|
|
142
|
+
if (selected.length < last) {
|
|
143
|
+
lines.push(`(--last ${last} のうち DB に存在するのは ${selected.length} ターンのみ)`);
|
|
144
|
+
}
|
|
145
|
+
lines.push('');
|
|
146
|
+
|
|
147
|
+
const l3ByTurn = groupL3ByTurn(loadL3ForTurns(db, sessionId, selected));
|
|
148
|
+
|
|
149
|
+
const bodyStmt = db.prepare(
|
|
150
|
+
`SELECT role, text, created_at
|
|
151
|
+
FROM bodies
|
|
152
|
+
WHERE session_id = ? AND origin_session_id = ? AND turn_number = ? AND created_at < ?
|
|
153
|
+
ORDER BY created_at ASC`,
|
|
154
|
+
);
|
|
155
|
+
for (const turn of selected) {
|
|
156
|
+
const rows = bodyStmt
|
|
157
|
+
.all(sessionId, turn.originSessionId, Number(turn.turnNumber), beforeMs)
|
|
158
|
+
.filter((r) => r.text);
|
|
159
|
+
for (let i = 0; i < rows.length; i += 1) {
|
|
160
|
+
const r = rows[i];
|
|
161
|
+
const isLast = i === rows.length - 1;
|
|
162
|
+
const partCounts = isLast ? (l3ByTurn.get(turn.turnKey)?.partCounts ?? new Map()) : new Map();
|
|
163
|
+
const suffix = buildPartsSummary(partCounts);
|
|
164
|
+
lines.push(`[${formatTime(r.created_at)}] [${r.role}]: ${r.text}${suffix}`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return { text: lines.join('\n'), turnCount: selected.length };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* --l1: 境界から skip 件を飛ばした先の全ターン一覧(古い順)。
|
|
172
|
+
* L1 要約があれば要約行、無ければ「未要約」と明示して detail 誘導を出す。
|
|
173
|
+
*/
|
|
174
|
+
export function renderRecallL1(db, { sessionId, beforeMs, skip, last = null }) {
|
|
175
|
+
const turnsDesc = listTurnsBefore(db, { sessionId, beforeMs });
|
|
176
|
+
let olderDesc = turnsDesc.slice(skip);
|
|
177
|
+
if (last != null) olderDesc = olderDesc.slice(0, last);
|
|
178
|
+
const selected = [...olderDesc].reverse(); // 古い順
|
|
179
|
+
const lines = [];
|
|
180
|
+
|
|
181
|
+
if (selected.length === 0) {
|
|
182
|
+
lines.push('## Throughline recall (L1): 該当ターンなし');
|
|
183
|
+
return { text: lines.join('\n'), turnCount: 0, summarizedCount: 0 };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const skelRows = db
|
|
187
|
+
.prepare(
|
|
188
|
+
`SELECT origin_session_id, turn_number, summary, created_at
|
|
189
|
+
FROM skeletons
|
|
190
|
+
WHERE session_id = ?
|
|
191
|
+
ORDER BY created_at ASC`,
|
|
192
|
+
)
|
|
193
|
+
.all(sessionId);
|
|
194
|
+
const skelByTurn = new Map();
|
|
195
|
+
for (const r of skelRows) {
|
|
196
|
+
const key = `${r.origin_session_id}\x00${r.turn_number}`;
|
|
197
|
+
if (!skelByTurn.has(key)) skelByTurn.set(key, []);
|
|
198
|
+
skelByTurn.get(key).push(r);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
let summarizedCount = 0;
|
|
202
|
+
const bodyLines = [];
|
|
203
|
+
for (const turn of selected) {
|
|
204
|
+
const time = formatTime(turn.minCreatedAt);
|
|
205
|
+
const skels = skelByTurn.get(turn.turnKey);
|
|
206
|
+
if (skels && skels.length > 0) {
|
|
207
|
+
summarizedCount += 1;
|
|
208
|
+
for (const s of skels) {
|
|
209
|
+
if (!s.summary || s.summary === '(no content)') continue;
|
|
210
|
+
const summary = s.summary.replace(/\n+/g, ' ').trim();
|
|
211
|
+
bodyLines.push(`[${time}] ${summary}`);
|
|
212
|
+
}
|
|
213
|
+
} else {
|
|
214
|
+
bodyLines.push(`[${time}] (未要約) 全文: throughline detail ${time}`);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const range = `${formatTime(selected[0].minCreatedAt)}〜${formatTime(selected[selected.length - 1].minCreatedAt)}`;
|
|
219
|
+
lines.push(
|
|
220
|
+
`## Throughline recall (L1): 全${selected.length}ターン / 要約済み ${summarizedCount} (${range}, 古い順)`,
|
|
221
|
+
);
|
|
222
|
+
lines.push('各ターンの全文・ツール入出力: `throughline detail <時刻>` で取得可');
|
|
223
|
+
lines.push('');
|
|
224
|
+
lines.push(...bodyLines);
|
|
225
|
+
return { text: lines.join('\n'), turnCount: selected.length, summarizedCount };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export function runRecall(db, opts) {
|
|
229
|
+
if (opts.mode === 'l2') {
|
|
230
|
+
return renderRecallL2(db, {
|
|
231
|
+
sessionId: opts.sessionId,
|
|
232
|
+
beforeMs: opts.beforeMs,
|
|
233
|
+
last: opts.last,
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
return renderRecallL1(db, {
|
|
237
|
+
sessionId: opts.sessionId,
|
|
238
|
+
beforeMs: opts.beforeMs,
|
|
239
|
+
skip: opts.skip,
|
|
240
|
+
last: opts.last,
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export function run(argv) {
|
|
245
|
+
let opts;
|
|
246
|
+
try {
|
|
247
|
+
opts = parseRecallArgs(argv);
|
|
248
|
+
} catch (err) {
|
|
249
|
+
process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
|
|
250
|
+
return 1;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const dbPath = opts.dbPath ?? defaultRecallDbPath();
|
|
254
|
+
if (!existsSync(dbPath)) {
|
|
255
|
+
process.stderr.write(`recall: DB がありません: ${dbPath}(recall は DB を作成しません)\n`);
|
|
256
|
+
return 1;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
let db;
|
|
260
|
+
try {
|
|
261
|
+
db = new DatabaseSync(dbPath, { readOnly: true });
|
|
262
|
+
} catch (err) {
|
|
263
|
+
process.stderr.write(
|
|
264
|
+
`recall: DB を read-only で開けませんでした: ${err instanceof Error ? err.message : String(err)}\n`,
|
|
265
|
+
);
|
|
266
|
+
return 1;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
try {
|
|
270
|
+
const result = runRecall(db, opts);
|
|
271
|
+
process.stdout.write(`${result.text}\n`);
|
|
272
|
+
return 0;
|
|
273
|
+
} catch (err) {
|
|
274
|
+
process.stderr.write(`recall: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
275
|
+
return 1;
|
|
276
|
+
} finally {
|
|
277
|
+
db.close();
|
|
278
|
+
}
|
|
279
|
+
}
|