arisa 5.1.2 → 5.1.8

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 (39) hide show
  1. package/AGENTS.md +12 -4
  2. package/ARISA-MASTER-SLAVE-SPEC.md +844 -0
  3. package/README.md +25 -0
  4. package/package.json +1 -1
  5. package/src/core/agent/agent-manager.js +55 -12
  6. package/src/core/config/config-defaults.js +3 -1
  7. package/src/core/tools/daemon-processes.js +48 -6
  8. package/src/core/tools/daemon-runtime.js +370 -138
  9. package/src/core/tools/ipc-client.js +3 -0
  10. package/src/core/tools/official-tool-catalog.js +32 -0
  11. package/src/core/tools/official-tool-installer.js +183 -0
  12. package/src/core/tools/tool-registry.js +203 -18
  13. package/src/core/tools/tool-resource-note-store.js +78 -0
  14. package/src/index.js +25 -2
  15. package/src/official-tools.lock.json +40 -0
  16. package/src/runtime/arisa-capabilities.js +43 -3
  17. package/src/runtime/create-app.js +9 -0
  18. package/src/runtime/create-headless-app.js +77 -0
  19. package/src/runtime/doctor.js +27 -2
  20. package/src/runtime/headless-tool-executor.js +45 -0
  21. package/src/runtime/paths.js +16 -4
  22. package/src/runtime/secure-request-file.js +21 -0
  23. package/src/runtime/slave-bootstrap-url.js +51 -0
  24. package/src/runtime/slave-cli.js +267 -0
  25. package/src/runtime/slave-service.js +225 -0
  26. package/src/runtime/tool-usage-report.js +11 -3
  27. package/src/transport/telegram/bot.js +37 -7
  28. package/test/capabilities-security.test.js +29 -0
  29. package/test/daemon-catalog-conformance.test.js +3 -1
  30. package/test/daemon-runtime.test.js +58 -2
  31. package/test/official-tool-installer.test.js +107 -0
  32. package/test/paths.test.js +6 -12
  33. package/test/slave-cli.test.js +282 -0
  34. package/test/telegram-text-artifact.test.js +24 -1
  35. package/test/tool-capability-search.test.js +55 -0
  36. package/test/tool-registry-run.test.js +70 -1
  37. package/test/tool-resource-note.test.js +50 -0
  38. package/test/tool-usage.test.js +10 -5
  39. package/test-fixtures/fake-daemon.js +12 -1
package/README.md CHANGED
@@ -103,6 +103,31 @@ Per chat (`~/.arisa/chats/<chatId>/`):
103
103
 
104
104
  Managed daemons become ready only after their tool-defined health operation succeeds through the normal command queue. Arisa records heartbeats, successful jobs, errors, and standard lifecycle states, then retries recovery or recreates an unhealthy process with its persisted scope and startup context.
105
105
 
106
+ Daemon tools may opt into the `arisa-daemon-v1` local protocol for immediate
107
+ multiplexed jobs and incremental NDJSON events over a capability-protected local
108
+ socket. The runtime persists request, accepted and terminal records so a restart
109
+ can recover queued work and will not silently repeat an accepted effect. Legacy
110
+ daemon tools continue to use the existing request-file contract.
111
+
112
+ ### Arisa Master and Slave
113
+
114
+ The official `master-slave` daemon tool lets one normal Arisa installation act
115
+ as Master for deterministic headless Slave hosts. Master keeps Telegram and Pi;
116
+ Slave runs only the IPC host, daemon supervisor and installed tools. Connections
117
+ are authenticated, encrypted, initiated by Slave and restricted by per-Slave
118
+ roots and capability grants.
119
+
120
+ Linux with systemd is the first supported Slave target. Bootstrap uses a
121
+ single-use URL issued by Master:
122
+
123
+ ```bash
124
+ npm i -g arisa && arisa slave tcp://198.51.100.12:4719/arisa_secret_v1_<secret>
125
+ ```
126
+
127
+ The URL is sensitive and may remain in shell history. `arisa slave status`,
128
+ `log`, `tools`, `start`, `stop`, `restart` and `unpair` operate the isolated
129
+ headless service without starting Telegram or Pi Agent.
130
+
106
131
  Pi authentication can use either:
107
132
  - an API key entered during bootstrap
108
133
  - or Pi's existing OAuth login when supported, such as `openai-codex`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arisa",
3
- "version": "5.1.2",
3
+ "version": "5.1.8",
4
4
  "description": "Telegram + Pi Agent modular assistant",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -11,6 +11,8 @@ import { createSystemShellTool } from "./system-shell-tool.js";
11
11
  import { clampModelThinkingLevel } from "./pi-runtime.js";
12
12
  import { clampModelSpeed, createModelSpeedController } from "./model-speed.js";
13
13
  import { arisaHomeDir, getChatPiSessionsDir } from "../../runtime/paths.js";
14
+ import { searchOfficialToolCatalog } from "../tools/official-tool-catalog.js";
15
+ import { ToolResourceNoteStore } from "../tools/tool-resource-note-store.js";
14
16
 
15
17
  const piValidationTimeoutMs = 60_000;
16
18
  const arisaToolNames = [
@@ -18,6 +20,7 @@ const arisaToolNames = [
18
20
  "tool_help",
19
21
  "tool_skills",
20
22
  "set_tool_config",
23
+ "set_tool_resource_note",
21
24
  "run_tool",
22
25
  "list_scheduled_tasks",
23
26
  "cancel_scheduled_task",
@@ -221,6 +224,7 @@ export class AgentManager {
221
224
  this.toolRegistry = toolRegistry;
222
225
  this.taskStore = taskStore;
223
226
  this.logger = logger;
227
+ this.resourceNotes = new ToolResourceNoteStore();
224
228
  this.sessions = new Map();
225
229
  this.pendingNewSessions = new Set();
226
230
  this.pendingSessionHandoffs = new Map();
@@ -505,7 +509,12 @@ export class AgentManager {
505
509
  await this.toolRegistry.load();
506
510
  this.logger?.log("agent", `run_tool ${name}`);
507
511
  const chatArtifactStore = this.artifactStore.forChat(chatId);
508
- const result = await this.toolRegistry.run({ name, request, chatId });
512
+ const resourceId = String(request?.resourceId || "").trim();
513
+ const resourceNote = resourceId
514
+ ? await this.resourceNotes.get(chatId, name, resourceId)
515
+ : "";
516
+ const enrichedRequest = resourceNote ? { ...request, resourceId, resourceNote } : request;
517
+ const result = await this.toolRegistry.run({ name, request: enrichedRequest, chatId });
509
518
 
510
519
  if (result.output?.text) {
511
520
  const outArtifact = await chatArtifactStore.createText({
@@ -551,9 +560,9 @@ export class AgentManager {
551
560
  defineTool({
552
561
  name: "list_tools",
553
562
  label: "List tools",
554
- description: "List Arisa core, native shell, and modular CLI tools with their capabilities and daemon diagnostics.",
555
- parameters: Type.Object({}),
556
- execute: async () => {
563
+ description: "List Arisa tools, or search installed tool metadata by capability with automatic official-catalog fallback.",
564
+ parameters: Type.Object({ query: Type.Optional(Type.String()) }),
565
+ execute: async (_id, params) => {
557
566
  await this.toolRegistry.load();
558
567
  const coreTools = getCoreCodingTools({
559
568
  tools: policy.tools,
@@ -567,17 +576,35 @@ export class AgentManager {
567
576
  shell: policy.shell.shellPath || (process.platform === "win32" ? "powershell" : "sh"),
568
577
  enabled: !(policy.excludeTools || []).includes("system_shell")
569
578
  }];
570
- const cliTools = (await this.toolRegistry.listWithRuntime(chatId)).map((tool) => ({
571
- ...tool,
572
- source: "arisa-modular",
573
- invocation: "run_tool"
574
- }));
579
+ const query = params.query?.trim() || "";
580
+ let catalogFallback = null;
581
+ const cliTools = query
582
+ ? this.toolRegistry.search(query).map((tool) => ({
583
+ ...tool,
584
+ source: "arisa-modular",
585
+ invocation: "run_tool"
586
+ }))
587
+ : (await this.toolRegistry.listWithRuntime(chatId)).map((tool) => ({
588
+ ...tool,
589
+ source: "arisa-modular",
590
+ invocation: "run_tool"
591
+ }));
592
+ if (query && cliTools.length === 0) {
593
+ try {
594
+ catalogFallback = await searchOfficialToolCatalog(query);
595
+ } catch (error) {
596
+ catalogFallback = { unavailable: true, error: error?.message || String(error), matches: [] };
597
+ }
598
+ }
575
599
  const result = {
600
+ query: query || null,
576
601
  workspaceDir: policy.workspaceDir,
577
- coreTools,
578
- nativeTools,
602
+ coreTools: query ? [] : coreTools,
603
+ nativeTools: query ? [] : nativeTools,
579
604
  cliTools,
580
- tools: [...coreTools.filter((tool) => tool.enabled), ...nativeTools.filter((tool) => tool.enabled), ...cliTools]
605
+ officialCatalogMatches: Array.isArray(catalogFallback) ? catalogFallback : catalogFallback?.matches || [],
606
+ catalogFallback: catalogFallback && !Array.isArray(catalogFallback) ? catalogFallback : null,
607
+ tools: query ? cliTools : [...coreTools.filter((tool) => tool.enabled), ...nativeTools.filter((tool) => tool.enabled), ...cliTools]
581
608
  };
582
609
  return {
583
610
  content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
@@ -619,6 +646,20 @@ export class AgentManager {
619
646
  return { content: [{ type: "text", text: JSON.stringify(result) }], details: result };
620
647
  }
621
648
  }),
649
+ defineTool({
650
+ name: "set_tool_resource_note",
651
+ label: "Set tool resource note",
652
+ description: "Set or clear a deterministic chat-scoped note of up to 200 characters for one tool resource.",
653
+ parameters: Type.Object({
654
+ name: Type.String(),
655
+ resourceId: Type.String(),
656
+ note: Type.String()
657
+ }),
658
+ execute: async (_id, params) => {
659
+ const result = await this.resourceNotes.set(chatId, params.name, params.resourceId, params.note);
660
+ return { content: [{ type: "text", text: JSON.stringify(result) }], details: result };
661
+ }
662
+ }),
622
663
  defineTool({
623
664
  name: "run_tool",
624
665
  label: "Run tool",
@@ -627,6 +668,7 @@ export class AgentManager {
627
668
  name: Type.String(),
628
669
  artifactId: Type.Optional(Type.String()),
629
670
  text: Type.Optional(Type.String()),
671
+ resourceId: Type.Optional(Type.String()),
630
672
  args: Type.Optional(Type.Record(Type.String(), Type.String())),
631
673
  deliver: Type.Optional(Type.Boolean())
632
674
  }),
@@ -643,6 +685,7 @@ export class AgentManager {
643
685
  request: {
644
686
  artifact,
645
687
  text: params.text,
688
+ resourceId: params.resourceId,
646
689
  args: params.args || {}
647
690
  },
648
691
  chatId
@@ -11,7 +11,9 @@ export const daemonConfigDefaults = Object.freeze({
11
11
  restartBackoffMaxMs: 60_000,
12
12
  startupTimeoutMs: 120_000,
13
13
  stopTimeoutMs: 3_000,
14
- queuePollIntervalMs: 250
14
+ queuePollIntervalMs: 250,
15
+ streamBufferBytes: 1_048_576,
16
+ ipcFrameBytes: 1_048_576
15
17
  });
16
18
 
17
19
  export const telegramConfigDefaults = Object.freeze({
@@ -1,7 +1,7 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import crypto from "node:crypto";
3
3
  import { closeSync, openSync } from "node:fs";
4
- import { mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
4
+ import { chmod, mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
5
5
  import path from "node:path";
6
6
  import {
7
7
  chatsDir,
@@ -35,6 +35,7 @@ function daemonIdentity(toolNameOrOptions, scope) {
35
35
  export function daemonPaths(toolNameOrOptions, scope) {
36
36
  const identity = daemonIdentity(toolNameOrOptions, scope);
37
37
  const root = getDaemonInstanceDir(identity.toolName, identity.scope);
38
+ const localSocket = path.join(root, "daemon.sock");
38
39
  return {
39
40
  ...identity,
40
41
  instanceId: getDaemonInstanceId(identity.scope),
@@ -44,7 +45,13 @@ export function daemonPaths(toolNameOrOptions, scope) {
44
45
  metaFile: path.join(root, "daemon.meta.json"),
45
46
  statusFile: path.join(root, "status.json"),
46
47
  logFile: path.join(root, "daemon.log"),
47
- startLockFile: path.join(root, "daemon.start.lock")
48
+ startLockFile: path.join(root, "daemon.start.lock"),
49
+ socketFile: process.platform === "win32"
50
+ ? `\\\\.\\pipe\\arisa-daemon-${crypto.createHash("sha256").update(root).digest("hex").slice(0, 16)}`
51
+ : Buffer.byteLength(localSocket) <= 96
52
+ ? localSocket
53
+ : path.join("/tmp", `arisa-daemon-${crypto.createHash("sha256").update(root).digest("hex").slice(0, 24)}.sock`),
54
+ capabilityFile: path.join(root, "daemon.capability")
48
55
  };
49
56
  }
50
57
 
@@ -63,6 +70,25 @@ export async function writeJson(file, value) {
63
70
  await rename(temporary, file);
64
71
  }
65
72
 
73
+ export async function ensureDaemonCapability(pathsOrIdentity) {
74
+ const paths = pathsOrIdentity.capabilityFile ? pathsOrIdentity : daemonPaths(pathsOrIdentity);
75
+ await mkdir(paths.root, { recursive: true });
76
+ try {
77
+ const existing = (await readFile(paths.capabilityFile, "utf8")).trim();
78
+ if (existing) return existing;
79
+ } catch {}
80
+ const token = crypto.randomBytes(32).toString("base64url");
81
+ try {
82
+ await writeFile(paths.capabilityFile, `${token}\n`, { encoding: "utf8", mode: 0o600, flag: "wx" });
83
+ return token;
84
+ } catch (error) {
85
+ if (error?.code !== "EEXIST") throw error;
86
+ const existing = (await readFile(paths.capabilityFile, "utf8")).trim();
87
+ if (!existing) throw new Error(`Invalid daemon capability file for ${paths.toolName}`);
88
+ return existing;
89
+ }
90
+ }
91
+
66
92
  export function isProcessAlive(pid) {
67
93
  if (!pid) return false;
68
94
  try {
@@ -232,6 +258,8 @@ export async function unregisterManagedDaemon(toolNameOrOptions, { scope } = {})
232
258
  rm(paths.pidFile, { force: true }),
233
259
  rm(paths.statusFile, { force: true }),
234
260
  rm(paths.startLockFile, { force: true }),
261
+ rm(paths.capabilityFile, { force: true }),
262
+ process.platform === "win32" ? Promise.resolve() : rm(paths.socketFile, { force: true }),
235
263
  rm(paths.commandsDir, { recursive: true, force: true })
236
264
  ]);
237
265
  return { toolName: paths.toolName, scope: paths.scope, instanceId: paths.instanceId };
@@ -311,8 +339,9 @@ export async function startManagedDaemon({
311
339
  return current.pid;
312
340
  }
313
341
  await rm(paths.pidFile, { force: true });
314
- await rm(paths.commandsDir, { recursive: true, force: true });
315
342
  await mkdir(paths.commandsDir, { recursive: true });
343
+ const capabilityToken = await ensureDaemonCapability(paths);
344
+ if (process.platform !== "win32") await chmod(paths.capabilityFile, 0o600);
316
345
  if (beforeStart) await beforeStart();
317
346
 
318
347
  const startedAt = new Date().toISOString();
@@ -344,7 +373,8 @@ export async function startManagedDaemon({
344
373
  env: {
345
374
  ...process.env,
346
375
  ARISA_DAEMON_META_FILE: paths.metaFile,
347
- ARISA_DAEMON_INSTANCE_ID: paths.instanceId
376
+ ARISA_DAEMON_INSTANCE_ID: paths.instanceId,
377
+ ARISA_DAEMON_CAPABILITY: capabilityToken
348
378
  }
349
379
  });
350
380
  await new Promise((resolve, reject) => {
@@ -411,7 +441,13 @@ async function listGlobalDaemonRecords() {
411
441
  const records = [];
412
442
  for (const entry of entries) {
413
443
  if (!entry.isDirectory()) continue;
414
- const meta = await readJson(daemonPaths(entry.name).metaFile, null);
444
+ let paths;
445
+ try {
446
+ paths = daemonPaths(entry.name);
447
+ } catch {
448
+ continue;
449
+ }
450
+ const meta = await readJson(paths.metaFile, null);
415
451
  if (meta?.toolName && meta?.entryPath) records.push(meta);
416
452
  }
417
453
  return records;
@@ -437,7 +473,13 @@ async function listChatDaemonRecords() {
437
473
  const toolEntries = await readdir(toolsRoot, { withFileTypes: true }).catch(() => []);
438
474
  for (const toolEntry of toolEntries) {
439
475
  if (!toolEntry.isDirectory()) continue;
440
- const meta = await readJson(daemonPaths({ toolName: toolEntry.name, scope }).metaFile, null);
476
+ let paths;
477
+ try {
478
+ paths = daemonPaths({ toolName: toolEntry.name, scope });
479
+ } catch {
480
+ continue;
481
+ }
482
+ const meta = await readJson(paths.metaFile, null);
441
483
  if (meta?.toolName && meta?.entryPath) records.push(meta);
442
484
  }
443
485
  }