arisa 5.2.19 → 5.2.21

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 (37) hide show
  1. package/LOW-MEMORY.md +49 -0
  2. package/README.md +2 -1
  3. package/package.json +7 -8
  4. package/src/core/agent/agent-turn-coordinator.js +59 -3
  5. package/src/core/agent/model-selection.js +4 -3
  6. package/src/core/agent/model-speed.js +11 -3
  7. package/src/core/artifacts/artifact-index.js +107 -0
  8. package/src/core/artifacts/artifact-store.js +15 -84
  9. package/src/core/artifacts/legacy-artifact-reader.js +46 -0
  10. package/src/core/config/config-defaults.js +4 -0
  11. package/src/core/tasks/task-database.js +128 -0
  12. package/src/core/tasks/task-store.js +42 -101
  13. package/src/core/tools/daemon-journal.js +115 -0
  14. package/src/core/tools/daemon-processes.js +10 -1
  15. package/src/core/tools/daemon-protocol.js +1 -1
  16. package/src/core/tools/daemon-worker.js +37 -5
  17. package/src/index.js +45 -5
  18. package/src/platform/paths.js +7 -1
  19. package/src/runtime/worker-recovery-report.js +7 -4
  20. package/src/transport/telegram/model-callback.js +3 -2
  21. package/src/transport/telegram/model-controls.js +3 -3
  22. package/src/transport/telegram/model-picker.js +1 -1
  23. package/src/transport/telegram/task-dispatcher.js +8 -8
  24. package/test/agent-turn-coordinator.test.js +48 -0
  25. package/test/artifact-index-memory.test.js +46 -0
  26. package/test/artifact-index-migration.test.js +88 -0
  27. package/test/artifact-store.test.js +3 -3
  28. package/test/cli-command.test.js +52 -0
  29. package/test/cli-memory.test.js +22 -0
  30. package/test/daemon-runtime.test.js +44 -4
  31. package/test/model-selection.test.js +11 -2
  32. package/test/paths.test.js +2 -0
  33. package/test/pi-compaction.test.js +1 -0
  34. package/test/pi-speed-integration.test.js +9 -8
  35. package/test/task-database.test.js +130 -0
  36. package/test/task-store.test.js +10 -5
  37. package/test/telegram-task-dispatcher.test.js +2 -5
package/LOW-MEMORY.md ADDED
@@ -0,0 +1,49 @@
1
+ # Low-memory operation
2
+
3
+ Arisa can use swap to keep cold pages out of RAM. Swap occupancy alone does not indicate failure: sustained swap-in/out, memory PSI, disk wait, worker restarts and operation latency are the useful signals. Do not run `swapoff` to clear swap on a constrained host, or increase every Node heap to mask an allocation problem.
4
+
5
+ ## Artifact index
6
+
7
+ The artifact store uses Node's bundled SQLite support (Node >=22.19) and the chat-scoped `getChatArtifactsDatabaseFile(chatId)` path helper. `getChatArtifactsIndexFile(chatId)` still identifies the legacy JSON file, not the live database. Tools must access artifacts through Arisa IPC, not parse storage files.
8
+
9
+ - Each operation opens its database, uses a 1 MiB page cache with mmap disabled, then closes it. There is no resident history cache per chat.
10
+ - Writes insert one artifact. ID lookups and recent-item queries use indexes; history size does not determine their JS heap consumption.
11
+ - `listRecent` accepts integer limits from 0 to 1000; 0 returns no items. Results are read incrementally and rejected if their combined serialized size exceeds 16 MiB; callers can reduce the limit or retrieve individual IDs. Memory still depends on individual record size.
12
+ - SQLite serializes writes across processes. FULL synchronous commits and rollback journaling preserve atomic writes without accumulating an unbounded WAL.
13
+ - The CLI loads agent, bootstrap, slave and TUI modules only in the branches that need them. The persistent service supervisor does not load the worker's agent dependencies.
14
+
15
+ ### Migration and backups
16
+
17
+ First access to an uninitialized database streams the legacy JSON array one object at a time into a single transaction. Artifact IDs, scope, all fields and insertion order are preserved. Duplicate IDs, invalid chat identities or malformed/truncated input abort the whole migration. A failed migration leaves the original unchanged and can be retried after repairing the input. Memory scales with the largest individual legacy record, not total history size.
18
+
19
+ A committed database has schema version 1. Subsequent operations do not read or import the legacy JSON again. The original JSON is retained unchanged as a pre-migration backup, but **does not contain later writes**. Never delete a live database assuming that the legacy file is current.
20
+
21
+ Back up the SQLite database with SQLite's backup API, or stop all writers and copy it along with any journal files. Preserve artifact files separately. A rollback to an older JSON-only core requires stopping all writers, backing up the database, and streaming `SELECT data FROM artifacts ORDER BY seq` into a new JSON array. Fsync and atomically replace the legacy index only after export succeeds. Do not simply revert the code and resume writes to the old JSON snapshot.
22
+
23
+ ## Task storage
24
+
25
+ `tasksDatabaseFile` identifies the live global scheduler database (`tasks.sqlite`); `tasksFile` is now only the legacy `tasks.json` migration source. Tools must use task IPC rather than read either file directly.
26
+
27
+ - Due claims use a partial index for pending/authentication-blocked tasks. ID mutations only load and update the selected row; an idle poll does not parse terminal history or rewrite the queue.
28
+ - Claim/read/update occurs in one synchronous `BEGIN IMMEDIATE` transaction, including across processes. Connections close after each operation, with a 1 MiB page cache, mmap disabled, FULL synchronous commits and a 5-second lock timeout. No asynchronous work runs while holding the write lock.
29
+ - First access imports the legacy array atomically and records schema version 1. This one-time import still loads the legacy array in memory. Startup recovery and unbounded history listing also remain proportional to history size; they are not the per-second hot path.
30
+ - Missing legacy input means a fresh database. Malformed input, duplicate IDs, invalid identities, unsupported schemas and unreadable databases fail explicitly instead of silently becoming an empty queue.
31
+ - The legacy file stays unchanged and is never reimported after a successful migration. Routes, auth blocks, retry state and interrupted-execution semantics are preserved. Do not run old JSON-writing workers alongside a migrated scheduler.
32
+
33
+ Back up using SQLite's backup API or stop all writers before copying the database and any journal. To downgrade, stop all writers and export `SELECT data FROM tasks ORDER BY seq` into a new UTF-8 JSON array, fsync and atomically replace `tasks.json` before starting the old core. The retained legacy JSON does not include subsequent changes and is not a safe downgrade by itself.
34
+
35
+ ## Daemons
36
+
37
+ Keep external ingress daemons running. Request-driven tools should use `autoStart: false` in both their manifest and runtime registration, start through the shared runtime when invoked, and stop when idle. Otherwise the supervisor will repeatedly restart a daemon that deliberately stopped for inactivity. Do not disable ingress or drop sessions to improve a memory benchmark.
38
+
39
+ ## Verification
40
+
41
+ Run the test suite serially on 1 GiB hosts:
42
+
43
+ ```sh
44
+ node --test --test-concurrency=1
45
+ ```
46
+
47
+ `test/artifact-index-memory.test.js` migrates a 100 MiB index and runs 300 subsequent operations in a child with a 48 MiB V8 heap. Migration tests cover UTF-8 chunk boundaries, escaping, corruption, rollback/retry, stable IDs, ordering and concurrent writers in separate processes. `test/cli-memory.test.js` runs the status command with a 24 MiB heap. `test/task-database.test.js` checks atomic migration, corruption, separate-process claims and 200 idle polls under a 32 MiB heap with approximately 60 MB of terminal history; idle polling must not rewrite the database.
48
+
49
+ After deployment, check a real scheduled tool result, PID continuity, daemon health, `free -h`, `vmstat 1`, and `/proc/pressure/memory`. Short observations cannot establish long-term stability or guarantee that every browser workload fits this host.
package/README.md CHANGED
@@ -102,7 +102,8 @@ Global:
102
102
 
103
103
  Per chat (`~/.arisa/chats/<chatId>/`):
104
104
  - artifact files are stored under `artifacts/`
105
- - the artifact index is stored in `state/artifacts.json`
105
+ - the artifact index is stored in `state/artifacts.sqlite`; the legacy `state/artifacts.json` is imported once, incrementally, and retained unchanged as a migration backup
106
+ - artifact reads and writes use indexed SQLite operations rather than loading the chat's complete history into memory; see [low-memory operation and migration](LOW-MEMORY.md)
106
107
  - Pi sessions live under `state/pi-sessions/<revision>/`
107
108
  - chat-scoped tool config overrides live in `config/tools/<tool>/config.js`
108
109
  - chat-scoped daemon infrastructure lives in `state/tools/<tool>/daemon/`; persistent tool data stays beside it
package/package.json CHANGED
@@ -1,17 +1,12 @@
1
1
  {
2
2
  "name": "arisa",
3
- "version": "5.2.19",
3
+ "version": "5.2.21",
4
4
  "description": "Telegram + Pi Agent modular assistant",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
7
7
  "bin": {
8
8
  "arisa": "bin/arisa.js"
9
9
  },
10
- "scripts": {
11
- "start": "node src/index.js",
12
- "bootstrap": "node src/index.js --bootstrap",
13
- "test": "node --test"
14
- },
15
10
  "keywords": [
16
11
  "telegram",
17
12
  "pi-agent",
@@ -44,10 +39,14 @@
44
39
  "url": "https://github.com/clasen/Arisa/issues"
45
40
  },
46
41
  "homepage": "https://arisa.sh",
47
- "packageManager": "pnpm@11.3.0+sha512.2c403d6594527287672b1f7056343a1f7c3634036a67ffabfcc2b3d7595d843768f8787148d1b57cf7956c90606bbd192857c363af19e96d2d0ec9ec5741d215",
48
42
  "dependencies": {
49
43
  "@earendil-works/pi-coding-agent": "0.85.1",
50
44
  "@sinclair/typebox": "^0.34.41",
51
45
  "grammy": "^1.42.0"
46
+ },
47
+ "scripts": {
48
+ "start": "node src/index.js",
49
+ "bootstrap": "node src/index.js --bootstrap",
50
+ "test": "node --test"
52
51
  }
53
- }
52
+ }
@@ -30,6 +30,12 @@ export class AgentTurnCoordinator {
30
30
  this.expired = 0;
31
31
  this.maxObservedQueue = 0;
32
32
  this.totalWaitMs = 0;
33
+ this.lastInteractiveActivityAt = 0;
34
+ this.backgroundWakeTimer = null;
35
+ this.priorityStats = {
36
+ interactive: { queued: 0, started: 0, completed: 0, expired: 0, totalWaitMs: 0, maxWaitMs: 0 },
37
+ background: { queued: 0, started: 0, completed: 0, expired: 0, totalWaitMs: 0, maxWaitMs: 0 }
38
+ };
33
39
  this.setConfig(config);
34
40
  }
35
41
 
@@ -38,6 +44,7 @@ export class AgentTurnCoordinator {
38
44
  enabled: config.enabled !== false,
39
45
  backgroundQueueTtlMs: positiveInteger(config.backgroundQueueTtlMs, 10 * 60_000, 60 * 60_000),
40
46
  interactiveQueueTtlMs: positiveInteger(config.interactiveQueueTtlMs, 0, 60 * 60_000),
47
+ interactiveQuietMs: positiveInteger(config.interactiveQuietMs, 0, 60_000),
41
48
  maxQueued: Math.max(1, positiveInteger(config.maxQueued, 100, 1_000))
42
49
  };
43
50
  }
@@ -53,14 +60,24 @@ export class AgentTurnCoordinator {
53
60
  }
54
61
 
55
62
  diagnostic() {
63
+ const priorities = Object.fromEntries(Object.entries(this.priorityStats).map(([name, stats]) => [name, {
64
+ queued: stats.queued,
65
+ started: stats.started,
66
+ completed: stats.completed,
67
+ expired: stats.expired,
68
+ averageWaitMs: stats.started ? Math.round(stats.totalWaitMs / stats.started) : 0,
69
+ maxWaitMs: stats.maxWaitMs
70
+ }]));
56
71
  return {
57
72
  enabled: this.config.enabled,
58
73
  active: this.describeActive(this.active),
59
74
  queued: this.queue.length,
75
+ queuedInteractive: this.queue.filter((entry) => entry.priorityName === "interactive").length,
60
76
  maxObservedQueue: this.maxObservedQueue,
61
77
  completed: this.completed,
62
78
  expired: this.expired,
63
- averageWaitMs: this.completed ? Math.round(this.totalWaitMs / this.completed) : 0
79
+ averageWaitMs: this.completed ? Math.round(this.totalWaitMs / this.completed) : 0,
80
+ priorities
64
81
  };
65
82
  }
66
83
 
@@ -69,14 +86,42 @@ export class AgentTurnCoordinator {
69
86
  if (index >= 0) this.queue.splice(index, 1);
70
87
  }
71
88
 
89
+ clearBackgroundWake() {
90
+ if (!this.backgroundWakeTimer) return;
91
+ clearTimeout(this.backgroundWakeTimer);
92
+ this.backgroundWakeTimer = null;
93
+ }
94
+
95
+ deferBackground(delayMs) {
96
+ if (this.backgroundWakeTimer) return;
97
+ this.backgroundWakeTimer = setTimeout(() => {
98
+ this.backgroundWakeTimer = null;
99
+ this.next();
100
+ }, delayMs);
101
+ this.backgroundWakeTimer.unref?.();
102
+ }
103
+
72
104
  next() {
73
105
  if (this.active || this.closed || !this.queue.length) return;
74
106
  this.queue.sort((left, right) => right.priority - left.priority || left.sequence - right.sequence);
75
- const entry = this.queue.shift();
107
+ const entry = this.queue[0];
108
+ if (entry.priorityName === "background") {
109
+ const quietRemainingMs = this.lastInteractiveActivityAt + this.config.interactiveQuietMs - this.now();
110
+ if (quietRemainingMs > 0) {
111
+ this.deferBackground(quietRemainingMs);
112
+ return;
113
+ }
114
+ }
115
+ this.clearBackgroundWake();
116
+ this.queue.shift();
76
117
  if (entry.timer) clearTimeout(entry.timer);
77
118
  entry.startedAt = new Date(this.now()).toISOString();
78
119
  this.active = entry;
79
120
  const waitMs = Math.max(0, this.now() - entry.queuedAt);
121
+ const stats = this.priorityStats[entry.priorityName];
122
+ stats.started += 1;
123
+ stats.totalWaitMs += waitMs;
124
+ stats.maxWaitMs = Math.max(stats.maxWaitMs, waitMs);
80
125
  this.totalWaitMs += waitMs;
81
126
  if (waitMs > 0) this.logger?.log("agent", `${entry.label} waited ${waitMs}ms for exclusive agent execution`);
82
127
  let released = false;
@@ -85,6 +130,8 @@ export class AgentTurnCoordinator {
85
130
  released = true;
86
131
  if (this.active === entry) this.active = null;
87
132
  this.completed += 1;
133
+ stats.completed += 1;
134
+ if (entry.priorityName === "interactive") this.lastInteractiveActivityAt = this.now();
88
135
  queueMicrotask(() => this.next());
89
136
  });
90
137
  }
@@ -96,11 +143,12 @@ export class AgentTurnCoordinator {
96
143
  return Promise.reject(Object.assign(new Error("Agent turn queue is full."), { code: "AGENT_TURN_QUEUE_FULL", retryable: true }));
97
144
  }
98
145
  const priority = normalizedPriority(priorityName);
146
+ const normalizedPriorityName = priorityName === "interactive" ? "interactive" : "background";
99
147
  const ttlMs = this.queueTtlMs(priorityName, queueTtlMs);
100
148
  return new Promise((resolve, reject) => {
101
149
  const entry = {
102
150
  label: String(label || "Agent turn").slice(0, 160),
103
- priorityName: priorityName === "interactive" ? "interactive" : "background",
151
+ priorityName: normalizedPriorityName,
104
152
  priority,
105
153
  sequence: this.sequence += 1,
106
154
  queuedAt: this.now(),
@@ -114,11 +162,18 @@ export class AgentTurnCoordinator {
114
162
  if (this.active === entry) return;
115
163
  this.remove(entry);
116
164
  this.expired += 1;
165
+ this.priorityStats[entry.priorityName].expired += 1;
117
166
  reject(queueExpiredError(entry.label));
167
+ queueMicrotask(() => this.next());
118
168
  }, ttlMs);
119
169
  entry.timer.unref?.();
120
170
  }
121
171
  this.queue.push(entry);
172
+ this.priorityStats[entry.priorityName].queued += 1;
173
+ if (entry.priorityName === "interactive") {
174
+ this.lastInteractiveActivityAt = this.now();
175
+ this.clearBackgroundWake();
176
+ }
122
177
  this.maxObservedQueue = Math.max(this.maxObservedQueue, this.queue.length);
123
178
  this.next();
124
179
  });
@@ -135,6 +190,7 @@ export class AgentTurnCoordinator {
135
190
 
136
191
  close() {
137
192
  this.closed = true;
193
+ this.clearBackgroundWake();
138
194
  for (const entry of this.queue.splice(0)) {
139
195
  if (entry.timer) clearTimeout(entry.timer);
140
196
  entry.reject(Object.assign(new Error("Agent turn coordinator closed before execution."), { retryable: true }));
@@ -1,4 +1,4 @@
1
- import { normalizeModelSpeed } from "./model-speed.js";
1
+ import { modelFastSpeed, normalizeModelSpeed } from "./model-speed.js";
2
2
 
3
3
  function chatKey(chatId) {
4
4
  return String(chatId);
@@ -49,9 +49,10 @@ export function resolveChatThinkingLevel(config, chatId) {
49
49
  }
50
50
 
51
51
  export function resolveChatSpeed(config, chatId) {
52
- const speed = resolveChatModelSelection(config, chatId).speed;
52
+ const { speed, model } = resolveChatModelSelection(config, chatId);
53
53
  if (speed === undefined) throw new Error("Model speed is not configured for the active runtime");
54
- return speed;
54
+ // Preserve legacy fast selections while displaying the model-specific multiplier.
55
+ return speed > 1 ? modelFastSpeed(model) : speed;
55
56
  }
56
57
 
57
58
  export function selectChatModel(config, chatId, model, { thinkingLevel, speed } = {}) {
@@ -1,4 +1,4 @@
1
- export const MODEL_SPEEDS = Object.freeze([1, 1.5]);
1
+ export const MODEL_SPEEDS = Object.freeze([1, 1.5, 2]);
2
2
 
3
3
  export function normalizeModelSpeed(speed) {
4
4
  const value = Number(speed);
@@ -21,13 +21,21 @@ export function modelSupportsSpeed(model) {
21
21
  );
22
22
  }
23
23
 
24
+ export function modelFastSpeed(modelId) {
25
+ return modelId === "gpt-6-astra" ? 2 : 1.5;
26
+ }
27
+
28
+ export function listModelSpeeds(model) {
29
+ return modelSupportsSpeed(model) ? [1, modelFastSpeed(model.id)] : [1];
30
+ }
31
+
24
32
  export function clampModelSpeed(model, speed) {
25
33
  const normalized = normalizeModelSpeed(speed);
26
- return normalized === 1.5 && !modelSupportsSpeed(model) ? 1 : normalized;
34
+ return normalized > 1 && modelSupportsSpeed(model) ? modelFastSpeed(model.id) : 1;
27
35
  }
28
36
 
29
37
  export function speedToServiceTier(speed) {
30
- return normalizeModelSpeed(speed) === 1.5 ? "priority" : "default";
38
+ return normalizeModelSpeed(speed) > 1 ? "priority" : "default";
31
39
  }
32
40
 
33
41
  export function createModelSpeedController(streamFn, initialSpeed) {
@@ -0,0 +1,107 @@
1
+ import { mkdir, open } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { DatabaseSync } from "node:sqlite";
4
+ import { readLegacyArtifacts } from "./legacy-artifact-reader.js";
5
+
6
+ const operations = new Map();
7
+
8
+ async function serialize(file, operation) {
9
+ const previous = operations.get(file) || Promise.resolve();
10
+ const current = previous.catch(() => {}).then(operation);
11
+ operations.set(file, current);
12
+ try {
13
+ return await current;
14
+ } finally {
15
+ if (operations.get(file) === current) operations.delete(file);
16
+ }
17
+ }
18
+
19
+ async function createPrivateFile(file) {
20
+ await mkdir(path.dirname(file), { recursive: true });
21
+ try {
22
+ const handle = await open(file, "wx", 0o600);
23
+ await handle.close();
24
+ } catch (error) {
25
+ if (error.code !== "EEXIST") throw error;
26
+ }
27
+ }
28
+
29
+ function insertArtifact(db, artifact) {
30
+ db.prepare("INSERT INTO artifacts (id, data) VALUES (?, ?)")
31
+ .run(artifact.id, JSON.stringify(artifact));
32
+ }
33
+
34
+ async function importLegacy(db, legacyFile, chatId) {
35
+ const insert = db.prepare("INSERT INTO artifacts (id, data) VALUES (?, ?)");
36
+ try {
37
+ for await (const artifact of readLegacyArtifacts(legacyFile)) {
38
+ if (!artifact || typeof artifact.id !== "string" || !artifact.id
39
+ || String(artifact.chatId) !== chatId) {
40
+ throw new Error("Invalid artifact identity or chat scope");
41
+ }
42
+ insert.run(artifact.id, JSON.stringify(artifact));
43
+ }
44
+ } catch (error) {
45
+ if (error.code !== "ENOENT") {
46
+ throw new Error(`Artifact index is unreadable: ${legacyFile}`, { cause: error });
47
+ }
48
+ }
49
+ }
50
+
51
+ async function initialize(db, legacyFile, chatId) {
52
+ const version = () => db.prepare("PRAGMA user_version").get().user_version;
53
+ if (version() === 1) return;
54
+ if (version() !== 0) throw new Error("Unsupported artifact database version");
55
+ db.exec("BEGIN IMMEDIATE");
56
+ try {
57
+ // Another process may have completed migration while this connection waited.
58
+ if (version() === 0) {
59
+ db.exec("CREATE TABLE artifacts (seq INTEGER PRIMARY KEY, id TEXT NOT NULL UNIQUE, data TEXT NOT NULL)");
60
+ await importLegacy(db, legacyFile, chatId);
61
+ db.exec("PRAGMA user_version = 1");
62
+ }
63
+ db.exec("COMMIT");
64
+ } catch (error) {
65
+ db.exec("ROLLBACK");
66
+ throw error;
67
+ }
68
+ }
69
+
70
+ // Open only for the operation: no per-chat resident history or idle DB cache.
71
+ // SQLite transactions coordinate separate Arisa processes as well as store instances.
72
+ export function withArtifactIndex({ databaseFile, legacyFile, chatId }, operation) {
73
+ return serialize(databaseFile, async () => {
74
+ await createPrivateFile(databaseFile);
75
+ const db = new DatabaseSync(databaseFile);
76
+ try {
77
+ db.exec("PRAGMA busy_timeout = 30000; PRAGMA cache_size = -1024; PRAGMA mmap_size = 0; PRAGMA synchronous = FULL");
78
+ await initialize(db, legacyFile, chatId);
79
+ return await operation(db);
80
+ } finally {
81
+ db.close();
82
+ }
83
+ });
84
+ }
85
+
86
+ export function appendArtifact(db, artifact) {
87
+ insertArtifact(db, artifact);
88
+ return artifact;
89
+ }
90
+
91
+ export function getArtifact(db, id) {
92
+ const row = db.prepare("SELECT data FROM artifacts WHERE id = ?").get(id);
93
+ return row ? JSON.parse(row.data) : null;
94
+ }
95
+
96
+ export function listRecentArtifacts(db, limit) {
97
+ const artifacts = [];
98
+ let bytes = 0;
99
+ for (const row of db.prepare("SELECT data FROM artifacts ORDER BY seq DESC LIMIT ?").iterate(limit)) {
100
+ bytes += Buffer.byteLength(row.data, "utf8");
101
+ if (bytes > 16 * 1024 * 1024) {
102
+ throw new RangeError("Recent artifacts exceed 16 MiB; request a smaller limit or retrieve individual IDs");
103
+ }
104
+ artifacts.push(JSON.parse(row.data));
105
+ }
106
+ return artifacts;
107
+ }
@@ -1,10 +1,10 @@
1
- import { copyFile, mkdir, open, readFile, rename, unlink, writeFile } from "node:fs/promises";
1
+ import { copyFile, mkdir, readFile, writeFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import crypto from "node:crypto";
4
- import { getChatArtifactsDir, getChatArtifactsIndexFile } from "../../platform/paths.js";
4
+ import { getChatArtifactsDir, getChatArtifactsIndexFile, getChatArtifactsDatabaseFile } from "../../platform/paths.js";
5
+ import { withArtifactIndex, appendArtifact, getArtifact, listRecentArtifacts } from "./artifact-index.js";
5
6
 
6
7
  const UTF8_BOM = Buffer.from([0xef, 0xbb, 0xbf]);
7
- const indexOperations = new Map();
8
8
 
9
9
  function id() {
10
10
  return crypto.randomUUID();
@@ -43,94 +43,27 @@ async function copyArtifactFile(originalPath, destPath, mimeType) {
43
43
  return writeFile(destPath, withUtf8Bom(content));
44
44
  }
45
45
 
46
- async function serializeIndexOperation(indexFile, operation) {
47
- const previous = indexOperations.get(indexFile) || Promise.resolve();
48
- const current = previous.catch(() => {}).then(operation);
49
- indexOperations.set(indexFile, current);
50
- try {
51
- return await current;
52
- } finally {
53
- if (indexOperations.get(indexFile) === current) indexOperations.delete(indexFile);
54
- }
55
- }
56
-
57
- async function syncParentDirectory(file) {
58
- let handle;
59
- try {
60
- handle = await open(path.dirname(file), "r");
61
- await handle.sync();
62
- } catch {
63
- // Some platforms do not support fsync on directories; rename remains atomic there.
64
- } finally {
65
- await handle?.close().catch(() => {});
66
- }
67
- }
68
-
69
- async function writeJsonAtomically(file, value) {
70
- await mkdir(path.dirname(file), { recursive: true });
71
- const temporary = `${file}.${process.pid}.${id()}.tmp`;
72
- let handle;
73
- try {
74
- handle = await open(temporary, "wx", 0o600);
75
- await handle.writeFile(`${JSON.stringify(value, null, 2)}\n`, "utf8");
76
- await handle.sync();
77
- await handle.close();
78
- handle = null;
79
- await rename(temporary, file);
80
- await syncParentDirectory(file);
81
- } catch (error) {
82
- await handle?.close().catch(() => {});
83
- await unlink(temporary).catch(() => {});
84
- throw error;
85
- }
86
- }
87
-
88
46
  class ChatArtifactStore {
89
47
  constructor(chatId) {
90
48
  this.chatId = String(chatId);
91
49
  this.rootDir = getChatArtifactsDir(this.chatId);
92
- this.indexFile = getChatArtifactsIndexFile(this.chatId);
93
- this.items = null;
94
- }
95
-
96
- async reload() {
97
- try {
98
- const parsed = JSON.parse(await readFile(this.indexFile, "utf8"));
99
- if (!Array.isArray(parsed)) throw new Error("Artifact index must contain a JSON array");
100
- this.items = parsed;
101
- } catch (error) {
102
- if (error?.code === "ENOENT") {
103
- this.items = [];
104
- return;
105
- }
106
- throw new Error(`Artifact index is unreadable: ${this.indexFile}`, { cause: error });
107
- }
50
+ this.index = {
51
+ chatId: this.chatId,
52
+ legacyFile: getChatArtifactsIndexFile(this.chatId),
53
+ databaseFile: getChatArtifactsDatabaseFile(this.chatId)
54
+ };
108
55
  }
109
56
 
110
57
  async init() {
111
58
  await mkdir(this.rootDir, { recursive: true });
112
- if (!this.items) await this.reload();
59
+ await withArtifactIndex(this.index, () => {});
113
60
  }
114
61
 
115
62
  async appendToIndex(artifact) {
116
- return serializeIndexOperation(this.indexFile, async () => {
117
- await this.reload();
118
- this.items.push(artifact);
119
- await writeJsonAtomically(this.indexFile, this.items);
120
- return artifact;
121
- });
122
- }
123
-
124
- async readIndex() {
125
- return serializeIndexOperation(this.indexFile, async () => {
126
- await this.reload();
127
- return this.items;
128
- });
63
+ return withArtifactIndex(this.index, (db) => appendArtifact(db, artifact));
129
64
  }
130
65
 
131
66
  async createText({ text, mimeType = "text/plain", source, metadata = {} }) {
132
- await this.init();
133
- await this.reload();
134
67
  const artifact = {
135
68
  id: id(),
136
69
  chatId: this.chatId,
@@ -146,7 +79,6 @@ class ChatArtifactStore {
146
79
 
147
80
  async createFileArtifact({ fileName, kind, mimeType, source, metadata = {}, writeFileContent }) {
148
81
  await this.init();
149
- await this.reload();
150
82
  const artifactId = id();
151
83
  const dir = path.join(this.rootDir, artifactId);
152
84
  await mkdir(dir, { recursive: true });
@@ -188,15 +120,14 @@ class ChatArtifactStore {
188
120
  }
189
121
 
190
122
  async get(artifactId) {
191
- await this.init();
192
- const items = await this.readIndex();
193
- return items.find((item) => item.id === artifactId) || null;
123
+ return withArtifactIndex(this.index, (db) => getArtifact(db, artifactId));
194
124
  }
195
125
 
196
126
  async listRecent(limit = 20) {
197
- await this.init();
198
- const items = await this.readIndex();
199
- return [...items].slice(-limit).reverse();
127
+ if (!Number.isSafeInteger(limit) || limit < 0 || limit > 1000) {
128
+ throw new RangeError("Artifact list limit must be an integer between 0 and 1000");
129
+ }
130
+ return withArtifactIndex(this.index, (db) => listRecentArtifacts(db, limit));
200
131
  }
201
132
  }
202
133
 
@@ -0,0 +1,46 @@
1
+ import { createReadStream } from "node:fs";
2
+
3
+ // The legacy format is a JSON array of objects. Retain only one object's bytes,
4
+ // not the complete index. JSON.parse validates each object's JSON grammar.
5
+ export async function* readLegacyArtifacts(file, { highWaterMark = 64 * 1024 } = {}) {
6
+ const stream = createReadStream(file, { encoding: "utf8", highWaterMark });
7
+ let state = "array";
8
+ let depth = 0;
9
+ let quoted = false;
10
+ let escaped = false;
11
+ let parts = [];
12
+ for await (const chunk of stream) {
13
+ let start = depth ? 0 : -1;
14
+ for (let i = 0; i < chunk.length; i++) {
15
+ const char = chunk[i];
16
+ if (depth) {
17
+ if (quoted) {
18
+ if (escaped) escaped = false;
19
+ else if (char === "\\") escaped = true;
20
+ else if (char === '"') quoted = false;
21
+ } else if (char === '"') quoted = true;
22
+ else if (char === "{" || char === "[") depth++;
23
+ else if (char === "}" || char === "]") depth--;
24
+ if (!depth) {
25
+ parts.push(chunk.slice(start, i + 1));
26
+ const value = JSON.parse(parts.join(""));
27
+ parts = [];
28
+ start = -1;
29
+ state = "separator";
30
+ yield value;
31
+ }
32
+ continue;
33
+ }
34
+ if (char === " " || char === "\n" || char === "\r" || char === "\t") continue;
35
+ if (state === "array" && char === "[") state = "first";
36
+ else if ((state === "first" || state === "separator") && char === "]") state = "done";
37
+ else if (state === "separator" && char === ",") state = "value";
38
+ else if ((state === "first" || state === "value") && char === "{") {
39
+ depth = 1;
40
+ start = i;
41
+ } else throw new Error("Invalid legacy artifact array");
42
+ }
43
+ if (start >= 0) parts.push(chunk.slice(start));
44
+ }
45
+ if (state !== "done" || depth) throw new Error("Truncated legacy artifact array");
46
+ }
@@ -12,6 +12,9 @@ export const daemonConfigDefaults = Object.freeze({
12
12
  startupTimeoutMs: 120_000,
13
13
  stopTimeoutMs: 3_000,
14
14
  queuePollIntervalMs: 250,
15
+ journalRetentionMs: 24 * 60 * 60_000,
16
+ journalMaxCompleted: 2_048,
17
+ journalSweepIntervalMs: 5 * 60_000,
15
18
  streamBufferBytes: 1_048_576,
16
19
  ipcFrameBytes: 1_048_576
17
20
  });
@@ -94,6 +97,7 @@ export const piConfigDefaults = Object.freeze({
94
97
  enabled: true,
95
98
  backgroundQueueTtlMs: 10 * 60_000,
96
99
  interactiveQueueTtlMs: 0,
100
+ interactiveQuietMs: 2_000,
97
101
  maxQueued: 100
98
102
  }),
99
103
  compaction: Object.freeze({