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.
- package/AGENTS.md +0 -2
- package/README.md +9 -0
- package/package.json +1 -1
- package/src/core/agent/agent-manager.js +49 -489
- package/src/core/agent/agent-session-lifecycle.js +181 -0
- package/src/core/agent/pi-capability-tools.js +183 -0
- package/src/core/artifacts/artifact-store.js +73 -17
- package/src/core/capabilities/capability-service.js +340 -0
- package/src/core/config/config-defaults.js +28 -1
- package/src/core/tasks/task-routing.js +7 -0
- package/src/core/tasks/task-runner.js +68 -0
- package/src/core/tasks/task-store.js +382 -92
- package/src/core/tools/tool-output-materializer.js +5 -5
- package/src/core/tools/tool-registry.js +20 -5
- package/src/core/tools/weighted-resource-governor.js +153 -0
- package/src/index.js +20 -0
- package/src/official-tools.lock.json +62 -45
- package/src/runtime/arisa-capabilities.js +51 -242
- package/src/runtime/create-app.js +11 -2
- package/src/runtime/create-headless-app.js +7 -4
- package/src/runtime/paths.js +4 -0
- package/src/runtime/service-manager.js +3 -1
- package/src/runtime/service-supervisor.js +98 -0
- package/src/transport/telegram/bot.js +186 -374
- package/src/transport/telegram/chat-queue.js +83 -6
- package/src/transport/telegram/prompt-builders.js +9 -0
- package/src/transport/telegram/reply-topic-routing.js +111 -0
- package/src/transport/telegram/task-dispatcher.js +96 -36
- package/src/transport/telegram/telegram-auth-controller.js +180 -0
- package/src/transport/telegram/telegram-session-bridge.js +177 -0
- package/src/transport/telegram/telegram-tools-command.js +28 -0
- package/src/transport/telegram/telegram-workspace-controller.js +66 -0
- package/src/transport/telegram/workspace-topic-store.js +228 -0
- package/test/agent-session-lifecycle.test.js +58 -0
- package/test/artifact-store.test.js +38 -2
- package/test/capabilities-security.test.js +58 -0
- package/test/chat-queue.test.js +32 -0
- package/test/context-and-task-bounds.test.js +76 -1
- package/test/device-code-message.test.js +9 -0
- package/test/media-caption.test.js +1 -1
- package/test/model-selection.test.js +9 -1
- package/test/official-tool-dependencies.test.js +1 -1
- package/test/paths.test.js +8 -0
- package/test/pi-capability-tools.test.js +65 -0
- package/test/service-manager.test.js +48 -0
- package/test/session-start-operational-notes.test.js +1 -1
- package/test/task-idempotency.test.js +40 -0
- package/test/task-routing.test.js +62 -0
- package/test/task-store.test.js +231 -7
- package/test/telegram-reply-topic-routing.test.js +94 -0
- package/test/telegram-task-dispatcher.test.js +150 -23
- package/test/telegram-text-artifact.test.js +13 -2
- package/test/telegram-tools-command.test.js +47 -0
- package/test/telegram-workspace-topic-store.test.js +124 -0
- package/test/tool-registry-run.test.js +41 -0
- package/test/weighted-resource-governor.test.js +95 -0
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { SessionManager } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { getChatPiSessionsDir, sessionStartOperationalNotesFile } from "../../runtime/paths.js";
|
|
4
|
+
import { arisaInstallDir } from "./runtime-context.js";
|
|
5
|
+
|
|
6
|
+
const operationalNoteMaxChars = 220;
|
|
7
|
+
|
|
8
|
+
function normalizeOperationalNote(note) {
|
|
9
|
+
const text = typeof note === "string" ? note : note?.text;
|
|
10
|
+
const trimmed = String(text || "").replace(/\s+/g, " ").trim();
|
|
11
|
+
if (!trimmed) return "";
|
|
12
|
+
return trimmed.length <= operationalNoteMaxChars
|
|
13
|
+
? trimmed
|
|
14
|
+
: `${trimmed.slice(0, operationalNoteMaxChars - 1).trim()}…`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function loadSessionStartOperationalNotes() {
|
|
18
|
+
try {
|
|
19
|
+
const raw = readFileSync(sessionStartOperationalNotesFile, "utf8");
|
|
20
|
+
const parsed = JSON.parse(raw);
|
|
21
|
+
const notes = Array.isArray(parsed) ? parsed : parsed?.notes;
|
|
22
|
+
if (!Array.isArray(notes)) return [];
|
|
23
|
+
return notes.map(normalizeOperationalNote).filter(Boolean).slice(0, 20);
|
|
24
|
+
} catch {
|
|
25
|
+
return [];
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function formatSessionStartOperationalNotes(notes) {
|
|
30
|
+
if (!notes.length) return "";
|
|
31
|
+
return [
|
|
32
|
+
"Durable operating notes for this Arisa session:",
|
|
33
|
+
...notes.map((note) => `- ${note}`)
|
|
34
|
+
].join("\n");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function closeAgentSession(session) {
|
|
38
|
+
if (session?.close) return session.close();
|
|
39
|
+
if (session?.dispose) return session.dispose();
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export class AgentSessionLifecycle {
|
|
44
|
+
constructor({ logger, summarizeContext }) {
|
|
45
|
+
this.logger = logger;
|
|
46
|
+
this.summarizeContext = summarizeContext;
|
|
47
|
+
this.sessions = new Map();
|
|
48
|
+
this.pendingNewSessions = new Set();
|
|
49
|
+
this.pendingSessionHandoffs = new Map();
|
|
50
|
+
this.sessionClosePromises = new Map();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
closeCached(sessionKey) {
|
|
54
|
+
const key = String(sessionKey);
|
|
55
|
+
const existing = this.sessions.get(key);
|
|
56
|
+
this.sessions.delete(key);
|
|
57
|
+
const closeSession = (existing?.session?.close || existing?.session?.dispose)
|
|
58
|
+
? () => closeAgentSession(existing.session)
|
|
59
|
+
: null;
|
|
60
|
+
if (!closeSession) return this.sessionClosePromises.get(key) || Promise.resolve();
|
|
61
|
+
|
|
62
|
+
const previousClose = this.sessionClosePromises.get(key);
|
|
63
|
+
const closePromise = Promise.resolve(previousClose)
|
|
64
|
+
.catch(() => {})
|
|
65
|
+
.then(closeSession)
|
|
66
|
+
.catch((error) => {
|
|
67
|
+
this.logger?.error?.("agent", `session close failed for chat ${key}: ${error instanceof Error ? error.message : String(error)}`);
|
|
68
|
+
})
|
|
69
|
+
.finally(() => {
|
|
70
|
+
if (this.sessionClosePromises.get(key) === closePromise) {
|
|
71
|
+
this.sessionClosePromises.delete(key);
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
this.sessionClosePromises.set(key, closePromise);
|
|
75
|
+
return closePromise;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async waitForClose(sessionKey) {
|
|
79
|
+
const key = String(sessionKey);
|
|
80
|
+
let closing = this.sessionClosePromises.get(key);
|
|
81
|
+
while (closing) {
|
|
82
|
+
await closing;
|
|
83
|
+
closing = this.sessionClosePromises.get(key);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
resetConfigState() {
|
|
88
|
+
for (const key of this.sessions.keys()) this.closeCached(key);
|
|
89
|
+
this.pendingNewSessions.clear();
|
|
90
|
+
this.pendingSessionHandoffs.clear();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
resetSession(chatId, { handoff = "", parentSession = "" } = {}) {
|
|
94
|
+
const sessionKey = String(chatId);
|
|
95
|
+
this.closeCached(sessionKey);
|
|
96
|
+
this.pendingNewSessions.add(sessionKey);
|
|
97
|
+
const text = String(handoff || "").trim();
|
|
98
|
+
const parent = String(parentSession || "").trim();
|
|
99
|
+
if (text || parent) {
|
|
100
|
+
this.pendingSessionHandoffs.set(sessionKey, { text, parentSession: parent });
|
|
101
|
+
} else {
|
|
102
|
+
this.pendingSessionHandoffs.delete(sessionKey);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
createSessionManager(chatId, workspaceDir = arisaInstallDir, sessionRevision = 0) {
|
|
107
|
+
const sessionKey = String(chatId);
|
|
108
|
+
const sessionDir = getChatPiSessionsDir(sessionKey, sessionRevision);
|
|
109
|
+
if (this.pendingNewSessions.has(sessionKey)) {
|
|
110
|
+
this.logger?.log("agent", `starting new persisted session for chat ${sessionKey}`);
|
|
111
|
+
const handoff = this.pendingSessionHandoffs.get(sessionKey);
|
|
112
|
+
const sessionManager = SessionManager.create(
|
|
113
|
+
workspaceDir,
|
|
114
|
+
sessionDir,
|
|
115
|
+
handoff?.parentSession ? { parentSession: handoff.parentSession } : undefined
|
|
116
|
+
);
|
|
117
|
+
const operationalNotes = formatSessionStartOperationalNotes(loadSessionStartOperationalNotes());
|
|
118
|
+
if (operationalNotes) {
|
|
119
|
+
sessionManager.appendCustomMessageEntry(
|
|
120
|
+
"arisa-operational-notes",
|
|
121
|
+
operationalNotes,
|
|
122
|
+
false,
|
|
123
|
+
{ source: "session-start" }
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
if (handoff?.text) {
|
|
127
|
+
sessionManager.appendCustomMessageEntry(
|
|
128
|
+
"arisa-session-handoff",
|
|
129
|
+
handoff.text,
|
|
130
|
+
false,
|
|
131
|
+
{ source: "telegram-new" }
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
return { sessionManager, isNewSession: true };
|
|
135
|
+
}
|
|
136
|
+
this.logger?.log("agent", `recovering persisted session for chat ${sessionKey}`);
|
|
137
|
+
return {
|
|
138
|
+
sessionManager: SessionManager.continueRecent(workspaceDir, sessionDir),
|
|
139
|
+
isNewSession: false
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
completeNewSession(sessionKey) {
|
|
144
|
+
this.pendingNewSessions.delete(sessionKey);
|
|
145
|
+
this.pendingSessionHandoffs.delete(sessionKey);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async getDiagnostic() {
|
|
149
|
+
const contexts = await Promise.all([...this.sessions.entries()].map(async ([chatId, context]) => {
|
|
150
|
+
const base = { chatId };
|
|
151
|
+
try {
|
|
152
|
+
const stats = context.session.getSessionStats();
|
|
153
|
+
const retained = this.summarizeContext(context.session.messages);
|
|
154
|
+
return {
|
|
155
|
+
...base,
|
|
156
|
+
...retained,
|
|
157
|
+
tokens: stats.contextUsage?.tokens ?? null,
|
|
158
|
+
contextWindow: stats.contextUsage?.contextWindow ?? null,
|
|
159
|
+
percent: stats.contextUsage?.percent ?? null
|
|
160
|
+
};
|
|
161
|
+
} catch (error) {
|
|
162
|
+
return { ...base, error: error instanceof Error ? error.message : String(error) };
|
|
163
|
+
}
|
|
164
|
+
}));
|
|
165
|
+
return {
|
|
166
|
+
harness: "pi",
|
|
167
|
+
sessions: this.sessions.size,
|
|
168
|
+
closingSessions: this.sessionClosePromises.size,
|
|
169
|
+
contexts
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async closeAll() {
|
|
174
|
+
const contexts = [...this.sessions.values()];
|
|
175
|
+
this.sessions.clear();
|
|
176
|
+
await Promise.allSettled([
|
|
177
|
+
...this.sessionClosePromises.values(),
|
|
178
|
+
...contexts.map((context) => closeAgentSession(context.session))
|
|
179
|
+
]);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { defineTool } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Type } from "@sinclair/typebox";
|
|
3
|
+
import { getCoreCodingTools } from "./core-tools.js";
|
|
4
|
+
import { maxScheduledTaskListLimit } from "../capabilities/capability-service.js";
|
|
5
|
+
|
|
6
|
+
function jsonResult(result, text = JSON.stringify(result, null, 2)) {
|
|
7
|
+
return { content: [{ type: "text", text }], details: result };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function nativeTools(policy) {
|
|
11
|
+
return [{
|
|
12
|
+
name: "system_shell",
|
|
13
|
+
source: "arisa-native",
|
|
14
|
+
description: "Run native system shell commands in the active Arisa workspace.",
|
|
15
|
+
workspaceDir: policy.workspaceDir,
|
|
16
|
+
shell: policy.shell.shellPath || (process.platform === "win32" ? "powershell" : "sh"),
|
|
17
|
+
enabled: !(policy.excludeTools || []).includes("system_shell")
|
|
18
|
+
}];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function createPiCapabilityTools({ capabilityService, telegram, chatId, policy, logger }) {
|
|
22
|
+
if (!capabilityService?.execute) throw new Error("Pi capability tools require CapabilityService");
|
|
23
|
+
|
|
24
|
+
const baseContext = {
|
|
25
|
+
telegram,
|
|
26
|
+
returnMissingArtifact: true,
|
|
27
|
+
selectScheduledTasks: true,
|
|
28
|
+
wrapTaskResult: true,
|
|
29
|
+
allowTargetToolName: true,
|
|
30
|
+
delivery: async (artifact, options) => {
|
|
31
|
+
logger?.log("agent", `deliver artifact ${artifact.id} as ${options.method}`);
|
|
32
|
+
await telegram.sendMedia(artifact.path, options);
|
|
33
|
+
return {
|
|
34
|
+
method: options.method,
|
|
35
|
+
fileName: options.filename,
|
|
36
|
+
artifactId: artifact.id
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const execute = (actorToolName, method, params = {}, context = {}) => capabilityService.execute({
|
|
42
|
+
method,
|
|
43
|
+
actorToolName,
|
|
44
|
+
chatId,
|
|
45
|
+
params,
|
|
46
|
+
context: {
|
|
47
|
+
...baseContext,
|
|
48
|
+
taskContext: telegram.getTaskContext(),
|
|
49
|
+
...context
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
return [
|
|
54
|
+
defineTool({
|
|
55
|
+
name: "list_tools",
|
|
56
|
+
label: "List tools",
|
|
57
|
+
description: "List Arisa tools, or search installed tool metadata by capability with automatic official-catalog fallback.",
|
|
58
|
+
parameters: Type.Object({ query: Type.Optional(Type.String()) }),
|
|
59
|
+
execute: async (_id, params) => jsonResult(await execute("list_tools", "tools.list", params, {
|
|
60
|
+
workspaceDir: policy.workspaceDir,
|
|
61
|
+
coreTools: getCoreCodingTools({ tools: policy.tools, excludeTools: policy.excludeTools }),
|
|
62
|
+
nativeTools: nativeTools(policy)
|
|
63
|
+
}))
|
|
64
|
+
}),
|
|
65
|
+
defineTool({
|
|
66
|
+
name: "tool_help",
|
|
67
|
+
label: "Tool help",
|
|
68
|
+
description: "Show --help text for a CLI tool.",
|
|
69
|
+
parameters: Type.Object({ name: Type.String() }),
|
|
70
|
+
execute: async (_id, params) => {
|
|
71
|
+
const help = await execute("tool_help", "tools.help", params);
|
|
72
|
+
return { content: [{ type: "text", text: help }], details: { help } };
|
|
73
|
+
}
|
|
74
|
+
}),
|
|
75
|
+
defineTool({
|
|
76
|
+
name: "tool_skills",
|
|
77
|
+
label: "Tool skills",
|
|
78
|
+
description: "Show skills assigned to a CLI tool via its manifest skillHints.",
|
|
79
|
+
parameters: Type.Object({ name: Type.String() }),
|
|
80
|
+
execute: async (_id, params) => {
|
|
81
|
+
const skills = await execute("tool_skills", "tools.skills", params);
|
|
82
|
+
const visible = skills.map(({ content, ...item }) => item);
|
|
83
|
+
return jsonResult(visible);
|
|
84
|
+
}
|
|
85
|
+
}),
|
|
86
|
+
defineTool({
|
|
87
|
+
name: "set_tool_config",
|
|
88
|
+
label: "Set tool config",
|
|
89
|
+
description: "Write a tool config value scoped to the current chat.",
|
|
90
|
+
parameters: Type.Object({ name: Type.String(), field: Type.String(), value: Type.String() }),
|
|
91
|
+
execute: async (_id, params) => jsonResult(await execute("set_tool_config", "tools.setConfig", params))
|
|
92
|
+
}),
|
|
93
|
+
defineTool({
|
|
94
|
+
name: "set_tool_resource_note",
|
|
95
|
+
label: "Set tool resource note",
|
|
96
|
+
description: "Set or clear a deterministic chat-scoped note of up to 200 characters for one tool resource.",
|
|
97
|
+
parameters: Type.Object({
|
|
98
|
+
name: Type.String(),
|
|
99
|
+
resourceId: Type.String(),
|
|
100
|
+
note: Type.String()
|
|
101
|
+
}),
|
|
102
|
+
execute: async (_id, params) => jsonResult(await execute("set_tool_resource_note", "tools.setResourceNote", params))
|
|
103
|
+
}),
|
|
104
|
+
defineTool({
|
|
105
|
+
name: "run_tool",
|
|
106
|
+
label: "Run tool",
|
|
107
|
+
description: "Run a CLI tool using text input or an artifactId. Inspect the returned status/resolution fields. If a tool reports missing config, ask the user naturally, use set_tool_config, and retry. Set `deliver: true` to also send the generated file to the chat in one step (only when you want the user to receive it now, not for intermediate pipe steps).",
|
|
108
|
+
parameters: Type.Object({
|
|
109
|
+
name: Type.String(),
|
|
110
|
+
artifactId: Type.Optional(Type.String()),
|
|
111
|
+
text: Type.Optional(Type.String()),
|
|
112
|
+
resourceId: Type.Optional(Type.String()),
|
|
113
|
+
args: Type.Optional(Type.Record(Type.String(), Type.String())),
|
|
114
|
+
deliver: Type.Optional(Type.Boolean())
|
|
115
|
+
}),
|
|
116
|
+
execute: async (_id, params) => jsonResult(await execute("run_tool", "tools.run", params))
|
|
117
|
+
}),
|
|
118
|
+
defineTool({
|
|
119
|
+
name: "list_scheduled_tasks",
|
|
120
|
+
label: "List scheduled tasks",
|
|
121
|
+
description: "List scheduled async tasks for the current Telegram chat. Results default to 50 tasks, always include pending/running tasks, and accept an optional limit up to 100.",
|
|
122
|
+
parameters: Type.Object({
|
|
123
|
+
status: Type.Optional(Type.String()),
|
|
124
|
+
limit: Type.Optional(Type.Integer({ minimum: 1, maximum: maxScheduledTaskListLimit }))
|
|
125
|
+
}),
|
|
126
|
+
execute: async (_id, params) => jsonResult(await execute("list_scheduled_tasks", "tasks.list", params))
|
|
127
|
+
}),
|
|
128
|
+
defineTool({
|
|
129
|
+
name: "cancel_scheduled_task",
|
|
130
|
+
label: "Cancel scheduled task",
|
|
131
|
+
description: "Cancel one scheduled async task by id for the current Telegram chat.",
|
|
132
|
+
parameters: Type.Object({ id: Type.String() }),
|
|
133
|
+
execute: async (_id, params) => jsonResult(await execute("cancel_scheduled_task", "tasks.cancel", params))
|
|
134
|
+
}),
|
|
135
|
+
defineTool({
|
|
136
|
+
name: "cancel_all_scheduled_tasks",
|
|
137
|
+
label: "Cancel all scheduled tasks",
|
|
138
|
+
description: "Cancel all pending or running async tasks for the current Telegram chat.",
|
|
139
|
+
parameters: Type.Object({}),
|
|
140
|
+
execute: async () => jsonResult(await execute("cancel_all_scheduled_tasks", "tasks.cancelAll"))
|
|
141
|
+
}),
|
|
142
|
+
defineTool({
|
|
143
|
+
name: "create_telegram_topic",
|
|
144
|
+
label: "Create Telegram topic",
|
|
145
|
+
description: "Create and initialize a new topic in the current owner-only Telegram forum. Topic names are dynamic, and context seeds the isolated session without copying unrelated history.",
|
|
146
|
+
parameters: Type.Object({
|
|
147
|
+
name: Type.String({ minLength: 1, maxLength: 128 }),
|
|
148
|
+
context: Type.String({ minLength: 1, maxLength: 4000 })
|
|
149
|
+
}),
|
|
150
|
+
execute: async (_id, params) => jsonResult(await execute("create_telegram_topic", "telegram.createTopic", params))
|
|
151
|
+
}),
|
|
152
|
+
defineTool({
|
|
153
|
+
name: "initialize_telegram_topic",
|
|
154
|
+
label: "Initialize Telegram topic",
|
|
155
|
+
description: "Seed or replace the isolated context of an existing topic in the current owner-only Telegram forum.",
|
|
156
|
+
parameters: Type.Object({
|
|
157
|
+
messageThreadId: Type.Integer({ minimum: 2 }),
|
|
158
|
+
name: Type.String({ minLength: 1, maxLength: 128 }),
|
|
159
|
+
context: Type.String({ minLength: 1, maxLength: 4000 })
|
|
160
|
+
}),
|
|
161
|
+
execute: async (_id, params) => jsonResult(await execute("initialize_telegram_topic", "telegram.initializeTopic", params))
|
|
162
|
+
}),
|
|
163
|
+
defineTool({
|
|
164
|
+
name: "send_artifact",
|
|
165
|
+
label: "Send artifact",
|
|
166
|
+
description: "Deliver an existing chat artifact to the current Telegram chat. Pass the `artifactId` returned by run_tool or from an inbound file. The delivery method and filename are derived from the artifact (its delivery hint, kind, and stored name); internal local paths are never exposed. No caption is shown by default, since the filename already appears on the attachment; set `caption` only to add a separate visible label, or `method` to override the delivery method. The artifact is not deleted.",
|
|
167
|
+
parameters: Type.Object({
|
|
168
|
+
artifactId: Type.String(),
|
|
169
|
+
caption: Type.Optional(Type.String()),
|
|
170
|
+
method: Type.Optional(Type.Union([
|
|
171
|
+
Type.Literal("voice"),
|
|
172
|
+
Type.Literal("audio"),
|
|
173
|
+
Type.Literal("document")
|
|
174
|
+
]))
|
|
175
|
+
}),
|
|
176
|
+
execute: async (_id, params) => {
|
|
177
|
+
const result = await execute("send_artifact", "artifacts.deliver", params);
|
|
178
|
+
if (result?.ok === false) return jsonResult(result);
|
|
179
|
+
return jsonResult({ ok: true, sent: result }, `Media sent to Telegram as ${result.method}.`);
|
|
180
|
+
}
|
|
181
|
+
})
|
|
182
|
+
];
|
|
183
|
+
}
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import { mkdir, readFile,
|
|
1
|
+
import { copyFile, mkdir, open, readFile, rename, unlink, writeFile } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import crypto from "node:crypto";
|
|
4
4
|
import { getChatArtifactsDir, getChatArtifactsIndexFile } from "../../runtime/paths.js";
|
|
5
5
|
|
|
6
6
|
const UTF8_BOM = Buffer.from([0xef, 0xbb, 0xbf]);
|
|
7
|
+
const indexOperations = new Map();
|
|
7
8
|
|
|
8
9
|
function id() {
|
|
9
10
|
return crypto.randomUUID();
|
|
@@ -42,6 +43,48 @@ async function copyArtifactFile(originalPath, destPath, mimeType) {
|
|
|
42
43
|
return writeFile(destPath, withUtf8Bom(content));
|
|
43
44
|
}
|
|
44
45
|
|
|
46
|
+
async function serializeIndexOperation(indexFile, operation) {
|
|
47
|
+
const previous = indexOperations.get(indexFile) || Promise.resolve();
|
|
48
|
+
const current = previous.catch(() => {}).then(operation);
|
|
49
|
+
indexOperations.set(indexFile, current);
|
|
50
|
+
try {
|
|
51
|
+
return await current;
|
|
52
|
+
} finally {
|
|
53
|
+
if (indexOperations.get(indexFile) === current) indexOperations.delete(indexFile);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function syncParentDirectory(file) {
|
|
58
|
+
let handle;
|
|
59
|
+
try {
|
|
60
|
+
handle = await open(path.dirname(file), "r");
|
|
61
|
+
await handle.sync();
|
|
62
|
+
} catch {
|
|
63
|
+
// Some platforms do not support fsync on directories; rename remains atomic there.
|
|
64
|
+
} finally {
|
|
65
|
+
await handle?.close().catch(() => {});
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function writeJsonAtomically(file, value) {
|
|
70
|
+
await mkdir(path.dirname(file), { recursive: true });
|
|
71
|
+
const temporary = `${file}.${process.pid}.${id()}.tmp`;
|
|
72
|
+
let handle;
|
|
73
|
+
try {
|
|
74
|
+
handle = await open(temporary, "wx", 0o600);
|
|
75
|
+
await handle.writeFile(`${JSON.stringify(value, null, 2)}\n`, "utf8");
|
|
76
|
+
await handle.sync();
|
|
77
|
+
await handle.close();
|
|
78
|
+
handle = null;
|
|
79
|
+
await rename(temporary, file);
|
|
80
|
+
await syncParentDirectory(file);
|
|
81
|
+
} catch (error) {
|
|
82
|
+
await handle?.close().catch(() => {});
|
|
83
|
+
await unlink(temporary).catch(() => {});
|
|
84
|
+
throw error;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
45
88
|
class ChatArtifactStore {
|
|
46
89
|
constructor(chatId) {
|
|
47
90
|
this.chatId = String(chatId);
|
|
@@ -52,9 +95,15 @@ class ChatArtifactStore {
|
|
|
52
95
|
|
|
53
96
|
async reload() {
|
|
54
97
|
try {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
this.items =
|
|
98
|
+
const parsed = JSON.parse(await readFile(this.indexFile, "utf8"));
|
|
99
|
+
if (!Array.isArray(parsed)) throw new Error("Artifact index must contain a JSON array");
|
|
100
|
+
this.items = parsed;
|
|
101
|
+
} catch (error) {
|
|
102
|
+
if (error?.code === "ENOENT") {
|
|
103
|
+
this.items = [];
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
throw new Error(`Artifact index is unreadable: ${this.indexFile}`, { cause: error });
|
|
58
107
|
}
|
|
59
108
|
}
|
|
60
109
|
|
|
@@ -63,9 +112,20 @@ class ChatArtifactStore {
|
|
|
63
112
|
if (!this.items) await this.reload();
|
|
64
113
|
}
|
|
65
114
|
|
|
66
|
-
async
|
|
67
|
-
|
|
68
|
-
|
|
115
|
+
async appendToIndex(artifact) {
|
|
116
|
+
return serializeIndexOperation(this.indexFile, async () => {
|
|
117
|
+
await this.reload();
|
|
118
|
+
this.items.push(artifact);
|
|
119
|
+
await writeJsonAtomically(this.indexFile, this.items);
|
|
120
|
+
return artifact;
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async readIndex() {
|
|
125
|
+
return serializeIndexOperation(this.indexFile, async () => {
|
|
126
|
+
await this.reload();
|
|
127
|
+
return this.items;
|
|
128
|
+
});
|
|
69
129
|
}
|
|
70
130
|
|
|
71
131
|
async createText({ text, mimeType = "text/plain", source, metadata = {} }) {
|
|
@@ -81,9 +141,7 @@ class ChatArtifactStore {
|
|
|
81
141
|
metadata,
|
|
82
142
|
createdAt: new Date().toISOString()
|
|
83
143
|
};
|
|
84
|
-
this.
|
|
85
|
-
await this.saveIndex();
|
|
86
|
-
return artifact;
|
|
144
|
+
return this.appendToIndex(artifact);
|
|
87
145
|
}
|
|
88
146
|
|
|
89
147
|
async createFileArtifact({ fileName, kind, mimeType, source, metadata = {}, writeFileContent }) {
|
|
@@ -104,9 +162,7 @@ class ChatArtifactStore {
|
|
|
104
162
|
metadata,
|
|
105
163
|
createdAt: new Date().toISOString()
|
|
106
164
|
};
|
|
107
|
-
this.
|
|
108
|
-
await this.saveIndex();
|
|
109
|
-
return artifact;
|
|
165
|
+
return this.appendToIndex(artifact);
|
|
110
166
|
}
|
|
111
167
|
|
|
112
168
|
async createFromFile({ originalPath, fileName, kind, mimeType, source, metadata = {} }) {
|
|
@@ -133,14 +189,14 @@ class ChatArtifactStore {
|
|
|
133
189
|
|
|
134
190
|
async get(artifactId) {
|
|
135
191
|
await this.init();
|
|
136
|
-
await this.
|
|
137
|
-
return
|
|
192
|
+
const items = await this.readIndex();
|
|
193
|
+
return items.find((item) => item.id === artifactId) || null;
|
|
138
194
|
}
|
|
139
195
|
|
|
140
196
|
async listRecent(limit = 20) {
|
|
141
197
|
await this.init();
|
|
142
|
-
await this.
|
|
143
|
-
return [...
|
|
198
|
+
const items = await this.readIndex();
|
|
199
|
+
return [...items].slice(-limit).reverse();
|
|
144
200
|
}
|
|
145
201
|
}
|
|
146
202
|
|