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 +37 -0
- package/README.md +2 -1
- package/package.json +7 -8
- package/src/core/agent/model-selection.js +4 -3
- package/src/core/agent/model-speed.js +11 -3
- package/src/core/artifacts/artifact-index.js +107 -0
- package/src/core/artifacts/artifact-store.js +15 -84
- package/src/core/artifacts/legacy-artifact-reader.js +46 -0
- package/src/index.js +10 -4
- package/src/platform/paths.js +5 -0
- package/src/transport/telegram/model-callback.js +3 -2
- package/src/transport/telegram/model-controls.js +3 -3
- package/src/transport/telegram/model-picker.js +1 -1
- package/test/artifact-index-memory.test.js +46 -0
- package/test/artifact-index-migration.test.js +88 -0
- package/test/artifact-store.test.js +3 -3
- package/test/cli-memory.test.js +22 -0
- package/test/model-selection.test.js +11 -2
- package/test/paths.test.js +2 -0
- package/test/pi-speed-integration.test.js +9 -8
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.
|
|
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)
|
|
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
|
-
|
|
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
|
|
34
|
+
return normalized > 1 && modelSupportsSpeed(model) ? modelFastSpeed(model.id) : 1;
|
|
27
35
|
}
|
|
28
36
|
|
|
29
37
|
export function speedToServiceTier(speed) {
|
|
30
|
-
return normalizeModelSpeed(speed)
|
|
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,
|
|
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.
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
59
|
+
await withArtifactIndex(this.index, () => {});
|
|
113
60
|
}
|
|
114
61
|
|
|
115
62
|
async appendToIndex(artifact) {
|
|
116
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
}
|
package/src/platform/paths.js
CHANGED
|
@@ -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
|
|
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,
|
|
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
|
|
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:
|
|
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)
|
|
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 =
|
|
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
|
|
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:
|
|
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.
|
|
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 () => {
|
package/test/paths.test.js
CHANGED
|
@@ -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:
|
|
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",
|
|
58
|
-
assert.equal(resolveChatSpeed(writes[0], "123:topic:7"),
|
|
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
|
|
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
|
|
109
|
+
assert.equal(requests.at(-1).service_tier, speed > 1 ? "priority" : "default");
|
|
109
110
|
}
|
|
110
|
-
assert.equal(requests.length,
|
|
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,
|
|
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", {});
|