@cueloop/daemon 0.1.0-alpha.10 → 0.1.0-alpha.100

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 (82) hide show
  1. package/README.md +7 -0
  2. package/SDK.md +102 -0
  3. package/package.json +43 -22
  4. package/src/agent-tools.ts +98 -0
  5. package/src/agent-turn.test.ts +24 -0
  6. package/src/agent-turn.ts +34 -0
  7. package/src/api.test.ts +1081 -65
  8. package/src/api.ts +1425 -89
  9. package/src/capabilities.test.ts +57 -0
  10. package/src/capabilities.ts +95 -0
  11. package/src/client-errors.ts +24 -0
  12. package/src/client-reliability.test.ts +189 -0
  13. package/src/client.test.ts +37 -0
  14. package/src/client.ts +820 -94
  15. package/src/curate.ts +109 -0
  16. package/src/daemon-sdk-effect.ts +113 -0
  17. package/src/daemon-sdk.test.ts +306 -0
  18. package/src/daemon-sdk.ts +397 -0
  19. package/src/daemon-version.test.ts +142 -0
  20. package/src/diff-hot-reload.test.ts +419 -0
  21. package/src/diff-watcher.test.ts +117 -0
  22. package/src/diff-watcher.ts +326 -0
  23. package/src/examples/daemon-sdk.ts +55 -0
  24. package/src/gh.test.ts +59 -0
  25. package/src/gh.ts +74 -0
  26. package/src/ghostty-thread-surface-store.test.ts +49 -0
  27. package/src/ghostty-thread-surface-store.ts +101 -0
  28. package/src/ghostty-thread-surface.test.ts +398 -0
  29. package/src/ghostty-thread-surface.ts +267 -0
  30. package/src/harness-delivery.test.ts +269 -0
  31. package/src/harness-diagnostics.test.ts +26 -0
  32. package/src/harness-diagnostics.ts +30 -0
  33. package/src/harness-state-store.test.ts +93 -0
  34. package/src/harness-state-store.ts +213 -0
  35. package/src/herdr-split.test.ts +106 -0
  36. package/src/herdr-split.ts +85 -0
  37. package/src/herdr-thread-surface-store.test.ts +81 -0
  38. package/src/herdr-thread-surface-store.ts +66 -0
  39. package/src/herdr-thread-surface.test.ts +465 -0
  40. package/src/herdr-thread-surface.ts +378 -0
  41. package/src/index.ts +8 -9
  42. package/src/interruptible-wait.test.ts +127 -0
  43. package/src/interruptible-wait.ts +66 -0
  44. package/src/jj-working-tree.ts +131 -0
  45. package/src/main.ts +4 -1
  46. package/src/operation-receipts.test.ts +111 -0
  47. package/src/operation-receipts.ts +91 -0
  48. package/src/paths.ts +49 -0
  49. package/src/pr-poller.test.ts +102 -0
  50. package/src/pr-poller.ts +73 -0
  51. package/src/project-files.test.ts +134 -0
  52. package/src/project-files.ts +68 -0
  53. package/src/protocol.ts +78 -7
  54. package/src/retention.test.ts +191 -0
  55. package/src/retention.ts +123 -0
  56. package/src/sdk-identities.ts +35 -0
  57. package/src/server.test.ts +456 -42
  58. package/src/server.ts +601 -88
  59. package/src/share-blob.test.ts +119 -0
  60. package/src/share-blob.ts +57 -0
  61. package/src/single-instance.test.ts +162 -13
  62. package/src/store.conformance.test.ts +41 -0
  63. package/src/store.jsonl.test.ts +173 -0
  64. package/src/store.ts +267 -34
  65. package/src/testing/store-conformance.ts +192 -0
  66. package/src/thread-agent-validation.ts +119 -0
  67. package/src/thread-agent.test.ts +412 -0
  68. package/src/thread-agent.ts +713 -0
  69. package/src/thread-review.test.ts +483 -0
  70. package/src/thread-review.ts +382 -0
  71. package/src/thread-subscription.test.ts +174 -0
  72. package/src/thread-subscription.ts +129 -0
  73. package/src/thread-surface-config.test.ts +63 -0
  74. package/src/thread-surface-config.ts +61 -0
  75. package/src/validate.test.ts +279 -37
  76. package/src/validate.ts +418 -33
  77. package/src/vcs-source.test.ts +180 -0
  78. package/src/vcs-source.ts +229 -0
  79. package/src/version.ts +6 -0
  80. package/src/working-tree.ts +150 -0
  81. package/src/review.test.ts +0 -167
  82. package/src/review.ts +0 -174
@@ -0,0 +1,134 @@
1
+ /** Read-only repo introspection: the tracked file tree and safe, bounded file reads. */
2
+
3
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
4
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
5
+ import { tmpdir } from "node:os";
6
+ import { join } from "node:path";
7
+ import { listProjectFiles, readProjectFile } from "./project-files";
8
+
9
+ function git(args: string[], cwd: string): void {
10
+ const result = Bun.spawnSync(["git", ...args], { cwd, stdout: "ignore", stderr: "ignore" });
11
+
12
+ if (result.exitCode !== 0) throw new Error(`git ${args.join(" ")} failed in ${cwd}`);
13
+ }
14
+
15
+ let repo: string;
16
+
17
+ beforeEach(() => {
18
+ repo = mkdtempSync(join(tmpdir(), "cueloop-project-files-"));
19
+ git(["init", "-q", "-b", "main"], repo);
20
+ git(["config", "user.email", "t@t"], repo);
21
+ git(["config", "user.name", "t"], repo);
22
+ });
23
+
24
+ afterEach(() => {
25
+ rmSync(repo, { recursive: true, force: true });
26
+ });
27
+
28
+ describe("listProjectFiles", () => {
29
+ test("returns the tracked files sorted ascending", async () => {
30
+ // Given a repo whose files are committed out of alphabetical order
31
+ writeFileSync(join(repo, "z.ts"), "export const z = 1;\n");
32
+ writeFileSync(join(repo, "a.ts"), "export const a = 1;\n");
33
+ writeFileSync(join(repo, "m.ts"), "export const m = 1;\n");
34
+ git(["add", "."], repo);
35
+ git(["commit", "-qm", "init"], repo);
36
+
37
+ // When the tracked files are listed
38
+ const files = await listProjectFiles(repo);
39
+
40
+ // Then every tracked path comes back in ascending order
41
+ expect(files).toEqual(["a.ts", "m.ts", "z.ts"]);
42
+ });
43
+
44
+ test("omits a tracked file deleted from the working tree", async () => {
45
+ writeFileSync(join(repo, "keep.ts"), "export const keep = true;\n");
46
+ writeFileSync(join(repo, "gone.ts"), "export const gone = true;\n");
47
+ git(["add", "."], repo);
48
+ git(["commit", "-qm", "init"], repo);
49
+ rmSync(join(repo, "gone.ts"));
50
+
51
+ expect(await listProjectFiles(repo)).toEqual(["keep.ts"]);
52
+ });
53
+
54
+ test("returns [] for a directory that is not a git repo", async () => {
55
+ // Given a plain directory with a file but no git repo
56
+ const plain = mkdtempSync(join(tmpdir(), "cueloop-project-files-plain-"));
57
+
58
+ writeFileSync(join(plain, "a.ts"), "export const a = 1;\n");
59
+ try {
60
+ // When the tracked files are listed
61
+ const files = await listProjectFiles(plain);
62
+
63
+ // Then nothing comes back and no error is thrown
64
+ expect(files).toEqual([]);
65
+ } finally {
66
+ rmSync(plain, { recursive: true, force: true });
67
+ }
68
+ });
69
+
70
+ test("returns [] when there is no repoRoot", async () => {
71
+ expect(await listProjectFiles(undefined)).toEqual([]);
72
+ });
73
+ });
74
+
75
+ describe("readProjectFile", () => {
76
+ test("reads a repo-relative file as UTF-8", async () => {
77
+ // Given a committed file with known contents
78
+ writeFileSync(join(repo, "a.ts"), "export const a = 1;\n");
79
+ git(["add", "."], repo);
80
+ git(["commit", "-qm", "init"], repo);
81
+
82
+ // When it is read by its repo-relative path
83
+ const contents = await readProjectFile(repo, "a.ts");
84
+
85
+ // Then its exact bytes come back
86
+ expect(contents).toBe("export const a = 1;\n");
87
+ });
88
+
89
+ test("returns null for a path that escapes the repo via ../", async () => {
90
+ // Given a secret file just outside the repo root
91
+ const secret = join(repo, "..", "outside-secret.txt");
92
+
93
+ writeFileSync(secret, "top secret\n");
94
+ try {
95
+ // When a traversal path tries to reach it
96
+ const contents = await readProjectFile(repo, "../outside-secret.txt");
97
+
98
+ // Then the read is refused
99
+ expect(contents).toBeNull();
100
+ } finally {
101
+ rmSync(secret, { force: true });
102
+ }
103
+ });
104
+
105
+ test("returns null for a missing file", async () => {
106
+ expect(await readProjectFile(repo, "nope.ts")).toBeNull();
107
+ });
108
+
109
+ test("returns null for a directory (not a regular file)", async () => {
110
+ // Given a directory inside the repo
111
+ mkdirSync(join(repo, "src"));
112
+
113
+ // When the directory path is read
114
+ const contents = await readProjectFile(repo, "src");
115
+
116
+ // Then it is refused
117
+ expect(contents).toBeNull();
118
+ });
119
+
120
+ test("returns null for a file larger than the cap", async () => {
121
+ // Given a file just over one megabyte
122
+ writeFileSync(join(repo, "big.bin"), "x".repeat(1024 * 1024 + 1));
123
+
124
+ // When it is read
125
+ const contents = await readProjectFile(repo, "big.bin");
126
+
127
+ // Then it is refused for exceeding the size cap
128
+ expect(contents).toBeNull();
129
+ });
130
+
131
+ test("returns null when there is no repoRoot", async () => {
132
+ expect(await readProjectFile(undefined, "a.ts")).toBeNull();
133
+ });
134
+ });
@@ -0,0 +1,68 @@
1
+ /** Read-only repo introspection for the Project panel: the tracked file tree and a file's contents. */
2
+
3
+ import { stat } from "node:fs/promises";
4
+ import { isAbsolute, relative, resolve, sep } from "node:path";
5
+
6
+ /** Ceiling on the returned tree; a pathological repo cannot flood the panel or the wire. */
7
+ const MAX_FILES = 5000;
8
+ /** Ceiling on a served file; the panel renders source, not multi-megabyte blobs. */
9
+ const MAX_FILE_BYTES = 1024 * 1024;
10
+
11
+ async function gitFileList(repoRoot: string, args: string[]): Promise<string[] | null> {
12
+ const gitProcess = Bun.spawn(["git", "-C", repoRoot, "ls-files", "-z", ...args], {
13
+ stdout: "pipe",
14
+ stderr: "ignore",
15
+ });
16
+ const stdout = await new Response(gitProcess.stdout).text();
17
+
18
+ return (await gitProcess.exited) === 0 ? stdout.split("\0").filter(Boolean) : null;
19
+ }
20
+
21
+ /** Existing tracked paths, sorted ascending and capped; [] on failure or a non-repo. */
22
+ export async function listProjectFiles(repoRoot: string | undefined): Promise<string[]> {
23
+ if (repoRoot === undefined || repoRoot.length === 0) return [];
24
+ try {
25
+ const [tracked, deleted] = await Promise.all([
26
+ gitFileList(repoRoot, ["--cached"]),
27
+ gitFileList(repoRoot, ["--deleted"]),
28
+ ]);
29
+
30
+ if (tracked === null || deleted === null) return [];
31
+ const missing = new Set(deleted);
32
+
33
+ return tracked
34
+ .filter((path) => !missing.has(path))
35
+ .toSorted()
36
+ .slice(0, MAX_FILES);
37
+ } catch {
38
+ return [];
39
+ }
40
+ }
41
+
42
+ /** True when `candidate` resolves outside `root` (path traversal) or is `root` itself. */
43
+ function escapesRoot(root: string, candidate: string): boolean {
44
+ const rel = relative(root, candidate);
45
+
46
+ return rel.length === 0 || rel === ".." || rel.startsWith(".." + sep) || isAbsolute(rel);
47
+ }
48
+
49
+ /** UTF-8 contents of `repoRoot/path`, or null: no repo, traversal escape, not a regular file, oversized, or any error. */
50
+ export async function readProjectFile(
51
+ repoRoot: string | undefined,
52
+ path: string,
53
+ ): Promise<string | null> {
54
+ if (repoRoot === undefined || repoRoot.length === 0) return null;
55
+ const root = resolve(repoRoot);
56
+ const target = resolve(root, path);
57
+
58
+ if (escapesRoot(root, target)) return null;
59
+ try {
60
+ const stats = await stat(target);
61
+
62
+ if (!stats.isFile() || stats.size > MAX_FILE_BYTES) return null;
63
+
64
+ return await Bun.file(target).text();
65
+ } catch {
66
+ return null;
67
+ }
68
+ }
package/src/protocol.ts CHANGED
@@ -1,9 +1,11 @@
1
1
  /**
2
- * Wire protocol (#14): newline-delimited JSON over the unix socket.
2
+ * Wire protocol: newline-delimited JSON over the unix socket.
3
3
  * Requests: { id, method, params } Responses: { id, result } | { id, error }
4
4
  * Events (push, after events.subscribe): { event, sessionId }
5
5
  */
6
6
 
7
+ import * as v from "valibot";
8
+
7
9
  export interface Request {
8
10
  id: number;
9
11
  method: string;
@@ -19,22 +21,91 @@ export interface Response {
19
21
  export interface EventFrame {
20
22
  event: string;
21
23
  sessionId: string;
24
+ entryId?: string;
22
25
  }
23
26
 
24
27
  export type Frame = Response | EventFrame;
25
28
 
29
+ const RequestSchema = v.object({
30
+ id: v.number(),
31
+ method: v.string(),
32
+ params: v.optional(v.unknown()),
33
+ });
34
+ const ResponseSchema = v.object({
35
+ id: v.number(),
36
+ result: v.optional(v.unknown()),
37
+ error: v.optional(v.object({ code: v.string(), message: v.string() })),
38
+ });
39
+ const EventFrameSchema = v.object({
40
+ event: v.string(),
41
+ sessionId: v.string(),
42
+ entryId: v.optional(v.string()),
43
+ });
44
+
45
+ export function parseRequestFrame(line: string): Request {
46
+ return v.parse(RequestSchema, JSON.parse(line));
47
+ }
48
+
49
+ export function parseInboundFrame(line: string): Frame {
50
+ return v.parse(v.union([ResponseSchema, EventFrameSchema]), JSON.parse(line));
51
+ }
52
+
26
53
  /** Incremental NDJSON splitter; tolerates partial writes. */
27
54
  export class LineBuffer {
28
- private buf = "";
55
+ private buffered = "";
29
56
 
30
57
  push(chunk: string, onLine: (line: string) => void): void {
31
- this.buf += chunk;
58
+ this.buffered += chunk;
32
59
  for (;;) {
33
- const nl = this.buf.indexOf("\n");
34
- if (nl === -1) return;
35
- const line = this.buf.slice(0, nl).trim();
36
- this.buf = this.buf.slice(nl + 1);
60
+ const newlineIndex = this.buffered.indexOf("\n");
61
+
62
+ if (newlineIndex === -1) return;
63
+ const line = this.buffered.slice(0, newlineIndex).trim();
64
+
65
+ this.buffered = this.buffered.slice(newlineIndex + 1);
37
66
  if (line) onLine(line);
38
67
  }
39
68
  }
40
69
  }
70
+
71
+ /** The surface both Bun.listen and Bun.connect sockets satisfy. */
72
+ interface WritableSocket {
73
+ write(data: Buffer): number;
74
+ }
75
+
76
+ /**
77
+ * Outbound writes honoring socket backpressure. A frame larger than the
78
+ * kernel buffer is written partially by the socket; the unwritten tail must
79
+ * wait for the drain callback or the frame truncates mid-line and the NDJSON
80
+ * stream corrupts for good. Frames never reorder: while a tail is pending,
81
+ * new writes append behind it.
82
+ */
83
+ export class BackpressureWriter {
84
+ private pending: Buffer | null = null;
85
+
86
+ constructor(private socket: WritableSocket) {}
87
+
88
+ write(data: string): void {
89
+ const bytes = Buffer.from(data, "utf8");
90
+
91
+ if (this.pending) {
92
+ this.pending = Buffer.concat([this.pending, bytes]);
93
+
94
+ return;
95
+ }
96
+ const written = Math.max(0, this.socket.write(bytes));
97
+
98
+ if (written < bytes.length) this.pending = bytes.subarray(written);
99
+ }
100
+
101
+ /** Call from the socket's drain handler. */
102
+ drain(): void {
103
+ if (!this.pending) return;
104
+ const tail = this.pending;
105
+
106
+ this.pending = null;
107
+ const written = Math.max(0, this.socket.write(tail));
108
+
109
+ if (written < tail.length) this.pending = tail.subarray(written);
110
+ }
111
+ }
@@ -0,0 +1,191 @@
1
+ /** Retention: the cleanup period defaults to 30, honors [cleanup] period_days, and prune drops only the sessions and reports past the window. */
2
+
3
+ import { describe, expect, test } from "bun:test";
4
+ import { mkdirSync, mkdtempSync, utimesSync, writeFileSync } from "node:fs";
5
+ import { tmpdir } from "node:os";
6
+ import { join } from "node:path";
7
+ import type { Thread, SessionStatus } from "@cueloop/schema";
8
+ import { ThreadStore } from "./store";
9
+ import { reportsDir } from "./paths";
10
+ import {
11
+ DEFAULT_CLEANUP_PERIOD_DAYS,
12
+ LATEST_REPORT_FILENAME,
13
+ isExpired,
14
+ parseRefineState,
15
+ pruneExpiredReports,
16
+ pruneExpiredSessions,
17
+ resolveCleanupPeriodDays,
18
+ } from "./retention";
19
+
20
+ const DAY_MS = 24 * 60 * 60 * 1000;
21
+ const NOW_MS = Date.parse("2026-08-26T00:00:00Z");
22
+
23
+ function tempDir(prefix: string): string {
24
+ return mkdtempSync(join(tmpdir(), prefix));
25
+ }
26
+
27
+ function daysAgo(days: number): string {
28
+ return new Date(NOW_MS - days * DAY_MS).toISOString();
29
+ }
30
+
31
+ function session(id: string, createdAt: string, status: SessionStatus = "resolved"): Thread {
32
+ return {
33
+ schemaVersion: "1",
34
+ id,
35
+ workspace: { repoRoot: "/repo", branch: "main" },
36
+ artifact: { type: "plan", content: "", meta: {} },
37
+ revisions: [],
38
+ annotations: [],
39
+ message: null,
40
+ status,
41
+ createdAt,
42
+ };
43
+ }
44
+
45
+ describe("resolveCleanupPeriodDays", () => {
46
+ test("defaults to 30 when no config file is present", () => {
47
+ // Arrange
48
+ const missing = join(tempDir("cueloop-retention-"), "config.toml");
49
+
50
+ // Act
51
+ const days = resolveCleanupPeriodDays({ CUELOOP_CONFIG: missing });
52
+
53
+ // Assert
54
+ expect(days).toBe(DEFAULT_CLEANUP_PERIOD_DAYS);
55
+ });
56
+
57
+ test("honors [cleanup] period_days", () => {
58
+ // Arrange
59
+ const path = join(tempDir("cueloop-retention-"), "config.toml");
60
+
61
+ writeFileSync(path, "[cleanup]\nperiod_days = 7\n");
62
+
63
+ // Act
64
+ const days = resolveCleanupPeriodDays({ CUELOOP_CONFIG: path });
65
+
66
+ // Assert
67
+ expect(days).toBe(7);
68
+ });
69
+
70
+ test("falls back to the default when the value is not a number", () => {
71
+ // Arrange
72
+ const path = join(tempDir("cueloop-retention-"), "config.toml");
73
+
74
+ writeFileSync(path, '[cleanup]\nperiod_days = "soon"\n');
75
+
76
+ // Act
77
+ const days = resolveCleanupPeriodDays({ CUELOOP_CONFIG: path });
78
+
79
+ // Assert
80
+ expect(days).toBe(DEFAULT_CLEANUP_PERIOD_DAYS);
81
+ });
82
+ });
83
+
84
+ describe("parseRefineState", () => {
85
+ test("reads the analyzed fingerprint map", () => {
86
+ // Assert
87
+ expect([...parseRefineState({ analyzed: { ses_a: "1:2:x" } })]).toEqual([["ses_a", "1:2:x"]]);
88
+ });
89
+
90
+ test("returns an empty map for the legacy or malformed shape", () => {
91
+ // Assert
92
+ expect(parseRefineState({ seenSessionIds: ["ses_a"] }).size).toBe(0);
93
+ expect(parseRefineState({ analyzed: { ses_a: 5 } }).size).toBe(0);
94
+ expect(parseRefineState(null).size).toBe(0);
95
+ });
96
+ });
97
+
98
+ describe("isExpired", () => {
99
+ test("is true past the window and false inside it", () => {
100
+ // Assert
101
+ expect(isExpired(daysAgo(40), 30, NOW_MS)).toBe(true);
102
+ expect(isExpired(daysAgo(10), 30, NOW_MS)).toBe(false);
103
+ });
104
+
105
+ test("a period of zero disables expiry", () => {
106
+ // Assert
107
+ expect(isExpired(daysAgo(999), 0, NOW_MS)).toBe(false);
108
+ });
109
+ });
110
+
111
+ describe("pruneExpiredSessions", () => {
112
+ test("deletes resolved sessions past the window and keeps recent ones", () => {
113
+ // Arrange
114
+ const home = tempDir("cueloop-retention-home-");
115
+ const store = new ThreadStore(home);
116
+
117
+ store.upsert(session("ses_old", daysAgo(40)));
118
+ store.upsert(session("ses_new", daysAgo(2)));
119
+
120
+ // Act
121
+ const pruned = pruneExpiredSessions(store, 30, NOW_MS);
122
+
123
+ // Assert
124
+ expect(pruned).toEqual(["ses_old"]);
125
+ expect(store.get("ses_old")).toBeUndefined();
126
+ expect(store.get("ses_new")).toBeDefined();
127
+ });
128
+
129
+ test("keeps an expired resolved Thread while its Message is awaiting delivery", () => {
130
+ const store = new ThreadStore(tempDir("cueloop-retention-home-"));
131
+
132
+ store.upsert(session("ses_waiting", daysAgo(40)));
133
+
134
+ expect(pruneExpiredSessions(store, 30, NOW_MS, new Set(["ses_waiting"]))).toEqual([]);
135
+ expect(store.get("ses_waiting")).toBeDefined();
136
+ });
137
+
138
+ test("never deletes an active pending session however old", () => {
139
+ // Arrange
140
+ const home = tempDir("cueloop-retention-home-");
141
+ const store = new ThreadStore(home);
142
+
143
+ store.upsert(session("ses_active", daysAgo(400), "pending"));
144
+
145
+ // Act
146
+ const pruned = pruneExpiredSessions(store, 30, NOW_MS);
147
+
148
+ // Assert
149
+ expect(pruned).toEqual([]);
150
+ expect(store.get("ses_active")).toBeDefined();
151
+ });
152
+
153
+ test("a period of zero prunes nothing", () => {
154
+ // Arrange
155
+ const home = tempDir("cueloop-retention-home-");
156
+ const store = new ThreadStore(home);
157
+
158
+ store.upsert(session("ses_old", daysAgo(999)));
159
+
160
+ // Act
161
+ const pruned = pruneExpiredSessions(store, 0, NOW_MS);
162
+
163
+ // Assert
164
+ expect(pruned).toEqual([]);
165
+ expect(store.get("ses_old")).toBeDefined();
166
+ });
167
+ });
168
+
169
+ describe("pruneExpiredReports", () => {
170
+ test("removes timestamped reports past the window but keeps the latest report", () => {
171
+ // Arrange
172
+ const home = tempDir("cueloop-retention-home-");
173
+ const directory = reportsDir(home);
174
+
175
+ mkdirSync(directory, { recursive: true });
176
+ const latest = join(directory, LATEST_REPORT_FILENAME);
177
+ const stale = join(directory, "refine-2026-06-01T00-00-00-000Z.md");
178
+ const fresh = join(directory, "refine-2026-08-25T00-00-00-000Z.md");
179
+
180
+ for (const path of [latest, stale, fresh]) writeFileSync(path, "report");
181
+ const staleSeconds = (NOW_MS - 60 * DAY_MS) / 1000;
182
+
183
+ utimesSync(stale, staleSeconds, staleSeconds);
184
+
185
+ // Act
186
+ const pruned = pruneExpiredReports(directory, 30, NOW_MS);
187
+
188
+ // Assert
189
+ expect(pruned).toEqual(["refine-2026-06-01T00-00-00-000Z.md"]);
190
+ });
191
+ });
@@ -0,0 +1,123 @@
1
+ import { readdirSync, readFileSync, rmSync, statSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import * as v from "valibot";
5
+ import type { ThreadStore } from "./store";
6
+
7
+ export const DEFAULT_CLEANUP_PERIOD_DAYS = 30;
8
+ export const LATEST_REPORT_FILENAME = "report.md";
9
+ export const TIMESTAMPED_REPORT_PREFIX = "refine-";
10
+
11
+ const MILLISECONDS_PER_DAY = 24 * 60 * 60 * 1000;
12
+
13
+ const CleanupConfigSchema = v.object({
14
+ cleanup: v.optional(v.object({ period_days: v.optional(v.number()) })),
15
+ });
16
+
17
+ const RefineStateSchema = v.object({
18
+ analyzed: v.optional(v.record(v.string(), v.string()), {}),
19
+ });
20
+
21
+ export type RefineState = v.InferOutput<typeof RefineStateSchema>;
22
+
23
+ export function parseRefineState(raw: Parameters<typeof v.safeParse>[1]): Map<string, string> {
24
+ const parsed = v.safeParse(RefineStateSchema, raw);
25
+
26
+ if (!parsed.success) return new Map();
27
+
28
+ return new Map(Object.entries(parsed.output.analyzed));
29
+ }
30
+
31
+ function userConfigPath(env: NodeJS.ProcessEnv): string {
32
+ const explicit = env.CUELOOP_CONFIG;
33
+
34
+ if (explicit && explicit.trim()) return explicit;
35
+ const xdgConfigHome = env.XDG_CONFIG_HOME;
36
+ const base = xdgConfigHome && xdgConfigHome.trim() ? xdgConfigHome : join(homedir(), ".config");
37
+
38
+ return join(base, "cueloop", "config.toml");
39
+ }
40
+
41
+ function readConfiguredPeriodDays(path: string): number | undefined {
42
+ let raw: unknown;
43
+
44
+ try {
45
+ raw = Bun.TOML.parse(readFileSync(path, "utf8"));
46
+ } catch {
47
+ return undefined;
48
+ }
49
+ const parsed = v.safeParse(CleanupConfigSchema, raw);
50
+
51
+ if (!parsed.success) return undefined;
52
+ const value = parsed.output.cleanup?.period_days;
53
+
54
+ return value !== undefined && Number.isFinite(value) && value >= 0 ? value : undefined;
55
+ }
56
+
57
+ export function resolveCleanupPeriodDays(env: NodeJS.ProcessEnv = process.env): number {
58
+ return readConfiguredPeriodDays(userConfigPath(env)) ?? DEFAULT_CLEANUP_PERIOD_DAYS;
59
+ }
60
+
61
+ export function isExpired(createdAt: string, periodDays: number, nowMs: number): boolean {
62
+ if (periodDays <= 0) return false;
63
+ const createdMs = Date.parse(createdAt);
64
+
65
+ if (Number.isNaN(createdMs)) return false;
66
+
67
+ return nowMs - createdMs > periodDays * MILLISECONDS_PER_DAY;
68
+ }
69
+
70
+ export function pruneExpiredSessions(
71
+ store: ThreadStore,
72
+ periodDays: number,
73
+ nowMs: number,
74
+ protectedThreadIds: ReadonlySet<string> = new Set(),
75
+ ): string[] {
76
+ if (periodDays <= 0) return [];
77
+ const pruned: string[] = [];
78
+
79
+ for (const session of store.list()) {
80
+ if (session.status !== "resolved" || protectedThreadIds.has(session.id)) continue;
81
+ if (isExpired(session.createdAt, periodDays, nowMs) && store.delete(session.id)) {
82
+ pruned.push(session.id);
83
+ }
84
+ }
85
+
86
+ return pruned;
87
+ }
88
+
89
+ export function timestampedReportFilename(nowMs: number): string {
90
+ const stamp = new Date(nowMs).toISOString().replace(/[:.]/g, "-");
91
+
92
+ return `${TIMESTAMPED_REPORT_PREFIX}${stamp}.md`;
93
+ }
94
+
95
+ export function pruneExpiredReports(
96
+ reportsDirectory: string,
97
+ periodDays: number,
98
+ nowMs: number,
99
+ ): string[] {
100
+ if (periodDays <= 0) return [];
101
+ let entries: string[];
102
+
103
+ try {
104
+ entries = readdirSync(reportsDirectory);
105
+ } catch {
106
+ return [];
107
+ }
108
+ const cutoffMs = nowMs - periodDays * MILLISECONDS_PER_DAY;
109
+ const pruned: string[] = [];
110
+
111
+ for (const entry of entries) {
112
+ if (entry === LATEST_REPORT_FILENAME) continue;
113
+ if (!entry.startsWith(TIMESTAMPED_REPORT_PREFIX)) continue;
114
+ const path = join(reportsDirectory, entry);
115
+
116
+ if (statSync(path).mtimeMs < cutoffMs) {
117
+ rmSync(path, { force: true });
118
+ pruned.push(entry);
119
+ }
120
+ }
121
+
122
+ return pruned;
123
+ }
@@ -0,0 +1,35 @@
1
+ import * as v from "valibot";
2
+
3
+ const ThreadIdSchema = v.pipe(v.string(), v.minLength(1), v.maxLength(128), v.brand("SdkThreadId"));
4
+ const OperationIdSchema = v.pipe(
5
+ v.string(),
6
+ v.minLength(1),
7
+ v.maxLength(128),
8
+ v.brand("SdkOperationId"),
9
+ );
10
+ const SubmissionIdSchema = v.pipe(
11
+ v.string(),
12
+ v.minLength(1),
13
+ v.maxLength(128),
14
+ v.brand("SdkSubmissionId"),
15
+ );
16
+
17
+ /** Thread, operation and submission IDs share a wire representation, not a domain type. */
18
+ export type SdkThreadId = v.InferOutput<typeof ThreadIdSchema>;
19
+ /** Operation IDs are caller-owned and persist across explicit retries. */
20
+ export type SdkOperationId = v.InferOutput<typeof OperationIdSchema>;
21
+ /** Submission IDs are minted by the daemon after accepting agent input. */
22
+ export type SdkSubmissionId = v.InferOutput<typeof SubmissionIdSchema>;
23
+
24
+ /** External Thread identities must be nonempty and at most 128 characters. */
25
+ export function createSdkThreadId(value: string): SdkThreadId {
26
+ return v.parse(ThreadIdSchema, value);
27
+ }
28
+ /** Caller operation identities are stable across retries and at most 128 characters. */
29
+ export function createSdkOperationId(value: string): SdkOperationId {
30
+ return v.parse(OperationIdSchema, value);
31
+ }
32
+ /** Submission identities are daemon-minted; parsing grants no capability. */
33
+ export function createSdkSubmissionId(value: string): SdkSubmissionId {
34
+ return v.parse(SubmissionIdSchema, value);
35
+ }