sakura-agent 0.1.0 → 0.1.2

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.
Files changed (103) hide show
  1. package/README.md +207 -77
  2. package/README.zh-CN.md +208 -78
  3. package/THIRD_PARTY_NOTICES.md +8 -58
  4. package/dist/packages/agent/src/index.d.ts +49 -11
  5. package/dist/packages/agent/src/index.js +50 -55
  6. package/dist/packages/agent/src/restrict-tools.d.ts +6 -0
  7. package/dist/packages/agent/src/restrict-tools.js +11 -0
  8. package/dist/packages/agent/src/system-prompt.d.ts +5 -0
  9. package/dist/packages/agent/src/system-prompt.js +1 -1
  10. package/dist/packages/agent/src/wake.d.ts +41 -0
  11. package/dist/packages/agent/src/wake.js +71 -0
  12. package/dist/packages/env/src/confine.d.ts +13 -0
  13. package/dist/packages/env/src/confine.js +40 -0
  14. package/dist/packages/env/src/connect-ssh.d.ts +45 -0
  15. package/dist/packages/env/src/connect-ssh.js +242 -0
  16. package/dist/packages/env/src/index.d.ts +6 -1
  17. package/dist/packages/env/src/index.js +3 -1
  18. package/dist/packages/env/src/local.d.ts +11 -2
  19. package/dist/packages/env/src/local.js +42 -10
  20. package/dist/packages/env/src/remote-stream.d.ts +48 -0
  21. package/dist/packages/env/src/remote-stream.js +143 -0
  22. package/dist/packages/env/src/remote-types.d.ts +17 -0
  23. package/dist/packages/env/src/remote-types.js +10 -0
  24. package/dist/packages/env/src/remote.d.ts +16 -7
  25. package/dist/packages/env/src/remote.js +73 -10
  26. package/dist/packages/evals/src/bfcl.d.ts +24 -0
  27. package/dist/packages/evals/src/bfcl.js +71 -0
  28. package/dist/packages/evals/src/index.d.ts +3 -0
  29. package/dist/packages/evals/src/index.js +2 -0
  30. package/dist/packages/evals/src/png.d.ts +2 -0
  31. package/dist/packages/evals/src/png.js +30 -0
  32. package/dist/packages/evals/src/pool.d.ts +3 -0
  33. package/dist/packages/evals/src/pool.js +22 -0
  34. package/dist/packages/evals/src/providers.d.ts +13 -0
  35. package/dist/packages/evals/src/providers.js +23 -0
  36. package/dist/packages/kernel/src/compaction.d.ts +24 -3
  37. package/dist/packages/kernel/src/compaction.js +23 -16
  38. package/dist/packages/kernel/src/goal.d.ts +10 -1
  39. package/dist/packages/kernel/src/goal.js +28 -0
  40. package/dist/packages/kernel/src/hooks.d.ts +5 -0
  41. package/dist/packages/kernel/src/hooks.js +21 -1
  42. package/dist/packages/kernel/src/index.d.ts +3 -2
  43. package/dist/packages/kernel/src/index.js +1 -0
  44. package/dist/packages/kernel/src/loop.d.ts +28 -4
  45. package/dist/packages/kernel/src/loop.js +69 -47
  46. package/dist/packages/kernel/src/types.d.ts +25 -4
  47. package/dist/packages/kernel/src/usage.d.ts +3 -0
  48. package/dist/packages/kernel/src/usage.js +16 -0
  49. package/dist/packages/llm/src/chat/errors.d.ts +11 -0
  50. package/dist/packages/llm/src/chat/errors.js +17 -0
  51. package/dist/packages/llm/src/chat/request.js +22 -4
  52. package/dist/packages/llm/src/chat/retry.d.ts +1 -1
  53. package/dist/packages/llm/src/chat/retry.js +1 -1
  54. package/dist/packages/llm/src/chat/stream.js +2 -1
  55. package/dist/packages/llm/src/index.js +7 -2
  56. package/dist/packages/plugins/context/src/index.d.ts +2 -1
  57. package/dist/packages/plugins/context/src/index.js +2 -1
  58. package/dist/packages/plugins/limits/src/index.d.ts +4 -1
  59. package/dist/packages/plugins/redaction/src/index.d.ts +3 -0
  60. package/dist/packages/plugins/redaction/src/index.js +3 -0
  61. package/dist/packages/plugins/redaction/src/rules.gen.js +1 -1
  62. package/dist/packages/plugins/review/src/chain.d.ts +10 -1
  63. package/dist/packages/plugins/review/src/chain.js +7 -4
  64. package/dist/packages/plugins/review/src/hard-rules.d.ts +6 -0
  65. package/dist/packages/plugins/review/src/hard-rules.js +58 -25
  66. package/dist/packages/plugins/review/src/index.d.ts +21 -0
  67. package/dist/packages/plugins/review/src/index.js +42 -7
  68. package/dist/packages/plugins/review/src/reviewer.d.ts +32 -3
  69. package/dist/packages/plugins/review/src/reviewer.js +115 -27
  70. package/dist/packages/plugins/tools/src/builtin/bash.js +24 -10
  71. package/dist/packages/plugins/tools/src/builtin/image.d.ts +11 -0
  72. package/dist/packages/plugins/tools/src/builtin/image.js +84 -0
  73. package/dist/packages/plugins/tools/src/builtin/read.js +26 -12
  74. package/dist/packages/plugins/toolset/src/codemode.d.ts +17 -6
  75. package/dist/packages/plugins/toolset/src/codemode.js +84 -26
  76. package/dist/packages/plugins/toolset/src/index.d.ts +1 -1
  77. package/dist/packages/plugins/toolset/src/index.js +1 -1
  78. package/dist/packages/plugins/trace/src/index.d.ts +27 -0
  79. package/dist/packages/plugins/trace/src/index.js +29 -0
  80. package/dist/packages/plugins/trace/src/modules.d.ts +6 -0
  81. package/dist/packages/plugins/trace/src/modules.js +28 -0
  82. package/dist/packages/session/src/file-log.d.ts +6 -1
  83. package/dist/packages/session/src/file-log.js +45 -25
  84. package/dist/packages/session/src/fork.d.ts +16 -0
  85. package/dist/packages/session/src/fork.js +70 -0
  86. package/dist/packages/session/src/index.d.ts +2 -0
  87. package/dist/packages/session/src/index.js +1 -0
  88. package/dist/packages/session/src/log-core.d.ts +37 -0
  89. package/dist/packages/session/src/log-core.js +47 -0
  90. package/dist/packages/session/src/memory-log.d.ts +3 -12
  91. package/dist/packages/session/src/memory-log.js +3 -23
  92. package/dist/packages/session/src/projection.js +22 -3
  93. package/dist/packages/testing/src/faux.d.ts +36 -0
  94. package/dist/packages/testing/src/faux.js +69 -0
  95. package/dist/packages/testing/src/index.d.ts +4 -0
  96. package/dist/packages/testing/src/index.js +2 -0
  97. package/dist/packages/testing/src/rng.d.ts +7 -0
  98. package/dist/packages/testing/src/rng.js +17 -0
  99. package/dist/sdk/index.d.ts +11 -3
  100. package/dist/sdk/index.js +5 -1
  101. package/dist/sdk/testing.d.ts +2 -0
  102. package/dist/sdk/testing.js +2 -0
  103. package/package.json +7 -2
@@ -1,7 +1,7 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { appendFile, mkdir, open, readFile, rm, truncate, writeFile } from "node:fs/promises";
3
3
  import { dirname } from "node:path";
4
- import { projectMessages } from "./projection.js";
4
+ import { createLog } from "./log-core.js";
5
5
  import { createTree } from "./tree.js";
6
6
  const FORMAT_VERSION = 1;
7
7
  function isProcessAlive(pid) {
@@ -34,6 +34,7 @@ function parseFile(text) {
34
34
  const complete = lines.slice(0, -1); // the last piece has no trailing newline: it is empty or a torn write
35
35
  let header;
36
36
  const entries = [];
37
+ let rewoundTo;
37
38
  let validBytes = 0;
38
39
  for (const [index, line] of complete.entries()) {
39
40
  let value;
@@ -50,14 +51,18 @@ function parseFile(text) {
50
51
  if (header.version > FORMAT_VERSION)
51
52
  throw new Error(`session file version ${header.version} is newer than supported`);
52
53
  }
54
+ else if (value.type === "rewind") {
55
+ rewoundTo = value.to;
56
+ }
53
57
  else {
54
58
  entries.push(value);
59
+ rewoundTo = undefined;
55
60
  }
56
61
  validBytes += Buffer.byteLength(line) + 1;
57
62
  }
58
63
  if (!header)
59
64
  throw new Error("corrupt session file: missing header");
60
- return { header, entries, validBytes };
65
+ return { header, entries, validBytes, ...(rewoundTo !== undefined ? { rewoundTo } : {}) };
61
66
  }
62
67
  /**
63
68
  * One JSONL file per session: a header line, then one complete entry per line.
@@ -71,6 +76,8 @@ export async function openFileLog(path, options = {}) {
71
76
  await acquireLock(lockPath);
72
77
  let header;
73
78
  let entries;
79
+ let rewoundTo;
80
+ let size; // bytes of the file that are known to be good
74
81
  try {
75
82
  const existing = await readFile(path, "utf8").catch((error) => {
76
83
  if (error.code === "ENOENT")
@@ -79,15 +86,19 @@ export async function openFileLog(path, options = {}) {
79
86
  });
80
87
  if (existing === undefined || existing === "") {
81
88
  header = { type: "header", version: FORMAT_VERSION, id: newId(), cwd: options.cwd ?? process.cwd(), ts: now() };
82
- await writeFile(path, `${JSON.stringify(header)}\n`);
89
+ const line = `${JSON.stringify(header)}\n`;
90
+ await writeFile(path, line);
83
91
  entries = [];
92
+ size = Buffer.byteLength(line);
84
93
  }
85
94
  else {
86
95
  const parsed = parseFile(existing);
87
96
  header = parsed.header;
88
97
  entries = parsed.entries;
89
- if (parsed.validBytes < Buffer.byteLength(existing))
90
- await truncate(path, parsed.validBytes);
98
+ rewoundTo = parsed.rewoundTo;
99
+ size = parsed.validBytes;
100
+ if (size < Buffer.byteLength(existing))
101
+ await truncate(path, size);
91
102
  }
92
103
  }
93
104
  catch (error) {
@@ -95,29 +106,38 @@ export async function openFileLog(path, options = {}) {
95
106
  throw error;
96
107
  }
97
108
  const tree = createTree(entries);
98
- let writing = Promise.resolve();
109
+ if (rewoundTo !== undefined && (rewoundTo === null || entries.some((entry) => entry.id === rewoundTo)))
110
+ tree.moveLeaf(rewoundTo);
111
+ // A write that fails can leave half a line behind. The next write first cuts the file back to what is known to be good, so that half line
112
+ // never ends up in the middle of the file where it would make the whole session unreadable.
113
+ let damaged = false;
114
+ async function write(line) {
115
+ try {
116
+ if (damaged) {
117
+ await truncate(path, size);
118
+ damaged = false;
119
+ }
120
+ await appendFile(path, line);
121
+ size += Buffer.byteLength(line);
122
+ }
123
+ catch (error) {
124
+ damaged = true;
125
+ throw error;
126
+ }
127
+ }
128
+ const log = createLog(tree, {
129
+ saveEntry: (entry) => write(`${JSON.stringify(entry)}\n`),
130
+ saveRewind: (to) => write(`${JSON.stringify({ type: "rewind", to })}\n`),
131
+ }, options);
99
132
  return {
100
133
  header,
101
- async append(body) {
102
- const entry = { ...body, id: newId(), parentId: tree.leaf(), ts: now() };
103
- tree.add(entry);
104
- writing = writing.then(() => appendFile(path, `${JSON.stringify(entry)}\n`));
105
- await writing;
106
- return entry;
107
- },
108
- async branch() {
109
- return tree.branch();
110
- },
111
- async project(upTo) {
112
- const branch = tree.branch();
113
- return projectMessages(upTo === undefined
114
- ? branch
115
- : branch.slice(0, Math.max(0, branch.findIndex((entry) => entry.id === upTo))));
116
- },
117
- rewindTo: (entryId) => tree.moveLeaf(entryId),
118
- leaf: () => tree.leaf(),
134
+ append: log.append,
135
+ branch: log.branch,
136
+ project: log.project,
137
+ rewindTo: log.rewindTo,
138
+ leaf: log.leaf,
119
139
  async close() {
120
- await writing;
140
+ await log.settled();
121
141
  await rm(lockPath, { force: true });
122
142
  },
123
143
  };
@@ -0,0 +1,16 @@
1
+ import type { Log } from "../../kernel/src/index.js";
2
+ import type { SessionHeader } from "./file-log.js";
3
+ export interface ForkOptions {
4
+ /** The last entry to copy, an id from `log.branch()`. Left out, the whole current branch is copied. */
5
+ upTo?: string;
6
+ /** The working directory to record in the new session. Default: this process's. */
7
+ cwd?: string;
8
+ /** The id of the session the log comes from, if it has one; recorded so that the new session says where it came from. */
9
+ sessionId?: string;
10
+ }
11
+ /**
12
+ * Copies the conversation up to a point into a new session file, which is then a session of its own: its entries are the same entries (same ids, same
13
+ * parents), so what the model is sent from it is exactly what it was sent from the original at that point, and a provider's prompt cache still applies.
14
+ * The source is only read. The file appears whole or not at all, and an existing file is never replaced.
15
+ */
16
+ export declare function forkLog(log: Log, path: string, options?: ForkOptions): Promise<SessionHeader>;
@@ -0,0 +1,70 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { link, mkdir, unlink, writeFile } from "node:fs/promises";
3
+ import { dirname } from "node:path";
4
+ /** Where to stop copying so that no turn is cut in two: after the results that answer the calls of the last assistant message that was copied. */
5
+ function endOfTurn(branch, last) {
6
+ let assistant = -1;
7
+ for (let index = last; index >= 0; index--) {
8
+ const entry = branch[index];
9
+ if (entry?.type === "message" && entry.message.role === "assistant") {
10
+ assistant = index;
11
+ break;
12
+ }
13
+ }
14
+ const opened = assistant >= 0 ? branch[assistant] : undefined;
15
+ if (opened?.type !== "message")
16
+ return last;
17
+ const open = new Set(opened.message.content.flatMap((part) => (part.type === "tool_call" ? [part.id] : [])));
18
+ for (let index = assistant + 1; index <= last; index++) {
19
+ const entry = branch[index];
20
+ if (entry?.type === "message" && entry.message.toolCallId)
21
+ open.delete(entry.message.toolCallId);
22
+ }
23
+ let end = last;
24
+ while (open.size > 0 && end + 1 < branch.length) {
25
+ const next = branch[end + 1];
26
+ if (next.type === "message" && next.message.role !== "tool")
27
+ break; // a new turn begins: calls that were never answered stay as they were in the source
28
+ end++;
29
+ if (next.type === "message" && next.message.toolCallId)
30
+ open.delete(next.message.toolCallId);
31
+ }
32
+ return end;
33
+ }
34
+ /**
35
+ * Copies the conversation up to a point into a new session file, which is then a session of its own: its entries are the same entries (same ids, same
36
+ * parents), so what the model is sent from it is exactly what it was sent from the original at that point, and a provider's prompt cache still applies.
37
+ * The source is only read. The file appears whole or not at all, and an existing file is never replaced.
38
+ */
39
+ export async function forkLog(log, path, options = {}) {
40
+ const branch = await log.branch();
41
+ let last = branch.length - 1;
42
+ if (options.upTo !== undefined) {
43
+ last = branch.findIndex((entry) => entry.id === options.upTo);
44
+ if (last === -1)
45
+ throw new Error(`entry ${options.upTo} is not on the current branch`);
46
+ }
47
+ const copied = branch.slice(0, endOfTurn(branch, last) + 1);
48
+ const header = {
49
+ type: "header",
50
+ version: 1,
51
+ id: randomUUID().slice(0, 8),
52
+ cwd: options.cwd ?? process.cwd(),
53
+ ts: Date.now(),
54
+ ...(copied.length > 0 ? { forkedFrom: { entry: copied.at(-1).id, ...(options.sessionId !== undefined ? { session: options.sessionId } : {}) } } : {}),
55
+ };
56
+ const text = [header, ...copied].map((line) => `${JSON.stringify(line)}\n`).join("");
57
+ await mkdir(dirname(path), { recursive: true });
58
+ const temporary = `${path}.${randomUUID().slice(0, 8)}.tmp`;
59
+ try {
60
+ await writeFile(temporary, text, { flag: "wx" });
61
+ // a hard link fails if the target exists, so a session that is there is never replaced, and the new one never shows up half written
62
+ await link(temporary, path).catch((error) => {
63
+ throw error.code === "EEXIST" ? new Error(`${path} already exists`) : error;
64
+ });
65
+ }
66
+ finally {
67
+ await unlink(temporary).catch(() => undefined);
68
+ }
69
+ return header;
70
+ }
@@ -1,4 +1,6 @@
1
1
  export { createMemoryLog } from "./memory-log.js";
2
2
  export type { LogOptions, RewindableLog } from "./memory-log.js";
3
3
  export { openFileLog } from "./file-log.js";
4
+ export { forkLog } from "./fork.js";
5
+ export type { ForkOptions } from "./fork.js";
4
6
  export type { FileLog, SessionHeader } from "./file-log.js";
@@ -1,2 +1,3 @@
1
1
  export { createMemoryLog } from "./memory-log.js";
2
2
  export { openFileLog } from "./file-log.js";
3
+ export { forkLog } from "./fork.js";
@@ -0,0 +1,37 @@
1
+ import type { EntryBody, Log, LogEntry } from "../../kernel/src/index.js";
2
+ import type { createTree } from "./tree.js";
3
+ export interface LogOptions {
4
+ now?: () => number;
5
+ newId?: () => string;
6
+ }
7
+ /** A session log that can go back to an earlier entry and carry on from there. */
8
+ export interface RewindableLog extends Log {
9
+ /** Moves the end of the conversation back to an earlier entry (or to the very start with null). Later entries start a new branch. */
10
+ rewindTo(entryId: string | null): void;
11
+ /** Id of the entry the next one will follow. */
12
+ leaf(): string | null;
13
+ }
14
+ /** Where a log keeps its entries. A memory log keeps them nowhere else. */
15
+ export interface Storage {
16
+ /** Makes the entry durable. If this fails the entry is not part of the log. */
17
+ saveEntry(entry: LogEntry): Promise<void>;
18
+ /** Records that the end of the conversation moved. Best effort: the next entry carries the same information, so a lost record only matters if nothing follows. */
19
+ saveRewind(to: string | null): Promise<void>;
20
+ }
21
+ /**
22
+ * The one implementation of "a log": entries in a tree, one leaf, appends in order. The log is only as durable as its storage, and a log and its
23
+ * storage never disagree: an entry joins the tree after it is saved, and an append that fails changes nothing and does not stop the ones after it.
24
+ */
25
+ export declare function createLog(tree: ReturnType<typeof createTree>, storage: Storage, options?: LogOptions): {
26
+ /** Resolves when everything started so far has been saved or has failed. */
27
+ settled: () => Promise<undefined>;
28
+ append(body: EntryBody): Promise<LogEntry>;
29
+ branch(): Promise<LogEntry[]>;
30
+ project(upTo?: string): Promise<import("../../kernel/src/index.js").Message[]>;
31
+ /**
32
+ * Takes effect at once. Entries that were already waiting to be saved follow the new end, so do not rewind while appends are in flight
33
+ * (the kernel does not: one run at a time, and it waits for each append).
34
+ */
35
+ rewindTo(entryId: string | null): void;
36
+ leaf: () => string | null;
37
+ };
@@ -0,0 +1,47 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { projectMessages } from "./projection.js";
3
+ /**
4
+ * The one implementation of "a log": entries in a tree, one leaf, appends in order. The log is only as durable as its storage, and a log and its
5
+ * storage never disagree: an entry joins the tree after it is saved, and an append that fails changes nothing and does not stop the ones after it.
6
+ */
7
+ export function createLog(tree, storage, options = {}) {
8
+ const now = options.now ?? Date.now;
9
+ const newId = options.newId ?? (() => randomUUID().slice(0, 8));
10
+ // Appends run one after another, each starting from where the one before ended. A failed one is reported to its caller and not to the next.
11
+ let queue = Promise.resolve();
12
+ return {
13
+ /** Resolves when everything started so far has been saved or has failed. */
14
+ settled: () => queue.then(() => undefined, () => undefined),
15
+ append(body) {
16
+ const run = queue.then(async () => {
17
+ const entry = { ...body, id: newId(), parentId: tree.leaf(), ts: now() };
18
+ await storage.saveEntry(entry);
19
+ tree.add(entry);
20
+ return entry;
21
+ });
22
+ queue = run.catch(() => undefined);
23
+ return run;
24
+ },
25
+ async branch() {
26
+ return tree.branch();
27
+ },
28
+ async project(upTo) {
29
+ const branch = tree.branch();
30
+ return projectMessages(upTo === undefined
31
+ ? branch
32
+ : branch.slice(0, Math.max(0, branch.findIndex((entry) => entry.id === upTo))));
33
+ },
34
+ /**
35
+ * Takes effect at once. Entries that were already waiting to be saved follow the new end, so do not rewind while appends are in flight
36
+ * (the kernel does not: one run at a time, and it waits for each append).
37
+ */
38
+ rewindTo(entryId) {
39
+ const before = tree.leaf();
40
+ tree.moveLeaf(entryId);
41
+ if (entryId === before)
42
+ return;
43
+ queue = queue.then(() => storage.saveRewind(entryId)).catch(() => undefined);
44
+ },
45
+ leaf: () => tree.leaf(),
46
+ };
47
+ }
@@ -1,15 +1,6 @@
1
- import type { Log, LogEntry } from "../../kernel/src/index.js";
2
- export interface LogOptions {
3
- now?: () => number;
4
- newId?: () => string;
5
- }
6
- /** A session log that can go back to an earlier entry and carry on from there. */
7
- export interface RewindableLog extends Log {
8
- /** Moves the end of the conversation back to an earlier entry (or to the very start with null). Later entries start a new branch. */
9
- rewindTo(entryId: string | null): void;
10
- /** Id of the entry the next one will follow. */
11
- leaf(): string | null;
12
- }
1
+ import type { LogEntry } from "../../kernel/src/index.js";
2
+ import type { LogOptions, RewindableLog } from "./log-core.js";
3
+ export type { LogOptions, RewindableLog } from "./log-core.js";
13
4
  /** A log that lives only in memory (or wraps entries that were loaded elsewhere). */
14
5
  export declare function createMemoryLog(options?: LogOptions & {
15
6
  entries?: readonly LogEntry[];
@@ -1,27 +1,7 @@
1
- import { randomUUID } from "node:crypto";
2
- import { projectMessages } from "./projection.js";
1
+ import { createLog } from "./log-core.js";
3
2
  import { createTree } from "./tree.js";
4
3
  /** A log that lives only in memory (or wraps entries that were loaded elsewhere). */
5
4
  export function createMemoryLog(options = {}) {
6
- const now = options.now ?? Date.now;
7
- const newId = options.newId ?? (() => randomUUID().slice(0, 8));
8
- const tree = createTree(options.entries ?? []);
9
- return {
10
- async append(body) {
11
- const entry = { ...body, id: newId(), parentId: tree.leaf(), ts: now() };
12
- tree.add(entry);
13
- return entry;
14
- },
15
- async branch() {
16
- return tree.branch();
17
- },
18
- async project(upTo) {
19
- const branch = tree.branch();
20
- return projectMessages(upTo === undefined
21
- ? branch
22
- : branch.slice(0, Math.max(0, branch.findIndex((entry) => entry.id === upTo))));
23
- },
24
- rewindTo: (entryId) => tree.moveLeaf(entryId),
25
- leaf: () => tree.leaf(),
26
- };
5
+ const { settled: _settled, ...log } = createLog(createTree(options.entries ?? []), { saveEntry: async () => undefined, saveRewind: async () => undefined }, options);
6
+ return log;
27
7
  }
@@ -1,4 +1,6 @@
1
1
  import { COMPACTION_PREFIX } from "../../kernel/src/index.js";
2
+ /** The message that tells the model about its goal. It is the one pinned message that does not sit in front: see `projectMessages`. */
3
+ const isGoalStatement = (entry) => entry?.type === "custom_message" && entry.customType === "goal";
2
4
  function entryToMessage(entry) {
3
5
  if (entry.type === "message")
4
6
  return entry.message;
@@ -55,25 +57,42 @@ function orderToolResults(messages) {
55
57
  }
56
58
  return ordered;
57
59
  }
60
+ /** The goal statement still in force that lies before `start` (so the compaction took it out of the visible history), as a message. */
61
+ function goalCompactedAway(branch, start, edits) {
62
+ for (let index = start - 1; index >= 0; index--) {
63
+ const entry = branch[index];
64
+ if (!entry || !isGoalStatement(entry))
65
+ continue;
66
+ return edits.get(entry.id) === null ? undefined : entryToMessage(entry);
67
+ }
68
+ return undefined;
69
+ }
58
70
  export function projectMessages(branch) {
59
71
  const { start, summary, compactedAt } = findStart(branch);
60
72
  const edits = collectEdits(branch);
61
73
  const messages = [];
62
- // Pinned entries (project instructions) stay in front even after compaction.
74
+ // Pinned entries (project instructions) stay in front even after compaction. They are written once, at the start, so the front never changes.
75
+ // The goal is pinned too, but it is set in the middle of a conversation: in front it would change everything after it and cost the provider's
76
+ // prompt cache. It stays where it was written, and after a compaction it follows the summary.
63
77
  for (let index = 0; index < branch.length; index++) {
64
78
  const entry = branch[index];
65
- if (entry?.type !== "custom_message" || !entry.pinned)
79
+ if (entry?.type !== "custom_message" || !entry.pinned || isGoalStatement(entry))
66
80
  continue;
81
+ if (edits.get(entry.id) === null)
82
+ continue; // a pinned message can be withdrawn too
67
83
  const message = entryToMessage(entry);
68
84
  if (message)
69
85
  messages.push(message);
70
86
  }
71
87
  if (summary !== undefined) {
72
88
  messages.push({ role: "user", content: [{ type: "text", text: `${COMPACTION_PREFIX}\n\n${summary}` }], ts: 0 });
89
+ const carried = goalCompactedAway(branch, start, edits);
90
+ if (carried)
91
+ messages.push(carried);
73
92
  }
74
93
  for (let index = start; index < branch.length; index++) {
75
94
  const entry = branch[index];
76
- if (!entry || (entry.type === "custom_message" && entry.pinned))
95
+ if (!entry || (entry.type === "custom_message" && entry.pinned && !isGoalStatement(entry)))
77
96
  continue;
78
97
  const message = entryToMessage(entry);
79
98
  if (!message)
@@ -0,0 +1,36 @@
1
+ import type { LlmClient, LlmRequest, ProviderError, StopReason, Usage } from "../../kernel/src/index.js";
2
+ export interface FauxTurn {
3
+ text?: string;
4
+ reasoning?: string;
5
+ toolCalls?: Array<{
6
+ name: string;
7
+ args: unknown;
8
+ id?: string;
9
+ }>;
10
+ /** Cut the output off in the middle of this tool call (finish_reason "length"). */
11
+ truncatedToolCall?: {
12
+ name: string;
13
+ id?: string;
14
+ };
15
+ usage?: Partial<Usage>;
16
+ stop?: StopReason;
17
+ /** Fail the request instead of answering. */
18
+ error?: ProviderError;
19
+ /** Never answer; wait until the request is aborted. */
20
+ hang?: boolean;
21
+ /** Send this text, then a restart (as an adapter does after a dropped connection), then the real reply. */
22
+ droppedFirst?: string;
23
+ /** Split text into pieces of this many characters (default: one piece). */
24
+ chunkSize?: number;
25
+ }
26
+ export type FauxScript = FauxTurn[] | ((request: LlmRequest, index: number) => FauxTurn);
27
+ export interface FauxClient extends LlmClient {
28
+ /** Every request received, in order. */
29
+ requests: LlmRequest[];
30
+ }
31
+ export interface FauxOptions {
32
+ contextWindow?: number;
33
+ supportsImages?: boolean;
34
+ }
35
+ /** A scripted stand-in for a model: replays the given turns, one per request. */
36
+ export declare function createFauxClient(script: FauxScript, options?: FauxOptions): FauxClient;
@@ -0,0 +1,69 @@
1
+ function abortError() {
2
+ const error = new Error("aborted");
3
+ error.name = "AbortError";
4
+ return error;
5
+ }
6
+ function waitForAbort(signal) {
7
+ return new Promise((_, reject) => {
8
+ if (signal.aborted)
9
+ reject(abortError());
10
+ signal.addEventListener("abort", () => reject(abortError()), { once: true });
11
+ });
12
+ }
13
+ function estimateTokens(value) {
14
+ return Math.ceil(JSON.stringify(value).length / 4);
15
+ }
16
+ function* pieces(value, size) {
17
+ if (!size || size >= value.length) {
18
+ yield value;
19
+ return;
20
+ }
21
+ for (let start = 0; start < value.length; start += size)
22
+ yield value.slice(start, start + size);
23
+ }
24
+ /** A scripted stand-in for a model: replays the given turns, one per request. */
25
+ export function createFauxClient(script, options = {}) {
26
+ const requests = [];
27
+ let callCount = 0;
28
+ return {
29
+ requests,
30
+ model: { id: "faux", contextWindow: options.contextWindow ?? 100_000, supportsImages: options.supportsImages ?? true },
31
+ async *stream(request, signal) {
32
+ requests.push(request);
33
+ const index = callCount++;
34
+ const turn = typeof script === "function" ? script(request, index) : script[index];
35
+ if (!turn)
36
+ throw new Error(`faux script has no turn for request ${index + 1}`);
37
+ if (signal.aborted)
38
+ throw abortError();
39
+ if (turn.error)
40
+ throw turn.error;
41
+ if (turn.hang)
42
+ await waitForAbort(signal);
43
+ if (turn.droppedFirst) {
44
+ yield { type: "text_delta", delta: turn.droppedFirst };
45
+ yield { type: "restart" };
46
+ }
47
+ if (turn.reasoning)
48
+ yield { type: "reasoning_delta", delta: turn.reasoning };
49
+ if (turn.text)
50
+ for (const piece of pieces(turn.text, turn.chunkSize))
51
+ yield { type: "text_delta", delta: piece };
52
+ let toolCount = 0;
53
+ for (const call of turn.toolCalls ?? []) {
54
+ toolCount++;
55
+ yield { type: "tool_call", call: { id: call.id ?? `call_${index + 1}_${toolCount}`, name: call.name, args: call.args } };
56
+ }
57
+ if (turn.truncatedToolCall) {
58
+ yield { type: "tool_call_truncated", id: turn.truncatedToolCall.id ?? `call_${index + 1}_cut`, name: turn.truncatedToolCall.name };
59
+ }
60
+ const output = estimateTokens([turn.text, turn.reasoning, turn.toolCalls]);
61
+ yield {
62
+ type: "usage",
63
+ usage: { input: estimateTokens(request.messages), output, cacheRead: 0, cacheWrite: 0, ...turn.usage },
64
+ };
65
+ const stop = turn.stop ?? (turn.truncatedToolCall ? "length" : toolCount > 0 ? "tool_use" : "stop");
66
+ yield { type: "done", stop };
67
+ },
68
+ };
69
+ }
@@ -0,0 +1,4 @@
1
+ export { createRng } from "./rng.js";
2
+ export type { Rng } from "./rng.js";
3
+ export { createFauxClient } from "./faux.js";
4
+ export type { FauxClient, FauxOptions, FauxScript, FauxTurn } from "./faux.js";
@@ -0,0 +1,2 @@
1
+ export { createRng } from "./rng.js";
2
+ export { createFauxClient } from "./faux.js";
@@ -0,0 +1,7 @@
1
+ export interface Rng {
2
+ next(): number;
3
+ int(minInclusive: number, maxInclusive: number): number;
4
+ pick<T>(items: readonly T[]): T;
5
+ chance(probability: number): boolean;
6
+ }
7
+ export declare function createRng(seed: number): Rng;
@@ -0,0 +1,17 @@
1
+ // Small seeded random generator (mulberry32) so generated test cases are reproducible.
2
+ export function createRng(seed) {
3
+ let state = seed >>> 0;
4
+ const next = () => {
5
+ state = (state + 0x6d2b79f5) >>> 0;
6
+ let t = state;
7
+ t = Math.imul(t ^ (t >>> 15), t | 1);
8
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
9
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
10
+ };
11
+ return {
12
+ next,
13
+ int: (min, max) => min + Math.floor(next() * (max - min + 1)),
14
+ pick: (items) => items[Math.floor(next() * items.length)],
15
+ chance: (probability) => next() < probability,
16
+ };
17
+ }
@@ -1,10 +1,18 @@
1
1
  export { buildSystemPrompt, bundle, compose, CompositionError, corePlugins, createAgent } from "../packages/agent/src/index.js";
2
2
  export type { Agent, AgentConfig, Composable, ModelConfig } from "../packages/agent/src/index.js";
3
- export { createLocalEnvironment, createRemoteEnvironment } from "../packages/env/src/index.js";
4
- export type { RemoteRunner } from "../packages/env/src/index.js";
5
- export type { Content, Environment, Event, LlmClient, Plugin, RunHandle, RunResult, Tool, ToolContext, ToolResult } from "../packages/kernel/src/index.js";
3
+ export { createLocalEnvironment, createRemoteEnvironment, createSshEnvironment, OutsideDirectoryError, RemoteConnectionError } from "../packages/env/src/index.js";
4
+ export type { LocalOptions, RemoteRunner, SshOptions } from "../packages/env/src/index.js";
5
+ export type { CompactionConfig, Content, ContextStatus, Decision, EntryBody, Environment, Event, EventTransform, ExecResult, FileInfo, GoalRecord, GoalStatus, HookContext, HookName, Hooks, LlmClient, LlmEvent, LlmRequest, Log, LogEntry, Message, ModelInfo, PauseReason, Plugin, PluginApi, PrepareContext, ProcessHandle, Prompts, ProviderErrorKind, Role, RunEndReason, RunHandle, RunOptions, RunResult, StopReason, ThinkingLevel, Tool, ToolCall, ToolContext, ToolDecl, ToolEffect, ToolResult, TurnInfo, Usage, UsageTotals, UserInput, } from "../packages/kernel/src/index.js";
6
+ export { FatalHookError, ProviderError } from "../packages/kernel/src/index.js";
6
7
  export { compatPresets, createChatClient } from "../packages/llm/src/index.js";
7
8
  export type { ChatClientOptions, ChatCompat } from "../packages/llm/src/index.js";
9
+ export { contextPlugin } from "../packages/plugins/context/src/index.js";
10
+ export type { ContextOptions, ContextPlugin } from "../packages/plugins/context/src/index.js";
8
11
  export { limitsPlugin } from "../packages/plugins/limits/src/index.js";
12
+ export { resiliencePlugin } from "../packages/plugins/resilience/src/index.js";
13
+ export type { FixOptions, ResilienceOptions } from "../packages/plugins/resilience/src/index.js";
9
14
  export { redactionPlugin } from "../packages/plugins/redaction/src/index.js";
10
15
  export { reviewPlugin } from "../packages/plugins/review/src/index.js";
16
+ export type { McpServerConfig, McpServerStatus } from "../packages/plugins/toolset/src/index.js";
17
+ export { toolsPlugin } from "../packages/plugins/tools/src/index.js";
18
+ export type { ToolsPluginOptions } from "../packages/plugins/tools/src/index.js";
package/dist/sdk/index.js CHANGED
@@ -1,6 +1,10 @@
1
1
  export { buildSystemPrompt, bundle, compose, CompositionError, corePlugins, createAgent } from "../packages/agent/src/index.js";
2
- export { createLocalEnvironment, createRemoteEnvironment } from "../packages/env/src/index.js";
2
+ export { createLocalEnvironment, createRemoteEnvironment, createSshEnvironment, OutsideDirectoryError, RemoteConnectionError } from "../packages/env/src/index.js";
3
+ export { FatalHookError, ProviderError } from "../packages/kernel/src/index.js";
3
4
  export { compatPresets, createChatClient } from "../packages/llm/src/index.js";
5
+ export { contextPlugin } from "../packages/plugins/context/src/index.js";
4
6
  export { limitsPlugin } from "../packages/plugins/limits/src/index.js";
7
+ export { resiliencePlugin } from "../packages/plugins/resilience/src/index.js";
5
8
  export { redactionPlugin } from "../packages/plugins/redaction/src/index.js";
6
9
  export { reviewPlugin } from "../packages/plugins/review/src/index.js";
10
+ export { toolsPlugin } from "../packages/plugins/tools/src/index.js";
@@ -0,0 +1,2 @@
1
+ export { createFauxClient } from "../packages/testing/src/index.js";
2
+ export type { FauxClient, FauxOptions, FauxScript, FauxTurn } from "../packages/testing/src/index.js";
@@ -0,0 +1,2 @@
1
+ // A scripted model for testing an application built on the SDK: the replies are written in advance, so a test needs no provider, no key and no network.
2
+ export { createFauxClient } from "../packages/testing/src/index.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sakura-agent",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "workspaces": [
@@ -23,7 +23,8 @@
23
23
  "example": "node examples/quickstart.ts",
24
24
  "build": "node scripts/build-sdk.mjs",
25
25
  "prepack": "npm run build",
26
- "example:tools": "node examples/tools.ts"
26
+ "example:tools": "node examples/tools.ts",
27
+ "example:fleet": "node examples/fleet.ts"
27
28
  },
28
29
  "devDependencies": {
29
30
  "@biomejs/biome": "^2.6.4",
@@ -55,6 +56,10 @@
55
56
  ".": {
56
57
  "types": "./dist/sdk/index.d.ts",
57
58
  "import": "./dist/sdk/index.js"
59
+ },
60
+ "./testing": {
61
+ "types": "./dist/sdk/testing.d.ts",
62
+ "import": "./dist/sdk/testing.js"
58
63
  }
59
64
  },
60
65
  "types": "./dist/sdk/index.d.ts",