arisa 4.2.0 → 4.2.4

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/AGENTS.md CHANGED
@@ -43,6 +43,8 @@ Each tool declares in `tool.manifest.json`:
43
43
  - `input`: supported input types
44
44
  - `output`: produced output types
45
45
  - `configSchema`: required config fields
46
+ - `category`: optional broad capability bucket for discovery
47
+ - `keywords`: optional intent tags for capability discovery
46
48
  - `skillHints`: optional skills to apply when using or editing the tool
47
49
 
48
50
  ## Tool-to-Arisa IPC
@@ -166,6 +168,8 @@ Do not assume a rigid question/answer protocol. Continue the conversation natura
166
168
  ## Capability resolution
167
169
  Reason in terms of capabilities, not tool names. Do not stop at "I cannot do that" when the task is realistically implementable through the tool architecture.
168
170
 
171
+ Before asking the user for recurrent context, inspect available tools by `category` and `keywords`, especially `memory`, `context`, `contacts`, and `essential`.
172
+
169
173
  When the user asks for something new:
170
174
  1. check whether an existing registered tool, or an indirect use of one, can satisfy the task
171
175
  2. check the official catalog at `https://github.com/clasen/Arisa/tree/main/tools` before building anything
package/README.md CHANGED
@@ -86,6 +86,7 @@ All runtime state lives under `~/.arisa/`, split between global state and per-ch
86
86
 
87
87
  Global:
88
88
  - runtime config is stored in `~/.arisa/state/config.json`
89
+ - Pi OAuth credentials are stored in `~/.arisa/state/pi-auth.json`
89
90
  - the scheduled-task queue is stored in `~/.arisa/state/tasks.json`
90
91
  - installed tools live under `~/.arisa/tools/<tool>/`, each with a default `config.js` template
91
92
  - global tool runtime state (daemons, caches, temp) lives under `~/.arisa/state/tools/<tool>/`
@@ -134,6 +135,17 @@ Notes:
134
135
 
135
136
  - it only affects the current Arisa process and does not update `~/.arisa/state/config.json`
136
137
 
138
+ ## Multiple instances
139
+
140
+ Set `ARISA_HOME` to run separate Arisa instances on the same machine:
141
+
142
+ ```bash
143
+ ARISA_HOME=~/.arisa-work arisa
144
+ ARISA_HOME=~/.arisa-personal arisa start
145
+ ```
146
+
147
+ Each home has its own Telegram config, Pi login, installed tools, daemons, chat sessions, artifacts, IPC socket, PID file, and logs. Pi OAuth credentials are stored in `<home>/state/pi-auth.json`, so existing installs authenticate once again after upgrading.
148
+
137
149
  ## Bootstrap flow
138
150
 
139
151
  On first run, Arisa walks you through three steps:
@@ -156,6 +168,7 @@ src/
156
168
  ~/.arisa/
157
169
  state/ global config, task queue, IPC socket
158
170
  config.json
171
+ pi-auth.json
159
172
  tasks.json
160
173
  tools/<tool>/ global tool state (daemons, caches, tmp)
161
174
  tools/<tool>/ installed tools (catalog, user-chosen, or agent-created)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arisa",
3
- "version": "4.2.0",
3
+ "version": "4.2.4",
4
4
  "description": "Telegram + Pi Agent modular assistant",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -1,7 +1,8 @@
1
1
  import { AuthStorage } from "@earendil-works/pi-coding-agent";
2
+ import { piAuthFile } from "../../runtime/paths.js";
2
3
 
3
4
  export function createPiOAuthLogin({ provider, onAuth, onDeviceCode, onPrompt, onProgress, onSelect } = {}) {
4
- const authStorage = AuthStorage.create();
5
+ const authStorage = AuthStorage.create(piAuthFile);
5
6
  const oauthProvider = authStorage.getOAuthProviders().find((item) => item.id === provider);
6
7
  if (!oauthProvider) {
7
8
  throw new Error(`No internal OAuth login flow is available for ${provider}.`);
@@ -1,11 +1,12 @@
1
1
  import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent";
2
+ import { piAuthFile } from "../../runtime/paths.js";
2
3
 
3
4
  function compareText(a, b) {
4
5
  return a.localeCompare(b, undefined, { sensitivity: "base", numeric: true });
5
6
  }
6
7
 
7
8
  export function createPiRuntime({ provider, apiKey } = {}) {
8
- const authStorage = AuthStorage.create();
9
+ const authStorage = AuthStorage.create(piAuthFile);
9
10
  if (provider && apiKey) {
10
11
  authStorage.setRuntimeApiKey(provider, apiKey);
11
12
  }
@@ -21,6 +21,28 @@ function runProcess(command, args, options = {}) {
21
21
  });
22
22
  }
23
23
 
24
+ function normalizeCategory(category) {
25
+ if (typeof category !== "string") return null;
26
+ const trimmed = category.trim();
27
+ return trimmed || null;
28
+ }
29
+
30
+ function normalizeKeywords(keywords) {
31
+ if (!Array.isArray(keywords)) return [];
32
+ return [...new Set(keywords
33
+ .filter((keyword) => typeof keyword === "string")
34
+ .map((keyword) => keyword.trim())
35
+ .filter(Boolean))];
36
+ }
37
+
38
+ function formatSemanticMetadata(tool) {
39
+ return [
40
+ "Semantic metadata:",
41
+ `- category: ${tool.category || "none"}`,
42
+ `- keywords: ${tool.keywords?.length ? tool.keywords.join(", ") : "none"}`
43
+ ].join("\n");
44
+ }
45
+
24
46
  export class ToolRegistry {
25
47
  constructor({ logger } = {}) {
26
48
  this.logger = logger;
@@ -52,6 +74,8 @@ export class ToolRegistry {
52
74
  const skillHints = this.skillRegistry.normalizeHints(manifest);
53
75
  this.tools.set(manifest.name, {
54
76
  ...manifest,
77
+ category: normalizeCategory(manifest.category),
78
+ keywords: normalizeKeywords(manifest.keywords),
55
79
  skillHints,
56
80
  dir: toolDir,
57
81
  entry: path.join(toolDir, manifest.entry || "index.js"),
@@ -75,6 +99,8 @@ export class ToolRegistry {
75
99
  input: tool.input,
76
100
  output: tool.output,
77
101
  configSchema: tool.configSchema || {},
102
+ category: tool.category,
103
+ keywords: tool.keywords || [],
78
104
  skillHints: tool.skillHints || []
79
105
  }));
80
106
  }
@@ -89,13 +115,19 @@ export class ToolRegistry {
89
115
  const result = await runProcess("node", [tool.entry, "--help"], { cwd: tool.dir, env: toolEnv() });
90
116
  const help = result.stdout || result.stderr;
91
117
  const skills = await this.resolveSkills(name);
92
- if (!skills.length) return help;
93
- const skillHelp = skills.map((item) => [
94
- `- ${item.name}${item.when ? ` (${item.when})` : ""}`,
95
- item.description ? ` ${item.description}` : null,
96
- item.found ? ` path: ${item.path}` : " warning: skill not found"
97
- ].filter(Boolean).join("\n")).join("\n");
98
- return `${help}\n\nAssigned skills:\n${skillHelp}\n`;
118
+ const sections = [
119
+ help.trimEnd(),
120
+ formatSemanticMetadata(tool)
121
+ ];
122
+ if (skills.length) {
123
+ const skillHelp = skills.map((item) => [
124
+ `- ${item.name}${item.when ? ` (${item.when})` : ""}`,
125
+ item.description ? ` ${item.description}` : null,
126
+ item.found ? ` path: ${item.path}` : " warning: skill not found"
127
+ ].filter(Boolean).join("\n")).join("\n");
128
+ sections.push(`Assigned skills:\n${skillHelp}`);
129
+ }
130
+ return `${sections.filter(Boolean).join("\n\n")}\n`;
99
131
  }
100
132
 
101
133
  async resolveSkills(name) {
@@ -5,9 +5,12 @@ import path from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
 
7
7
  export const arisaPackageDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
8
- export const arisaHomeDir = path.join(os.homedir(), ".arisa");
8
+ export const arisaHomeDir = process.env.ARISA_HOME
9
+ ? path.resolve(process.env.ARISA_HOME)
10
+ : path.join(os.homedir(), ".arisa");
9
11
  export const stateDir = path.join(arisaHomeDir, "state");
10
12
  export const configFile = path.join(stateDir, "config.json");
13
+ export const piAuthFile = path.join(stateDir, "pi-auth.json");
11
14
  export const servicePidFile = path.join(stateDir, "arisa.pid");
12
15
  export const serviceLogFile = path.join(stateDir, "arisa.log");
13
16
  export function createIpcSocketPath({ homeDir = arisaHomeDir, platform = process.platform } = {}) {
@@ -1,7 +1,7 @@
1
1
  import { Bot, InputFile } from "grammy";
2
2
  import path from "node:path";
3
3
  import { authorizeChat } from "./auth.js";
4
- import { captureIncomingArtifact } from "./media.js";
4
+ import { captureIncomingArtifact, formatLocationText } from "./media.js";
5
5
  import { renderTelegramHtml } from "./text-format.js";
6
6
  import { buildPiAuthRecoveryBlockedMessage, buildPiAuthTelegramMessage, getErrorMessage, getPiAuthIssue, getPiAuthStatus } from "../../core/agent/auth-flow.js";
7
7
  import { createPiOAuthLogin } from "../../core/agent/pi-auth-login.js";
@@ -29,6 +29,7 @@ function quotedMessageSummary(message) {
29
29
  if (message.document) parts.push(`quotedKind: document`);
30
30
  if (message.video) parts.push(`quotedKind: video`);
31
31
  if (message.sticker) parts.push(`quotedKind: sticker`);
32
+ if (message.location) parts.push(`quotedKind: location`, `quotedLocation: ${formatLocationText(message)}`);
32
33
 
33
34
  if (!message.text && !message.caption) {
34
35
  parts.push(`Important: this message replies to a Telegram message with no textual body available in the update. Use the quoted kind and metadata as context.`);
@@ -45,7 +46,7 @@ function getTelegramCommand(ctx) {
45
46
  }
46
47
 
47
48
  function getIncomingMessageText(message) {
48
- return message?.text || message?.caption || "";
49
+ return message?.text || message?.caption || formatLocationText(message) || "";
49
50
  }
50
51
 
51
52
  function baseMimeType(mimeType = "") {
@@ -32,6 +32,20 @@ function incomingCaptionMetadata(ctx) {
32
32
  return ctx.message?.caption ? { caption: ctx.message.caption } : {};
33
33
  }
34
34
 
35
+ export function formatLocationText(message) {
36
+ const location = message?.venue?.location || message?.location;
37
+ if (!location) return "";
38
+
39
+ const venue = message.venue;
40
+ const lines = [];
41
+ if (venue?.title) lines.push(`Venue: ${venue.title}`);
42
+ if (venue?.address) lines.push(`Address: ${venue.address}`);
43
+ lines.push(`Latitude: ${location.latitude}`);
44
+ lines.push(`Longitude: ${location.longitude}`);
45
+ lines.push(`Maps: https://maps.google.com/?q=${location.latitude},${location.longitude}`);
46
+ return lines.join("\n");
47
+ }
48
+
35
49
  export async function captureIncomingArtifact(ctx, artifactStore) {
36
50
  const chatId = ctx.chat.id;
37
51
  const store = artifactStore.forChat(chatId);
@@ -124,6 +138,14 @@ export async function captureIncomingArtifact(ctx, artifactStore) {
124
138
  });
125
139
  }
126
140
 
141
+ if (ctx.message?.location) {
142
+ return store.createText({
143
+ text: formatLocationText(ctx.message),
144
+ source: baseSource,
145
+ metadata: { visibility: "internal", representation: "inline-message" }
146
+ });
147
+ }
148
+
127
149
  if (ctx.message?.text) {
128
150
  return store.createText({
129
151
  text: ctx.message.text,
@@ -1,6 +1,10 @@
1
1
  import assert from "node:assert/strict";
2
+ import { execFile } from "node:child_process";
3
+ import { mkdtemp, rm } from "node:fs/promises";
4
+ import os from "node:os";
2
5
  import path from "node:path";
3
6
  import test from "node:test";
7
+ import { promisify } from "node:util";
4
8
  import {
5
9
  chatsDir,
6
10
  createIpcSocketPath,
@@ -11,6 +15,8 @@ import {
11
15
  stateDir
12
16
  } from "../src/runtime/paths.js";
13
17
 
18
+ const execFileAsync = promisify(execFile);
19
+
14
20
  test("keeps chat artifact paths scoped below the chat directory", () => {
15
21
  const artifactsDir = getChatArtifactsDir("chat-1");
16
22
 
@@ -52,3 +58,25 @@ test("creates POSIX IPC socket paths under the state directory", () => {
52
58
 
53
59
  assert.equal(socketPath, path.join("/tmp/arisa-home", "state", "arisa.sock"));
54
60
  });
61
+
62
+ test("uses ARISA_HOME for instance-scoped paths", async (t) => {
63
+ const customHome = await mkdtemp(path.join(os.tmpdir(), "arisa-home-"));
64
+ t.after(() => rm(customHome, { recursive: true, force: true }));
65
+
66
+ const script = `
67
+ const paths = await import(${JSON.stringify(new URL("../src/runtime/paths.js", import.meta.url).href)});
68
+ process.stdout.write(JSON.stringify({
69
+ arisaHomeDir: paths.arisaHomeDir,
70
+ configFile: paths.configFile,
71
+ piAuthFile: paths.piAuthFile
72
+ }));
73
+ `;
74
+ const { stdout } = await execFileAsync(process.execPath, ["--input-type=module", "--eval", script], {
75
+ env: { ...process.env, ARISA_HOME: customHome }
76
+ });
77
+ const paths = JSON.parse(stdout);
78
+
79
+ assert.equal(paths.arisaHomeDir, path.resolve(customHome));
80
+ assert.equal(paths.configFile, path.join(customHome, "state", "config.json"));
81
+ assert.equal(paths.piAuthFile, path.join(customHome, "state", "pi-auth.json"));
82
+ });
@@ -79,3 +79,64 @@ test("marks incoming Telegram text artifacts as internal inline messages", async
79
79
  representation: "inline-message"
80
80
  });
81
81
  });
82
+
83
+ function createLocationContext({ location, venue } = {}) {
84
+ return {
85
+ chat: { id: 123 },
86
+ from: { id: 456, username: "martin" },
87
+ msg: { message_id: 789 },
88
+ message: {
89
+ message_id: 789,
90
+ location: location || { latitude: 40.4168, longitude: -3.7038 },
91
+ ...(venue ? { venue } : {})
92
+ }
93
+ };
94
+ }
95
+
96
+ test("turns a shared Telegram location into a processable text artifact", async () => {
97
+ const artifactStore = {
98
+ forChat: () => ({
99
+ createText: async (request) => ({ id: "artifact-1", kind: "text", mimeType: "text/plain", ...request })
100
+ })
101
+ };
102
+
103
+ const artifact = await captureIncomingArtifact(createLocationContext(), artifactStore);
104
+
105
+ assert.match(artifact.text, /Latitude: 40\.4168/);
106
+ assert.match(artifact.text, /Longitude: -3\.7038/);
107
+ assert.match(artifact.text, /Maps: https:\/\/maps\.google\.com\/\?q=40\.4168,-3\.7038/);
108
+ });
109
+
110
+ test("includes venue title and address when a location is shared as a venue", async () => {
111
+ const artifactStore = {
112
+ forChat: () => ({
113
+ createText: async (request) => ({ id: "artifact-1", kind: "text", mimeType: "text/plain", ...request })
114
+ })
115
+ };
116
+
117
+ const ctx = createLocationContext({
118
+ venue: { title: "Cafe Tortoni", address: "Av. de Mayo 825", location: { latitude: -34.6083, longitude: -58.3712 } }
119
+ });
120
+
121
+ const artifact = await captureIncomingArtifact(ctx, artifactStore);
122
+
123
+ assert.match(artifact.text, /Venue: Cafe Tortoni/);
124
+ assert.match(artifact.text, /Address: Av\. de Mayo 825/);
125
+ assert.match(artifact.text, /Latitude: -34\.6083/);
126
+ });
127
+
128
+ test("surfaces shared location coordinates inline in the agent prompt", () => {
129
+ const ctx = createLocationContext();
130
+ const prompt = buildPrompt({
131
+ ctx,
132
+ artifact: {
133
+ id: "artifact-1",
134
+ kind: "text",
135
+ mimeType: "text/plain",
136
+ text: "Latitude: 40.4168\nLongitude: -3.7038\nMaps: https://maps.google.com/?q=40.4168,-3.7038"
137
+ }
138
+ });
139
+
140
+ assert.match(prompt, /text: Latitude: 40\.4168/);
141
+ assert.doesNotMatch(prompt, /artifactId: artifact-1/);
142
+ });
@@ -20,10 +20,10 @@ async function resetHome() {
20
20
  await rm(arisaHomeDir, { recursive: true, force: true });
21
21
  }
22
22
 
23
- async function createFakeTool(name = "fake-tool") {
23
+ async function createFakeTool(name = "fake-tool", manifestOverrides = {}) {
24
24
  const dir = path.join(toolsDir, name);
25
25
  await mkdir(dir, { recursive: true });
26
- await writeFile(path.join(dir, "tool.manifest.json"), `${JSON.stringify({
26
+ const manifest = {
27
27
  name,
28
28
  description: "Fake test tool",
29
29
  entry: "index.js",
@@ -33,6 +33,10 @@ async function createFakeTool(name = "fake-tool") {
33
33
  apiKey: { type: "string", required: false }
34
34
  },
35
35
  skillHints: [{ name: "missing-skill", when: "testing" }]
36
+ };
37
+ await writeFile(path.join(dir, "tool.manifest.json"), `${JSON.stringify({
38
+ ...manifest,
39
+ ...manifestOverrides
36
40
  }, null, 2)}\n`, "utf8");
37
41
  await writeFile(path.join(dir, "config.js"), "export default {\n apiKey: \"default-key\"\n};\n", "utf8");
38
42
  await writeFile(path.join(dir, "index.js"), `import { readFile } from "node:fs/promises";
@@ -67,7 +71,10 @@ process.stdout.write(JSON.stringify({
67
71
 
68
72
  test("loads and lists installed tools from the user tools directory", async () => {
69
73
  await resetHome();
70
- await createFakeTool("fake-tool");
74
+ await createFakeTool("fake-tool", {
75
+ category: "memory",
76
+ keywords: ["memory", "essential"]
77
+ });
71
78
 
72
79
  const registry = new ToolRegistry();
73
80
  await registry.load();
@@ -80,10 +87,40 @@ test("loads and lists installed tools from the user tools directory", async () =
80
87
  configSchema: {
81
88
  apiKey: { type: "string", required: false }
82
89
  },
90
+ category: "memory",
91
+ keywords: ["memory", "essential"],
83
92
  skillHints: [{ name: "missing-skill", when: "testing" }]
84
93
  }]);
85
94
  });
86
95
 
96
+ test("lists optional semantic metadata with stable defaults", async () => {
97
+ await resetHome();
98
+ await createFakeTool("fake-tool");
99
+
100
+ const registry = new ToolRegistry();
101
+ await registry.load();
102
+
103
+ assert.equal(registry.list()[0].category, null);
104
+ assert.deepEqual(registry.list()[0].keywords, []);
105
+ });
106
+
107
+ test("shows semantic metadata in tool help", async () => {
108
+ await resetHome();
109
+ await createFakeTool("fake-tool", {
110
+ category: "memory",
111
+ keywords: ["memory", "essential"]
112
+ });
113
+
114
+ const registry = new ToolRegistry();
115
+ await registry.load();
116
+
117
+ const help = await registry.help("fake-tool");
118
+
119
+ assert.match(help, /Fake test tool help/);
120
+ assert.match(help, /Semantic metadata:\n- category: memory\n- keywords: memory, essential/);
121
+ assert.match(help, /Assigned skills:/);
122
+ });
123
+
87
124
  test("runs a registered tool process with an enriched request and cleans up request files", async () => {
88
125
  await resetHome();
89
126
  await createFakeTool("fake-tool");