arisa 5.2.17 → 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.
Files changed (47) hide show
  1. package/LOW-MEMORY.md +37 -0
  2. package/README.md +6 -3
  3. package/package.json +3 -3
  4. package/pnpm-workspace.yaml +7 -5
  5. package/src/core/agent/agent-manager.js +13 -14
  6. package/src/core/agent/auth-flow.js +6 -6
  7. package/src/core/agent/model-selection.js +4 -3
  8. package/src/core/agent/model-speed.js +13 -3
  9. package/src/core/agent/pi-auth-login.js +28 -28
  10. package/src/core/agent/pi-capability-tools.js +1 -0
  11. package/src/core/agent/pi-runtime.js +14 -21
  12. package/src/core/artifacts/artifact-index.js +107 -0
  13. package/src/core/artifacts/artifact-store.js +15 -84
  14. package/src/core/artifacts/legacy-artifact-reader.js +46 -0
  15. package/src/core/capabilities/capability-service.js +25 -0
  16. package/src/core/tasks/task-store.js +2 -1
  17. package/src/index.js +10 -4
  18. package/src/official-tools.lock.json +25 -18
  19. package/src/platform/paths.js +5 -0
  20. package/src/runtime/bootstrap-cli.js +3 -3
  21. package/src/runtime/bootstrap-telegram.js +7 -7
  22. package/src/runtime/slave-cli.js +20 -10
  23. package/src/runtime/slave-service.js +299 -7
  24. package/src/runtime/tui.js +5 -6
  25. package/src/transport/telegram/bot.js +9 -5
  26. package/src/transport/telegram/model-callback.js +3 -2
  27. package/src/transport/telegram/model-controls.js +4 -4
  28. package/src/transport/telegram/model-picker.js +1 -1
  29. package/src/transport/telegram/task-dispatcher.js +50 -23
  30. package/src/transport/telegram/telegram-auth-controller.js +7 -7
  31. package/src/transport/telegram/telegram-session-bridge.js +2 -1
  32. package/test/agent-turn-coordinator.test.js +5 -2
  33. package/test/artifact-index-memory.test.js +46 -0
  34. package/test/artifact-index-migration.test.js +88 -0
  35. package/test/artifact-store.test.js +3 -3
  36. package/test/auth-flow.test.js +2 -2
  37. package/test/capabilities-security.test.js +36 -0
  38. package/test/cli-memory.test.js +22 -0
  39. package/test/model-selection.test.js +14 -4
  40. package/test/official-tool-installer.test.js +13 -0
  41. package/test/paths.test.js +2 -0
  42. package/test/pi-auth-login.test.js +78 -0
  43. package/test/pi-capability-tools.test.js +3 -0
  44. package/test/pi-speed-integration.test.js +177 -0
  45. package/test/slave-cli.test.js +221 -5
  46. package/test/task-store.test.js +3 -1
  47. package/test/telegram-task-dispatcher.test.js +57 -2
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
@@ -127,8 +128,10 @@ Slave runs only the IPC host, daemon supervisor and installed tools. Connections
127
128
  are authenticated, encrypted, initiated by Slave and restricted by per-Slave
128
129
  roots and capability grants.
129
130
 
130
- Linux with systemd is the first supported Slave target. Bootstrap uses a
131
- single-use URL issued by Master:
131
+ Arisa Slave supports Linux with systemd, macOS with launchd, and Windows with
132
+ Task Scheduler. On desktop systems, run bootstrap as the normal user so the
133
+ persistent background task retains access to that user's browser profile.
134
+ Bootstrap uses a single-use URL issued by Master:
132
135
 
133
136
  ```bash
134
137
  npm i -g arisa && arisa slave tcp://198.51.100.12:4719/arisa_secret_v1_<secret>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arisa",
3
- "version": "5.2.17",
3
+ "version": "5.2.20",
4
4
  "description": "Telegram + Pi Agent modular assistant",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -29,7 +29,7 @@
29
29
  "author": "Martin Clasen",
30
30
  "license": "MIT",
31
31
  "engines": {
32
- "node": ">=22.8.0"
32
+ "node": ">=22.19.0"
33
33
  },
34
34
  "repository": {
35
35
  "type": "git",
@@ -40,7 +40,7 @@
40
40
  },
41
41
  "homepage": "https://arisa.sh",
42
42
  "dependencies": {
43
- "@earendil-works/pi-coding-agent": "0.80.6",
43
+ "@earendil-works/pi-coding-agent": "0.85.1",
44
44
  "@sinclair/typebox": "^0.34.41",
45
45
  "grammy": "^1.42.0"
46
46
  },
@@ -1,11 +1,13 @@
1
1
  allowBuilds:
2
2
  '@google/genai': false
3
+ esbuild: true
3
4
 
4
5
  minimumReleaseAgeExclude:
5
- - '@earendil-works/pi-agent-core@0.80.6'
6
- - '@earendil-works/pi-ai@0.80.6'
7
- - '@earendil-works/pi-coding-agent@0.80.6'
8
- - '@earendil-works/pi-tui@0.80.6'
6
+ - '@earendil-works/pi-agent-core@0.85.1'
7
+ - '@earendil-works/pi-ai@0.85.1'
8
+ - '@earendil-works/pi-coding-agent@0.85.1'
9
+ - '@earendil-works/pi-tui@0.85.1'
10
+ - '@earendil-works/chord@0.85.1'
9
11
 
10
12
  overrides:
11
13
  'node-fetch@2.7.0>whatwg-url': '13.0.0'
@@ -14,7 +16,7 @@ overrides:
14
16
  basic-ftp@<=5.2.2: '>=5.3.0'
15
17
  basic-ftp@<=5.3.0: '>=5.3.1'
16
18
  basic-ftp@=5.2.0: '>=5.2.1'
17
- brace-expansion@>=5.0.0 <5.0.6: '>=5.0.6'
19
+ brace-expansion@>=4.0.0 <5.0.9: '5.0.9'
18
20
  ip-address@<=10.1.0: '>=10.1.1'
19
21
  protobufjs@<7.5.5: '>=7.5.5'
20
22
  protobufjs@<=7.5.5: '>=7.5.6'
@@ -323,22 +323,21 @@ export class AgentManager {
323
323
 
324
324
  async validatePiAgent(config = this.config) {
325
325
  this.logger?.log("agent", "validating Pi session");
326
- const { authStorage, modelRegistry } = createPiRuntime({
326
+ const modelRuntime = await createPiRuntime({
327
327
  provider: config.pi.provider,
328
328
  apiKey: config.pi.apiKey
329
329
  });
330
- const model = modelRegistry.find(config.pi.provider, config.pi.model);
330
+ const model = modelRuntime.getModel(config.pi.provider, config.pi.model);
331
331
  if (!model) {
332
332
  throw new Error(`Model not found: ${config.pi.provider}/${config.pi.model}`);
333
333
  }
334
- if (requiresProviderAuth(model) && !config.pi.apiKey && !hasProviderAuth(config.pi.provider, { authStorage, modelRegistry })) {
334
+ if (requiresProviderAuth(model) && !config.pi.apiKey && !hasProviderAuth(config.pi.provider, modelRuntime)) {
335
335
  throw new Error(`No auth found for ${config.pi.provider}. Provide a Pi API key in bootstrap, or authenticate with Pi login for this provider during bootstrap.`);
336
336
  }
337
337
 
338
338
  const settingsManager = createPiSettingsManager(config);
339
339
  const { session } = await createAgentSession({
340
- authStorage,
341
- modelRegistry,
340
+ modelRuntime,
342
341
  model,
343
342
  settingsManager,
344
343
  sessionManager: SessionManager.inMemory()
@@ -386,13 +385,13 @@ export class AgentManager {
386
385
  this.pendingNewSessions.add(sessionKey);
387
386
  }
388
387
 
389
- const { authStorage, modelRegistry } = createPiRuntime({
388
+ const modelRuntime = await createPiRuntime({
390
389
  provider: this.config.pi.provider,
391
390
  apiKey: this.config.pi.apiKey
392
391
  });
393
- const model = modelRegistry.find(this.config.pi.provider, effectiveModelId);
392
+ const model = modelRuntime.getModel(this.config.pi.provider, effectiveModelId);
394
393
  if (!model) throw new Error(`Model not found: ${this.config.pi.provider}/${effectiveModelId}`);
395
- if (requiresProviderAuth(model) && !this.config.pi.apiKey && !hasProviderAuth(this.config.pi.provider, { authStorage, modelRegistry })) {
394
+ if (requiresProviderAuth(model) && !this.config.pi.apiKey && !hasProviderAuth(this.config.pi.provider, modelRuntime)) {
396
395
  throw new Error(`No auth found for ${this.config.pi.provider}. Re-run bootstrap and complete login for this provider before Telegram starts.`);
397
396
  }
398
397
  const thinkingLevel = clampModelThinkingLevel(model, modelSelection.thinkingLevel);
@@ -418,7 +417,8 @@ export class AgentManager {
418
417
  initializeForumTopic: (...args) => telegramTarget.current.initializeForumTopic(...args),
419
418
  prepareRestartReceipt: (...args) => telegramTarget.current.prepareRestartReceipt(...args),
420
419
  cancelRestartReceipt: (...args) => telegramTarget.current.cancelRestartReceipt(...args),
421
- getTaskContext: (...args) => telegramTarget.current.getTaskContext?.(...args) || null
420
+ getTaskContext: (...args) => telegramTarget.current.getTaskContext?.(...args) || null,
421
+ getAgentTaskExecution: (...args) => telegramTarget.current.getAgentTaskExecution?.(...args) || null
422
422
  };
423
423
  const assertAccess = () => accessGuardTarget.current();
424
424
  const customTools = guardTools([
@@ -440,8 +440,7 @@ export class AgentManager {
440
440
  cwd: policy.workspaceDir,
441
441
  agentDir: arisaHomeDir,
442
442
  resourceLoader,
443
- authStorage,
444
- modelRegistry,
443
+ modelRuntime,
445
444
  model,
446
445
  thinkingLevel,
447
446
  tools: policy.tools,
@@ -450,8 +449,8 @@ export class AgentManager {
450
449
  settingsManager,
451
450
  sessionManager
452
451
  });
453
- const speedController = createModelSpeedController(session.agent.streamFn, speed);
454
- session.agent.streamFn = speedController.streamFn;
452
+ const speedController = createModelSpeedController(session.agent.streamFunction, speed);
453
+ session.agent.streamFunction = speedController.streamFn;
455
454
 
456
455
  if (!hasExistingSession) {
457
456
  this.logger?.log("agent", `created new session for chat ${sessionKey}`);
@@ -479,7 +478,7 @@ export class AgentManager {
479
478
 
480
479
  async getAvailableModels(chatId) {
481
480
  const { listProviderModels } = await import("./pi-runtime.js");
482
- const runtime = createPiRuntime({ provider: this.config.pi.provider, apiKey: this.config.pi.apiKey });
481
+ const runtime = await createPiRuntime({ provider: this.config.pi.provider, apiKey: this.config.pi.apiKey });
483
482
  return listProviderModels(this.config.pi.provider, runtime);
484
483
  }
485
484
 
@@ -33,8 +33,8 @@ export function getPiAuthIssue(error) {
33
33
  return null;
34
34
  }
35
35
 
36
- export function getPiAuthStatus(config, chatId = null) {
37
- const runtime = createPiRuntime({
36
+ export async function getPiAuthStatus(config, chatId = null) {
37
+ const runtime = await createPiRuntime({
38
38
  provider: config.pi.provider,
39
39
  apiKey: config.pi.apiKey
40
40
  });
@@ -51,8 +51,8 @@ export function getPiAuthStatus(config, chatId = null) {
51
51
  };
52
52
  }
53
53
 
54
- export function buildPiAuthTelegramMessage({ config, chatId = null, issue = null, verified = false }) {
55
- const status = getPiAuthStatus(config, chatId);
54
+ export async function buildPiAuthTelegramMessage({ config, chatId = null, issue = null, verified = false }) {
55
+ const status = await getPiAuthStatus(config, chatId);
56
56
  let title = `Pi authentication status for ${status.provider}/${status.model}.`;
57
57
  if (issue) {
58
58
  title = `Pi authentication needs attention for ${status.provider}/${status.model}.`;
@@ -95,8 +95,8 @@ export function buildPiAuthTelegramMessage({ config, chatId = null, issue = null
95
95
  return lines.join("\n");
96
96
  }
97
97
 
98
- export function buildPiAuthRecoveryBlockedMessage({ config, chatId = null, issue = null, renewalActive = false }) {
99
- const status = getPiAuthStatus(config, chatId);
98
+ export async function buildPiAuthRecoveryBlockedMessage({ config, chatId = null, issue = null, renewalActive = false }) {
99
+ const status = await getPiAuthStatus(config, chatId);
100
100
  const lines = [
101
101
  `Pi authentication is not ready for ${status.provider}/${status.model}.`,
102
102
  "I did not send your message to the agent."
@@ -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);
@@ -17,16 +17,25 @@ export function modelSupportsSpeed(model) {
17
17
  || model.id === "gpt-5.5"
18
18
  || model.id === "gpt-5.6"
19
19
  || model.id.startsWith("gpt-5.6-")
20
+ || model.id === "gpt-6-astra"
20
21
  );
21
22
  }
22
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
+
23
32
  export function clampModelSpeed(model, speed) {
24
33
  const normalized = normalizeModelSpeed(speed);
25
- return normalized === 1.5 && !modelSupportsSpeed(model) ? 1 : normalized;
34
+ return normalized > 1 && modelSupportsSpeed(model) ? modelFastSpeed(model.id) : 1;
26
35
  }
27
36
 
28
37
  export function speedToServiceTier(speed) {
29
- return normalizeModelSpeed(speed) === 1.5 ? "priority" : "default";
38
+ return normalizeModelSpeed(speed) > 1 ? "priority" : "default";
30
39
  }
31
40
 
32
41
  export function createModelSpeedController(streamFn, initialSpeed) {
@@ -40,6 +49,7 @@ export function createModelSpeedController(streamFn, initialSpeed) {
40
49
  speed = normalizeModelSpeed(nextSpeed);
41
50
  },
42
51
  streamFn(model, context, options) {
52
+ if (!modelSupportsSpeed(model)) return streamFn(model, context, options);
43
53
  const serviceTier = speedToServiceTier(speed);
44
54
  const onPayload = options?.onPayload;
45
55
  return streamFn(model, context, {
@@ -1,13 +1,6 @@
1
- import { AuthStorage } from "@earendil-works/pi-coding-agent";
2
- import { piAuthFile } from "../../platform/paths.js";
1
+ import { createPiRuntime, supportsProviderOAuth } from "./pi-runtime.js";
3
2
 
4
3
  export function createPiOAuthLogin({ provider, onAuth, onDeviceCode, onPrompt, onProgress, onSelect } = {}) {
5
- const authStorage = AuthStorage.create(piAuthFile);
6
- const oauthProvider = authStorage.getOAuthProviders().find((item) => item.id === provider);
7
- if (!oauthProvider) {
8
- throw new Error(`No internal OAuth login flow is available for ${provider}.`);
9
- }
10
-
11
4
  let resolveManualCode;
12
5
  const manualCodePromise = new Promise((resolve) => {
13
6
  resolveManualCode = resolve;
@@ -15,7 +8,6 @@ export function createPiOAuthLogin({ provider, onAuth, onDeviceCode, onPrompt, o
15
8
 
16
9
  const controller = {
17
10
  provider,
18
- oauthProvider,
19
11
  manualInputRequested: false,
20
12
  submitManualCode(value) {
21
13
  if (!resolveManualCode) return false;
@@ -31,25 +23,33 @@ export function createPiOAuthLogin({ provider, onAuth, onDeviceCode, onPrompt, o
31
23
  promise: null
32
24
  };
33
25
 
34
- controller.promise = authStorage.login(provider, {
35
- onAuth: async (params) => {
36
- await onAuth?.({ ...params, controller });
37
- },
38
- onDeviceCode: async (params) => {
39
- await onDeviceCode?.({ ...params, controller });
40
- },
41
- onPrompt: async (params) => {
42
- if (!onPrompt) return "";
43
- return onPrompt({ ...params, controller });
44
- },
45
- onProgress: (message) => {
46
- onProgress?.(message);
47
- },
48
- onSelect: async (params) => {
49
- if (!onSelect) return params.options?.[0]?.id;
50
- return onSelect({ ...params, controller });
51
- },
52
- onManualCodeInput: () => controller.waitForManualCode()
26
+ controller.promise = createPiRuntime().then(async (runtime) => {
27
+ if (!supportsProviderOAuth(provider, runtime)) {
28
+ throw new Error(`No internal OAuth login flow is available for ${provider}.`);
29
+ }
30
+ let notifications = Promise.resolve();
31
+ let notificationError;
32
+ const credential = await runtime.login(provider, "oauth", {
33
+ notify(event) {
34
+ notifications = notifications.then(async () => {
35
+ if (event.type === "auth_url") await onAuth?.({ ...event, controller });
36
+ else if (event.type === "device_code") await onDeviceCode?.({ ...event, controller });
37
+ else await onProgress?.(event.message);
38
+ }).catch((error) => { notificationError = error; });
39
+ },
40
+ async prompt(params) {
41
+ await notifications;
42
+ if (notificationError) throw notificationError;
43
+ if (params.type === "select") {
44
+ return onSelect ? onSelect({ ...params, controller }) : params.options?.[0]?.id;
45
+ }
46
+ if (params.type === "manual_code") return controller.waitForManualCode();
47
+ return onPrompt ? onPrompt({ ...params, controller }) : "";
48
+ }
49
+ });
50
+ await notifications;
51
+ if (notificationError) throw notificationError;
52
+ return credential;
53
53
  }).finally(() => {
54
54
  controller.submitManualCode("");
55
55
  });
@@ -47,6 +47,7 @@ export function createPiCapabilityTools({ capabilityService, telegram, chatId, p
47
47
  context: {
48
48
  ...baseContext,
49
49
  taskContext: telegram.getTaskContext(),
50
+ agentTaskExecution: telegram.getAgentTaskExecution?.() || null,
50
51
  ...context
51
52
  }
52
53
  });
@@ -1,34 +1,29 @@
1
- import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent";
1
+ import { ModelRuntime } from "@earendil-works/pi-coding-agent";
2
2
  import { piAuthFile } from "../../platform/paths.js";
3
3
 
4
4
  function compareText(a, b) {
5
5
  return a.localeCompare(b, undefined, { sensitivity: "base", numeric: true });
6
6
  }
7
7
 
8
- export function createPiRuntime({ provider, apiKey } = {}) {
9
- const authStorage = AuthStorage.create(piAuthFile);
8
+ export async function createPiRuntime({ provider, apiKey } = {}) {
9
+ const runtime = await ModelRuntime.create({ authPath: piAuthFile });
10
10
  if (provider && apiKey) {
11
- authStorage.setRuntimeApiKey(provider, apiKey);
11
+ await runtime.setRuntimeApiKey(provider, apiKey);
12
12
  }
13
- const modelRegistry = ModelRegistry.create(authStorage);
14
- const oauthProviders = authStorage.getOAuthProviders();
15
- return { authStorage, modelRegistry, oauthProviders };
13
+ return runtime;
16
14
  }
17
15
 
18
- export function hasProviderAuth(provider, { authStorage, modelRegistry }) {
19
- return modelRegistry.hasConfiguredAuth(provider) || authStorage.hasAuth(provider);
16
+ export function hasProviderAuth(provider, runtime) {
17
+ return runtime.getProviderAuthStatus(provider).configured;
20
18
  }
21
19
 
22
- export function supportsProviderOAuth(provider, { oauthProviders }) {
23
- return oauthProviders.some((item) => item.id === provider);
20
+ export function supportsProviderOAuth(provider, runtime) {
21
+ return Boolean(runtime.getProvider(provider)?.auth.oauth);
24
22
  }
25
23
 
26
- export function listPiProviders(runtime = createPiRuntime()) {
27
- const { modelRegistry, oauthProviders } = runtime;
28
- const allModels = modelRegistry.getAll();
29
- const oauthIds = new Set(oauthProviders.map((item) => item.id));
24
+ export function listPiProviders(runtime) {
30
25
  const counts = new Map();
31
- for (const model of allModels) {
26
+ for (const model of runtime.getModels()) {
32
27
  counts.set(model.provider, (counts.get(model.provider) || 0) + 1);
33
28
  }
34
29
 
@@ -36,7 +31,7 @@ export function listPiProviders(runtime = createPiRuntime()) {
36
31
  .map((provider) => ({
37
32
  provider,
38
33
  authConfigured: hasProviderAuth(provider, runtime),
39
- supportsOAuth: oauthIds.has(provider),
34
+ supportsOAuth: supportsProviderOAuth(provider, runtime),
40
35
  modelCount: counts.get(provider) || 0
41
36
  }))
42
37
  .sort((a, b) => {
@@ -46,10 +41,8 @@ export function listPiProviders(runtime = createPiRuntime()) {
46
41
  });
47
42
  }
48
43
 
49
- export function listProviderModels(provider, runtime = createPiRuntime()) {
50
- return runtime.modelRegistry
51
- .getAll()
52
- .filter((model) => model.provider === provider)
44
+ export function listProviderModels(provider, runtime) {
45
+ return [...runtime.getModels(provider)]
53
46
  .sort((a, b) => compareText(a.name || a.id, b.name || b.id));
54
47
  }
55
48
 
@@ -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
+ }