@h1v35/hivex 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/LICENSE +21 -0
- package/README.md +213 -0
- package/docs/CONTEXT.md +59 -0
- package/docs/README.md +14 -0
- package/docs/adr/0003-independent-bun-installation.md +37 -0
- package/docs/adr/0010-practical-knowledge-assistance.md +92 -0
- package/docs/engineering.md +174 -0
- package/package.json +64 -0
- package/skills/hivex/SKILL.md +108 -0
- package/skills/hivex/references/markdown.md +64 -0
- package/src/cli/diagnostic.ts +26 -0
- package/src/cli.ts +92 -0
- package/src/documents.ts +575 -0
- package/src/errors.ts +15 -0
- package/src/implementation.ts +191 -0
- package/src/ingestion-units.ts +155 -0
- package/src/knowledge-maintenance.ts +76 -0
- package/src/knowledge-model.ts +418 -0
- package/src/knowledge-store.ts +657 -0
- package/src/knowledge.ts +1184 -0
- package/src/markdown.ts +98 -0
- package/src/model/connection.ts +207 -0
- package/src/model/failure.ts +33 -0
- package/src/model/invoke.ts +265 -0
- package/src/model/profile.ts +211 -0
- package/src/model/server.ts +174 -0
- package/src/model/thread.ts +50 -0
- package/src/model/transcript.ts +114 -0
- package/src/retrieval/lexical.ts +92 -0
- package/src/review.ts +129 -0
package/src/markdown.ts
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { basename } from 'node:path';
|
|
3
|
+
import { fromMarkdown } from 'mdast-util-from-markdown';
|
|
4
|
+
import { toString } from 'mdast-util-to-string';
|
|
5
|
+
import { gfmFromMarkdown } from 'mdast-util-gfm';
|
|
6
|
+
import { gfm } from 'micromark-extension-gfm';
|
|
7
|
+
import { frontmatterFromMarkdown } from 'mdast-util-frontmatter';
|
|
8
|
+
import { frontmatter } from 'micromark-extension-frontmatter';
|
|
9
|
+
import { normalizeIdentifier } from 'micromark-util-normalize-identifier';
|
|
10
|
+
import { parseDocument } from 'yaml';
|
|
11
|
+
|
|
12
|
+
export const hash = (text: string) => createHash('sha256').update(text).digest('hex');
|
|
13
|
+
export const isMarkdownPath = (path: string) => /\.(?:md|markdown|mdown)$/i.test(path);
|
|
14
|
+
|
|
15
|
+
export function parseMarkdown(content: string) {
|
|
16
|
+
return fromMarkdown(content, {
|
|
17
|
+
extensions: [gfm(), frontmatter(['yaml'])],
|
|
18
|
+
mdastExtensions: [gfmFromMarkdown(), frontmatterFromMarkdown(['yaml'])],
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
type MarkdownNode = {
|
|
23
|
+
type: string;
|
|
24
|
+
children?: MarkdownNode[];
|
|
25
|
+
url?: string;
|
|
26
|
+
identifier?: string;
|
|
27
|
+
position?: { start: { line: number; offset?: number }; end: { line: number; offset?: number } };
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export function* descendants(tree: MarkdownNode): Generator<MarkdownNode> {
|
|
31
|
+
const pending = [tree];
|
|
32
|
+
while (pending.length) {
|
|
33
|
+
const node = pending.pop();
|
|
34
|
+
if (!node) break;
|
|
35
|
+
yield node;
|
|
36
|
+
for (let index = (node.children?.length ?? 0) - 1; index >= 0; index--) {
|
|
37
|
+
const child = node.children?.[index];
|
|
38
|
+
if (child) pending.push(child);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function metadata(tree: ReturnType<typeof parseMarkdown>) {
|
|
44
|
+
const node = tree.children.find((entry) => entry.type === 'yaml');
|
|
45
|
+
if (node?.type !== 'yaml') return { title: null, status: null };
|
|
46
|
+
try {
|
|
47
|
+
const parsed = parseDocument(node.value, { uniqueKeys: true });
|
|
48
|
+
if (parsed.errors.length) return { title: null, status: null };
|
|
49
|
+
const value: unknown = parsed.toJS({ maxAliasCount: 0 });
|
|
50
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
51
|
+
return { title: null, status: null };
|
|
52
|
+
return {
|
|
53
|
+
title:
|
|
54
|
+
'title' in value && typeof value.title === 'string' && value.title.trim()
|
|
55
|
+
? value.title
|
|
56
|
+
: null,
|
|
57
|
+
status: 'status' in value && typeof value.status === 'string' ? value.status : null,
|
|
58
|
+
};
|
|
59
|
+
} catch {
|
|
60
|
+
return { title: null, status: null };
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function describeMarkdown(path: string, content: string) {
|
|
65
|
+
const tree = parseMarkdown(content);
|
|
66
|
+
const front = metadata(tree);
|
|
67
|
+
const heading = tree.children.find((node) => node.type === 'heading');
|
|
68
|
+
const definitions = new Map<string, string>();
|
|
69
|
+
for (const node of descendants(tree)) {
|
|
70
|
+
if (node.type === 'definition' && node.identifier && node.url) {
|
|
71
|
+
const id = normalizeIdentifier(node.identifier);
|
|
72
|
+
if (!definitions.has(id)) definitions.set(id, node.url);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const links = [...descendants(tree)].flatMap((node) => {
|
|
76
|
+
if (node.type === 'link' && node.url) return [node.url];
|
|
77
|
+
if (node.type !== 'linkReference' || !node.identifier) return [];
|
|
78
|
+
const url = definitions.get(normalizeIdentifier(node.identifier));
|
|
79
|
+
return url ? [url] : [];
|
|
80
|
+
});
|
|
81
|
+
return {
|
|
82
|
+
title: front.title ?? (heading ? toString(heading) : basename(path)),
|
|
83
|
+
status: front.status,
|
|
84
|
+
links,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function rawMarkdownLines(text: string): string[] {
|
|
89
|
+
const lines = (text.match(/[^\r\n]*(?:\r\n|\r|\n|$)/g) ?? []).filter((line) => line.length > 0);
|
|
90
|
+
return lines.length ? lines : [''];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export const lineContent = (line: string) => line.replace(/(?:\r\n|\r|\n)$/, '');
|
|
94
|
+
|
|
95
|
+
export function sourceRange(text: string, from: number, to: number) {
|
|
96
|
+
const selected = rawMarkdownLines(text).slice(from - 1, to);
|
|
97
|
+
return selected.slice(0, -1).join('') + lineContent(selected.at(-1) ?? '');
|
|
98
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { createInterface, type Interface } from 'node:readline';
|
|
2
|
+
import type { Readable, Writable } from 'node:stream';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
|
|
5
|
+
const IdSchema = z.union([z.string(), z.number().int()]);
|
|
6
|
+
const RemoteErrorSchema = z.looseObject({
|
|
7
|
+
code: z.number().int(),
|
|
8
|
+
message: z.string(),
|
|
9
|
+
data: z.unknown().optional(),
|
|
10
|
+
});
|
|
11
|
+
const FrameSchema = z.looseObject({
|
|
12
|
+
id: IdSchema.optional(),
|
|
13
|
+
method: z.string().optional(),
|
|
14
|
+
params: z.unknown().optional(),
|
|
15
|
+
result: z.unknown().optional(),
|
|
16
|
+
error: RemoteErrorSchema.optional(),
|
|
17
|
+
});
|
|
18
|
+
type Frame = z.infer<typeof FrameSchema>;
|
|
19
|
+
|
|
20
|
+
interface ConnectionOptions {
|
|
21
|
+
readonly input: Writable;
|
|
22
|
+
readonly output: Readable;
|
|
23
|
+
readonly onNotification: (method: string, params: unknown) => void;
|
|
24
|
+
readonly onInteractiveRequest?: (method: string) => void;
|
|
25
|
+
}
|
|
26
|
+
interface RequestOptions {
|
|
27
|
+
readonly timeoutMilliseconds?: number;
|
|
28
|
+
// Cancels the local wait. Remote turns require an explicit turn/interrupt request.
|
|
29
|
+
readonly signal?: AbortSignal;
|
|
30
|
+
}
|
|
31
|
+
interface PendingRequest {
|
|
32
|
+
readonly resolve: (value: unknown) => void;
|
|
33
|
+
readonly reject: (error: unknown) => void;
|
|
34
|
+
readonly cleanup: () => void;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export class AppServerRpcError extends Error {
|
|
38
|
+
readonly code: number;
|
|
39
|
+
readonly data: unknown;
|
|
40
|
+
|
|
41
|
+
constructor(error: z.infer<typeof RemoteErrorSchema>) {
|
|
42
|
+
super(error.message);
|
|
43
|
+
this.name = 'AppServerRpcError';
|
|
44
|
+
this.code = error.code;
|
|
45
|
+
this.data = error.data;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export class AppServerConnection {
|
|
50
|
+
private readonly options: ConnectionOptions;
|
|
51
|
+
private readonly reader: Interface;
|
|
52
|
+
private readonly pending = new Map<string | number, PendingRequest>();
|
|
53
|
+
private readonly ending = Promise.withResolvers<never>();
|
|
54
|
+
private failure: Error | undefined;
|
|
55
|
+
private nextId = 1;
|
|
56
|
+
private trailingBytes = 0;
|
|
57
|
+
private streamBytes = 0;
|
|
58
|
+
readonly closed = this.ending.promise;
|
|
59
|
+
|
|
60
|
+
constructor(options: ConnectionOptions) {
|
|
61
|
+
this.options = options;
|
|
62
|
+
options.output.on('data', this.measureChunk);
|
|
63
|
+
this.reader = createInterface({ input: options.output, crlfDelay: Infinity });
|
|
64
|
+
this.reader.on('line', this.receiveLine);
|
|
65
|
+
this.reader.on('close', this.receiveClose);
|
|
66
|
+
options.input.on('error', this.receiveStreamError);
|
|
67
|
+
options.output.on('error', this.receiveStreamError);
|
|
68
|
+
void this.closed.catch(() => undefined);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
request(method: string, params: unknown, options: RequestOptions = {}): Promise<unknown> {
|
|
72
|
+
if (this.failure !== undefined) return Promise.reject(this.failure);
|
|
73
|
+
if (options.signal?.aborted) return Promise.reject(new Error('request cancelled'));
|
|
74
|
+
const timeout = options.timeoutMilliseconds ?? 30_000;
|
|
75
|
+
if (!Number.isSafeInteger(timeout) || timeout <= 0) {
|
|
76
|
+
return Promise.reject(new Error('request timeout must be a positive integer'));
|
|
77
|
+
}
|
|
78
|
+
const id = this.nextId++;
|
|
79
|
+
const result = Promise.withResolvers<unknown>();
|
|
80
|
+
const abort = () => this.rejectRequest(id, new Error('request cancelled'));
|
|
81
|
+
const timer = setTimeout(
|
|
82
|
+
() => this.rejectRequest(id, new Error(`request timed out: ${method}`)),
|
|
83
|
+
timeout,
|
|
84
|
+
);
|
|
85
|
+
this.pending.set(id, {
|
|
86
|
+
resolve: result.resolve,
|
|
87
|
+
reject: result.reject,
|
|
88
|
+
cleanup: () => {
|
|
89
|
+
clearTimeout(timer);
|
|
90
|
+
options.signal?.removeEventListener('abort', abort);
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
options.signal?.addEventListener('abort', abort, { once: true });
|
|
94
|
+
this.send({ id, method, params });
|
|
95
|
+
return result.promise;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
notify(method: string, params?: unknown): void {
|
|
99
|
+
if (this.failure !== undefined) throw this.failure;
|
|
100
|
+
this.send({ method, params });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
dispose(): void {
|
|
104
|
+
this.fail(new Error('connection closed'));
|
|
105
|
+
this.reader.off('line', this.receiveLine);
|
|
106
|
+
this.reader.off('close', this.receiveClose);
|
|
107
|
+
this.reader.close();
|
|
108
|
+
this.options.input.off('error', this.receiveStreamError);
|
|
109
|
+
this.options.output.off('error', this.receiveStreamError);
|
|
110
|
+
this.options.output.off('data', this.measureChunk);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
private readonly receiveClose = (): void => this.fail(new Error('connection closed'));
|
|
114
|
+
private readonly receiveStreamError = (error: Error): void => this.fail(error);
|
|
115
|
+
|
|
116
|
+
private readonly measureChunk = (chunk: Buffer): void => {
|
|
117
|
+
if (this.failure) return;
|
|
118
|
+
this.streamBytes += chunk.byteLength;
|
|
119
|
+
if (this.streamBytes > 33_554_432) {
|
|
120
|
+
this.fail(new Error('app-server stream exceeded 32 MiB'));
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
let offset = 0;
|
|
124
|
+
for (let newline = chunk.indexOf(10); newline !== -1; newline = chunk.indexOf(10, offset)) {
|
|
125
|
+
this.trailingBytes += newline - offset;
|
|
126
|
+
if (this.trailingBytes > 4_194_304) {
|
|
127
|
+
this.fail(new Error('app-server frame exceeded 4 MiB'));
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
this.trailingBytes = 0;
|
|
131
|
+
offset = newline + 1;
|
|
132
|
+
}
|
|
133
|
+
this.trailingBytes += chunk.byteLength - offset;
|
|
134
|
+
if (this.trailingBytes > 4_194_304) this.fail(new Error('app-server frame exceeded 4 MiB'));
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
private readonly receiveLine = (line: string): void => {
|
|
138
|
+
if (this.failure !== undefined) return;
|
|
139
|
+
try {
|
|
140
|
+
const frame = FrameSchema.parse(JSON.parse(line) as unknown);
|
|
141
|
+
this.receiveFrame(frame);
|
|
142
|
+
} catch (error: unknown) {
|
|
143
|
+
this.fail(new Error('invalid app-server frame', { cause: error }));
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
private receiveFrame(frame: Frame): void {
|
|
148
|
+
if (frame.method !== undefined) {
|
|
149
|
+
if (Object.hasOwn(frame, 'result') || frame.error !== undefined) {
|
|
150
|
+
throw new Error('a method frame cannot also be a response');
|
|
151
|
+
}
|
|
152
|
+
if (frame.id === undefined) this.options.onNotification(frame.method, frame.params);
|
|
153
|
+
else this.declineInteractiveRequest(frame.id, frame.method);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
if (frame.id === undefined || Object.hasOwn(frame, 'result') === (frame.error !== undefined)) {
|
|
157
|
+
throw new Error('a response requires an id and exactly one outcome');
|
|
158
|
+
}
|
|
159
|
+
const pending = this.pending.get(frame.id);
|
|
160
|
+
if (pending === undefined) return;
|
|
161
|
+
this.pending.delete(frame.id);
|
|
162
|
+
pending.cleanup();
|
|
163
|
+
if (frame.error !== undefined) pending.reject(new AppServerRpcError(frame.error));
|
|
164
|
+
else pending.resolve(frame.result);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
private declineInteractiveRequest(id: string | number, method: string): void {
|
|
168
|
+
this.options.onInteractiveRequest?.(method);
|
|
169
|
+
if (
|
|
170
|
+
method === 'item/commandExecution/requestApproval' ||
|
|
171
|
+
method === 'item/fileChange/requestApproval'
|
|
172
|
+
) {
|
|
173
|
+
this.send({ id, result: { decision: 'decline' } });
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
this.send({
|
|
177
|
+
id,
|
|
178
|
+
error: { code: -32601, message: 'interactive requests are outside this pilot' },
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
private send(frame: unknown): void {
|
|
183
|
+
try {
|
|
184
|
+
this.options.input.write(`${JSON.stringify(frame)}\n`, (error) => {
|
|
185
|
+
if (error !== null && error !== undefined) this.fail(error);
|
|
186
|
+
});
|
|
187
|
+
} catch (error: unknown) {
|
|
188
|
+
this.fail(new Error('app-server write failed', { cause: error }));
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
private rejectRequest(id: string | number, error: Error): void {
|
|
193
|
+
const pending = this.pending.get(id);
|
|
194
|
+
if (pending === undefined) return;
|
|
195
|
+
this.pending.delete(id);
|
|
196
|
+
pending.cleanup();
|
|
197
|
+
pending.reject(error);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
private fail(error: Error): void {
|
|
201
|
+
if (this.failure !== undefined) return;
|
|
202
|
+
this.failure = error;
|
|
203
|
+
this.reader.close();
|
|
204
|
+
for (const id of this.pending.keys()) this.rejectRequest(id, error);
|
|
205
|
+
this.ending.reject(error);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { AppServerRpcError } from './connection.ts';
|
|
3
|
+
import type { ProfileEvidence } from './profile.ts';
|
|
4
|
+
|
|
5
|
+
export class ServerAdmissionFailure extends Error {
|
|
6
|
+
readonly cleanup: 'confirmed' | 'failed';
|
|
7
|
+
readonly processId: number;
|
|
8
|
+
readonly admission: (ProfileEvidence & { launchPolicyHash: string }) | undefined;
|
|
9
|
+
constructor(options: {
|
|
10
|
+
cause: unknown;
|
|
11
|
+
cleanup: 'confirmed' | 'failed';
|
|
12
|
+
processId: number;
|
|
13
|
+
admission?: ProfileEvidence & { launchPolicyHash: string };
|
|
14
|
+
}) {
|
|
15
|
+
super('Native server admission failed', { cause: options.cause });
|
|
16
|
+
this.cleanup = options.cleanup;
|
|
17
|
+
this.processId = options.processId;
|
|
18
|
+
this.admission = options.admission;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function failureDiagnostic(error: unknown): Record<string, unknown> {
|
|
23
|
+
if (error instanceof ServerAdmissionFailure) return failureDiagnostic(error.cause);
|
|
24
|
+
if (error instanceof z.ZodError)
|
|
25
|
+
return {
|
|
26
|
+
kind: 'invalid-native-response',
|
|
27
|
+
fields: error.issues.map((issue) => issue.path.join('.')),
|
|
28
|
+
};
|
|
29
|
+
if (error instanceof AppServerRpcError) return { kind: 'rpc-rejection', code: error.code };
|
|
30
|
+
if (error instanceof Error && error.constructor === Error)
|
|
31
|
+
return { kind: 'native-admission', message: error.message };
|
|
32
|
+
return { kind: 'native-failure' };
|
|
33
|
+
}
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import { mkdtempSync, readdirSync, realpathSync, rmSync } from 'node:fs';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
import { startServer } from './server.ts';
|
|
6
|
+
import { knowledgeTurn, type ProfileEvidence } from './profile.ts';
|
|
7
|
+
import { captureTranscript, type Usage } from './transcript.ts';
|
|
8
|
+
import { startKnowledgeThread } from './thread.ts';
|
|
9
|
+
import { failureDiagnostic, ServerAdmissionFailure } from './failure.ts';
|
|
10
|
+
|
|
11
|
+
export type NativeProcessStarted = (nativeProcessId: number) => void;
|
|
12
|
+
|
|
13
|
+
export type InvocationOptions = {
|
|
14
|
+
binary: string;
|
|
15
|
+
prompt: string;
|
|
16
|
+
schema: Record<string, unknown>;
|
|
17
|
+
deadlineMilliseconds: number;
|
|
18
|
+
onNativeProcessStarted?: NativeProcessStarted;
|
|
19
|
+
};
|
|
20
|
+
export type InvocationReport = {
|
|
21
|
+
outcome: string;
|
|
22
|
+
code?: string;
|
|
23
|
+
deadlineMilliseconds: number;
|
|
24
|
+
interruption?: string;
|
|
25
|
+
usage: Usage | null;
|
|
26
|
+
turnAccepted?: 'confirmed' | 'unknown';
|
|
27
|
+
threadId?: string;
|
|
28
|
+
turnId?: string;
|
|
29
|
+
cleanup?: 'confirmed' | 'failed' | 'not-observed';
|
|
30
|
+
startedAt?: string;
|
|
31
|
+
durationMilliseconds?: number;
|
|
32
|
+
admission?: ProfileEvidence & { launchPolicyHash: string };
|
|
33
|
+
diagnostic?: Record<string, unknown>;
|
|
34
|
+
nativeProcessId?: number;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
class ModelTimeout extends Error {}
|
|
38
|
+
class ModelCancelled extends Error {}
|
|
39
|
+
|
|
40
|
+
async function completedWithin(options: {
|
|
41
|
+
captured: ReturnType<typeof captureTranscript>;
|
|
42
|
+
server: Awaited<ReturnType<typeof startServer>>;
|
|
43
|
+
milliseconds: number;
|
|
44
|
+
signal?: AbortSignal;
|
|
45
|
+
}) {
|
|
46
|
+
const expired = Promise.withResolvers<never>();
|
|
47
|
+
const cancelled = () => expired.reject(new ModelCancelled());
|
|
48
|
+
options.signal?.addEventListener('abort', cancelled, { once: true });
|
|
49
|
+
if (options.signal?.aborted) cancelled();
|
|
50
|
+
const timer = setTimeout(() => expired.reject(new ModelTimeout()), options.milliseconds);
|
|
51
|
+
try {
|
|
52
|
+
return await Promise.race([
|
|
53
|
+
options.captured.done.promise,
|
|
54
|
+
options.server.rpc.closed,
|
|
55
|
+
expired.promise,
|
|
56
|
+
]);
|
|
57
|
+
} finally {
|
|
58
|
+
clearTimeout(timer);
|
|
59
|
+
options.signal?.removeEventListener('abort', cancelled);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function interrupt(options: {
|
|
64
|
+
captured: ReturnType<typeof captureTranscript>;
|
|
65
|
+
server: Awaited<ReturnType<typeof startServer>>;
|
|
66
|
+
threadId: string;
|
|
67
|
+
turnId: string;
|
|
68
|
+
}) {
|
|
69
|
+
try {
|
|
70
|
+
await options.server.rpc.request(
|
|
71
|
+
'turn/interrupt',
|
|
72
|
+
{ threadId: options.threadId, turnId: options.turnId },
|
|
73
|
+
{ timeoutMilliseconds: 5000 },
|
|
74
|
+
);
|
|
75
|
+
const end = await completedWithin({
|
|
76
|
+
captured: options.captured,
|
|
77
|
+
server: options.server,
|
|
78
|
+
milliseconds: 5000,
|
|
79
|
+
});
|
|
80
|
+
return (
|
|
81
|
+
end.threadId === options.threadId &&
|
|
82
|
+
end.turn.id === options.turnId &&
|
|
83
|
+
end.turn.status === 'interrupted'
|
|
84
|
+
);
|
|
85
|
+
} catch {
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function runTurn(
|
|
91
|
+
options: InvocationOptions & {
|
|
92
|
+
server: Awaited<ReturnType<typeof startServer>>;
|
|
93
|
+
captured: ReturnType<typeof captureTranscript>;
|
|
94
|
+
workspace: string;
|
|
95
|
+
threadId: string;
|
|
96
|
+
signal: AbortSignal;
|
|
97
|
+
},
|
|
98
|
+
) {
|
|
99
|
+
const { server, captured, threadId } = options;
|
|
100
|
+
const begin = performance.now();
|
|
101
|
+
const accepted = await server.rpc
|
|
102
|
+
.request(
|
|
103
|
+
'turn/start',
|
|
104
|
+
{
|
|
105
|
+
threadId,
|
|
106
|
+
...knowledgeTurn,
|
|
107
|
+
input: [{ type: 'text', text: options.prompt }],
|
|
108
|
+
outputSchema: options.schema,
|
|
109
|
+
},
|
|
110
|
+
{ timeoutMilliseconds: Math.min(options.deadlineMilliseconds, 30_000) },
|
|
111
|
+
)
|
|
112
|
+
.then((response) => z.looseObject({ turn: z.looseObject({ id: z.string() }) }).parse(response))
|
|
113
|
+
.catch(() => null);
|
|
114
|
+
if (!accepted) {
|
|
115
|
+
const report: InvocationReport = {
|
|
116
|
+
outcome: 'failed',
|
|
117
|
+
code: 'MODEL_START_UNCONFIRMED',
|
|
118
|
+
turnAccepted: 'unknown',
|
|
119
|
+
threadId,
|
|
120
|
+
deadlineMilliseconds: options.deadlineMilliseconds,
|
|
121
|
+
usage: null,
|
|
122
|
+
};
|
|
123
|
+
return { value: null, report, retry: false };
|
|
124
|
+
}
|
|
125
|
+
const turnId = accepted.turn.id;
|
|
126
|
+
try {
|
|
127
|
+
if (options.signal.aborted) throw new ModelCancelled();
|
|
128
|
+
const remaining = options.deadlineMilliseconds - (performance.now() - begin);
|
|
129
|
+
if (remaining <= 0) throw new ModelTimeout();
|
|
130
|
+
const end = await completedWithin({ ...options, milliseconds: remaining });
|
|
131
|
+
if (end.threadId !== threadId || end.turn.id !== turnId || end.turn.status !== 'completed')
|
|
132
|
+
throw new Error('Model completion was not established');
|
|
133
|
+
captured.assertValid({ threadId, turnId });
|
|
134
|
+
const final = captured.items.at(-1);
|
|
135
|
+
if (!final || final.threadId !== threadId || final.turnId !== turnId || !final.item.text)
|
|
136
|
+
throw new Error('Structured model output is missing');
|
|
137
|
+
if (readdirSync(options.workspace).length) throw new Error('Knowledge workspace was mutated');
|
|
138
|
+
const report: InvocationReport = {
|
|
139
|
+
outcome: 'completed',
|
|
140
|
+
threadId,
|
|
141
|
+
turnId,
|
|
142
|
+
turnAccepted: 'confirmed',
|
|
143
|
+
deadlineMilliseconds: options.deadlineMilliseconds,
|
|
144
|
+
usage: captured.measured({ threadId, turnId }),
|
|
145
|
+
};
|
|
146
|
+
return { value: final.item.text, report, retry: false };
|
|
147
|
+
} catch (error) {
|
|
148
|
+
const confirmed = await interrupt({ ...options, turnId });
|
|
149
|
+
const timeout = error instanceof ModelTimeout;
|
|
150
|
+
const cancelled = error instanceof ModelCancelled;
|
|
151
|
+
const report: InvocationReport = {
|
|
152
|
+
outcome: timeout ? 'timeout' : 'failed',
|
|
153
|
+
threadId,
|
|
154
|
+
turnId,
|
|
155
|
+
code: failureCode({ timeout, cancelled }),
|
|
156
|
+
interruption: confirmed ? 'confirmed' : 'unconfirmed',
|
|
157
|
+
turnAccepted: 'confirmed',
|
|
158
|
+
deadlineMilliseconds: options.deadlineMilliseconds,
|
|
159
|
+
usage: captured.measured({ threadId, turnId }),
|
|
160
|
+
};
|
|
161
|
+
return { value: null, report, retry: timeout && confirmed };
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export async function invokeModel(options: InvocationOptions) {
|
|
166
|
+
const startedAt = new Date().toISOString();
|
|
167
|
+
const began = performance.now();
|
|
168
|
+
const workspace = realpathSync(mkdtempSync(join(tmpdir(), 'hivex-model-')));
|
|
169
|
+
const captured = captureTranscript();
|
|
170
|
+
const controller = new AbortController();
|
|
171
|
+
const cancel = () => controller.abort();
|
|
172
|
+
process.on('SIGINT', cancel);
|
|
173
|
+
process.on('SIGTERM', cancel);
|
|
174
|
+
const initialReport: InvocationReport = {
|
|
175
|
+
outcome: 'failed',
|
|
176
|
+
code: 'MODEL_ADMISSION_FAILED',
|
|
177
|
+
deadlineMilliseconds: options.deadlineMilliseconds,
|
|
178
|
+
usage: null,
|
|
179
|
+
};
|
|
180
|
+
const resource: {
|
|
181
|
+
server?: Awaited<ReturnType<typeof startServer>>;
|
|
182
|
+
result: { value: unknown; report: InvocationReport; retry: boolean };
|
|
183
|
+
} = { result: { value: null, report: initialReport, retry: false } };
|
|
184
|
+
try {
|
|
185
|
+
resource.server = await startServer({
|
|
186
|
+
binary: options.binary,
|
|
187
|
+
workspace,
|
|
188
|
+
signal: controller.signal,
|
|
189
|
+
notification: captured.notification,
|
|
190
|
+
interaction: () => {
|
|
191
|
+
throw new Error('Knowledge execution cannot request interaction');
|
|
192
|
+
},
|
|
193
|
+
});
|
|
194
|
+
options.onNativeProcessStarted?.(resource.server.pid);
|
|
195
|
+
const threadId = await startKnowledgeThread({
|
|
196
|
+
rpc: resource.server.rpc,
|
|
197
|
+
workspace,
|
|
198
|
+
signal: controller.signal,
|
|
199
|
+
});
|
|
200
|
+
controller.signal.throwIfAborted();
|
|
201
|
+
resource.result = await runTurn({
|
|
202
|
+
...options,
|
|
203
|
+
captured,
|
|
204
|
+
workspace,
|
|
205
|
+
server: resource.server,
|
|
206
|
+
threadId,
|
|
207
|
+
signal: controller.signal,
|
|
208
|
+
});
|
|
209
|
+
} catch (error) {
|
|
210
|
+
if (controller.signal.aborted) initialReport.code = 'MODEL_CANCELLED';
|
|
211
|
+
initialReport.diagnostic = failureDiagnostic(error);
|
|
212
|
+
if (error instanceof ServerAdmissionFailure) {
|
|
213
|
+
initialReport.cleanup = error.cleanup;
|
|
214
|
+
initialReport.nativeProcessId = error.processId;
|
|
215
|
+
initialReport.admission = error.admission;
|
|
216
|
+
}
|
|
217
|
+
resource.result = { value: null, report: initialReport, retry: false };
|
|
218
|
+
} finally {
|
|
219
|
+
try {
|
|
220
|
+
await resource.server?.stop();
|
|
221
|
+
rmSync(workspace, { recursive: true, force: true });
|
|
222
|
+
resource.result.report.cleanup = resource.server
|
|
223
|
+
? 'confirmed'
|
|
224
|
+
: (resource.result.report.cleanup ?? 'not-observed');
|
|
225
|
+
} catch {
|
|
226
|
+
resource.result = {
|
|
227
|
+
value: null,
|
|
228
|
+
retry: false,
|
|
229
|
+
report: {
|
|
230
|
+
...resource.result.report,
|
|
231
|
+
outcome: 'failed',
|
|
232
|
+
code: 'MODEL_CLEANUP_FAILED',
|
|
233
|
+
cleanup: 'failed',
|
|
234
|
+
},
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
process.off('SIGINT', cancel);
|
|
238
|
+
process.off('SIGTERM', cancel);
|
|
239
|
+
}
|
|
240
|
+
const report = resource.result.report;
|
|
241
|
+
if (report.threadId && report.turnId)
|
|
242
|
+
report.usage = captured.measured({ threadId: report.threadId, turnId: report.turnId });
|
|
243
|
+
if (captured.invalid && report.outcome === 'completed') {
|
|
244
|
+
resource.result = {
|
|
245
|
+
value: null,
|
|
246
|
+
retry: false,
|
|
247
|
+
report: { ...report, outcome: 'failed', code: 'MODEL_PROTOCOL_FAILED' },
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
return {
|
|
251
|
+
...resource.result,
|
|
252
|
+
report: {
|
|
253
|
+
...resource.result.report,
|
|
254
|
+
admission: resource.server?.admission ?? resource.result.report.admission,
|
|
255
|
+
nativeProcessId: resource.server?.pid ?? resource.result.report.nativeProcessId,
|
|
256
|
+
startedAt,
|
|
257
|
+
durationMilliseconds: Math.round(performance.now() - began),
|
|
258
|
+
},
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function failureCode(options: { timeout: boolean; cancelled: boolean }) {
|
|
263
|
+
if (options.cancelled) return 'MODEL_CANCELLED';
|
|
264
|
+
return options.timeout ? 'MODEL_TIMEOUT' : 'MODEL_PROTOCOL_FAILED';
|
|
265
|
+
}
|