arisa 5.2.19 → 5.2.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LOW-MEMORY.md ADDED
@@ -0,0 +1,37 @@
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
+ ## Daemons
24
+
25
+ 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.
26
+
27
+ ## Verification
28
+
29
+ Run the test suite serially on 1 GiB hosts:
30
+
31
+ ```sh
32
+ node --test --test-concurrency=1
33
+ ```
34
+
35
+ `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.
36
+
37
+ 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.20",
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
+ }
@@ -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
+ }
package/src/index.js CHANGED
@@ -1,7 +1,5 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { bootstrapIfNeeded } from "./runtime/bootstrap.js";
4
- import { applyRuntimeOverrides, createApp } from "./runtime/create-app.js";
5
3
  import { loadConfig } from "./core/config/config-store.js";
6
4
  import { createLogger } from "./runtime/logger.js";
7
5
  import { getServiceStatus, handoffServiceRestart, registerServiceProcess, restartService, serviceEntryFile, startService, stopService, unregisterServiceProcess } from "./runtime/service-manager.js";
@@ -10,10 +8,8 @@ import { recordUnexpectedWorkerExit } from "./runtime/worker-recovery-report.js"
10
8
  import { flushArisaHome } from "./runtime/flush.js";
11
9
  import { readPackageVersion, showServiceLogs } from "./runtime/log-viewer.js";
12
10
  import { arisaPackageDir } from "./platform/paths.js";
13
- import { runSlaveCli } from "./runtime/slave-cli.js";
14
11
  import { unregisterSlaveServiceProcess } from "./runtime/slave-service.js";
15
12
  import { protectCoreFromOom } from "./runtime/oom-protection.js";
16
- import { runTui } from "./runtime/tui.js";
17
13
 
18
14
  process.env.ARISA_PACKAGE_DIR = arisaPackageDir;
19
15
 
@@ -128,7 +124,13 @@ process.once("SIGINT", () => {
128
124
  shutdown(0);
129
125
  });
130
126
 
127
+ async function bootstrapIfNeeded(options) {
128
+ const bootstrap = await import("./runtime/bootstrap.js");
129
+ return bootstrap.bootstrapIfNeeded(options);
130
+ }
131
+
131
132
  async function startRuntimeApp() {
133
+ const { createApp } = await import("./runtime/create-app.js");
132
134
  const app = await createApp({
133
135
  logger,
134
136
  runtimeOverrides,
@@ -142,6 +144,7 @@ async function startRuntimeApp() {
142
144
  }
143
145
 
144
146
  async function startBackgroundService() {
147
+ const { applyRuntimeOverrides } = await import("./runtime/create-app.js");
145
148
  const persistedConfig = await loadConfig();
146
149
  applyRuntimeOverrides(persistedConfig, runtimeOverrides);
147
150
  const result = await startService({ verbose, cliArgs: toServiceRunnerArgs(cli.nestedFlags) });
@@ -155,6 +158,7 @@ async function startBackgroundService() {
155
158
  }
156
159
 
157
160
  async function restartBackgroundService() {
161
+ const { applyRuntimeOverrides } = await import("./runtime/create-app.js");
158
162
  const persistedConfig = await loadConfig();
159
163
  applyRuntimeOverrides(persistedConfig, runtimeOverrides);
160
164
  const result = await restartService({
@@ -224,6 +228,7 @@ async function runForeground() {
224
228
 
225
229
  async function main() {
226
230
  if (slaveCommand) {
231
+ const { runSlaveCli } = await import("./runtime/slave-cli.js");
227
232
  const result = await runSlaveCli({
228
233
  positionals: cli.positionals.slice(1),
229
234
  flags: cli.flags,
@@ -265,6 +270,7 @@ async function main() {
265
270
  }
266
271
 
267
272
  if (command === "tui") {
273
+ const { runTui } = await import("./runtime/tui.js");
268
274
  await runTui({ logger });
269
275
  return;
270
276
  }
@@ -46,10 +46,15 @@ export function getChatArtifactsDir(chatId) {
46
46
  return path.join(getChatDir(chatId), "artifacts");
47
47
  }
48
48
 
49
+ // Legacy JSON index; retained unchanged as a migration backup.
49
50
  export function getChatArtifactsIndexFile(chatId) {
50
51
  return path.join(getChatDir(chatId), "state", "artifacts.json");
51
52
  }
52
53
 
54
+ export function getChatArtifactsDatabaseFile(chatId) {
55
+ return path.join(getChatDir(chatId), "state", "artifacts.sqlite");
56
+ }
57
+
53
58
  export function getChatSessionSeedFile(chatId) {
54
59
  return path.join(getChatDir(chatId), "state", "session-seed.jsonl");
55
60
  }
@@ -1,7 +1,7 @@
1
1
  import { getErrorMessage } from "../../core/agent/auth-flow.js";
2
2
  import { resolveChatModel, resolveChatSpeed, resolveChatThinkingLevel } from "../../core/agent/model-selection.js";
3
3
  import { clampModelThinkingLevel, listModelThinkingLevels, modelSupportsThinking } from "../../core/agent/pi-runtime.js";
4
- import { modelSupportsSpeed } from "../../core/agent/model-speed.js";
4
+ import { clampModelSpeed, modelSupportsSpeed } from "../../core/agent/model-speed.js";
5
5
  import { parseEffortPickerAction, parseModelPickerAction, parseSpeedPickerAction } from "./model-picker.js";
6
6
 
7
7
  export async function closeModelPicker(ctx, { messageText, callbackText }) {
@@ -181,9 +181,10 @@ export function createTelegramModelCallbackHandler({
181
181
  return;
182
182
  }
183
183
  if (!modelSupportsSpeed(model)) {
184
- await ctx.answerCallbackQuery({ text: "This model does not support speed 1.5x.", show_alert: true });
184
+ await ctx.answerCallbackQuery({ text: "This model does not support fast mode.", show_alert: true });
185
185
  return;
186
186
  }
187
+ action.speed = clampModelSpeed(model, action.speed);
187
188
  const currentSpeed = resolveChatSpeed(config, modelChatId);
188
189
  if (action.speed === currentSpeed) {
189
190
  await closeModelPicker(ctx, {
@@ -8,7 +8,7 @@ import {
8
8
  selectChatThinkingLevel
9
9
  } from "../../core/agent/model-selection.js";
10
10
  import { clampModelThinkingLevel, createPiRuntime, listModelThinkingLevels, listProviderModels, modelSupportsThinking } from "../../core/agent/pi-runtime.js";
11
- import { clampModelSpeed, MODEL_SPEEDS, modelSupportsSpeed } from "../../core/agent/model-speed.js";
11
+ import { clampModelSpeed, listModelSpeeds, modelSupportsSpeed } from "../../core/agent/model-speed.js";
12
12
  import { buildEffortPicker, buildModelPicker, buildSpeedPicker, reverseModelOrder } from "./model-picker.js";
13
13
 
14
14
  function chatKey(chatId) {
@@ -89,12 +89,12 @@ export function createTelegramModelControls({ config, saveConfig, agentManager,
89
89
  const model = models.find((item) => item.id === resolveChatModel(config, route.sessionId));
90
90
  if (!model) throw new Error(`Model not found for provider ${agentConfig.provider}`);
91
91
  if (!modelSupportsSpeed(model)) {
92
- return editOrReplyText(ctx, `${model.provider}/${model.id} does not support speed 1.5x.`);
92
+ return editOrReplyText(ctx, `${model.provider}/${model.id} does not support fast mode.`);
93
93
  }
94
94
  const picker = buildSpeedPicker({
95
95
  provider: model.provider,
96
96
  modelId: model.id,
97
- speeds: MODEL_SPEEDS,
97
+ speeds: listModelSpeeds(model),
98
98
  selectedSpeed: resolveChatSpeed(config, route.sessionId)
99
99
  });
100
100
  return editOrReplyPicker(ctx, picker);
@@ -37,7 +37,7 @@ export function parseEffortPickerAction(data) {
37
37
 
38
38
  export function parseSpeedPickerAction(data) {
39
39
  if (data === "noop:page") return { type: "noop", value: null };
40
- const speed = /^speed:(1(?:\.5)?)$/.exec(String(data || ""));
40
+ const speed = /^speed:(1(?:\.5)?|2)$/.exec(String(data || ""));
41
41
  return speed ? { type: "speed", speed: Number(speed[1]) } : null;
42
42
  }
43
43
 
@@ -0,0 +1,46 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtemp, open, rm } from "node:fs/promises";
3
+ import { spawn } from "node:child_process";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import test from "node:test";
7
+
8
+ test("migrates a 100 MiB history and repeatedly accesses it with a 48 MiB heap", { timeout: 120_000 }, async (t) => {
9
+ const root = await mkdtemp(path.join(os.tmpdir(), "artifact-memory-"));
10
+ t.after(() => rm(root, { recursive: true, force: true }));
11
+ const options = {chatId:"test",legacyFile:path.join(root,"artifacts.json"),databaseFile:path.join(root,"artifacts.sqlite")};
12
+ const file = await open(options.legacyFile, "wx", 0o600);
13
+ try {
14
+ await file.write("[");
15
+ for (let i = 0; i < 1024; i++) {
16
+ await file.write((i ? "," : "") + JSON.stringify({id:String(i),chatId:"test",text:"x".repeat(100*1024)}));
17
+ }
18
+ await file.write("]");
19
+ } finally { await file.close(); }
20
+ const moduleUrl = new URL("../src/core/artifacts/artifact-index.js", import.meta.url).href;
21
+ const code = `
22
+ import assert from 'node:assert/strict';
23
+ import {withArtifactIndex,getArtifact,appendArtifact,listRecentArtifacts} from ${JSON.stringify(moduleUrl)};
24
+ const f=${JSON.stringify(options)};
25
+ const start=performance.now();
26
+ await withArtifactIndex(f,db=>assert.equal(getArtifact(db,'0').text.length,102400));
27
+ const migrated=performance.now();
28
+ for(let i=0;i<100;i++) {
29
+ await withArtifactIndex(f,db=>appendArtifact(db,{id:'new-'+i,chatId:'test',text:'small'}));
30
+ await withArtifactIndex(f,db=>assert.equal(getArtifact(db,String(i)).text.length,102400));
31
+ await withArtifactIndex(f,db=>assert.equal(listRecentArtifacts(db,20).length,20));
32
+ }
33
+ await withArtifactIndex(f,db=>assert.equal(db.prepare('SELECT count(*) AS n FROM artifacts').get().n,1124));
34
+ console.log(JSON.stringify({migrationMs:Math.round(migrated-start),operationsMs:Math.round(performance.now()-migrated),maxRssKiB:process.resourceUsage().maxRSS,heapMiB:Math.round(process.memoryUsage().heapUsed/1048576)}));
35
+ `;
36
+ const result = await new Promise((resolve, reject) => {
37
+ const child = spawn(process.execPath, ["--max-old-space-size=48", "--input-type=module", "-e", code], {stdio:["ignore","pipe","pipe"]});
38
+ let stdout = "", stderr = "";
39
+ child.stdout.on("data", data => {stdout += data;});
40
+ child.stderr.on("data", data => {stderr += data;});
41
+ child.on("error", reject);
42
+ child.on("exit", code => code === 0 ? resolve(JSON.parse(stdout)) : reject(new Error(stderr)));
43
+ });
44
+ t.diagnostic(JSON.stringify(result));
45
+ assert.ok(result.heapMiB < 48);
46
+ });
@@ -0,0 +1,88 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtemp, writeFile, readFile, rm, stat } from "node:fs/promises";
3
+ import { spawn } from "node:child_process";
4
+ import { DatabaseSync } from "node:sqlite";
5
+ import os from "node:os";
6
+ import path from "node:path";
7
+ import test from "node:test";
8
+ import { readLegacyArtifacts } from "../src/core/artifacts/legacy-artifact-reader.js";
9
+ import { withArtifactIndex, getArtifact, appendArtifact, listRecentArtifacts } from "../src/core/artifacts/artifact-index.js";
10
+
11
+ async function fixture(t) {
12
+ const root = await mkdtemp(path.join(os.tmpdir(), "artifact-migration-"));
13
+ t.after(() => rm(root, { recursive: true, force: true }));
14
+ return { chatId: "test", legacyFile: path.join(root, "artifacts.json"), databaseFile: path.join(root, "artifacts.sqlite") };
15
+ }
16
+
17
+ const artifacts = [
18
+ { id: "a", chatId: "test", kind: "text", text: 'ñ🙂[\\\"}]\n', metadata: { nested: [{ x: true }] } },
19
+ { id: "b", chatId: "test", kind: "document", path: "/unchanged/report.pdf", source: { type: "test" } }
20
+ ];
21
+
22
+ test("legacy parser handles UTF-8, escaping and every token crossing chunk boundaries", async (t) => {
23
+ const f = await fixture(t);
24
+ await writeFile(f.legacyFile, JSON.stringify(artifacts, null, 2));
25
+ for (const highWaterMark of [1, 2, 7, 64]) {
26
+ const result = [];
27
+ for await (const item of readLegacyArtifacts(f.legacyFile, { highWaterMark })) result.push(item);
28
+ assert.deepEqual(result, artifacts);
29
+ }
30
+ });
31
+
32
+ test("migration preserves all fields, order and original bytes; runs only once", async (t) => {
33
+ const f = await fixture(t);
34
+ const original = JSON.stringify(artifacts, null, 2);
35
+ await writeFile(f.legacyFile, original);
36
+ assert.deepEqual(await withArtifactIndex(f, db => listRecentArtifacts(db, 20)), artifacts.toReversed());
37
+ assert.equal(await readFile(f.legacyFile, "utf8"), original);
38
+ const next = { id: "c", chatId: "test", text: "new" };
39
+ await withArtifactIndex(f, db => appendArtifact(db, next));
40
+ assert.deepEqual(await withArtifactIndex(f, db => getArtifact(db, "a")), artifacts[0]);
41
+ assert.deepEqual(await withArtifactIndex(f, db => listRecentArtifacts(db, 20)), [next, ...artifacts.toReversed()]);
42
+ assert.equal((await stat(f.databaseFile)).mode & 0o777, 0o600);
43
+ const db = new DatabaseSync(f.databaseFile);
44
+ assert.equal(db.prepare("PRAGMA integrity_check").get().integrity_check, "ok");
45
+ db.close();
46
+ });
47
+
48
+ test("invalid migrations roll back completely and can be retried after repair", async (t) => {
49
+ const f = await fixture(t);
50
+ for (const broken of ['{}', '[', '[{}]', '[null]', '[1]', '[{"id":"a","chatId":"test"},]', JSON.stringify(artifacts).slice(0, -1), JSON.stringify(artifacts) + 'x', JSON.stringify([artifacts[0], artifacts[0]]), JSON.stringify([{...artifacts[0],chatId:"other"}])]) {
51
+ await writeFile(f.legacyFile, broken);
52
+ await assert.rejects(withArtifactIndex(f, () => {}), /Artifact index is unreadable/);
53
+ assert.equal(await readFile(f.legacyFile, "utf8"), broken);
54
+ const db = new DatabaseSync(f.databaseFile);
55
+ assert.equal(db.prepare("PRAGMA user_version").get().user_version, 0);
56
+ assert.equal(db.prepare("SELECT count(*) AS n FROM sqlite_master WHERE name='artifacts'").get().n, 0);
57
+ db.close();
58
+ }
59
+ await writeFile(f.legacyFile, JSON.stringify(artifacts));
60
+ assert.deepEqual(await withArtifactIndex(f, db => getArtifact(db, "a")), artifacts[0]);
61
+ });
62
+
63
+ test("separate processes migrate and append without lost writes", async (t) => {
64
+ const f = await fixture(t);
65
+ await writeFile(f.legacyFile, JSON.stringify(artifacts));
66
+ const moduleUrl = new URL("../src/core/artifacts/artifact-index.js", import.meta.url).href;
67
+ await Promise.all(Array.from({length: 3}, (_, n) => new Promise((resolve, reject) => {
68
+ const child = spawn(process.execPath, ["--input-type=module", "-e", `
69
+ import {withArtifactIndex,appendArtifact} from ${JSON.stringify(moduleUrl)};
70
+ for(let i=0;i<20;i++) await withArtifactIndex(${JSON.stringify(f)}, db => appendArtifact(db, {id:'${n}-'+i,chatId:'test',text:'ok'}));
71
+ `], {stdio:["ignore","ignore","pipe"]});
72
+ let stderr = "";
73
+ child.stderr.on("data", data => {stderr += data;});
74
+ child.on("error", reject);
75
+ child.on("exit", code => code === 0 ? resolve() : reject(new Error(stderr)));
76
+ })));
77
+ assert.equal((await withArtifactIndex(f, db => listRecentArtifacts(db, 100))).length, 62);
78
+ });
79
+
80
+ test("recent queries reject oversized results instead of materializing the whole selection", async (t) => {
81
+ const f = await fixture(t);
82
+ await withArtifactIndex(f, db => {
83
+ for (let i = 0; i < 20; i++) appendArtifact(db, {id: String(i), chatId: 'test', text: 'x'.repeat(1024 * 1024)});
84
+ });
85
+ await assert.rejects(withArtifactIndex(f, db => listRecentArtifacts(db, 20)), /exceed 16 MiB/);
86
+ assert.equal((await withArtifactIndex(f, db => listRecentArtifacts(db, 2))).length, 2);
87
+ assert.deepEqual(await withArtifactIndex(f, db => listRecentArtifacts(db, 0)), []);
88
+ });
@@ -1,5 +1,5 @@
1
1
  import assert from "node:assert/strict";
2
- import { mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
2
+ import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
5
  import test from "node:test";
@@ -57,7 +57,7 @@ test("serializes 100 concurrent artifact writes across store instances", async (
57
57
  })
58
58
  )));
59
59
 
60
- const persisted = JSON.parse(await readFile(getChatArtifactsIndexFile(chatId), "utf8"));
60
+ const persisted = await new ArtifactStore().forChat(chatId).listRecent(100);
61
61
  assert.equal(persisted.length, 100);
62
62
  assert.equal(new Set(persisted.map((artifact) => artifact.id)).size, 100);
63
63
  assert.deepEqual(
@@ -72,7 +72,7 @@ test("refuses to overwrite a corrupt artifact index", async () => {
72
72
  await resetHome();
73
73
  const chatId = "corrupt-chat";
74
74
  const indexFile = getChatArtifactsIndexFile(chatId);
75
- await new ArtifactStore().forChat(chatId).createText({ text: "safe", source: { type: "test" } });
75
+ await mkdir(path.dirname(indexFile), { recursive: true });
76
76
  await writeFile(indexFile, "{truncated", "utf8");
77
77
 
78
78
  await assert.rejects(
@@ -0,0 +1,22 @@
1
+ import assert from "node:assert/strict";
2
+ import { execFile } from "node:child_process";
3
+ import { mkdtemp, rm, readFile } from "node:fs/promises";
4
+ import { promisify } from "node:util";
5
+ import os from "node:os";
6
+ import path from "node:path";
7
+ import test from "node:test";
8
+
9
+ const exec = promisify(execFile);
10
+
11
+ test("status runs with a 24 MiB heap without loading the agent, SQLite or TUI", async (t) => {
12
+ const home = await mkdtemp(path.join(os.tmpdir(), "arisa-cli-memory-"));
13
+ t.after(() => rm(home, { recursive: true, force: true }));
14
+ const entry = new URL("../src/index.js", import.meta.url);
15
+ const {stdout, stderr} = await exec(process.execPath, ["--max-old-space-size=24", entry.pathname, "status"], {
16
+ env: {...process.env, ARISA_HOME: home}, timeout: 15_000
17
+ });
18
+ assert.match(stdout, /Arisa is not running/);
19
+ assert.equal(stderr, "");
20
+ const source = await readFile(entry, "utf8");
21
+ assert.doesNotMatch(source, /^import .*from .*runtime\/(create-app|bootstrap|tui|slave-cli)\.js/m);
22
+ });
@@ -197,7 +197,7 @@ test("builds and parses the speed picker", () => {
197
197
  assert.match(picker.replyMarkup.inline_keyboard[1][0].text, /^✓ 1\.5x$/);
198
198
  assert.deepEqual(parseSpeedPickerAction("speed:1.5"), { type: "speed", speed: 1.5 });
199
199
  assert.deepEqual(parseSpeedPickerAction("speed:1"), { type: "speed", speed: 1 });
200
- assert.equal(parseSpeedPickerAction("speed:2"), null);
200
+ assert.equal(parseSpeedPickerAction("speed:3"), null);
201
201
  });
202
202
 
203
203
  test("closes the picker after selecting the already active model and effort", async () => {
@@ -350,7 +350,16 @@ test("maps supported model speeds to provider service tiers", () => {
350
350
  assert.equal(clampModelSpeed({ ...fastModel, id: "gpt-5.3" }, 1.5), 1);
351
351
  assert.equal(speedToServiceTier(1), "default");
352
352
  assert.equal(speedToServiceTier(1.5), "priority");
353
- assert.throws(() => normalizeModelSpeed(2), /Invalid model speed/);
353
+ assert.equal(normalizeModelSpeed(2), 2);
354
+ assert.equal(speedToServiceTier(2), "priority");
355
+ const legacyConfig = { pi: { provider: "openai-codex", model: "gpt-6-astra", speed: 1.5 } };
356
+ assert.equal(resolveChatSpeed(legacyConfig, "legacy"), 2);
357
+ assert.equal(legacyConfig.pi.speed, 1.5);
358
+ assert.equal(clampModelSpeed({ ...fastModel, id: "gpt-6-astra" }, 1.5), 2);
359
+ assert.equal(clampModelSpeed({ ...fastModel, id: "gpt-6-astra" }, 2), 2);
360
+ assert.equal(clampModelSpeed(fastModel, 2), 1.5);
361
+ assert.deepEqual(parseSpeedPickerAction("speed:2"), { type: "speed", speed: 2 });
362
+ assert.throws(() => normalizeModelSpeed(3), /Invalid model speed/);
354
363
  });
355
364
 
356
365
  test("applies Pi speed to every provider request and updates it in place", async () => {
@@ -9,6 +9,7 @@ import {
9
9
  chatsDir,
10
10
  createIpcSocketPath,
11
11
  getChatArtifactsDir,
12
+ getChatArtifactsDatabaseFile,
12
13
  getChatSessionSeedFile,
13
14
  getChatTelegramWorkspacesFile,
14
15
  getChatToolConfigPath,
@@ -31,6 +32,7 @@ test("keeps chat artifact paths scoped below the chat directory", () => {
31
32
  const artifactsDir = getChatArtifactsDir("chat-1");
32
33
 
33
34
  assert.equal(artifactsDir, path.join(chatsDir, "chat-1", "artifacts"));
35
+ assert.equal(getChatArtifactsDatabaseFile("chat-1"), path.join(chatsDir, "chat-1", "state", "artifacts.sqlite"));
34
36
  });
35
37
 
36
38
  test("keeps pending session seeds scoped below the chat state directory", () => {
@@ -45,7 +45,7 @@ test("speed picker updates Astra in place, persists per topic, and closes unchan
45
45
  answerCallbackQuery: async (answer) => answers.push(answer)
46
46
  };
47
47
  await controls.showSpeedPicker(ctx);
48
- assert.equal(replies[0][1]?.reply_markup.inline_keyboard[1][0].callback_data, "speed:1.5");
48
+ assert.equal(replies[0][1]?.reply_markup.inline_keyboard[1][0].callback_data, "speed:2");
49
49
  const handler = createTelegramModelCallbackHandler({
50
50
  ...controls, config,
51
51
  authorizeContext: async () => ({ ok: true }),
@@ -54,13 +54,14 @@ test("speed picker updates Astra in place, persists per topic, and closes unchan
54
54
  });
55
55
  ctx.callbackQuery = { data: "speed:1.5", message: { message_id: 456 } };
56
56
  await handler(ctx);
57
- assert.deepEqual(updates, [["123:topic:7", 1.5]]);
58
- assert.equal(resolveChatSpeed(writes[0], "123:topic:7"), 1.5);
57
+ assert.deepEqual(updates, [["123:topic:7", 2]]);
58
+ assert.equal(resolveChatSpeed(writes[0], "123:topic:7"), 2);
59
59
  assert.equal(resolveChatSpeed(config, "123:topic:8"), 1);
60
60
  assert.equal(resolveChatModelSelection(config, "123:topic:7").sessionRevision, 0);
61
+ ctx.callbackQuery.data = "speed:2";
61
62
  await handler(ctx);
62
63
  assert.equal(writes.length, 1);
63
- assert.match(replies.at(-1)[2], /Already using speed 1.5x/);
64
+ assert.match(replies.at(-1)[2], /Already using speed 2.0x/);
64
65
  ctx.callbackQuery.data = "speed:1";
65
66
  await handler(ctx);
66
67
  assert.equal(resolveChatSpeed(config, "123:topic:7"), 1);
@@ -99,15 +100,15 @@ test("Pi SDK sends the selected speed in the actual Codex payload across turns",
99
100
  ...options, transport: "sse", maxRetries: 0
100
101
  }), 1);
101
102
  session.agent.streamFunction = controller.streamFn;
102
- for (const speed of [1, 1.5, 1]) {
103
+ for (const speed of [1, 1.5, 2, 1]) {
103
104
  controller.setSpeed(speed);
104
105
  await session.prompt("Reply OK");
105
106
  const message = session.messages.at(-1);
106
107
  assert.equal(message.stopReason, "stop", message.errorMessage);
107
108
  assert.equal(requests.at(-1).model, model.id);
108
- assert.equal(requests.at(-1).service_tier, speed === 1.5 ? "priority" : "default");
109
+ assert.equal(requests.at(-1).service_tier, speed > 1 ? "priority" : "default");
109
110
  }
110
- assert.equal(requests.length, 3);
111
+ assert.equal(requests.length, 4);
111
112
  });
112
113
 
113
114
  test("speed control leaves unsupported provider payloads and hooks untouched", async () => {
@@ -148,7 +149,7 @@ test("Arisa creates and reuses Telegram sessions and opens its TUI with the inst
148
149
  try {
149
150
  assert.equal(context.session.model.id, "gpt-6-astra");
150
151
  assert.equal(context.session.agent.streamFunction, context.speedController.streamFn);
151
- assert.equal(context.speedController.speed, 1.5);
152
+ assert.equal(context.speedController.speed, 2);
152
153
  await manager.setModelSpeed("123", 1);
153
154
  selectChatSpeed(config, "123", 1);
154
155
  const reused = await manager.getSessionContext("123", {});