@factory/droid-sdk 0.7.0 → 0.9.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/dist/{chunk-C3KHERVH.js → chunk-FJP5OKS5.js} +6250 -4329
- package/dist/chunk-FJP5OKS5.js.map +1 -0
- package/dist/{chunk-5UXINOXG.mjs → chunk-KXDG5ZWS.mjs} +8897 -6976
- package/dist/chunk-KXDG5ZWS.mjs.map +1 -0
- package/dist/{index-D_SzTnFR.d.mts → index-BowvC0_E.d.mts} +43128 -10981
- package/dist/{index-D_SzTnFR.d.ts → index-BowvC0_E.d.ts} +43128 -10981
- package/dist/index.d.mts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +10 -2
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +9 -1
- package/dist/node.d.mts +25 -3
- package/dist/node.d.ts +25 -3
- package/dist/node.js +565 -423
- package/dist/node.js.map +1 -1
- package/dist/node.mjs +186 -44
- package/dist/node.mjs.map +1 -1
- package/docs/typescript-sdk-reference.md +903 -726
- package/examples/browser/daemon-model-discovery.ts +47 -0
- package/examples/browser/index.html +2 -1
- package/examples/browser/list-sessions.ts +1 -3
- package/examples/node/abort-session-stream.ts +2 -0
- package/examples/node/ask-user-elicitation.ts +11 -7
- package/examples/node/compact-session.ts +33 -18
- package/examples/node/custom-system-prompt.ts +35 -0
- package/examples/node/document-input.ts +58 -0
- package/examples/node/enter-spec-mode.ts +50 -29
- package/examples/node/external-mcp.ts +45 -0
- package/examples/node/fork-session.ts +21 -10
- package/examples/node/hook-execution.ts +37 -37
- package/examples/node/image-attachment.ts +8 -2
- package/examples/node/interrupt-session.ts +27 -8
- package/examples/node/model-discovery.ts +28 -0
- package/examples/node/model-selection.ts +35 -0
- package/examples/node/multi-turn-session.ts +4 -0
- package/examples/node/observability.ts +4 -17
- package/examples/node/partial-stream.ts +56 -0
- package/examples/node/permission-handler.ts +9 -4
- package/examples/node/rename-session.ts +7 -3
- package/examples/node/result-metadata.ts +10 -1
- package/examples/node/resume-session.ts +41 -0
- package/examples/node/rewind-session.ts +19 -9
- package/examples/node/run.ts +14 -7
- package/examples/node/sdk-mcp-tool.ts +10 -8
- package/examples/node/session-settings.ts +9 -9
- package/examples/node/session-stream.ts +17 -2
- package/examples/node/skills.ts +24 -0
- package/examples/node/spec-mode-new-session.ts +38 -10
- package/examples/node/spec-mode-same-session.ts +37 -3
- package/examples/node/structured-output.ts +30 -7
- package/package.json +1 -1
- package/dist/chunk-5UXINOXG.mjs.map +0 -1
- package/dist/chunk-C3KHERVH.js.map +0 -1
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lists canonical model metadata through a daemon connection.
|
|
3
|
+
*
|
|
4
|
+
* Runs in a real browser (no `process`, no Node builtins). From a source
|
|
5
|
+
* checkout of this repository:
|
|
6
|
+
* npm run build
|
|
7
|
+
* FACTORY_API_KEY=... npm run serve:browser-example
|
|
8
|
+
* open http://127.0.0.1:8420/?example=daemon-model-discovery
|
|
9
|
+
*
|
|
10
|
+
* Never commit a real API key or deploy it in browser code.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { connectToDaemon } from '@factory/droid-sdk';
|
|
14
|
+
|
|
15
|
+
const { daemonUrl, apiKey } = (
|
|
16
|
+
globalThis as typeof globalThis & {
|
|
17
|
+
__DROID_SDK_EXAMPLE_CONFIG__: {
|
|
18
|
+
daemonUrl: string;
|
|
19
|
+
apiKey: string;
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
).__DROID_SDK_EXAMPLE_CONFIG__;
|
|
23
|
+
if (!apiKey) {
|
|
24
|
+
throw new Error('Set FACTORY_API_KEY.');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const droid = await connectToDaemon({
|
|
28
|
+
url: daemonUrl,
|
|
29
|
+
auth: { apiKey },
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
const models = await droid.models.list({ includeDisabled: true });
|
|
34
|
+
|
|
35
|
+
for (const model of models) {
|
|
36
|
+
const status = model.disabled
|
|
37
|
+
? `disabled: ${model.disabledReason}`
|
|
38
|
+
: 'available';
|
|
39
|
+
console.log(
|
|
40
|
+
`${model.displayName} (${model.id})\n` +
|
|
41
|
+
` status: ${status}\n` +
|
|
42
|
+
` reasoning: ${model.supportedReasoningEfforts.join(', ')}`
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
} finally {
|
|
46
|
+
droid.disconnect();
|
|
47
|
+
}
|
|
@@ -36,7 +36,8 @@
|
|
|
36
36
|
<code>DROID_SDK_EXAMPLE_CWD</code> before starting the server, then pick
|
|
37
37
|
one with <code>?example=daemon-session</code> (default),
|
|
38
38
|
<code>?example=daemon-multi-session</code>,
|
|
39
|
-
<code>?example=daemon-lifecycle</code>,
|
|
39
|
+
<code>?example=daemon-lifecycle</code>,
|
|
40
|
+
<code>?example=daemon-model-discovery</code>, or
|
|
40
41
|
<code>?example=list-sessions</code>.
|
|
41
42
|
</p>
|
|
42
43
|
|
|
@@ -24,7 +24,9 @@ try {
|
|
|
24
24
|
)) {
|
|
25
25
|
// Consume the stream until the abort signal cuts it short.
|
|
26
26
|
}
|
|
27
|
+
console.log('The turn completed before the abort signal fired.');
|
|
27
28
|
} catch (error) {
|
|
29
|
+
if (!controller.signal.aborted) throw error;
|
|
28
30
|
console.log(error instanceof Error ? error.message : String(error));
|
|
29
31
|
} finally {
|
|
30
32
|
clearTimeout(timeout);
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* AskUser elicitation example.
|
|
3
3
|
*
|
|
4
|
-
* When Droid needs input mid-turn
|
|
5
|
-
*
|
|
4
|
+
* When Droid needs input mid-turn, `askUserHandler` answers it. Without a
|
|
5
|
+
* handler, the request is declined.
|
|
6
6
|
*
|
|
7
7
|
* Usage:
|
|
8
8
|
* npx tsx examples/node/ask-user-elicitation.ts
|
|
@@ -10,11 +10,11 @@
|
|
|
10
10
|
|
|
11
11
|
import { run } from '@factory/droid-sdk/node';
|
|
12
12
|
|
|
13
|
-
const
|
|
13
|
+
const result = await run(
|
|
14
14
|
'Use the AskUser tool to ask me which color I prefer, then reply with the color I picked.',
|
|
15
15
|
{
|
|
16
|
-
askUserHandler(
|
|
17
|
-
for (const question of
|
|
16
|
+
askUserHandler({ questions }) {
|
|
17
|
+
for (const question of questions) {
|
|
18
18
|
console.log(`[asked] ${question.question}`);
|
|
19
19
|
console.log(`[options] ${question.options.join(', ')}`);
|
|
20
20
|
}
|
|
@@ -22,7 +22,7 @@ const { text } = await run(
|
|
|
22
22
|
// Answer every question with its first option. A real integration would
|
|
23
23
|
// prompt the user; returning `cancelled: true` declines instead.
|
|
24
24
|
return {
|
|
25
|
-
answers:
|
|
25
|
+
answers: questions.map((question) => ({
|
|
26
26
|
index: question.index,
|
|
27
27
|
question: question.question,
|
|
28
28
|
answer: question.options[0] ?? 'no preference',
|
|
@@ -32,4 +32,8 @@ const { text } = await run(
|
|
|
32
32
|
}
|
|
33
33
|
);
|
|
34
34
|
|
|
35
|
-
|
|
35
|
+
if (!result.success) {
|
|
36
|
+
throw new Error(result.error?.message ?? 'The turn was interrupted.');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
console.log(`\n${result.text}`);
|
|
@@ -10,6 +10,25 @@
|
|
|
10
10
|
|
|
11
11
|
import { createSession, DroidMessageType } from '@factory/droid-sdk/node';
|
|
12
12
|
|
|
13
|
+
async function streamText(
|
|
14
|
+
activeSession: Awaited<ReturnType<typeof createSession>>,
|
|
15
|
+
prompt: string
|
|
16
|
+
): Promise<string> {
|
|
17
|
+
let text = '';
|
|
18
|
+
for await (const msg of activeSession.stream(prompt)) {
|
|
19
|
+
if (msg.type === DroidMessageType.Assistant) {
|
|
20
|
+
text += msg.text;
|
|
21
|
+
}
|
|
22
|
+
if (msg.type === DroidMessageType.Result && !msg.success) {
|
|
23
|
+
throw new Error(msg.error?.message ?? `Turn ended: ${msg.subtype}`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
if (!text.trim()) {
|
|
27
|
+
throw new Error('The turn produced no assistant output.');
|
|
28
|
+
}
|
|
29
|
+
return text;
|
|
30
|
+
}
|
|
31
|
+
|
|
13
32
|
const session = await createSession();
|
|
14
33
|
|
|
15
34
|
try {
|
|
@@ -18,30 +37,26 @@ try {
|
|
|
18
37
|
'Say "two" and nothing else',
|
|
19
38
|
'Say "three" and nothing else',
|
|
20
39
|
]) {
|
|
21
|
-
|
|
22
|
-
if (msg.type === DroidMessageType.Assistant) {
|
|
23
|
-
console.log(msg.text);
|
|
24
|
-
}
|
|
25
|
-
}
|
|
40
|
+
console.log(await streamText(session, prompt));
|
|
26
41
|
}
|
|
27
42
|
|
|
28
43
|
const { session: compacted, removedCount } = await session.compact();
|
|
29
44
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
45
|
+
try {
|
|
46
|
+
// `session` is now a retired wrapper. Its persisted ID can still be passed
|
|
47
|
+
// to resumeSession() later if the uncompacted history is needed again.
|
|
48
|
+
console.log(`\noriginal session: ${session.id}`);
|
|
49
|
+
console.log(`compacted into: ${compacted.id}`);
|
|
50
|
+
console.log(`messages removed: ${removedCount}`);
|
|
35
51
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
52
|
+
const continued = await streamText(
|
|
53
|
+
compacted,
|
|
54
|
+
'Which numbers did I ask you to say?'
|
|
55
|
+
);
|
|
56
|
+
console.log(`continued: ${continued}`);
|
|
57
|
+
} finally {
|
|
58
|
+
await compacted.close();
|
|
42
59
|
}
|
|
43
|
-
|
|
44
|
-
await compacted.close();
|
|
45
60
|
} finally {
|
|
46
61
|
// Closes the active source, or does nothing if it was retired.
|
|
47
62
|
await session.close();
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Custom system prompt example.
|
|
3
|
+
*
|
|
4
|
+
* Usage:
|
|
5
|
+
* npx tsx examples/node/custom-system-prompt.ts
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { createSession, DroidMessageType } from '@factory/droid-sdk/node';
|
|
9
|
+
|
|
10
|
+
const session = await createSession({
|
|
11
|
+
systemPrompt: {
|
|
12
|
+
type: 'preset',
|
|
13
|
+
preset: 'droid',
|
|
14
|
+
append: 'Keep answers concise and cite relevant files.',
|
|
15
|
+
},
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
try {
|
|
19
|
+
let text = '';
|
|
20
|
+
for await (const message of session.stream(
|
|
21
|
+
'Summarize the main entry points in this project.'
|
|
22
|
+
)) {
|
|
23
|
+
if (message.type === DroidMessageType.Assistant) {
|
|
24
|
+
text += message.text;
|
|
25
|
+
}
|
|
26
|
+
if (message.type === DroidMessageType.Result && !message.success) {
|
|
27
|
+
throw new Error(
|
|
28
|
+
message.error?.message ?? `Turn ended: ${message.subtype}`
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
console.log(text);
|
|
33
|
+
} finally {
|
|
34
|
+
await session.close();
|
|
35
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Document input example.
|
|
3
|
+
*
|
|
4
|
+
* Attaches plain text or a base64 PDF through `files`. With no path, it uses
|
|
5
|
+
* an embedded text document so the example runs as-is.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* npx tsx examples/node/document-input.ts
|
|
9
|
+
* npx tsx examples/node/document-input.ts ./report.txt
|
|
10
|
+
* npx tsx examples/node/document-input.ts ./report.pdf
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { readFile } from 'node:fs/promises';
|
|
14
|
+
import { basename, extname } from 'node:path';
|
|
15
|
+
|
|
16
|
+
import { run, type DocumentSource } from '@factory/droid-sdk/node';
|
|
17
|
+
|
|
18
|
+
const inputPath = process.argv[2];
|
|
19
|
+
let document: DocumentSource;
|
|
20
|
+
|
|
21
|
+
if (!inputPath) {
|
|
22
|
+
document = {
|
|
23
|
+
type: 'text',
|
|
24
|
+
mediaType: 'text/plain',
|
|
25
|
+
data: [
|
|
26
|
+
'Release: 2.4.0',
|
|
27
|
+
'Status: Ready',
|
|
28
|
+
'Main change: Added document input to the TypeScript SDK guide.',
|
|
29
|
+
].join('\n'),
|
|
30
|
+
name: 'release-notes.txt',
|
|
31
|
+
};
|
|
32
|
+
} else if (extname(inputPath).toLowerCase() === '.pdf') {
|
|
33
|
+
document = {
|
|
34
|
+
type: 'base64',
|
|
35
|
+
mediaType: 'application/pdf',
|
|
36
|
+
data: (await readFile(inputPath)).toString('base64'),
|
|
37
|
+
name: basename(inputPath),
|
|
38
|
+
};
|
|
39
|
+
} else {
|
|
40
|
+
document = {
|
|
41
|
+
type: 'text',
|
|
42
|
+
mediaType: 'text/plain',
|
|
43
|
+
data: await readFile(inputPath, 'utf8'),
|
|
44
|
+
name: basename(inputPath),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const result = await run('Summarize this document in one sentence.', {
|
|
49
|
+
files: [document],
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
if (!result.success) {
|
|
53
|
+
throw new Error(
|
|
54
|
+
result.error?.message ?? `Document summary failed (${result.subtype}).`
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
console.log(result.text);
|
|
@@ -1,13 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Auto to Spec to implementation example.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* what a host app does when the user toggles spec mode on an open conversation.
|
|
7
|
-
*
|
|
8
|
-
* The switch is observable in the confirmations Droid asks for: it proposes a
|
|
9
|
-
* plan (`ExitSpecMode`) before touching the filesystem, instead of going
|
|
10
|
-
* straight to `Create`.
|
|
4
|
+
* Switches an Auto session to Spec mode, approves the plan, then allows the
|
|
5
|
+
* planned file creation in the same session.
|
|
11
6
|
*
|
|
12
7
|
* Usage:
|
|
13
8
|
* npx tsx examples/node/enter-spec-mode.ts
|
|
@@ -18,6 +13,9 @@ import { tmpdir } from 'node:os';
|
|
|
18
13
|
import { join } from 'node:path';
|
|
19
14
|
|
|
20
15
|
import {
|
|
16
|
+
type ClientPermissionHandler,
|
|
17
|
+
DroidInteractionMode,
|
|
18
|
+
DroidMessageType,
|
|
21
19
|
ToolConfirmationOutcome,
|
|
22
20
|
ToolConfirmationType,
|
|
23
21
|
createSession,
|
|
@@ -26,41 +24,64 @@ import {
|
|
|
26
24
|
const tempDir = await mkdtemp(join(tmpdir(), 'droid-sdk-enter-spec-'));
|
|
27
25
|
const outputPath = join(tempDir, 'hello.txt');
|
|
28
26
|
|
|
27
|
+
const handlePermissionRequest: ClientPermissionHandler = ({
|
|
28
|
+
toolUses,
|
|
29
|
+
options,
|
|
30
|
+
}) => {
|
|
31
|
+
const exitsSpecMode =
|
|
32
|
+
toolUses.length > 0 &&
|
|
33
|
+
toolUses.every(
|
|
34
|
+
(toolUse) => toolUse.details.type === ToolConfirmationType.ExitSpecMode
|
|
35
|
+
);
|
|
36
|
+
const createsExpectedFile =
|
|
37
|
+
toolUses.length > 0 &&
|
|
38
|
+
toolUses.every(
|
|
39
|
+
(toolUse) =>
|
|
40
|
+
toolUse.details.type === ToolConfirmationType.Create &&
|
|
41
|
+
toolUse.details.filePath === outputPath
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
if (exitsSpecMode) {
|
|
45
|
+
for (const toolUse of toolUses) {
|
|
46
|
+
if (toolUse.details.type === ToolConfirmationType.ExitSpecMode) {
|
|
47
|
+
console.log(toolUse.details.plan);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const outcome =
|
|
53
|
+
exitsSpecMode || createsExpectedFile
|
|
54
|
+
? ToolConfirmationOutcome.ProceedOnce
|
|
55
|
+
: ToolConfirmationOutcome.Cancel;
|
|
56
|
+
if (!options.some((option) => option.value === outcome)) {
|
|
57
|
+
throw new Error(`Outcome not offered: ${outcome}`);
|
|
58
|
+
}
|
|
59
|
+
return outcome;
|
|
60
|
+
};
|
|
61
|
+
|
|
29
62
|
try {
|
|
30
63
|
const session = await createSession({
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
const label =
|
|
34
|
-
toolUse.details.type === ToolConfirmationType.ExitSpecMode
|
|
35
|
-
? 'plan proposed'
|
|
36
|
-
: 'implementing';
|
|
37
|
-
console.log(`[${label}] ${toolUse.details.type}`);
|
|
38
|
-
}
|
|
39
|
-
// Approving ExitSpecMode accepts the plan and lets implementation run.
|
|
40
|
-
return ToolConfirmationOutcome.ProceedOnce;
|
|
41
|
-
},
|
|
64
|
+
interactionMode: DroidInteractionMode.Auto,
|
|
65
|
+
permissionHandler: handlePermissionRequest,
|
|
42
66
|
});
|
|
43
67
|
|
|
44
68
|
try {
|
|
45
|
-
console.log(
|
|
46
|
-
`mode at creation: ${String(session.settings.interactionMode)}`
|
|
47
|
-
);
|
|
48
|
-
|
|
49
69
|
await session.enterSpecMode();
|
|
50
|
-
console.log('switched to spec mode\n');
|
|
51
70
|
|
|
52
|
-
for await (const
|
|
71
|
+
for await (const message of session.stream(
|
|
53
72
|
`Create ${outputPath} containing "hello".`
|
|
54
73
|
)) {
|
|
55
|
-
|
|
74
|
+
if (message.type === DroidMessageType.Result && !message.success) {
|
|
75
|
+
throw new Error(
|
|
76
|
+
message.error?.message ?? `Turn ended: ${message.subtype}`
|
|
77
|
+
);
|
|
78
|
+
}
|
|
56
79
|
}
|
|
57
80
|
} finally {
|
|
58
81
|
await session.close();
|
|
59
82
|
}
|
|
60
83
|
|
|
61
|
-
console.log(
|
|
62
|
-
`\nfile contents: ${JSON.stringify(await readFile(outputPath, 'utf8'))}`
|
|
63
|
-
);
|
|
84
|
+
console.log(await readFile(outputPath, 'utf8'));
|
|
64
85
|
} finally {
|
|
65
86
|
await rm(tempDir, { recursive: true, force: true });
|
|
66
87
|
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* External MCP server example.
|
|
3
|
+
*
|
|
4
|
+
* Connects an HTTP MCP server to one Node session, then lists its status and
|
|
5
|
+
* discovered tools.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* MCP_SERVER_URL=https://example.com/mcp \
|
|
9
|
+
* npx tsx examples/node/external-mcp.ts
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { createSession } from '@factory/droid-sdk/node';
|
|
13
|
+
|
|
14
|
+
const url = process.env.MCP_SERVER_URL;
|
|
15
|
+
if (!url) {
|
|
16
|
+
throw new Error('Set MCP_SERVER_URL to an HTTP MCP endpoint.');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const token = process.env.MCP_SERVER_TOKEN;
|
|
20
|
+
const session = await createSession({
|
|
21
|
+
mcpServers: [
|
|
22
|
+
{
|
|
23
|
+
name: 'external',
|
|
24
|
+
type: 'http',
|
|
25
|
+
url,
|
|
26
|
+
headers: token
|
|
27
|
+
? [{ name: 'Authorization', value: `Bearer ${token}` }]
|
|
28
|
+
: [],
|
|
29
|
+
},
|
|
30
|
+
],
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
const { servers } = await session.listMcpServers();
|
|
35
|
+
const tools = await session.listMcpTools();
|
|
36
|
+
const server = servers.find((item) => item.name === 'external');
|
|
37
|
+
|
|
38
|
+
console.log(`status: ${server?.status ?? 'unknown'}`);
|
|
39
|
+
|
|
40
|
+
for (const tool of tools.filter((item) => item.serverName === 'external')) {
|
|
41
|
+
console.log(tool.name);
|
|
42
|
+
}
|
|
43
|
+
} finally {
|
|
44
|
+
await session.close();
|
|
45
|
+
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Fork session example.
|
|
3
3
|
*
|
|
4
|
-
* Establishes context in one session, forks it with `fork()`, then
|
|
5
|
-
*
|
|
4
|
+
* Establishes context in one session, forks it with `fork()`, then uses the
|
|
5
|
+
* returned fork handle to show the copied context.
|
|
6
6
|
*
|
|
7
7
|
* Usage:
|
|
8
8
|
* npx tsx examples/node/fork-session.ts
|
|
@@ -19,6 +19,12 @@ async function streamText(
|
|
|
19
19
|
if (msg.type === DroidMessageType.Assistant) {
|
|
20
20
|
text += msg.text;
|
|
21
21
|
}
|
|
22
|
+
if (msg.type === DroidMessageType.Result && !msg.success) {
|
|
23
|
+
throw new Error(msg.error?.message ?? `Turn ended: ${msg.subtype}`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
if (!text.trim()) {
|
|
27
|
+
throw new Error('The turn produced no assistant output.');
|
|
22
28
|
}
|
|
23
29
|
return text;
|
|
24
30
|
}
|
|
@@ -30,15 +36,20 @@ try {
|
|
|
30
36
|
|
|
31
37
|
const fork = await session.fork();
|
|
32
38
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
`fork recalls: ${await streamText(fork, 'What phrase did I ask you to remember?')}`
|
|
39
|
-
);
|
|
39
|
+
try {
|
|
40
|
+
// `session` is now a retired wrapper. Its persisted ID can still be passed
|
|
41
|
+
// to resumeSession() later if the original branch is needed again.
|
|
42
|
+
console.log(`original session: ${session.id}`);
|
|
43
|
+
console.log(`forked session: ${fork.id}`);
|
|
40
44
|
|
|
41
|
-
|
|
45
|
+
const recalled = await streamText(
|
|
46
|
+
fork,
|
|
47
|
+
'What phrase did I ask you to remember?'
|
|
48
|
+
);
|
|
49
|
+
console.log(`fork recalls: ${recalled}`);
|
|
50
|
+
} finally {
|
|
51
|
+
await fork.close();
|
|
52
|
+
}
|
|
42
53
|
} finally {
|
|
43
54
|
// Closes the active source, or does nothing if it was retired.
|
|
44
55
|
await session.close();
|
|
@@ -1,23 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Hook execution example.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* The hook lives in a throwaway project directory (`<tempdir>/.factory/hooks.json`)
|
|
8
|
-
* that is passed as the session `cwd` and deleted afterwards, so running this
|
|
9
|
-
* example never writes to your own `~/.factory` configuration. It does not
|
|
10
|
-
* isolate you from it: hook lists are concatenated across settings levels
|
|
11
|
-
* rather than replaced, so your own user-level `PreToolUse` hooks are appended
|
|
12
|
-
* to the one below and fire during this run whenever their matcher matches.
|
|
13
|
-
*
|
|
14
|
-
* `hooksDisabled` resolves first-defined-wins across settings levels, so the
|
|
15
|
-
* config below sets it explicitly rather than leaving it absent. An absent
|
|
16
|
-
* value would inherit a global `"hooksDisabled": true` from `~/.factory` and
|
|
17
|
-
* the hook would never run.
|
|
18
|
-
*
|
|
19
|
-
* See https://docs.factory.ai/cli/configuration/hooks-guide for the `/hooks`
|
|
20
|
-
* slash command, the other events, and security guidance.
|
|
4
|
+
* Creates a temporary project with a PreToolUse hook and prints its stream
|
|
5
|
+
* events.
|
|
6
|
+
* User-level hooks still apply to the temporary project.
|
|
21
7
|
*
|
|
22
8
|
* Usage:
|
|
23
9
|
* npx tsx examples/node/hook-execution.ts
|
|
@@ -30,6 +16,7 @@ import { join } from 'node:path';
|
|
|
30
16
|
import {
|
|
31
17
|
DroidMessageType,
|
|
32
18
|
ToolConfirmationOutcome,
|
|
19
|
+
ToolConfirmationType,
|
|
33
20
|
createSession,
|
|
34
21
|
} from '@factory/droid-sdk/node';
|
|
35
22
|
|
|
@@ -46,6 +33,7 @@ const hookSettings = {
|
|
|
46
33
|
const projectDir = await mkdtemp(join(tmpdir(), 'droid-sdk-hook-project-'));
|
|
47
34
|
|
|
48
35
|
let hookCount = 0;
|
|
36
|
+
const expectedCommand = 'echo hello';
|
|
49
37
|
|
|
50
38
|
try {
|
|
51
39
|
await mkdir(join(projectDir, '.factory'), { recursive: true });
|
|
@@ -56,25 +44,43 @@ try {
|
|
|
56
44
|
|
|
57
45
|
const session = await createSession({
|
|
58
46
|
cwd: projectDir,
|
|
59
|
-
permissionHandler
|
|
47
|
+
permissionHandler({ toolUses, options }) {
|
|
48
|
+
const approvesExpectedCommand =
|
|
49
|
+
toolUses.length === 1 &&
|
|
50
|
+
toolUses[0].details.type === ToolConfirmationType.Execute &&
|
|
51
|
+
toolUses[0].details.fullCommand === expectedCommand;
|
|
52
|
+
const desired = approvesExpectedCommand
|
|
53
|
+
? ToolConfirmationOutcome.ProceedOnce
|
|
54
|
+
: ToolConfirmationOutcome.Cancel;
|
|
55
|
+
|
|
56
|
+
if (!options.some((option) => option.value === desired)) {
|
|
57
|
+
throw new Error(`Outcome not offered: ${desired}`);
|
|
58
|
+
}
|
|
59
|
+
return desired;
|
|
60
|
+
},
|
|
60
61
|
});
|
|
61
62
|
|
|
62
63
|
try {
|
|
63
|
-
for await (const
|
|
64
|
-
|
|
64
|
+
for await (const message of session.stream(
|
|
65
|
+
`Run the shell command \`${expectedCommand}\` using the Execute tool.`
|
|
65
66
|
)) {
|
|
66
|
-
if (
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
hookCount++;
|
|
71
|
-
if (msg.status === 'started') {
|
|
72
|
-
console.log(`[hook started] ${msg.eventName}: ${msg.command}`);
|
|
73
|
-
} else {
|
|
74
|
-
console.log(
|
|
75
|
-
`[hook ${msg.status}] exit code ${String(msg.exitCode)}: ${(msg.stdout ?? '').trim()}`
|
|
67
|
+
if (message.type === DroidMessageType.Result && !message.success) {
|
|
68
|
+
throw new Error(
|
|
69
|
+
message.error?.message ?? `Turn ended: ${message.subtype}`
|
|
76
70
|
);
|
|
77
71
|
}
|
|
72
|
+
if (message.type === DroidMessageType.Hook) {
|
|
73
|
+
hookCount++;
|
|
74
|
+
if (message.status === 'started') {
|
|
75
|
+
console.log(
|
|
76
|
+
`[hook started] ${message.eventName}: ${message.command}`
|
|
77
|
+
);
|
|
78
|
+
} else {
|
|
79
|
+
console.log(
|
|
80
|
+
`[hook ${message.status}] exit code ${String(message.exitCode)}: ${(message.stdout ?? '').trim()}`
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
78
84
|
}
|
|
79
85
|
} finally {
|
|
80
86
|
await session.close();
|
|
@@ -83,12 +89,6 @@ try {
|
|
|
83
89
|
await rm(projectDir, { recursive: true, force: true });
|
|
84
90
|
}
|
|
85
91
|
|
|
86
|
-
// A silent zero-hook run would look identical to a passing one, so fail loudly.
|
|
87
92
|
if (hookCount === 0) {
|
|
88
|
-
throw new Error(
|
|
89
|
-
'No hook messages arrived: the registered PreToolUse hook never ran. ' +
|
|
90
|
-
'The most likely cause is that the droid answered this turn without ' +
|
|
91
|
-
'calling the Execute tool, so the `Execute` matcher never fired. ' +
|
|
92
|
-
'Re-run, or widen the matcher to the tool the droid actually used.'
|
|
93
|
-
);
|
|
93
|
+
throw new Error('The PreToolUse hook did not run.');
|
|
94
94
|
}
|
|
@@ -19,7 +19,7 @@ const PURPLE_SQUARE_PNG =
|
|
|
19
19
|
'Bg0aNGjQoEGDFmjQAg0atECDFmjQoAUatECDBi3QoAUaNGiBBi3QoEELNGiBBn2g' +
|
|
20
20
|
'AU2hHmFuN1AZAAAAAElFTkSuQmCC';
|
|
21
21
|
|
|
22
|
-
const
|
|
22
|
+
const result = await run(
|
|
23
23
|
'What single shape and color is in this image? Answer in five words or fewer.',
|
|
24
24
|
{
|
|
25
25
|
images: [
|
|
@@ -32,4 +32,10 @@ const { text } = await run(
|
|
|
32
32
|
}
|
|
33
33
|
);
|
|
34
34
|
|
|
35
|
-
|
|
35
|
+
if (!result.success) {
|
|
36
|
+
throw new Error(
|
|
37
|
+
result.error?.message ?? `Image description failed (${result.subtype}).`
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
console.log(result.text);
|