arisa 4.3.5 → 5.1.2

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 (49) hide show
  1. package/AGENTS.md +21 -19
  2. package/README.md +30 -9
  3. package/package.json +6 -2
  4. package/pnpm-workspace.yaml +1 -0
  5. package/src/core/agent/agent-manager.js +288 -29
  6. package/src/core/agent/auth-flow.js +12 -8
  7. package/src/core/agent/model-selection.js +54 -14
  8. package/src/core/agent/model-speed.js +59 -0
  9. package/src/core/config/config-defaults.js +56 -4
  10. package/src/core/config/config-store.js +5 -1
  11. package/src/core/conversation/conversation-history-store.js +142 -0
  12. package/src/core/tasks/task-store.js +16 -0
  13. package/src/core/tools/daemon-health.js +11 -2
  14. package/src/core/tools/daemon-processes.js +92 -2
  15. package/src/core/tools/daemon-runtime.js +4 -2
  16. package/src/core/tools/ipc-client.js +15 -3
  17. package/src/core/tools/tool-registry.js +42 -2
  18. package/src/core/tools/tool-usage-store.js +59 -0
  19. package/src/index.js +61 -6
  20. package/src/runtime/arisa-capabilities.js +45 -1
  21. package/src/runtime/bootstrap.js +3 -2
  22. package/src/runtime/create-app.js +49 -11
  23. package/src/runtime/doctor.js +365 -0
  24. package/src/runtime/log-viewer.js +165 -0
  25. package/src/runtime/paths.js +8 -1
  26. package/src/runtime/report-format.js +51 -0
  27. package/src/runtime/service-manager.js +106 -8
  28. package/src/runtime/tool-process-supervisor.js +107 -10
  29. package/src/runtime/tool-usage-report.js +10 -0
  30. package/src/runtime/update-manager.js +206 -0
  31. package/src/transport/telegram/bot.js +633 -91
  32. package/src/transport/telegram/model-picker.js +28 -2
  33. package/test/agent-tool-policy.test.js +26 -1
  34. package/test/auth-flow.test.js +28 -2
  35. package/test/capabilities-security.test.js +37 -0
  36. package/test/context-and-task-bounds.test.js +280 -0
  37. package/test/daemon-runtime.test.js +130 -2
  38. package/test/dependency-warnings.test.js +17 -0
  39. package/test/doctor.test.js +111 -0
  40. package/test/log-viewer.test.js +90 -0
  41. package/test/model-selection.test.js +125 -2
  42. package/test/paths.test.js +16 -0
  43. package/test/pi-compaction.test.js +43 -0
  44. package/test/service-manager.test.js +237 -0
  45. package/test/task-store.test.js +31 -0
  46. package/test/telegram-text-artifact.test.js +36 -1
  47. package/test/tool-registry-run.test.js +21 -0
  48. package/test/tool-usage.test.js +37 -0
  49. package/test/update-manager.test.js +87 -0
@@ -0,0 +1,59 @@
1
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { getChatToolUsageFile } from "../../runtime/paths.js";
4
+
5
+ function emptyUsage() {
6
+ return { version: 1, tools: {} };
7
+ }
8
+
9
+ async function readUsage(file) {
10
+ try {
11
+ const parsed = JSON.parse(await readFile(file, "utf8"));
12
+ return parsed?.version === 1 && parsed.tools && typeof parsed.tools === "object"
13
+ ? parsed
14
+ : emptyUsage();
15
+ } catch (error) {
16
+ if (error?.code === "ENOENT") return emptyUsage();
17
+ throw error;
18
+ }
19
+ }
20
+
21
+ async function writeUsage(file, usage) {
22
+ await mkdir(path.dirname(file), { recursive: true });
23
+ const temporary = `${file}.${process.pid}.${Date.now()}.tmp`;
24
+ await writeFile(temporary, `${JSON.stringify(usage, null, 2)}\n`, "utf8");
25
+ await rename(temporary, file);
26
+ }
27
+
28
+ export class ToolUsageStore {
29
+ constructor({ resolveFile = getChatToolUsageFile } = {}) {
30
+ this.resolveFile = resolveFile;
31
+ this.queues = new Map();
32
+ }
33
+
34
+ async record(chatId, toolName) {
35
+ if (chatId == null || chatId === "") return;
36
+ const key = String(chatId);
37
+ const previous = this.queues.get(key) || Promise.resolve();
38
+ const current = previous.catch(() => {}).then(async () => {
39
+ const file = this.resolveFile(chatId);
40
+ const usage = await readUsage(file);
41
+ const count = Number(usage.tools[toolName]?.count) || 0;
42
+ usage.tools[toolName] = { count: count + 1 };
43
+ await writeUsage(file, usage);
44
+ });
45
+ this.queues.set(key, current);
46
+ try {
47
+ await current;
48
+ } finally {
49
+ if (this.queues.get(key) === current) this.queues.delete(key);
50
+ }
51
+ }
52
+
53
+ async counts(chatId) {
54
+ if (chatId == null || chatId === "") return {};
55
+ await (this.queues.get(String(chatId)) || Promise.resolve()).catch(() => {});
56
+ const usage = await readUsage(this.resolveFile(chatId));
57
+ return Object.fromEntries(Object.entries(usage.tools).map(([name, value]) => [name, Number(value?.count) || 0]));
58
+ }
59
+ }
package/src/index.js CHANGED
@@ -1,10 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import { bootstrapIfNeeded } from "./runtime/bootstrap.js";
4
- import { createApp } from "./runtime/create-app.js";
4
+ import { applyRuntimeOverrides, createApp } from "./runtime/create-app.js";
5
+ import { loadConfig } from "./core/config/config-store.js";
5
6
  import { createLogger } from "./runtime/logger.js";
6
- import { getServiceStatus, registerServiceProcess, startService, stopService, unregisterServiceProcess } from "./runtime/service-manager.js";
7
+ import { getServiceStatus, handoffServiceRestart, registerServiceProcess, restartService, startService, stopService, unregisterServiceProcess } from "./runtime/service-manager.js";
7
8
  import { flushArisaHome } from "./runtime/flush.js";
9
+ import { readPackageVersion, showServiceLogs } from "./runtime/log-viewer.js";
8
10
  import { arisaPackageDir } from "./runtime/paths.js";
9
11
 
10
12
  process.env.ARISA_PACKAGE_DIR = arisaPackageDir;
@@ -69,17 +71,18 @@ function toNestedOverrides(nestedFlags) {
69
71
 
70
72
  function toServiceRunnerArgs(nestedFlags) {
71
73
  const args = [];
72
- const serviceSafePiFlags = [
74
+ const serviceSafeAgentFlags = [
73
75
  "pi.provider",
74
76
  "pi.model",
75
77
  "pi.workspaceDir",
76
78
  "pi.tools",
77
79
  "pi.excludeTools",
78
80
  "pi.shellPath",
79
- "pi.shellTimeoutMs"
81
+ "pi.shellTimeoutMs",
82
+ "pi.speed"
80
83
  ];
81
84
 
82
- for (const flag of serviceSafePiFlags) {
85
+ for (const flag of serviceSafeAgentFlags) {
83
86
  if (nestedFlags[flag]) {
84
87
  args.push(`--${flag}`, nestedFlags[flag]);
85
88
  }
@@ -111,12 +114,21 @@ process.once("SIGINT", () => {
111
114
  });
112
115
 
113
116
  async function startRuntimeApp() {
114
- const app = await createApp({ logger, runtimeOverrides });
117
+ const app = await createApp({
118
+ logger,
119
+ runtimeOverrides,
120
+ requestRestart: () => handoffServiceRestart({
121
+ verbose,
122
+ cliArgs: toServiceRunnerArgs(cli.nestedFlags)
123
+ })
124
+ });
115
125
  activeApp = app;
116
126
  await app.start();
117
127
  }
118
128
 
119
129
  async function startBackgroundService() {
130
+ const persistedConfig = await loadConfig();
131
+ applyRuntimeOverrides(persistedConfig, runtimeOverrides);
120
132
  const result = await startService({ verbose, cliArgs: toServiceRunnerArgs(cli.nestedFlags) });
121
133
  if (!result.ok) {
122
134
  console.log(`Arisa is already running in background (pid ${result.pid}).`);
@@ -127,6 +139,31 @@ async function startBackgroundService() {
127
139
  return result;
128
140
  }
129
141
 
142
+ async function restartBackgroundService() {
143
+ const persistedConfig = await loadConfig();
144
+ applyRuntimeOverrides(persistedConfig, runtimeOverrides);
145
+ const result = await restartService({
146
+ verbose,
147
+ cliArgs: toServiceRunnerArgs(cli.nestedFlags),
148
+ shutdownTimeoutMs: persistedConfig.service.shutdownTimeoutMs,
149
+ shutdownPollIntervalMs: persistedConfig.service.shutdownPollIntervalMs
150
+ });
151
+ if (!result.ok) {
152
+ if (result.reason === "already-running") {
153
+ console.log(`Arisa could not be restarted because another instance is running (pid ${result.pid}).`);
154
+ return result;
155
+ }
156
+ throw new Error(`Arisa could not be restarted: ${result.reason || "unknown service error"}`);
157
+ }
158
+ if (result.wasRunning) {
159
+ console.log(`Arisa restarted in background (pid ${result.pid}; previous pid ${result.previousPid}).`);
160
+ } else {
161
+ console.log(`Arisa started in background (pid ${result.pid}); it was not running.`);
162
+ }
163
+ console.log(`Log file: ${result.logFile}`);
164
+ return result;
165
+ }
166
+
130
167
  async function runForeground() {
131
168
  const hasRuntimePiOverrides = Boolean(
132
169
  runtimeOverrides?.pi?.model
@@ -197,6 +234,16 @@ async function main() {
197
234
  return;
198
235
  }
199
236
 
237
+ if (command === "restart") {
238
+ const bootstrapResult = await bootstrapIfNeeded({ force: forceBootstrap });
239
+ if (bootstrapResult.configCreated && bootstrapResult.viaTelegram && !bootstrapResult.startInBackground) {
240
+ console.log("Config saved. Arisa was not started in background.");
241
+ return;
242
+ }
243
+ await restartBackgroundService();
244
+ return;
245
+ }
246
+
200
247
  if (command === "status") {
201
248
  const status = await getServiceStatus();
202
249
  if (!status.running) {
@@ -207,6 +254,14 @@ async function main() {
207
254
  return;
208
255
  }
209
256
 
257
+ if (command === "log") {
258
+ await showServiceLogs({
259
+ version: await readPackageVersion(),
260
+ follow: !cli.flags["no-follow"]
261
+ });
262
+ return;
263
+ }
264
+
210
265
  if (command === "flush") {
211
266
  const status = await getServiceStatus();
212
267
  if (status.running) {
@@ -41,10 +41,35 @@ function normalizeLimit(limit) {
41
41
  return Math.min(value, 100);
42
42
  }
43
43
 
44
- export function createArisaCapabilities({ artifactStore, taskStore, agentManager } = {}) {
44
+ export function createArisaCapabilities({ artifactStore, taskStore, toolRegistry, agentManager } = {}) {
45
45
  async function dispatch({ method, toolName, chatId = null, params = {} } = {}) {
46
46
  const scopedToolName = requireToolName(toolName);
47
47
 
48
+ if (method === "tools.list") {
49
+ await toolRegistry.load();
50
+ return toolRegistry.listWithRuntime(chatId);
51
+ }
52
+
53
+ if (method === "tools.help") {
54
+ await toolRegistry.load();
55
+ return toolRegistry.help(requireString(params.name, "name"));
56
+ }
57
+
58
+ if (method === "tools.skills") {
59
+ await toolRegistry.load();
60
+ return toolRegistry.resolveSkills(requireString(params.name, "name"));
61
+ }
62
+
63
+ if (method === "tools.setConfig") {
64
+ await toolRegistry.load();
65
+ return toolRegistry.setConfig(
66
+ requireString(params.name, "name"),
67
+ requireString(params.field, "field"),
68
+ requireString(params.value, "value"),
69
+ requireChatId(chatId, method)
70
+ );
71
+ }
72
+
48
73
  if (method === "tools.run") {
49
74
  if (!agentManager?.runTool) {
50
75
  throw new Error("tools.run requires agentManager");
@@ -90,6 +115,20 @@ export function createArisaCapabilities({ artifactStore, taskStore, agentManager
90
115
  return artifactStore.forChat(scopedChatId).get(requireString(params.artifactId, "artifactId"));
91
116
  }
92
117
 
118
+ if (method === "artifacts.deliver") {
119
+ const scopedChatId = requireChatId(chatId, method);
120
+ const artifactId = requireString(params.artifactId, "artifactId");
121
+ const artifact = await artifactStore.forChat(scopedChatId).get(artifactId);
122
+ if (!artifact?.path) throw new Error(`Artifact not found or has no file: ${artifactId}`);
123
+ if (!agentManager?.deliverArtifact) throw new Error("artifact delivery is unavailable");
124
+ return agentManager.deliverArtifact({
125
+ chatId: scopedChatId,
126
+ artifact,
127
+ caption: params.caption,
128
+ method: params.method
129
+ });
130
+ }
131
+
93
132
  if (method === "tasks.add") {
94
133
  const scopedChatId = requireChatId(chatId, method);
95
134
  return taskStore.add(params.task || {}, {
@@ -118,6 +157,11 @@ export function createArisaCapabilities({ artifactStore, taskStore, agentManager
118
157
  return taskStore.cancel(taskId);
119
158
  }
120
159
 
160
+ if (method === "tasks.cancelAll") {
161
+ const scopedChatId = requireChatId(chatId, method);
162
+ return taskStore.cancelAll({ chatId: scopedChatId });
163
+ }
164
+
121
165
  if (method === "agent.enqueueEvent") {
122
166
  const scopedChatId = requireChatId(chatId, method);
123
167
  return taskStore.add({
@@ -7,6 +7,7 @@ import { Bot } from "grammy";
7
7
  import { createPiOAuthLogin } from "../core/agent/pi-auth-login.js";
8
8
  import { createPiRuntime, formatPiModelOption, hasProviderAuth, listPiProviders, listProviderModels, supportsProviderOAuth } from "../core/agent/pi-runtime.js";
9
9
  import { applyConfigDefaults, telegramConfigDefaults } from "../core/config/config-defaults.js";
10
+ import { prepareConfigForSave } from "../core/config/config-store.js";
10
11
  import { buildDeviceCodeTelegramMessage } from "../transport/telegram/device-code-message.js";
11
12
  import { buildPagedInlineKeyboard } from "../transport/telegram/paged-inline-keyboard.js";
12
13
  import { configFile, ensureArisaHome } from "./paths.js";
@@ -29,7 +30,7 @@ async function exists(file) {
29
30
  }
30
31
  }
31
32
 
32
- function buildConfig({ telegramApiKey, telegramMaxChatIds, authorizedChatIds = [], chatMeta = {}, provider, model, piApiKey }) {
33
+ export function buildConfig({ telegramApiKey, telegramMaxChatIds, authorizedChatIds = [], chatMeta = {}, provider, model, piApiKey }) {
33
34
  return applyConfigDefaults({
34
35
  telegram: {
35
36
  token: telegramApiKey,
@@ -602,7 +603,7 @@ export async function bootstrapIfNeeded({ force = false } = {}) {
602
603
  result = await collectCliBootstrapChoices({ telegramApiKey, rl, ask });
603
604
  }
604
605
 
605
- await writeFile(configFile, `${JSON.stringify(result.config, null, 2)}\n`, "utf8");
606
+ await writeFile(configFile, `${JSON.stringify(prepareConfigForSave(result.config), null, 2)}\n`, "utf8");
606
607
  console.log(`\nConfig saved to ${configFile}\n`);
607
608
  return {
608
609
  configCreated: true,
@@ -8,6 +8,10 @@ import { createTelegramBot } from "../transport/telegram/bot.js";
8
8
  import { createToolProcessSupervisor } from "./tool-process-supervisor.js";
9
9
  import { createArisaCapabilities } from "./arisa-capabilities.js";
10
10
  import { createIpcServer } from "./ipc/ipc-server.js";
11
+ import { getAgentConfig } from "../core/agent/model-selection.js";
12
+ import { normalizeModelSpeed } from "../core/agent/model-speed.js";
13
+ import { runDoctor } from "./doctor.js";
14
+ import { checkForUpdates, formatUpdateReport } from "./update-manager.js";
11
15
 
12
16
  function normalizeString(value) {
13
17
  const text = String(value ?? "").trim();
@@ -51,10 +55,14 @@ function splitModelOverride(modelOverride) {
51
55
  }
52
56
 
53
57
  export function applyRuntimeOverrides(config, runtimeOverrides) {
58
+ const unsupportedNamespaces = Object.keys(runtimeOverrides || {}).filter((name) => name !== "pi");
59
+ if (unsupportedNamespaces.length) {
60
+ throw new Error(`Unsupported runtime override namespace: ${unsupportedNamespaces.join(", ")}`);
61
+ }
54
62
  const piRuntimeOverrides = runtimeOverrides?.pi || {};
55
63
  const pi = {};
56
- const providerOverride = normalizeString(runtimeOverrides?.pi?.provider);
57
- const modelOverride = normalizeString(runtimeOverrides?.pi?.model);
64
+ const providerOverride = normalizeString(piRuntimeOverrides.provider);
65
+ const modelOverride = normalizeString(piRuntimeOverrides.model);
58
66
 
59
67
  if (providerOverride || modelOverride) {
60
68
  const splitOverride = modelOverride ? splitModelOverride(modelOverride) : null;
@@ -77,6 +85,7 @@ export function applyRuntimeOverrides(config, runtimeOverrides) {
77
85
 
78
86
  const shellTimeoutMs = normalizePositiveInteger(piRuntimeOverrides.shellTimeoutMs);
79
87
  if (shellTimeoutMs) pi.shellTimeoutMs = shellTimeoutMs;
88
+ if (piRuntimeOverrides.speed !== undefined) pi.speed = normalizeModelSpeed(piRuntimeOverrides.speed);
80
89
 
81
90
  if (!Object.keys(pi).length) return config;
82
91
 
@@ -89,32 +98,56 @@ export function applyRuntimeOverrides(config, runtimeOverrides) {
89
98
  };
90
99
  }
91
100
 
92
- export async function createApp({ logger, runtimeOverrides } = {}) {
101
+ export async function createApp({ logger, runtimeOverrides, requestRestart } = {}) {
102
+ if (typeof requestRestart !== "function") {
103
+ throw new Error("createApp requires a service restart handoff");
104
+ }
93
105
  logger?.log("app", "loading config");
94
106
  const persistedConfig = await loadConfig();
95
- const config = applyRuntimeOverrides(persistedConfig, runtimeOverrides);
96
- if (config.pi.provider !== persistedConfig.pi.provider || config.pi.model !== persistedConfig.pi.model) {
97
- logger?.log("app", `applying runtime model override: ${persistedConfig.pi.provider}/${persistedConfig.pi.model} -> ${config.pi.provider}/${config.pi.model}`);
107
+ const overriddenConfig = applyRuntimeOverrides(persistedConfig, runtimeOverrides);
108
+ const config = overriddenConfig;
109
+ const activeConfig = getAgentConfig(config);
110
+ const persistedActiveConfig = getAgentConfig(persistedConfig);
111
+ if (activeConfig.provider !== persistedActiveConfig.provider || activeConfig.model !== persistedActiveConfig.model) {
112
+ logger?.log("app", `applying runtime model override: ${persistedActiveConfig.provider}/${persistedActiveConfig.model} -> ${activeConfig.provider}/${activeConfig.model}`);
98
113
  }
99
114
 
100
115
  const artifactStore = new ArtifactStore();
101
- const toolProcessSupervisor = createToolProcessSupervisor({ logger, policy: config.daemons });
102
116
  const toolRegistry = new ToolRegistry({ logger });
117
+ const toolProcessSupervisor = createToolProcessSupervisor({ logger, policy: config.daemons, toolRegistry });
103
118
  const taskStore = new TaskStore();
104
119
  await toolRegistry.load();
105
120
  logger?.log("app", `loaded ${toolRegistry.list().length} tools`);
106
121
 
107
122
  const agentManager = new AgentManager({ config, artifactStore, toolRegistry, taskStore, logger });
108
- const arisaCapabilities = createArisaCapabilities({ artifactStore, taskStore, agentManager });
123
+ const arisaCapabilities = createArisaCapabilities({ artifactStore, taskStore, toolRegistry, agentManager });
109
124
  const ipcServer = createIpcServer({ capabilities: arisaCapabilities, logger });
110
- const bot = await createTelegramBot({ config, artifactStore, toolRegistry, taskStore, agentManager, saveConfig, updateConfig, logger });
125
+ const bot = await createTelegramBot({
126
+ config,
127
+ artifactStore,
128
+ toolRegistry,
129
+ taskStore,
130
+ agentManager,
131
+ saveConfig,
132
+ updateConfig,
133
+ doctor: () => runDoctor({
134
+ agentManager,
135
+ toolProcessSupervisor,
136
+ daemonPolicy: config.daemons,
137
+ doctorPolicy: config.doctor,
138
+ logger
139
+ }),
140
+ checkUpdates: async (chatId) => formatUpdateReport(await checkForUpdates({ chatId, toolRegistry })),
141
+ requestRestart,
142
+ logger
143
+ });
111
144
 
112
145
  return {
113
146
  async start() {
114
- logger?.log("app", `validating Pi model ${config.pi.provider}/${config.pi.model}`);
147
+ logger?.log("app", `validating Pi model ${activeConfig.provider}/${activeConfig.model}`);
115
148
  let skipAgentStartupPrompts = false;
116
149
  try {
117
- await agentManager.validatePiAgent();
150
+ await agentManager.validateAgent();
118
151
  } catch (error) {
119
152
  const issue = getPiAuthIssue(error);
120
153
  if (!issue) {
@@ -129,6 +162,10 @@ export async function createApp({ logger, runtimeOverrides } = {}) {
129
162
  try {
130
163
  await ipcServer.start();
131
164
  ipcStarted = true;
165
+ const recoveredTasks = await taskStore.recoverInterrupted();
166
+ if (recoveredTasks.length) {
167
+ logger?.log("tasks", `recovered ${recoveredTasks.length} interrupted task(s)`);
168
+ }
132
169
  await toolProcessSupervisor.start();
133
170
  supervisorStarted = true;
134
171
  logger?.log("app", "starting Telegram bot");
@@ -142,6 +179,7 @@ export async function createApp({ logger, runtimeOverrides } = {}) {
142
179
 
143
180
  async stop() {
144
181
  await bot.stop?.();
182
+ await agentManager.close();
145
183
  await toolProcessSupervisor.stop();
146
184
  await ipcServer.stop();
147
185
  }