arisa 5.1.49 → 5.1.64

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 (56) hide show
  1. package/AGENTS.md +0 -2
  2. package/README.md +9 -0
  3. package/package.json +1 -1
  4. package/src/core/agent/agent-manager.js +49 -489
  5. package/src/core/agent/agent-session-lifecycle.js +181 -0
  6. package/src/core/agent/pi-capability-tools.js +183 -0
  7. package/src/core/artifacts/artifact-store.js +73 -17
  8. package/src/core/capabilities/capability-service.js +340 -0
  9. package/src/core/config/config-defaults.js +28 -1
  10. package/src/core/tasks/task-routing.js +7 -0
  11. package/src/core/tasks/task-runner.js +68 -0
  12. package/src/core/tasks/task-store.js +382 -92
  13. package/src/core/tools/tool-output-materializer.js +5 -5
  14. package/src/core/tools/tool-registry.js +20 -5
  15. package/src/core/tools/weighted-resource-governor.js +153 -0
  16. package/src/index.js +20 -0
  17. package/src/official-tools.lock.json +62 -45
  18. package/src/runtime/arisa-capabilities.js +51 -242
  19. package/src/runtime/create-app.js +11 -2
  20. package/src/runtime/create-headless-app.js +7 -4
  21. package/src/runtime/paths.js +4 -0
  22. package/src/runtime/service-manager.js +3 -1
  23. package/src/runtime/service-supervisor.js +98 -0
  24. package/src/transport/telegram/bot.js +186 -374
  25. package/src/transport/telegram/chat-queue.js +83 -6
  26. package/src/transport/telegram/prompt-builders.js +9 -0
  27. package/src/transport/telegram/reply-topic-routing.js +111 -0
  28. package/src/transport/telegram/task-dispatcher.js +96 -36
  29. package/src/transport/telegram/telegram-auth-controller.js +180 -0
  30. package/src/transport/telegram/telegram-session-bridge.js +177 -0
  31. package/src/transport/telegram/telegram-tools-command.js +28 -0
  32. package/src/transport/telegram/telegram-workspace-controller.js +66 -0
  33. package/src/transport/telegram/workspace-topic-store.js +228 -0
  34. package/test/agent-session-lifecycle.test.js +58 -0
  35. package/test/artifact-store.test.js +38 -2
  36. package/test/capabilities-security.test.js +58 -0
  37. package/test/chat-queue.test.js +32 -0
  38. package/test/context-and-task-bounds.test.js +76 -1
  39. package/test/device-code-message.test.js +9 -0
  40. package/test/media-caption.test.js +1 -1
  41. package/test/model-selection.test.js +9 -1
  42. package/test/official-tool-dependencies.test.js +1 -1
  43. package/test/paths.test.js +8 -0
  44. package/test/pi-capability-tools.test.js +65 -0
  45. package/test/service-manager.test.js +48 -0
  46. package/test/session-start-operational-notes.test.js +1 -1
  47. package/test/task-idempotency.test.js +40 -0
  48. package/test/task-routing.test.js +62 -0
  49. package/test/task-store.test.js +231 -7
  50. package/test/telegram-reply-topic-routing.test.js +94 -0
  51. package/test/telegram-task-dispatcher.test.js +150 -23
  52. package/test/telegram-text-artifact.test.js +13 -2
  53. package/test/telegram-tools-command.test.js +47 -0
  54. package/test/telegram-workspace-topic-store.test.js +124 -0
  55. package/test/tool-registry-run.test.js +41 -0
  56. package/test/weighted-resource-governor.test.js +95 -0
@@ -0,0 +1,177 @@
1
+ import path from "node:path";
2
+ import { InputFile } from "grammy";
3
+ import { cancelRestartReceipt, prepareRestartReceipt } from "../../runtime/restart-receipt.js";
4
+ import { isSilentReply } from "./prompt-builders.js";
5
+ import { renderTelegramHtml } from "./text-format.js";
6
+ import { resolveTelegramWorkspaceRoute, topicSessionId } from "./workspace-group.js";
7
+
8
+ function deliveryMethod(artifact, method) {
9
+ if (method) return method;
10
+ if (artifact.metadata?.delivery?.method) return artifact.metadata.delivery.method;
11
+ if (artifact.kind === "audio" || artifact.mimeType?.startsWith("audio/")) return "audio";
12
+ if (artifact.kind === "image" || artifact.mimeType?.startsWith("image/")) return "photo";
13
+ if (artifact.kind === "video" || artifact.mimeType?.startsWith("video/")) return "video";
14
+ return "document";
15
+ }
16
+
17
+ function safeCaption(caption) {
18
+ return caption && !/(^|\s)(\/[^\s]|[A-Za-z]:[\\/])/.test(caption) ? caption : undefined;
19
+ }
20
+
21
+ export function createTelegramSessionBridgeController({
22
+ config,
23
+ api,
24
+ agentManager,
25
+ artifactStore,
26
+ sessionSeeds,
27
+ workspaceTopics,
28
+ getChatState,
29
+ buildTopicInitializationHandoff,
30
+ logger
31
+ }) {
32
+ function createWorkspaceAccessGuard(route) {
33
+ return async () => {
34
+ if (!route.workspace) return;
35
+ const current = await resolveTelegramWorkspaceRoute({
36
+ config,
37
+ api,
38
+ ctx: {
39
+ chat: { id: route.transportChatId, type: "supergroup", is_forum: true },
40
+ from: { id: route.ownerChatId },
41
+ message: { message_thread_id: route.threadId }
42
+ }
43
+ });
44
+ if (!current.ok) throw new Error("Owner-only workspace access is paused.");
45
+ };
46
+ }
47
+
48
+ function createSessionBridge(route) {
49
+ const messageOptions = (extra = {}) => route.workspace && route.threadId
50
+ ? { ...extra, message_thread_id: route.threadId }
51
+ : extra;
52
+ const initializeForumTopic = async ({ messageThreadId, name, context }) => {
53
+ if (!route.workspace) throw new Error("Telegram topic initialization is only available from the owner workspace forum.");
54
+ await createWorkspaceAccessGuard(route)();
55
+ const initializedSessionId = topicSessionId({
56
+ ownerChatId: route.ownerChatId,
57
+ groupChatId: route.transportChatId,
58
+ threadId: messageThreadId,
59
+ generalTopicId: route.generalTopicId
60
+ });
61
+ if (initializedSessionId === String(route.ownerChatId)) {
62
+ throw new Error("The General topic already uses the owner's private session and cannot be reinitialized here.");
63
+ }
64
+ const handoff = buildTopicInitializationHandoff({ name, context });
65
+ await workspaceTopics.upsertTopic(route.ownerChatId, route.transportChatId, {
66
+ threadId: messageThreadId,
67
+ name,
68
+ description: context,
69
+ source: "arisa-initialized"
70
+ });
71
+ await sessionSeeds.set(initializedSessionId, handoff);
72
+ agentManager.resetSession(initializedSessionId, { handoff });
73
+ await agentManager.waitForSessionClose(initializedSessionId);
74
+ return {
75
+ ok: true,
76
+ chatId: route.transportChatId,
77
+ messageThreadId,
78
+ sessionId: initializedSessionId,
79
+ name,
80
+ initialized: true
81
+ };
82
+ };
83
+ return {
84
+ sendMedia: async (filePath, { method = "audio", caption, filename } = {}) => {
85
+ logger?.log("telegram", `sending ${method} reply for chat ${route.transportChatId}`);
86
+ const input = new InputFile(filePath, filename || undefined);
87
+ const options = messageOptions({ caption });
88
+ if (method === "voice") return api.sendVoice(route.transportChatId, input, options);
89
+ if (method === "document") return api.sendDocument(route.transportChatId, input, options);
90
+ if (method === "photo" || method === "image") return api.sendPhoto(route.transportChatId, input, options);
91
+ if (method === "video") return api.sendVideo(route.transportChatId, input, options);
92
+ return api.sendAudio(route.transportChatId, input, options);
93
+ },
94
+ createForumTopic: async (name, context) => {
95
+ if (!route.workspace) throw new Error("Telegram topic creation is only available from the owner workspace forum.");
96
+ await createWorkspaceAccessGuard(route)();
97
+ const topic = await api.createForumTopic(route.transportChatId, name);
98
+ return initializeForumTopic({
99
+ messageThreadId: topic.message_thread_id,
100
+ name: topic.name,
101
+ context
102
+ });
103
+ },
104
+ initializeForumTopic,
105
+ prepareRestartReceipt: (summary) => prepareRestartReceipt({
106
+ transportChatId: route.transportChatId,
107
+ threadId: route.threadId
108
+ }, { reason: String(summary || "Agent-requested restart").trim() }),
109
+ cancelRestartReceipt,
110
+ getTaskContext: () => route.workspace ? {
111
+ transport: "telegram",
112
+ destination: {
113
+ chatId: route.transportChatId,
114
+ threadId: route.topicThreadId
115
+ }
116
+ } : null
117
+ };
118
+ }
119
+
120
+ async function sendTextReply({ sendText, sendDocument, chatId, artifactChatId = chatId, text }) {
121
+ const maxInlineReplyLength = 3500;
122
+ if (isSilentReply(text)) {
123
+ logger?.log("telegram", `suppressing silent reply for chat ${chatId}`);
124
+ return;
125
+ }
126
+
127
+ if (text.length > maxInlineReplyLength) {
128
+ logger?.log("telegram", `sending long reply as markdown attachment for chat ${chatId}`);
129
+ const chatArtifactStore = artifactStore.forChat(artifactChatId);
130
+ const artifact = await chatArtifactStore.createGeneratedFile({
131
+ fileName: `reply-${Date.now()}.md`,
132
+ content: text,
133
+ kind: "document",
134
+ mimeType: "text/markdown",
135
+ source: { type: "assistant", chatId },
136
+ metadata: { delivery: "telegram-document" }
137
+ });
138
+ await sendDocument(new InputFile(artifact.path, path.basename(artifact.path)), {
139
+ caption: "Response attached as Markdown."
140
+ });
141
+ return;
142
+ }
143
+
144
+ logger?.log("telegram", `sending text reply for chat ${chatId}`);
145
+ const sent = await sendText(renderTelegramHtml(text), { parse_mode: "HTML" });
146
+ if (sent?.message_id) {
147
+ const messages = getChatState(chatId).assistantMessages;
148
+ messages.set(sent.message_id, text);
149
+ while (messages.size > 50) messages.delete(messages.keys().next().value);
150
+ }
151
+ }
152
+
153
+ function installArtifactDeliveryHandler() {
154
+ agentManager.setArtifactDeliveryHandler?.(async ({ chatId, artifact, caption, method }) => {
155
+ const resolvedMethod = deliveryMethod(artifact, method);
156
+ await createSessionBridge({
157
+ workspace: false,
158
+ sessionId: String(chatId),
159
+ scopeChatId: chatId,
160
+ transportChatId: chatId,
161
+ threadId: null
162
+ }).sendMedia(artifact.path, {
163
+ method: resolvedMethod,
164
+ caption: safeCaption(caption),
165
+ filename: path.basename(artifact.path)
166
+ });
167
+ return { ok: true, artifactId: artifact.id, method: resolvedMethod };
168
+ });
169
+ }
170
+
171
+ return {
172
+ createSessionBridge,
173
+ createWorkspaceAccessGuard,
174
+ installArtifactDeliveryHandler,
175
+ sendTextReply
176
+ };
177
+ }
@@ -0,0 +1,28 @@
1
+ import { getErrorMessage } from "../../core/agent/auth-flow.js";
2
+ import { formatToolUsageReport } from "../../runtime/tool-usage-report.js";
3
+ import { renderTelegramHtml } from "./text-format.js";
4
+
5
+ export function createTelegramToolsCommandHandler({
6
+ authorize,
7
+ contextRoute,
8
+ toolRegistry,
9
+ withTyping,
10
+ logger
11
+ }) {
12
+ return async (ctx) => {
13
+ logger?.log("telegram", `/tools command received in chat ${ctx.chat.id}`);
14
+ return withTyping(ctx, async () => {
15
+ const auth = await authorize(ctx);
16
+ if (!auth.ok) return;
17
+ try {
18
+ const report = await toolRegistry.usage(contextRoute(ctx).scopeChatId);
19
+ await ctx.reply(renderTelegramHtml(formatToolUsageReport(report)), { parse_mode: "HTML" });
20
+ logger?.log("telegram", `/tools command completed in chat ${ctx.chat.id}`);
21
+ } catch (error) {
22
+ const message = getErrorMessage(error);
23
+ logger?.error("telegram", `/tools command failed in chat ${ctx.chat.id}: ${message}`);
24
+ await ctx.reply(`Tool usage report failed: ${message}`);
25
+ }
26
+ });
27
+ };
28
+ }
@@ -0,0 +1,66 @@
1
+ import { authorizeChat } from "./auth.js";
2
+ import { resolveTelegramWorkspaceRoute } from "./workspace-group.js";
3
+
4
+ function incomingChatMeta(ctx) {
5
+ return {
6
+ languageCode: ctx.from?.language_code || "",
7
+ username: ctx.from?.username || "",
8
+ firstName: ctx.from?.first_name || "",
9
+ lastName: ctx.from?.last_name || ""
10
+ };
11
+ }
12
+
13
+ export function createTelegramWorkspaceController({ config, api, saveConfig }) {
14
+ const routes = new WeakMap();
15
+ const gateStates = new Map();
16
+
17
+ async function authorizeContext(ctx) {
18
+ const route = await resolveTelegramWorkspaceRoute({ config, api: ctx.api || api, ctx });
19
+ if (!route.workspace) {
20
+ const authorization = await authorizeChat({
21
+ config,
22
+ chatId: ctx.chat.id,
23
+ saveConfig,
24
+ chatMeta: incomingChatMeta(ctx)
25
+ });
26
+ if (authorization.ok) routes.set(ctx, route);
27
+ return authorization;
28
+ }
29
+
30
+ const gateKey = String(ctx.chat.id);
31
+ const previous = gateStates.get(gateKey);
32
+ if (!route.ok) {
33
+ gateStates.set(gateKey, route.reason || "locked");
34
+ if (previous !== (route.reason || "locked")) {
35
+ await ctx.reply("Private workspace access is paused because this forum is no longer owner-only.").catch(() => {});
36
+ }
37
+ return { ok: false, reason: route.reason || "workspace-locked" };
38
+ }
39
+ if (!(config.telegram.authorizedChatIds || []).includes(route.ownerChatId)) {
40
+ return { ok: false, reason: "owner-not-authorized" };
41
+ }
42
+ routes.set(ctx, route);
43
+ gateStates.set(gateKey, "ready");
44
+ if (previous && previous !== "ready") {
45
+ await ctx.reply("Private workspace access restored.").catch(() => {});
46
+ }
47
+ return { ok: true, firstTime: false, workspace: true };
48
+ }
49
+
50
+ function contextRoute(ctx) {
51
+ return routes.get(ctx) || {
52
+ ok: true,
53
+ workspace: false,
54
+ sessionId: String(ctx.chat.id),
55
+ scopeChatId: ctx.chat.id,
56
+ transportChatId: ctx.chat.id,
57
+ threadId: null
58
+ };
59
+ }
60
+
61
+ return {
62
+ authorizeContext,
63
+ contextRoute,
64
+ registerRoute: (ctx, route) => routes.set(ctx, route)
65
+ };
66
+ }
@@ -0,0 +1,228 @@
1
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { getChatTelegramWorkspacesFile } from "../../runtime/paths.js";
4
+
5
+ const recentProposalWindowMs = 30 * 24 * 60 * 60 * 1000;
6
+
7
+ function emptyState() {
8
+ return { version: 1, groups: {} };
9
+ }
10
+
11
+ function integer(value, label) {
12
+ const parsed = Number(value);
13
+ if (!Number.isSafeInteger(parsed)) throw new Error(`${label} must be an integer`);
14
+ return parsed;
15
+ }
16
+
17
+ function oneLine(value, maxLength) {
18
+ return String(value || "").replace(/\s+/g, " ").trim().slice(0, maxLength);
19
+ }
20
+
21
+ function normalizeState(parsed) {
22
+ if (!parsed || parsed.version !== 1 || !parsed.groups || typeof parsed.groups !== "object") {
23
+ throw new Error("Unsupported Telegram workspace topic state");
24
+ }
25
+ return parsed;
26
+ }
27
+
28
+ async function readState(file) {
29
+ try {
30
+ return normalizeState(JSON.parse(await readFile(file, "utf8")));
31
+ } catch (error) {
32
+ if (error?.code === "ENOENT") return emptyState();
33
+ throw error;
34
+ }
35
+ }
36
+
37
+ async function writeState(file, state) {
38
+ await mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
39
+ const temporary = `${file}.${process.pid}.${Date.now()}.tmp`;
40
+ await writeFile(temporary, `${JSON.stringify(state, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
41
+ await rename(temporary, file);
42
+ }
43
+
44
+ function workspace(state, groupChatId) {
45
+ const groupKey = String(groupChatId);
46
+ state.groups[groupKey] ||= { topics: {}, proposals: {} };
47
+ state.groups[groupKey].topics ||= {};
48
+ state.groups[groupKey].proposals ||= {};
49
+ return state.groups[groupKey];
50
+ }
51
+
52
+ function normalizeTopic(threadId, topic) {
53
+ return {
54
+ threadId: Number(threadId),
55
+ name: oneLine(topic?.name, 80),
56
+ description: oneLine(topic?.description, 240),
57
+ status: topic?.status === "closed" ? "closed" : "open",
58
+ source: oneLine(topic?.source, 40),
59
+ createdAt: oneLine(topic?.createdAt, 40),
60
+ updatedAt: oneLine(topic?.updatedAt, 40)
61
+ };
62
+ }
63
+
64
+ export async function migrateLegacyReplyTopics(config, store) {
65
+ let migratedGroups = 0;
66
+ for (const [groupChatId, workspaceConfig] of Object.entries(config.telegram?.ownerWorkspaceGroups || {})) {
67
+ const configured = workspaceConfig?.replyTopics;
68
+ if (!configured || typeof configured !== "object" || Array.isArray(configured)) continue;
69
+ const generalTopicId = Number(workspaceConfig.generalTopicId) || 1;
70
+ for (const [threadId, topic] of Object.entries(configured)) {
71
+ const numericThreadId = Number(threadId);
72
+ const name = oneLine(topic?.name, 80);
73
+ if (!Number.isSafeInteger(numericThreadId) || numericThreadId <= 0 || numericThreadId === generalTopicId || !name) continue;
74
+ await store.upsertTopic(workspaceConfig.ownerChatId, Number(groupChatId), {
75
+ threadId: numericThreadId,
76
+ name,
77
+ description: topic?.description,
78
+ source: "legacy-config-migration"
79
+ });
80
+ }
81
+ delete workspaceConfig.replyTopics;
82
+ migratedGroups += 1;
83
+ }
84
+ return migratedGroups;
85
+ }
86
+
87
+ export class WorkspaceTopicStore {
88
+ constructor({ resolveFile = getChatTelegramWorkspacesFile, now = () => Date.now() } = {}) {
89
+ this.resolveFile = resolveFile;
90
+ this.now = now;
91
+ this.queues = new Map();
92
+ }
93
+
94
+ async withOwnerLock(ownerChatId, work) {
95
+ const key = String(ownerChatId);
96
+ const previous = this.queues.get(key) || Promise.resolve();
97
+ const current = previous.catch(() => {}).then(work);
98
+ this.queues.set(key, current);
99
+ try {
100
+ return await current;
101
+ } finally {
102
+ if (this.queues.get(key) === current) this.queues.delete(key);
103
+ }
104
+ }
105
+
106
+ async read(ownerChatId) {
107
+ await (this.queues.get(String(ownerChatId)) || Promise.resolve()).catch(() => {});
108
+ return readState(this.resolveFile(ownerChatId));
109
+ }
110
+
111
+ async listTopics(ownerChatId, groupChatId, { includeClosed = false } = {}) {
112
+ const state = await this.read(ownerChatId);
113
+ const group = state.groups[String(groupChatId)];
114
+ if (!group?.topics) return [];
115
+ return Object.entries(group.topics)
116
+ .map(([threadId, topic]) => normalizeTopic(threadId, topic))
117
+ .filter((topic) => Number.isSafeInteger(topic.threadId)
118
+ && topic.threadId > 0
119
+ && topic.name
120
+ && (includeClosed || topic.status === "open"))
121
+ .sort((left, right) => left.threadId - right.threadId);
122
+ }
123
+
124
+ async upsertTopic(ownerChatId, groupChatId, { threadId, name, description, status = "open", source = "observed" }) {
125
+ const scopedOwner = integer(ownerChatId, "ownerChatId");
126
+ const scopedGroup = integer(groupChatId, "groupChatId");
127
+ const scopedThread = integer(threadId, "threadId");
128
+ const scopedName = oneLine(name, 80);
129
+ if (scopedThread <= 0) throw new Error("threadId must be positive");
130
+ if (!scopedName) throw new Error("topic name is required");
131
+
132
+ return this.withOwnerLock(scopedOwner, async () => {
133
+ const file = this.resolveFile(scopedOwner);
134
+ const state = await readState(file);
135
+ const group = workspace(state, scopedGroup);
136
+ const key = String(scopedThread);
137
+ const existing = group.topics[key] || {};
138
+ const timestamp = new Date(this.now()).toISOString();
139
+ group.topics[key] = {
140
+ ...existing,
141
+ name: scopedName,
142
+ description: description === undefined ? oneLine(existing.description, 240) : oneLine(description, 240),
143
+ status: status === "closed" ? "closed" : "open",
144
+ source: oneLine(source, 40) || oneLine(existing.source, 40) || "observed",
145
+ createdAt: existing.createdAt || timestamp,
146
+ updatedAt: timestamp
147
+ };
148
+ await writeState(file, state);
149
+ return normalizeTopic(key, group.topics[key]);
150
+ });
151
+ }
152
+
153
+ async setTopicStatus(ownerChatId, groupChatId, threadId, status) {
154
+ const scopedOwner = integer(ownerChatId, "ownerChatId");
155
+ const scopedGroup = integer(groupChatId, "groupChatId");
156
+ const scopedThread = integer(threadId, "threadId");
157
+ return this.withOwnerLock(scopedOwner, async () => {
158
+ const file = this.resolveFile(scopedOwner);
159
+ const state = await readState(file);
160
+ const topic = state.groups[String(scopedGroup)]?.topics?.[String(scopedThread)];
161
+ if (!topic) return null;
162
+ topic.status = status === "closed" ? "closed" : "open";
163
+ topic.updatedAt = new Date(this.now()).toISOString();
164
+ await writeState(file, state);
165
+ return normalizeTopic(scopedThread, topic);
166
+ });
167
+ }
168
+
169
+ async observeMessage(route, message = {}) {
170
+ if (!route?.workspace || !route.ownerChatId || !route.transportChatId) return null;
171
+ const threadId = route.topicThreadId;
172
+ if (!Number.isSafeInteger(threadId) || threadId === route.generalTopicId) return null;
173
+ if (message.forum_topic_created?.name) {
174
+ return this.upsertTopic(route.ownerChatId, route.transportChatId, {
175
+ threadId,
176
+ name: message.forum_topic_created.name,
177
+ source: "telegram-created"
178
+ });
179
+ }
180
+ if (message.forum_topic_edited?.name) {
181
+ return this.upsertTopic(route.ownerChatId, route.transportChatId, {
182
+ threadId,
183
+ name: message.forum_topic_edited.name,
184
+ source: "telegram-edited"
185
+ });
186
+ }
187
+ if (message.forum_topic_closed) {
188
+ return this.setTopicStatus(route.ownerChatId, route.transportChatId, threadId, "closed");
189
+ }
190
+ if (message.forum_topic_reopened) {
191
+ return this.setTopicStatus(route.ownerChatId, route.transportChatId, threadId, "open");
192
+ }
193
+ return null;
194
+ }
195
+
196
+ async recordProposal(ownerChatId, groupChatId, name) {
197
+ const scopedOwner = integer(ownerChatId, "ownerChatId");
198
+ const scopedGroup = integer(groupChatId, "groupChatId");
199
+ const scopedName = oneLine(name, 80);
200
+ if (!scopedName) return null;
201
+ return this.withOwnerLock(scopedOwner, async () => {
202
+ const file = this.resolveFile(scopedOwner);
203
+ const state = await readState(file);
204
+ const group = workspace(state, scopedGroup);
205
+ const key = scopedName.toLocaleLowerCase("en-US");
206
+ group.proposals[key] = {
207
+ name: scopedName,
208
+ proposedAt: new Date(this.now()).toISOString()
209
+ };
210
+ const ordered = Object.entries(group.proposals)
211
+ .sort((left, right) => String(right[1]?.proposedAt || "").localeCompare(String(left[1]?.proposedAt || "")))
212
+ .slice(0, 50);
213
+ group.proposals = Object.fromEntries(ordered);
214
+ await writeState(file, state);
215
+ return group.proposals[key];
216
+ });
217
+ }
218
+
219
+ async listRecentProposals(ownerChatId, groupChatId) {
220
+ const state = await this.read(ownerChatId);
221
+ const proposals = state.groups[String(groupChatId)]?.proposals || {};
222
+ const cutoff = this.now() - recentProposalWindowMs;
223
+ return Object.values(proposals)
224
+ .filter((proposal) => Date.parse(proposal?.proposedAt || "") >= cutoff)
225
+ .sort((left, right) => String(right.proposedAt).localeCompare(String(left.proposedAt)))
226
+ .slice(0, 12);
227
+ }
228
+ }
@@ -0,0 +1,58 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { AgentSessionLifecycle } from "../src/core/agent/agent-session-lifecycle.js";
4
+
5
+ test("session lifecycle owns cache closure and pending handoffs", async () => {
6
+ let closes = 0;
7
+ const lifecycle = new AgentSessionLifecycle({
8
+ logger: null,
9
+ summarizeContext: () => ({ messages: 0, estimatedTokens: 0 })
10
+ });
11
+ lifecycle.sessions.set("chat", {
12
+ session: {
13
+ async close() { closes += 1; }
14
+ }
15
+ });
16
+
17
+ lifecycle.resetSession("chat", { handoff: "continue here", parentSession: "parent.jsonl" });
18
+ await lifecycle.waitForClose("chat");
19
+
20
+ assert.equal(closes, 1);
21
+ assert.equal(lifecycle.sessions.has("chat"), false);
22
+ assert.equal(lifecycle.pendingNewSessions.has("chat"), true);
23
+ assert.deepEqual(lifecycle.pendingSessionHandoffs.get("chat"), {
24
+ text: "continue here",
25
+ parentSession: "parent.jsonl"
26
+ });
27
+
28
+ lifecycle.completeNewSession("chat");
29
+ assert.equal(lifecycle.pendingNewSessions.has("chat"), false);
30
+ assert.equal(lifecycle.pendingSessionHandoffs.has("chat"), false);
31
+ });
32
+
33
+ test("session lifecycle diagnostics remain available after extraction", async () => {
34
+ const lifecycle = new AgentSessionLifecycle({
35
+ logger: null,
36
+ summarizeContext: () => ({ messages: 2, estimatedTokens: 42 })
37
+ });
38
+ lifecycle.sessions.set("123", {
39
+ session: {
40
+ messages: [{ role: "user" }, { role: "assistant" }],
41
+ getSessionStats: () => ({ contextUsage: { tokens: 42, contextWindow: 1000, percent: 4.2 } })
42
+ }
43
+ });
44
+
45
+ assert.deepEqual(await lifecycle.getDiagnostic(), {
46
+ harness: "pi",
47
+ sessions: 1,
48
+ closingSessions: 0,
49
+ contexts: [{
50
+ chatId: "123",
51
+ messages: 2,
52
+ estimatedTokens: 42,
53
+ tokens: 42,
54
+ contextWindow: 1000,
55
+ percent: 4.2
56
+ }]
57
+ });
58
+ });
@@ -1,5 +1,5 @@
1
1
  import assert from "node:assert/strict";
2
- import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
2
+ import { 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";
@@ -9,7 +9,7 @@ process.env.HOME = homeDir;
9
9
  process.env.USERPROFILE = homeDir;
10
10
 
11
11
  const { ArtifactStore } = await import("../src/core/artifacts/artifact-store.js");
12
- const { arisaHomeDir } = await import("../src/runtime/paths.js");
12
+ const { arisaHomeDir, getChatArtifactsIndexFile } = await import("../src/runtime/paths.js");
13
13
 
14
14
  async function resetHome() {
15
15
  await rm(arisaHomeDir, { recursive: true, force: true });
@@ -46,6 +46,42 @@ test("creates, persists, reads, and lists text artifacts by recency", async () =
46
46
  assert.deepEqual(await reloadedStore.get(first.id), first);
47
47
  });
48
48
 
49
+ test("serializes 100 concurrent artifact writes across store instances", async () => {
50
+ await resetHome();
51
+ const chatId = "concurrent-chat";
52
+ const stores = Array.from({ length: 5 }, () => new ArtifactStore().forChat(chatId));
53
+ const artifacts = await Promise.all(Array.from({ length: 100 }, (_, index) => (
54
+ stores[index % stores.length].createText({
55
+ text: `message ${index}`,
56
+ source: { type: "test", index }
57
+ })
58
+ )));
59
+
60
+ const persisted = JSON.parse(await readFile(getChatArtifactsIndexFile(chatId), "utf8"));
61
+ assert.equal(persisted.length, 100);
62
+ assert.equal(new Set(persisted.map((artifact) => artifact.id)).size, 100);
63
+ assert.deepEqual(
64
+ new Set(persisted.map((artifact) => artifact.id)),
65
+ new Set(artifacts.map((artifact) => artifact.id))
66
+ );
67
+ const stateFiles = await readdir(path.dirname(getChatArtifactsIndexFile(chatId)));
68
+ assert.equal(stateFiles.some((name) => name.endsWith(".tmp")), false);
69
+ });
70
+
71
+ test("refuses to overwrite a corrupt artifact index", async () => {
72
+ await resetHome();
73
+ const chatId = "corrupt-chat";
74
+ const indexFile = getChatArtifactsIndexFile(chatId);
75
+ await new ArtifactStore().forChat(chatId).createText({ text: "safe", source: { type: "test" } });
76
+ await writeFile(indexFile, "{truncated", "utf8");
77
+
78
+ await assert.rejects(
79
+ new ArtifactStore().forChat(chatId).createText({ text: "must not replace", source: { type: "test" } }),
80
+ /Artifact index is unreadable/
81
+ );
82
+ assert.equal(await readFile(indexFile, "utf8"), "{truncated");
83
+ });
84
+
49
85
  test("copies file artifacts into the chat artifact directory", async () => {
50
86
  await resetHome();
51
87
  const originalDir = await mkdtemp(path.join(os.tmpdir(), "arisa-source-file-"));
@@ -142,6 +142,64 @@ test("requires chatId for chat-scoped IPC methods", async () => {
142
142
  }
143
143
  });
144
144
 
145
+ test("tool-emitted tasks cannot override trusted chat routing", async () => {
146
+ const capabilities = createCapabilities();
147
+ const created = await capabilities.dispatch({
148
+ method: "tasks.add",
149
+ toolName: "poller",
150
+ chatId: "trusted-chat",
151
+ params: {
152
+ task: {
153
+ kind: "agent_task",
154
+ route: { transport: "telegram", destination: { chatId: "attacker-chat" } },
155
+ payload: {
156
+ chatId: "attacker-chat",
157
+ telegramContext: { transportChatId: "attacker-chat" },
158
+ prompt: "Safe payload"
159
+ }
160
+ }
161
+ }
162
+ });
163
+
164
+ assert.equal(created.route, undefined);
165
+ assert.equal(created.payload.chatId, "trusted-chat");
166
+ assert.equal(created.payload.telegramContext, undefined);
167
+ assert.equal(created.payload.prompt, "Safe payload");
168
+ });
169
+
170
+ test("IPC resource notes remain scoped to the calling tool", async () => {
171
+ const calls = [];
172
+ const capabilities = createArisaCapabilities({
173
+ artifactStore: createFakeArtifactStore(),
174
+ taskStore: createFakeTaskStore(),
175
+ resourceNotes: {
176
+ set: async (...args) => {
177
+ calls.push(args);
178
+ return { ok: true };
179
+ }
180
+ }
181
+ });
182
+
183
+ await capabilities.dispatch({
184
+ method: "tools.setResourceNote",
185
+ toolName: "caller-tool",
186
+ chatId: "chat-1",
187
+ params: { name: "other-tool", resourceId: "resource-1", note: "watch" }
188
+ });
189
+
190
+ assert.deepEqual(calls, [["chat-1", "caller-tool", "resource-1", "watch"]]);
191
+ });
192
+
193
+ test("IPC does not expose Telegram-only capability methods", async () => {
194
+ const capabilities = createCapabilities();
195
+ await assert.rejects(() => capabilities.dispatch({
196
+ method: "telegram.createTopic",
197
+ toolName: "caller-tool",
198
+ chatId: "chat-1",
199
+ params: { name: "Unsafe", context: "No" }
200
+ }), /unknown IPC method: telegram\.createTopic/);
201
+ });
202
+
145
203
  test("agent events preserve a bounded immediate acknowledgement", async () => {
146
204
  const capabilities = createCapabilities();
147
205
  const created = await capabilities.dispatch({