@mastra/factory 0.24.0 → 0.25.0-alpha.0
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/README.md +36 -9
- package/dist/auth.d.ts.map +1 -1
- package/dist/auth.js +18 -4
- package/dist/auth.js.map +1 -1
- package/dist/boards/review.d.ts.map +1 -1
- package/dist/boards/review.js +3 -2
- package/dist/boards/review.js.map +1 -1
- package/dist/factory.d.ts +3 -0
- package/dist/factory.d.ts.map +1 -1
- package/dist/factory.js +61 -69
- package/dist/factory.js.map +1 -1
- package/dist/integrations/base.d.ts +14 -1
- package/dist/integrations/base.d.ts.map +1 -1
- package/dist/{session → integrations/slack}/channel-reply-processor.d.ts +4 -3
- package/dist/integrations/slack/channel-reply-processor.d.ts.map +1 -0
- package/dist/integrations/slack/channel-reply-processor.js +61 -0
- package/dist/integrations/slack/channel-reply-processor.js.map +1 -0
- package/dist/integrations/slack/channel-session-link-processor.d.ts.map +1 -0
- package/dist/{session → integrations/slack}/channel-session-link-processor.js +13 -4
- package/dist/integrations/slack/channel-session-link-processor.js.map +1 -0
- package/dist/integrations/slack/integration.d.ts +25 -1
- package/dist/integrations/slack/integration.d.ts.map +1 -1
- package/dist/integrations/slack/integration.js +55 -5
- package/dist/integrations/slack/integration.js.map +1 -1
- package/dist/integrations/slack/reply-reminder-processor.d.ts +23 -0
- package/dist/integrations/slack/reply-reminder-processor.d.ts.map +1 -0
- package/dist/integrations/slack/reply-reminder-processor.js +58 -0
- package/dist/integrations/slack/reply-reminder-processor.js.map +1 -0
- package/dist/integrations/slack/reply-tool.d.ts +31 -0
- package/dist/integrations/slack/reply-tool.d.ts.map +1 -0
- package/dist/integrations/slack/reply-tool.js +203 -0
- package/dist/integrations/slack/reply-tool.js.map +1 -0
- package/dist/integrations/slack/share-file-tool.d.ts +45 -0
- package/dist/integrations/slack/share-file-tool.d.ts.map +1 -0
- package/dist/integrations/slack/share-file-tool.js +106 -0
- package/dist/integrations/slack/share-file-tool.js.map +1 -0
- package/dist/integrations/slack/slack.d.ts.map +1 -1
- package/dist/integrations/slack/slack.js +54 -6
- package/dist/integrations/slack/slack.js.map +1 -1
- package/dist/mcp/events.d.ts +73 -0
- package/dist/mcp/events.d.ts.map +1 -0
- package/dist/mcp/events.js +158 -0
- package/dist/mcp/events.js.map +1 -0
- package/dist/mcp/routes.d.ts +0 -1
- package/dist/mcp/routes.d.ts.map +1 -1
- package/dist/mcp/routes.js +30 -3
- package/dist/mcp/routes.js.map +1 -1
- package/dist/packages/_internals/workspace/dist/index.js.map +1 -1
- package/dist/platform-auth-host.d.ts.map +1 -1
- package/dist/platform-auth-host.js +1 -1
- package/dist/platform-auth-host.js.map +1 -1
- package/dist/routes/config.d.ts +2 -0
- package/dist/routes/config.d.ts.map +1 -1
- package/dist/routes/config.js +50 -9
- package/dist/routes/config.js.map +1 -1
- package/dist/routes/contracts.d.ts +2 -0
- package/dist/routes/contracts.d.ts.map +1 -1
- package/dist/routes/contracts.js +1 -1
- package/dist/routes/contracts.js.map +1 -1
- package/dist/routes/session-access.d.ts +7 -2
- package/dist/routes/session-access.d.ts.map +1 -1
- package/dist/routes/session-access.js +41 -4
- package/dist/routes/session-access.js.map +1 -1
- package/dist/routes/skills.d.ts.map +1 -1
- package/dist/routes/skills.js +4 -3
- package/dist/routes/skills.js.map +1 -1
- package/dist/routes/source-control-sessions.d.ts +2 -0
- package/dist/routes/source-control-sessions.d.ts.map +1 -1
- package/dist/routes/source-control-sessions.js +161 -24
- package/dist/routes/source-control-sessions.js.map +1 -1
- package/dist/routes/surface.d.ts +2 -0
- package/dist/routes/surface.d.ts.map +1 -1
- package/dist/routes/surface.js +10 -4
- package/dist/routes/surface.js.map +1 -1
- package/dist/rules/dispatcher.d.ts.map +1 -1
- package/dist/rules/dispatcher.js +11 -3
- package/dist/rules/dispatcher.js.map +1 -1
- package/dist/rules/processor.d.ts +2 -2
- package/dist/rules/processor.d.ts.map +1 -1
- package/dist/rules/processor.js +3 -3
- package/dist/rules/processor.js.map +1 -1
- package/dist/sandbox/session-retirement.d.ts +11 -0
- package/dist/sandbox/session-retirement.d.ts.map +1 -1
- package/dist/sandbox/session-retirement.js +38 -1
- package/dist/sandbox/session-retirement.js.map +1 -1
- package/dist/sandbox/session-sandbox.d.ts +2 -0
- package/dist/sandbox/session-sandbox.d.ts.map +1 -1
- package/dist/sandbox/session-sandbox.js +5 -1
- package/dist/sandbox/session-sandbox.js.map +1 -1
- package/dist/session/credential-policy.d.ts +4 -2
- package/dist/session/credential-policy.d.ts.map +1 -1
- package/dist/session/credential-policy.js +11 -1
- package/dist/session/credential-policy.js.map +1 -1
- package/dist/session/factory-session.d.ts.map +1 -1
- package/dist/session/factory-session.js +14 -1
- package/dist/session/factory-session.js.map +1 -1
- package/dist/session/run-audit.d.ts +1 -0
- package/dist/session/run-audit.d.ts.map +1 -1
- package/dist/session/run-audit.js +7 -2
- package/dist/session/run-audit.js.map +1 -1
- package/dist/session/session-threads.d.ts +5 -0
- package/dist/session/session-threads.d.ts.map +1 -1
- package/dist/session/session-threads.js +8 -1
- package/dist/session/session-threads.js.map +1 -1
- package/dist/session/thinking-default.d.ts +6 -13
- package/dist/session/thinking-default.d.ts.map +1 -1
- package/dist/session/thinking-default.js +18 -22
- package/dist/session/thinking-default.js.map +1 -1
- package/dist/session-owner-signal.d.ts +2 -0
- package/dist/session-owner-signal.d.ts.map +1 -0
- package/dist/session-owner-signal.js +6 -0
- package/dist/session-owner-signal.js.map +1 -0
- package/dist/skills/service.d.ts +6 -7
- package/dist/skills/service.d.ts.map +1 -1
- package/dist/skills/service.js +9 -19
- package/dist/skills/service.js.map +1 -1
- package/dist/storage/domains/audit/actions.d.ts +1 -0
- package/dist/storage/domains/audit/actions.d.ts.map +1 -1
- package/dist/storage/domains/audit/actions.js +2 -1
- package/dist/storage/domains/audit/actions.js.map +1 -1
- package/dist/storage/domains/audit/base.d.ts +18 -2
- package/dist/storage/domains/audit/base.d.ts.map +1 -1
- package/dist/storage/domains/audit/base.js +22 -4
- package/dist/storage/domains/audit/base.js.map +1 -1
- package/dist/storage/domains/model-defaults/base.d.ts +8 -0
- package/dist/storage/domains/model-defaults/base.d.ts.map +1 -1
- package/dist/storage/domains/model-defaults/base.js +18 -0
- package/dist/storage/domains/model-defaults/base.js.map +1 -1
- package/dist/storage/domains/source-control/base.d.ts +26 -1
- package/dist/storage/domains/source-control/base.d.ts.map +1 -1
- package/dist/storage/domains/source-control/base.js +69 -5
- package/dist/storage/domains/source-control/base.js.map +1 -1
- package/dist/storage/domains/source-control/inmemory.d.ts +19 -1
- package/dist/storage/domains/source-control/inmemory.d.ts.map +1 -1
- package/dist/storage/domains/source-control/inmemory.js +30 -2
- package/dist/storage/domains/source-control/inmemory.js.map +1 -1
- package/dist/tools/permissions.d.ts.map +1 -1
- package/dist/tools/permissions.js +2 -1
- package/dist/tools/permissions.js.map +1 -1
- package/dist/tools/sandbox-tools.d.ts +12 -0
- package/dist/tools/sandbox-tools.d.ts.map +1 -0
- package/dist/tools/sandbox-tools.js +40 -0
- package/dist/tools/sandbox-tools.js.map +1 -0
- package/dist/tools/scope.d.ts.map +1 -1
- package/dist/tools/scope.js +2 -5
- package/dist/tools/scope.js.map +1 -1
- package/dist/tools/session-tools.d.ts.map +1 -1
- package/dist/tools/session-tools.js +6 -2
- package/dist/tools/session-tools.js.map +1 -1
- package/dist/work-item-branch.d.ts +11 -3
- package/dist/work-item-branch.d.ts.map +1 -1
- package/dist/work-item-branch.js +34 -9
- package/dist/work-item-branch.js.map +1 -1
- package/dist/workspace.d.ts +2 -1
- package/dist/workspace.d.ts.map +1 -1
- package/dist/workspace.js +4 -2
- package/dist/workspace.js.map +1 -1
- package/factory-skills/configure-factory-rules/SKILL.md +2 -2
- package/package.json +14 -13
- package/dist/session/channel-reply-processor.d.ts.map +0 -1
- package/dist/session/channel-reply-processor.js +0 -46
- package/dist/session/channel-reply-processor.js.map +0 -1
- package/dist/session/channel-session-link-processor.d.ts.map +0 -1
- package/dist/session/channel-session-link-processor.js.map +0 -1
- /package/dist/{session → integrations/slack}/channel-session-link-processor.d.ts +0 -0
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { readFactorySessionScope } from "../../rules/binding-context.js";
|
|
2
|
+
import { SESSION_LINK_LABEL, sessionLink } from "./channel-reply-processor.js";
|
|
3
|
+
import "./channel-session-link-processor.js";
|
|
4
|
+
import { postTarget, resolveRenderContext } from "./share-file-tool.js";
|
|
5
|
+
import "@mastra/core/channels";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
import { createTool } from "@mastra/core/tools";
|
|
8
|
+
//#region src/integrations/slack/reply-tool.ts
|
|
9
|
+
const REPLY_TOOL_NAME = "reply_in_thread";
|
|
10
|
+
/** Set once the first reply of a new session carried the session link. */
|
|
11
|
+
const LINKED_KEY = "factory__replyLinked";
|
|
12
|
+
const replyInput = z.object({ text: z.string().trim().min(1).max(12e3).describe("The message to post, in markdown.") }).strict();
|
|
13
|
+
/**
|
|
14
|
+
* The only way a channel run speaks when the adapter hides reply text
|
|
15
|
+
* (`textDisplay: 'hidden'`). Posting becomes a choice the agent makes, so it
|
|
16
|
+
* stops narrating each step into the thread and can say nothing on purpose.
|
|
17
|
+
* Offered only when the Slack adapter hides reply text and the run's thread is
|
|
18
|
+
* linked to Slack; with text shown the reply stream already posts and this
|
|
19
|
+
* tool would double every message.
|
|
20
|
+
*/
|
|
21
|
+
function createReplyTool(requestContext, deps = {}) {
|
|
22
|
+
return { [REPLY_TOOL_NAME]: createTool({
|
|
23
|
+
id: REPLY_TOOL_NAME,
|
|
24
|
+
description: "Post a message in this chat thread. Nothing you write outside this tool reaches the thread, so use it for anything the people there should see: a short note that you are starting a longer job, a question you need answered, and the outcome when you finish. Post when you have something new; do not repeat yourself or post that nothing changed. To stay quiet, do not call it.",
|
|
25
|
+
inputSchema: replyInput,
|
|
26
|
+
execute: async (input, context) => {
|
|
27
|
+
const link = firstReplyLink(requestContext);
|
|
28
|
+
const suffix = link ? `\n\n[${SESSION_LINK_LABEL}](${link})` : "";
|
|
29
|
+
const toolCallId = context?.agent?.toolCallId;
|
|
30
|
+
const streamed = toolCallId ? replyStreams(requestContext).get(toolCallId) : void 0;
|
|
31
|
+
if (toolCallId) replyStreams(requestContext).delete(toolCallId);
|
|
32
|
+
if (!streamed || !await streamed.finish(input.text, suffix)) await (await postTarget(requestContext, deps)).chatThread.post({ markdown: `${input.text}${suffix}` });
|
|
33
|
+
if (link) requestContext.set(LINKED_KEY, true);
|
|
34
|
+
return { posted: true };
|
|
35
|
+
}
|
|
36
|
+
}) };
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* The session link the first reply of a new session ends with. With reply
|
|
40
|
+
* text hidden, `FactoryChannelSessionLinkProcessor`'s text delta never
|
|
41
|
+
* reaches the thread, so the tool carries the link instead.
|
|
42
|
+
*/
|
|
43
|
+
function firstReplyLink(requestContext) {
|
|
44
|
+
if (!requestContext.get("factory__newChannelSession") || requestContext.get(LINKED_KEY)) return void 0;
|
|
45
|
+
const controller = requestContext.get("controller");
|
|
46
|
+
if (!controller?.threadId) return void 0;
|
|
47
|
+
return sessionLink({
|
|
48
|
+
threadId: controller.threadId,
|
|
49
|
+
sessionId: controller.sessionId,
|
|
50
|
+
resourceId: controller.resourceId,
|
|
51
|
+
factoryProjectId: readFactorySessionScope(requestContext)?.factoryProjectId
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
/** Live posts of `reply_in_thread` calls whose text is still streaming in, by tool call id. */
|
|
55
|
+
const STREAMS_KEY = "factory__replyStreams";
|
|
56
|
+
function replyStreams(requestContext) {
|
|
57
|
+
let streams = requestContext.get(STREAMS_KEY);
|
|
58
|
+
if (!streams) {
|
|
59
|
+
streams = /* @__PURE__ */ new Map();
|
|
60
|
+
requestContext.set(STREAMS_KEY, streams);
|
|
61
|
+
}
|
|
62
|
+
return streams;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* One streamed Slack post, opened on the first visible text so an abandoned
|
|
66
|
+
* call never leaves an empty message behind.
|
|
67
|
+
*/
|
|
68
|
+
function openReplyStream(chatThread) {
|
|
69
|
+
const queue = [];
|
|
70
|
+
let sent = "";
|
|
71
|
+
let closed = false;
|
|
72
|
+
let wake;
|
|
73
|
+
let done;
|
|
74
|
+
async function* chunks() {
|
|
75
|
+
while (true) {
|
|
76
|
+
while (queue.length > 0) yield queue.shift();
|
|
77
|
+
if (closed) return;
|
|
78
|
+
await new Promise((resolve) => wake = resolve);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const push = (text) => {
|
|
82
|
+
if (!text) return;
|
|
83
|
+
queue.push(text);
|
|
84
|
+
done ??= chatThread.post(chunks()).then(() => true, (error) => {
|
|
85
|
+
console.warn("[factory] streamed reply failed; posting it whole", { error });
|
|
86
|
+
return false;
|
|
87
|
+
});
|
|
88
|
+
wake?.();
|
|
89
|
+
};
|
|
90
|
+
return {
|
|
91
|
+
update(text) {
|
|
92
|
+
const visible = text.trimStart();
|
|
93
|
+
if (!visible.startsWith(sent)) return;
|
|
94
|
+
push(visible.slice(sent.length));
|
|
95
|
+
sent = visible;
|
|
96
|
+
},
|
|
97
|
+
async finish(text, suffix) {
|
|
98
|
+
if (text.startsWith(sent)) push(text.slice(sent.length));
|
|
99
|
+
if (!done) return false;
|
|
100
|
+
push(suffix);
|
|
101
|
+
closed = true;
|
|
102
|
+
wake?.();
|
|
103
|
+
return done;
|
|
104
|
+
},
|
|
105
|
+
async close() {
|
|
106
|
+
closed = true;
|
|
107
|
+
wake?.();
|
|
108
|
+
return done ?? false;
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Streams a `reply_in_thread` call's `text` into the thread while the model is
|
|
114
|
+
* still writing it, so a long reply appears as it is written instead of all at
|
|
115
|
+
* once when the call completes. The tool finishes the post with the validated
|
|
116
|
+
* text. Runs whose adapter doesn't stream, or that show reply text, are left
|
|
117
|
+
* alone.
|
|
118
|
+
*/
|
|
119
|
+
var FactoryReplyStreamProcessor = class {
|
|
120
|
+
deps;
|
|
121
|
+
id = "factory-reply-stream";
|
|
122
|
+
constructor(deps = {}) {
|
|
123
|
+
this.deps = deps;
|
|
124
|
+
}
|
|
125
|
+
async processOutputStream({ part, state, requestContext }) {
|
|
126
|
+
if (!requestContext) return part;
|
|
127
|
+
const args = state.args ??= {};
|
|
128
|
+
if (part.type === "tool-call-input-streaming-start" && part.payload.toolName === "reply_in_thread") {
|
|
129
|
+
const render = await resolveRenderContext(requestContext, this.deps);
|
|
130
|
+
if (render?.textDisplay !== "hidden" || !render.streaming?.enabled) return part;
|
|
131
|
+
args[part.payload.toolCallId] = "";
|
|
132
|
+
replyStreams(requestContext).set(part.payload.toolCallId, openReplyStream(render.chatThread));
|
|
133
|
+
} else if (part.type === "tool-call-delta" && part.payload.toolCallId in args) {
|
|
134
|
+
const text = partialStringField(args[part.payload.toolCallId] += part.payload.argsTextDelta, "text");
|
|
135
|
+
if (text !== void 0) replyStreams(requestContext).get(part.payload.toolCallId)?.update(text);
|
|
136
|
+
} else if ((part.type === "tool-error" || part.type === "tool-result") && part.payload.toolCallId in args) {
|
|
137
|
+
delete args[part.payload.toolCallId];
|
|
138
|
+
await closeStream(requestContext, part.payload.toolCallId);
|
|
139
|
+
} else if (RUN_END.has(part.type)) {
|
|
140
|
+
for (const toolCallId of Object.keys(args)) await closeStream(requestContext, toolCallId);
|
|
141
|
+
state.args = {};
|
|
142
|
+
}
|
|
143
|
+
return part;
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
/** Chunks after which no `reply_in_thread` call of this run will still execute. */
|
|
147
|
+
const RUN_END = /* @__PURE__ */ new Set([
|
|
148
|
+
"finish",
|
|
149
|
+
"abort",
|
|
150
|
+
"error",
|
|
151
|
+
"tripwire"
|
|
152
|
+
]);
|
|
153
|
+
/**
|
|
154
|
+
* Ends a streamed post whose call will never reach `execute`, keeping the text
|
|
155
|
+
* shown so far, so Slack isn't left with a message that never finishes.
|
|
156
|
+
*/
|
|
157
|
+
async function closeStream(requestContext, toolCallId) {
|
|
158
|
+
const streams = replyStreams(requestContext);
|
|
159
|
+
const stream = streams.get(toolCallId);
|
|
160
|
+
if (!stream) return;
|
|
161
|
+
streams.delete(toolCallId);
|
|
162
|
+
await stream.close();
|
|
163
|
+
}
|
|
164
|
+
const ESCAPES = {
|
|
165
|
+
"\"": "\"",
|
|
166
|
+
"\\": "\\",
|
|
167
|
+
"/": "/",
|
|
168
|
+
b: "\b",
|
|
169
|
+
f: "\f",
|
|
170
|
+
n: "\n",
|
|
171
|
+
r: "\r",
|
|
172
|
+
t: " "
|
|
173
|
+
};
|
|
174
|
+
/** The decoded value of a top-level string field in JSON that may still be cut off mid-string. */
|
|
175
|
+
function partialStringField(json, field) {
|
|
176
|
+
const start = new RegExp(`"${field}"\\s*:\\s*"`).exec(json);
|
|
177
|
+
if (!start) return void 0;
|
|
178
|
+
let out = "";
|
|
179
|
+
for (let i = start.index + start[0].length; i < json.length; i++) {
|
|
180
|
+
const ch = json[i];
|
|
181
|
+
if (ch === "\"") return out;
|
|
182
|
+
if (ch !== "\\") {
|
|
183
|
+
out += ch;
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
const next = json[i + 1];
|
|
187
|
+
if (next === void 0) return out;
|
|
188
|
+
if (next === "u") {
|
|
189
|
+
const hex = json.slice(i + 2, i + 6);
|
|
190
|
+
if (hex.length < 4) return out;
|
|
191
|
+
out += String.fromCharCode(parseInt(hex, 16));
|
|
192
|
+
i += 5;
|
|
193
|
+
} else {
|
|
194
|
+
out += ESCAPES[next] ?? next;
|
|
195
|
+
i += 1;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return out;
|
|
199
|
+
}
|
|
200
|
+
//#endregion
|
|
201
|
+
export { FactoryReplyStreamProcessor, REPLY_TOOL_NAME, createReplyTool, partialStringField };
|
|
202
|
+
|
|
203
|
+
//# sourceMappingURL=reply-tool.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"reply-tool.js","names":[],"sources":["../../../src/integrations/slack/reply-tool.ts"],"sourcesContent":["import type { AgentControllerRequestContext } from '@mastra/core/agent-controller';\nimport { CHAT_CHANNEL_RENDER_CONTEXT_KEY } from '@mastra/core/channels';\nimport type { ChatChannelRenderContext } from '@mastra/core/channels';\nimport type { Processor, ProcessOutputStreamArgs } from '@mastra/core/processors';\nimport type { RequestContext } from '@mastra/core/request-context';\nimport type { ChunkType } from '@mastra/core/stream';\nimport { createTool } from '@mastra/core/tools';\nimport { z } from 'zod';\n\nimport { readFactorySessionScope } from '../../rules/binding-context.js';\nimport type { IntegrationTools } from '../base.js';\nimport { SESSION_LINK_LABEL, sessionLink } from './channel-reply-processor.js';\nimport { NEW_CHANNEL_SESSION_KEY } from './channel-session-link-processor.js';\nimport { postTarget, resolveRenderContext } from './share-file-tool.js';\nimport type { ShareFileToolDeps } from './share-file-tool.js';\n\nexport const REPLY_TOOL_NAME = 'reply_in_thread';\n\n/** Set once the first reply of a new session carried the session link. */\nconst LINKED_KEY = 'factory__replyLinked';\n\nconst replyInput = z\n .object({\n text: z.string().trim().min(1).max(12_000).describe('The message to post, in markdown.'),\n })\n .strict();\n\n/**\n * The only way a channel run speaks when the adapter hides reply text\n * (`textDisplay: 'hidden'`). Posting becomes a choice the agent makes, so it\n * stops narrating each step into the thread and can say nothing on purpose.\n * Offered only when the Slack adapter hides reply text and the run's thread is\n * linked to Slack; with text shown the reply stream already posts and this\n * tool would double every message.\n */\nexport function createReplyTool(requestContext: RequestContext, deps: ShareFileToolDeps = {}): IntegrationTools {\n return {\n [REPLY_TOOL_NAME]: createTool({\n id: REPLY_TOOL_NAME,\n description:\n 'Post a message in this chat thread. Nothing you write outside this tool reaches the thread, so use it for anything the people there should see: a short note that you are starting a longer job, a question you need answered, and the outcome when you finish. Post when you have something new; do not repeat yourself or post that nothing changed. To stay quiet, do not call it.',\n inputSchema: replyInput,\n execute: async (input, context) => {\n const link = firstReplyLink(requestContext);\n const suffix = link ? `\\n\\n[${SESSION_LINK_LABEL}](${link})` : '';\n // FactoryReplyStreamProcessor may already be streaming this call's text\n // into the thread; finish that post rather than posting it again.\n const toolCallId = context?.agent?.toolCallId;\n const streamed = toolCallId ? replyStreams(requestContext).get(toolCallId) : undefined;\n if (toolCallId) replyStreams(requestContext).delete(toolCallId);\n if (!streamed || !(await streamed.finish(input.text, suffix))) {\n const render = await postTarget(requestContext, deps);\n await render.chatThread.post({ markdown: `${input.text}${suffix}` });\n }\n // Marked only once posted, so a failed first post still gets the link on the retry.\n if (link) requestContext.set(LINKED_KEY, true);\n return { posted: true };\n },\n }),\n };\n}\n\n/**\n * The session link the first reply of a new session ends with. With reply\n * text hidden, `FactoryChannelSessionLinkProcessor`'s text delta never\n * reaches the thread, so the tool carries the link instead.\n */\nfunction firstReplyLink(requestContext: RequestContext): string | undefined {\n if (!requestContext.get(NEW_CHANNEL_SESSION_KEY) || requestContext.get(LINKED_KEY)) return undefined;\n const controller = requestContext.get('controller') as AgentControllerRequestContext | undefined;\n if (!controller?.threadId) return undefined;\n return sessionLink({\n threadId: controller.threadId,\n sessionId: controller.sessionId,\n resourceId: controller.resourceId,\n factoryProjectId: readFactorySessionScope(requestContext)?.factoryProjectId,\n });\n}\n\n/** Live posts of `reply_in_thread` calls whose text is still streaming in, by tool call id. */\nconst STREAMS_KEY = 'factory__replyStreams';\n\ninterface ReplyStream {\n /** Show the text decoded from the args so far. */\n update(text: string): void;\n /** End the post with the validated text. Resolves false if the post failed. */\n finish(text: string, suffix: string): Promise<boolean>;\n /** End the post with whatever has been shown, for a call that never ran. */\n close(): Promise<boolean>;\n}\n\nfunction replyStreams(requestContext: RequestContext): Map<string, ReplyStream> {\n let streams = requestContext.get(STREAMS_KEY) as Map<string, ReplyStream> | undefined;\n if (!streams) {\n streams = new Map();\n requestContext.set(STREAMS_KEY, streams);\n }\n return streams;\n}\n\n/**\n * One streamed Slack post, opened on the first visible text so an abandoned\n * call never leaves an empty message behind.\n */\nfunction openReplyStream(chatThread: ChatChannelRenderContext['chatThread']): ReplyStream {\n const queue: string[] = [];\n let sent = '';\n let closed = false;\n let wake: (() => void) | undefined;\n let done: Promise<boolean> | undefined;\n\n async function* chunks(): AsyncGenerator<string> {\n while (true) {\n while (queue.length > 0) yield queue.shift()!;\n if (closed) return;\n await new Promise<void>(resolve => (wake = resolve));\n }\n }\n const push = (text: string) => {\n if (!text) return;\n queue.push(text);\n done ??= chatThread.post(chunks()).then(\n () => true,\n error => {\n console.warn('[factory] streamed reply failed; posting it whole', { error });\n return false;\n },\n );\n wake?.();\n };\n\n return {\n update(text) {\n const visible = text.trimStart();\n if (!visible.startsWith(sent)) return;\n push(visible.slice(sent.length));\n sent = visible;\n },\n async finish(text, suffix) {\n if (text.startsWith(sent)) push(text.slice(sent.length));\n if (!done) return false;\n push(suffix);\n closed = true;\n wake?.();\n return done;\n },\n async close() {\n closed = true;\n wake?.();\n return done ?? false;\n },\n };\n}\n\n/**\n * Streams a `reply_in_thread` call's `text` into the thread while the model is\n * still writing it, so a long reply appears as it is written instead of all at\n * once when the call completes. The tool finishes the post with the validated\n * text. Runs whose adapter doesn't stream, or that show reply text, are left\n * alone.\n */\nexport class FactoryReplyStreamProcessor implements Processor {\n readonly id = 'factory-reply-stream';\n\n constructor(private readonly deps: ShareFileToolDeps = {}) {}\n\n async processOutputStream({ part, state, requestContext }: ProcessOutputStreamArgs): Promise<ChunkType> {\n if (!requestContext) return part;\n const args = (state.args ??= {}) as Record<string, string>;\n if (part.type === 'tool-call-input-streaming-start' && part.payload.toolName === REPLY_TOOL_NAME) {\n // Resolve the thread the same way the tool does, so a run started from\n // the web UI or woken by a signal streams too, not only a Slack-started one.\n const render = await resolveRenderContext(requestContext, this.deps);\n // A static adapter (`streaming: false`) posts each reply whole, so the tool does too.\n if (render?.textDisplay !== 'hidden' || !render.streaming?.enabled) return part;\n args[part.payload.toolCallId] = '';\n replyStreams(requestContext).set(part.payload.toolCallId, openReplyStream(render.chatThread));\n } else if (part.type === 'tool-call-delta' && part.payload.toolCallId in args) {\n const json = (args[part.payload.toolCallId] += part.payload.argsTextDelta);\n const text = partialStringField(json, 'text');\n if (text !== undefined) replyStreams(requestContext).get(part.payload.toolCallId)?.update(text);\n } else if ((part.type === 'tool-error' || part.type === 'tool-result') && part.payload.toolCallId in args) {\n // A call that failed validation never reaches `execute`; a successful one already finished its post.\n delete args[part.payload.toolCallId];\n await closeStream(requestContext, part.payload.toolCallId);\n } else if (RUN_END.has(part.type)) {\n for (const toolCallId of Object.keys(args)) await closeStream(requestContext, toolCallId);\n state.args = {};\n }\n return part;\n }\n}\n\n/** Chunks after which no `reply_in_thread` call of this run will still execute. */\nconst RUN_END = new Set<string>(['finish', 'abort', 'error', 'tripwire']);\n\n/**\n * Ends a streamed post whose call will never reach `execute`, keeping the text\n * shown so far, so Slack isn't left with a message that never finishes.\n */\nasync function closeStream(requestContext: RequestContext, toolCallId: string): Promise<void> {\n const streams = replyStreams(requestContext);\n const stream = streams.get(toolCallId);\n if (!stream) return;\n streams.delete(toolCallId);\n await stream.close();\n}\n\nconst ESCAPES: Record<string, string> = { '\"': '\"', '\\\\': '\\\\', '/': '/', b: '\\b', f: '\\f', n: '\\n', r: '\\r', t: '\\t' };\n\n/** The decoded value of a top-level string field in JSON that may still be cut off mid-string. */\nexport function partialStringField(json: string, field: string): string | undefined {\n const start = new RegExp(`\"${field}\"\\\\s*:\\\\s*\"`).exec(json);\n if (!start) return undefined;\n let out = '';\n for (let i = start.index + start[0].length; i < json.length; i++) {\n const ch = json[i]!;\n if (ch === '\"') return out;\n if (ch !== '\\\\') {\n out += ch;\n continue;\n }\n const next = json[i + 1];\n if (next === undefined) return out;\n if (next === 'u') {\n const hex = json.slice(i + 2, i + 6);\n if (hex.length < 4) return out;\n out += String.fromCharCode(parseInt(hex, 16));\n i += 5;\n } else {\n out += ESCAPES[next] ?? next;\n i += 1;\n }\n }\n return out;\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { AgentChannels, ChatChannelRenderContext } from '@mastra/core/channels';
|
|
2
|
+
import type { RequestContext } from '@mastra/core/request-context';
|
|
3
|
+
import type { IntegrationTools } from '../base.js';
|
|
4
|
+
/** Slack rejects larger uploads well before this; keep the sandbox read bounded too. */
|
|
5
|
+
export declare const SHARE_FILE_MAX_BYTES: number;
|
|
6
|
+
export interface ShareFileToolDeps {
|
|
7
|
+
/**
|
|
8
|
+
* The channels attached to the controller, used to rebuild the chat thread
|
|
9
|
+
* for runs that did not start from a platform message. Omit when the caller
|
|
10
|
+
* has no controller; the tool is then offered only on inbound-webhook runs.
|
|
11
|
+
*/
|
|
12
|
+
channels?: () => AgentChannels | null | undefined;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* The chat thread this run replies into, if any.
|
|
16
|
+
*
|
|
17
|
+
* Inbound platform messages stash the render context on `requestContext`.
|
|
18
|
+
* A run woken by a signal (a subagent finishing, a scheduled fire) has no
|
|
19
|
+
* such entry even though its thread is still channel-backed — and that is
|
|
20
|
+
* exactly the run that ends a long job with a file to hand back. Rebuild the
|
|
21
|
+
* context from the thread's persisted channel coordinates the same way
|
|
22
|
+
* `ChatChannelOutputProcessor` does, so the tools post on both kinds of run.
|
|
23
|
+
* A forked subagent's thread carries no channel metadata, so forks get
|
|
24
|
+
* nothing here and never post to the platform.
|
|
25
|
+
*/
|
|
26
|
+
export declare function resolveRenderContext(requestContext: RequestContext, deps: ShareFileToolDeps): Promise<ChatChannelRenderContext | undefined>;
|
|
27
|
+
/**
|
|
28
|
+
* Whether this run's thread is a Slack thread, so the channel tools have
|
|
29
|
+
* somewhere to post. A run started from Slack says so on `requestContext`; a
|
|
30
|
+
* web prompt or a woken run on a Slack-linked session costs one thread read.
|
|
31
|
+
* Plain web sessions get `false` and no channel tools.
|
|
32
|
+
*/
|
|
33
|
+
export declare function isSlackThread(requestContext: RequestContext, deps: ShareFileToolDeps): Promise<boolean>;
|
|
34
|
+
/** The thread a channel tool posts into, resolved when the tool runs. */
|
|
35
|
+
export declare function postTarget(requestContext: RequestContext, deps: ShareFileToolDeps): Promise<ChatChannelRenderContext>;
|
|
36
|
+
/**
|
|
37
|
+
* Lets a channel-triggered run hand a workspace file back to the thread it
|
|
38
|
+
* came from. The agent's reply stream can only carry text, so a report it
|
|
39
|
+
* generated in the sandbox would otherwise be described by path and left
|
|
40
|
+
* there. Offered only on runs whose thread is linked to a chat thread (see
|
|
41
|
+
* `isSlackThread`); the file is read through the session's workspace
|
|
42
|
+
* filesystem so sandbox confinement still applies.
|
|
43
|
+
*/
|
|
44
|
+
export declare function createShareFileTool(requestContext: RequestContext, deps?: ShareFileToolDeps): IntegrationTools;
|
|
45
|
+
//# sourceMappingURL=share-file-tool.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"share-file-tool.d.ts","sourceRoot":"","sources":["../../../src/integrations/slack/share-file-tool.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,aAAa,EAAE,wBAAwB,EAAE,MAAM,uBAAuB,CAAC;AACrF,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,8BAA8B,CAAC;AAInE,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAC;AAEnD,wFAAwF;AACxF,eAAO,MAAM,oBAAoB,QAAmB,CAAC;AAErD,MAAM,WAAW,iBAAiB;IAChC;;;;OAIG;IACH,QAAQ,CAAC,EAAE,MAAM,aAAa,GAAG,IAAI,GAAG,SAAS,CAAC;CACnD;AAmBD;;;;;;;;;;;GAWG;AACH,wBAAsB,oBAAoB,CACxC,cAAc,EAAE,cAAc,EAC9B,IAAI,EAAE,iBAAiB,GACtB,OAAO,CAAC,wBAAwB,GAAG,SAAS,CAAC,CAO/C;AAED;;;;;GAKG;AACH,wBAAsB,aAAa,CAAC,cAAc,EAAE,cAAc,EAAE,IAAI,EAAE,iBAAiB,GAAG,OAAO,CAAC,OAAO,CAAC,CAQ7G;AAED,yEAAyE;AACzE,wBAAsB,UAAU,CAC9B,cAAc,EAAE,cAAc,EAC9B,IAAI,EAAE,iBAAiB,GACtB,OAAO,CAAC,wBAAwB,CAAC,CAInC;AAED;;;;;;;GAOG;AACH,wBAAgB,mBAAmB,CAAC,cAAc,EAAE,cAAc,EAAE,IAAI,GAAE,iBAAsB,GAAG,gBAAgB,CA4ClH"}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { CHAT_CHANNEL_RENDER_CONTEXT_KEY } from "@mastra/core/channels";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { createTool } from "@mastra/core/tools";
|
|
4
|
+
//#region src/integrations/slack/share-file-tool.ts
|
|
5
|
+
/** Slack rejects larger uploads well before this; keep the sandbox read bounded too. */
|
|
6
|
+
const SHARE_FILE_MAX_BYTES = 52428800;
|
|
7
|
+
/**
|
|
8
|
+
* Uploads need a scope (`files:write` on Slack) that an app created before
|
|
9
|
+
* this tool existed will not have. `@slack/web-api` reports that as a
|
|
10
|
+
* platform error with `data.error === 'missing_scope'` and the scope in
|
|
11
|
+
* `data.needed`; say so plainly instead of surfacing the raw API error, so
|
|
12
|
+
* the model tells the user what to fix rather than retrying.
|
|
13
|
+
*/
|
|
14
|
+
function missingScopeError(error, platform) {
|
|
15
|
+
if (!error || typeof error !== "object") return void 0;
|
|
16
|
+
const data = error.data;
|
|
17
|
+
if (data?.error !== "missing_scope") return void 0;
|
|
18
|
+
const needed = typeof data.needed === "string" && data.needed ? data.needed : "files:write";
|
|
19
|
+
return /* @__PURE__ */ new Error(`The ${platform} app is missing the \`${needed}\` scope, so it cannot upload files. Add the scope to the app and reinstall it, then try again.`);
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* The chat thread this run replies into, if any.
|
|
23
|
+
*
|
|
24
|
+
* Inbound platform messages stash the render context on `requestContext`.
|
|
25
|
+
* A run woken by a signal (a subagent finishing, a scheduled fire) has no
|
|
26
|
+
* such entry even though its thread is still channel-backed — and that is
|
|
27
|
+
* exactly the run that ends a long job with a file to hand back. Rebuild the
|
|
28
|
+
* context from the thread's persisted channel coordinates the same way
|
|
29
|
+
* `ChatChannelOutputProcessor` does, so the tools post on both kinds of run.
|
|
30
|
+
* A forked subagent's thread carries no channel metadata, so forks get
|
|
31
|
+
* nothing here and never post to the platform.
|
|
32
|
+
*/
|
|
33
|
+
async function resolveRenderContext(requestContext, deps) {
|
|
34
|
+
const stashed = requestContext.get(CHAT_CHANNEL_RENDER_CONTEXT_KEY);
|
|
35
|
+
if (stashed) return stashed;
|
|
36
|
+
const threadId = requestContext.get("controller")?.threadId;
|
|
37
|
+
if (!threadId) return void 0;
|
|
38
|
+
return await deps.channels?.()?.buildRenderContextForThread(threadId) ?? void 0;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Whether this run's thread is a Slack thread, so the channel tools have
|
|
42
|
+
* somewhere to post. A run started from Slack says so on `requestContext`; a
|
|
43
|
+
* web prompt or a woken run on a Slack-linked session costs one thread read.
|
|
44
|
+
* Plain web sessions get `false` and no channel tools.
|
|
45
|
+
*/
|
|
46
|
+
async function isSlackThread(requestContext, deps) {
|
|
47
|
+
const channel = requestContext.get("channel");
|
|
48
|
+
if (channel) return channel.platform === "slack";
|
|
49
|
+
const threadId = requestContext.get("controller")?.threadId;
|
|
50
|
+
if (!threadId) return false;
|
|
51
|
+
return (await deps.channels?.()?.buildChannelContextForThread(threadId))?.channel.platform === "slack";
|
|
52
|
+
}
|
|
53
|
+
/** The thread a channel tool posts into, resolved when the tool runs. */
|
|
54
|
+
async function postTarget(requestContext, deps) {
|
|
55
|
+
const render = await resolveRenderContext(requestContext, deps);
|
|
56
|
+
if (!render) throw new Error("This session is no longer linked to a chat thread.");
|
|
57
|
+
return render;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Lets a channel-triggered run hand a workspace file back to the thread it
|
|
61
|
+
* came from. The agent's reply stream can only carry text, so a report it
|
|
62
|
+
* generated in the sandbox would otherwise be described by path and left
|
|
63
|
+
* there. Offered only on runs whose thread is linked to a chat thread (see
|
|
64
|
+
* `isSlackThread`); the file is read through the session's workspace
|
|
65
|
+
* filesystem so sandbox confinement still applies.
|
|
66
|
+
*/
|
|
67
|
+
function createShareFileTool(requestContext, deps = {}) {
|
|
68
|
+
return { attach_file: createTool({
|
|
69
|
+
id: "attach_file",
|
|
70
|
+
description: "Upload a file from the workspace into this chat thread as an attachment. This is the only way the person you are talking to can receive a file you produced (a report, PDF, export, screenshot, image); they cannot open workspace paths. Call it whenever you have a file to hand over, then describe it briefly in your reply. The attachment posts on its own.",
|
|
71
|
+
inputSchema: z.object({ path: z.string().min(1).describe("Absolute path of the file inside the workspace") }),
|
|
72
|
+
execute: async ({ path }, context) => {
|
|
73
|
+
const fs = (requestContext.get("controller")?.workspace ?? context?.workspace)?.filesystem;
|
|
74
|
+
if (!fs) throw new Error("No workspace filesystem is attached to this session.");
|
|
75
|
+
const stat = await fs.stat(path);
|
|
76
|
+
if (stat.type === "directory") throw new Error(`${path} is a directory; attach a single file.`);
|
|
77
|
+
if (stat.size > 52428800) throw new Error(`${path} is ${stat.size} bytes; the limit is ${SHARE_FILE_MAX_BYTES} bytes.`);
|
|
78
|
+
const raw = await fs.readFile(path);
|
|
79
|
+
const data = Buffer.isBuffer(raw) ? raw : Buffer.from(raw);
|
|
80
|
+
if (data.length > 52428800) throw new Error(`${path} is ${data.length} bytes; the limit is ${SHARE_FILE_MAX_BYTES} bytes.`);
|
|
81
|
+
const filename = path.split("/").pop() || "file";
|
|
82
|
+
const render = await postTarget(requestContext, deps);
|
|
83
|
+
try {
|
|
84
|
+
await render.chatThread.post({
|
|
85
|
+
markdown: "",
|
|
86
|
+
files: [{
|
|
87
|
+
data,
|
|
88
|
+
filename,
|
|
89
|
+
...stat.mimeType ? { mimeType: stat.mimeType } : {}
|
|
90
|
+
}]
|
|
91
|
+
});
|
|
92
|
+
} catch (error) {
|
|
93
|
+
throw missingScopeError(error, render.platform) ?? error;
|
|
94
|
+
}
|
|
95
|
+
return {
|
|
96
|
+
attached: true,
|
|
97
|
+
filename,
|
|
98
|
+
bytes: data.length
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
}) };
|
|
102
|
+
}
|
|
103
|
+
//#endregion
|
|
104
|
+
export { SHARE_FILE_MAX_BYTES, createShareFileTool, isSlackThread, postTarget, resolveRenderContext };
|
|
105
|
+
|
|
106
|
+
//# sourceMappingURL=share-file-tool.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"share-file-tool.js","names":[],"sources":["../../../src/integrations/slack/share-file-tool.ts"],"sourcesContent":["import type { AgentControllerRequestContext } from '@mastra/core/agent-controller';\nimport { CHAT_CHANNEL_RENDER_CONTEXT_KEY } from '@mastra/core/channels';\nimport type { AgentChannels, ChatChannelRenderContext } from '@mastra/core/channels';\nimport type { RequestContext } from '@mastra/core/request-context';\nimport { createTool } from '@mastra/core/tools';\nimport { z } from 'zod';\n\nimport type { IntegrationTools } from '../base.js';\n\n/** Slack rejects larger uploads well before this; keep the sandbox read bounded too. */\nexport const SHARE_FILE_MAX_BYTES = 50 * 1024 * 1024;\n\nexport interface ShareFileToolDeps {\n /**\n * The channels attached to the controller, used to rebuild the chat thread\n * for runs that did not start from a platform message. Omit when the caller\n * has no controller; the tool is then offered only on inbound-webhook runs.\n */\n channels?: () => AgentChannels | null | undefined;\n}\n\n/**\n * Uploads need a scope (`files:write` on Slack) that an app created before\n * this tool existed will not have. `@slack/web-api` reports that as a\n * platform error with `data.error === 'missing_scope'` and the scope in\n * `data.needed`; say so plainly instead of surfacing the raw API error, so\n * the model tells the user what to fix rather than retrying.\n */\nfunction missingScopeError(error: unknown, platform: string): Error | undefined {\n if (!error || typeof error !== 'object') return undefined;\n const data = (error as { data?: { error?: unknown; needed?: unknown } }).data;\n if (data?.error !== 'missing_scope') return undefined;\n const needed = typeof data.needed === 'string' && data.needed ? data.needed : 'files:write';\n return new Error(\n `The ${platform} app is missing the \\`${needed}\\` scope, so it cannot upload files. Add the scope to the app and reinstall it, then try again.`,\n );\n}\n\n/**\n * The chat thread this run replies into, if any.\n *\n * Inbound platform messages stash the render context on `requestContext`.\n * A run woken by a signal (a subagent finishing, a scheduled fire) has no\n * such entry even though its thread is still channel-backed — and that is\n * exactly the run that ends a long job with a file to hand back. Rebuild the\n * context from the thread's persisted channel coordinates the same way\n * `ChatChannelOutputProcessor` does, so the tools post on both kinds of run.\n * A forked subagent's thread carries no channel metadata, so forks get\n * nothing here and never post to the platform.\n */\nexport async function resolveRenderContext(\n requestContext: RequestContext,\n deps: ShareFileToolDeps,\n): Promise<ChatChannelRenderContext | undefined> {\n const stashed = requestContext.get(CHAT_CHANNEL_RENDER_CONTEXT_KEY) as ChatChannelRenderContext | undefined;\n if (stashed) return stashed;\n const controller = requestContext.get('controller') as AgentControllerRequestContext | undefined;\n const threadId = controller?.threadId;\n if (!threadId) return undefined;\n return (await deps.channels?.()?.buildRenderContextForThread(threadId)) ?? undefined;\n}\n\n/**\n * Whether this run's thread is a Slack thread, so the channel tools have\n * somewhere to post. A run started from Slack says so on `requestContext`; a\n * web prompt or a woken run on a Slack-linked session costs one thread read.\n * Plain web sessions get `false` and no channel tools.\n */\nexport async function isSlackThread(requestContext: RequestContext, deps: ShareFileToolDeps): Promise<boolean> {\n const channel = requestContext.get('channel') as { platform?: string } | undefined;\n if (channel) return channel.platform === 'slack';\n const controller = requestContext.get('controller') as AgentControllerRequestContext | undefined;\n const threadId = controller?.threadId;\n if (!threadId) return false;\n const linked = await deps.channels?.()?.buildChannelContextForThread(threadId);\n return linked?.channel.platform === 'slack';\n}\n\n/** The thread a channel tool posts into, resolved when the tool runs. */\nexport async function postTarget(\n requestContext: RequestContext,\n deps: ShareFileToolDeps,\n): Promise<ChatChannelRenderContext> {\n const render = await resolveRenderContext(requestContext, deps);\n if (!render) throw new Error('This session is no longer linked to a chat thread.');\n return render;\n}\n\n/**\n * Lets a channel-triggered run hand a workspace file back to the thread it\n * came from. The agent's reply stream can only carry text, so a report it\n * generated in the sandbox would otherwise be described by path and left\n * there. Offered only on runs whose thread is linked to a chat thread (see\n * `isSlackThread`); the file is read through the session's workspace\n * filesystem so sandbox confinement still applies.\n */\nexport function createShareFileTool(requestContext: RequestContext, deps: ShareFileToolDeps = {}): IntegrationTools {\n return {\n attach_file: createTool({\n id: 'attach_file',\n description:\n 'Upload a file from the workspace into this chat thread as an attachment. This is the only way the person you are talking to can receive a file you produced (a report, PDF, export, screenshot, image); they cannot open workspace paths. Call it whenever you have a file to hand over, then describe it briefly in your reply. The attachment posts on its own.',\n inputSchema: z.object({\n path: z.string().min(1).describe('Absolute path of the file inside the workspace'),\n }),\n execute: async ({ path }, context) => {\n const controller = requestContext.get('controller') as AgentControllerRequestContext | undefined;\n const workspace = controller?.workspace ?? context?.workspace;\n const fs = workspace?.filesystem;\n if (!fs) throw new Error('No workspace filesystem is attached to this session.');\n const stat = await fs.stat(path);\n if (stat.type === 'directory') throw new Error(`${path} is a directory; attach a single file.`);\n // The stat check keeps us from reading something enormous; the check on\n // the bytes we actually hold is the one that bounds the upload, since the\n // file can change between the two calls.\n if (stat.size > SHARE_FILE_MAX_BYTES) {\n throw new Error(`${path} is ${stat.size} bytes; the limit is ${SHARE_FILE_MAX_BYTES} bytes.`);\n }\n const raw = await fs.readFile(path);\n const data = Buffer.isBuffer(raw) ? raw : Buffer.from(raw);\n if (data.length > SHARE_FILE_MAX_BYTES) {\n throw new Error(`${path} is ${data.length} bytes; the limit is ${SHARE_FILE_MAX_BYTES} bytes.`);\n }\n const filename = path.split('/').pop() || 'file';\n const render = await postTarget(requestContext, deps);\n // File only. The Slack adapter posts any non-empty text as a second\n // message after the upload, so a comment would land as its own bubble\n // between the reply and the file; an empty body returns after the upload.\n try {\n await render.chatThread.post({\n markdown: '',\n files: [{ data, filename, ...(stat.mimeType ? { mimeType: stat.mimeType } : {}) }],\n });\n } catch (error) {\n throw missingScopeError(error, render.platform) ?? error;\n }\n return { attached: true, filename, bytes: data.length };\n },\n }),\n };\n}\n"],"mappings":";;;;;AAUA,MAAa,uBAAuB;;;;;;;;AAkBpC,SAAS,kBAAkB,OAAgB,UAAqC;CAC9E,IAAI,CAAC,SAAS,OAAO,UAAU,UAAU,OAAO,KAAA;CAChD,MAAM,OAAQ,MAA2D;CACzE,IAAI,MAAM,UAAU,iBAAiB,OAAO,KAAA;CAC5C,MAAM,SAAS,OAAO,KAAK,WAAW,YAAY,KAAK,SAAS,KAAK,SAAS;CAC9E,uBAAO,IAAI,MACT,OAAO,SAAS,wBAAwB,OAAO,gGACjD;AACF;;;;;;;;;;;;;AAcA,eAAsB,qBACpB,gBACA,MAC+C;CAC/C,MAAM,UAAU,eAAe,IAAI,+BAA+B;CAClE,IAAI,SAAS,OAAO;CAEpB,MAAM,WADa,eAAe,IAAI,YACZ,CAAC,EAAE;CAC7B,IAAI,CAAC,UAAU,OAAO,KAAA;CACtB,OAAQ,MAAM,KAAK,WAAW,CAAC,EAAE,4BAA4B,QAAQ,KAAM,KAAA;AAC7E;;;;;;;AAQA,eAAsB,cAAc,gBAAgC,MAA2C;CAC7G,MAAM,UAAU,eAAe,IAAI,SAAS;CAC5C,IAAI,SAAS,OAAO,QAAQ,aAAa;CAEzC,MAAM,WADa,eAAe,IAAI,YACZ,CAAC,EAAE;CAC7B,IAAI,CAAC,UAAU,OAAO;CAEtB,QAAO,MADc,KAAK,WAAW,CAAC,EAAE,6BAA6B,QAAQ,EAAA,EAC9D,QAAQ,aAAa;AACtC;;AAGA,eAAsB,WACpB,gBACA,MACmC;CACnC,MAAM,SAAS,MAAM,qBAAqB,gBAAgB,IAAI;CAC9D,IAAI,CAAC,QAAQ,MAAM,IAAI,MAAM,oDAAoD;CACjF,OAAO;AACT;;;;;;;;;AAUA,SAAgB,oBAAoB,gBAAgC,OAA0B,CAAC,GAAqB;CAClH,OAAO,EACL,aAAa,WAAW;EACtB,IAAI;EACJ,aACE;EACF,aAAa,EAAE,OAAO,EACpB,MAAM,EAAE,OAAO,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,SAAS,gDAAgD,EACnF,CAAC;EACD,SAAS,OAAO,EAAE,QAAQ,YAAY;GAGpC,MAAM,MAFa,eAAe,IAAI,YACX,CAAC,EAAE,aAAa,SAAS,UAAA,EAC9B;GACtB,IAAI,CAAC,IAAI,MAAM,IAAI,MAAM,sDAAsD;GAC/E,MAAM,OAAO,MAAM,GAAG,KAAK,IAAI;GAC/B,IAAI,KAAK,SAAS,aAAa,MAAM,IAAI,MAAM,GAAG,KAAK,uCAAuC;GAI9F,IAAI,KAAK,OAAA,UACP,MAAM,IAAI,MAAM,GAAG,KAAK,MAAM,KAAK,KAAK,uBAAuB,qBAAqB,QAAQ;GAE9F,MAAM,MAAM,MAAM,GAAG,SAAS,IAAI;GAClC,MAAM,OAAO,OAAO,SAAS,GAAG,IAAI,MAAM,OAAO,KAAK,GAAG;GACzD,IAAI,KAAK,SAAA,UACP,MAAM,IAAI,MAAM,GAAG,KAAK,MAAM,KAAK,OAAO,uBAAuB,qBAAqB,QAAQ;GAEhG,MAAM,WAAW,KAAK,MAAM,GAAG,CAAC,CAAC,IAAI,KAAK;GAC1C,MAAM,SAAS,MAAM,WAAW,gBAAgB,IAAI;GAIpD,IAAI;IACF,MAAM,OAAO,WAAW,KAAK;KAC3B,UAAU;KACV,OAAO,CAAC;MAAE;MAAM;MAAU,GAAI,KAAK,WAAW,EAAE,UAAU,KAAK,SAAS,IAAI,CAAC;KAAG,CAAC;IACnF,CAAC;GACH,SAAS,OAAO;IACd,MAAM,kBAAkB,OAAO,OAAO,QAAQ,KAAK;GACrD;GACA,OAAO;IAAE,UAAU;IAAM;IAAU,OAAO,KAAK;GAAO;EACxD;CACF,CAAC,EACH;AACF"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"slack.d.ts","sourceRoot":"","sources":["../../../src/integrations/slack/slack.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EACV,cAAc,EAEd,eAAe,EACf,qBAAqB,EACrB,mBAAmB,EACnB,iBAAiB,EACjB,eAAe,EAChB,MAAM,uBAAuB,CAAC;AAG/B,OAAO,KAAK,EAAE,yBAAyB,EAAE,MAAM,eAAe,CAAC;
|
|
1
|
+
{"version":3,"file":"slack.d.ts","sourceRoot":"","sources":["../../../src/integrations/slack/slack.ts"],"names":[],"mappings":"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"}
|
|
@@ -6,7 +6,7 @@ import { readRequestContextOrgId, seedSessionOrg } from "../../session/org-seed.
|
|
|
6
6
|
import { prepareSessionRunContext } from "../subscription-session.js";
|
|
7
7
|
import { FactorySourceControlConflictError, createSourceControlSessionLookup, hydrateFactorySession, resolveFactorySessionModel, resolveFactorySourceControl, resolvePrimaryEnvironmentRepository } from "../../session/factory-session.js";
|
|
8
8
|
import { FACTORY_SLACK_SENDER_KEY } from "../../tools/scope.js";
|
|
9
|
-
import { markNewChannelSession } from "
|
|
9
|
+
import { markNewChannelSession } from "./channel-session-link-processor.js";
|
|
10
10
|
import { resolveEmojiShortcodes } from "./emoji.js";
|
|
11
11
|
import { slackCommentSource } from "./feed-publisher.js";
|
|
12
12
|
import { ChannelSessionRejectedError } from "@mastra/core/channels";
|
|
@@ -455,12 +455,12 @@ async function findInternalThread(mastra, thread) {
|
|
|
455
455
|
} },
|
|
456
456
|
perPage: 1
|
|
457
457
|
}) ?? { threads: [] };
|
|
458
|
-
return threads[0];
|
|
458
|
+
return threads[0] ?? null;
|
|
459
459
|
}
|
|
460
460
|
async function prepareExistingSessionOwnerContext(thread, deps, ctx, options) {
|
|
461
461
|
const sourceControls = configuredSourceControls(deps);
|
|
462
462
|
if (sourceControls.length === 0 && !options.requireInternalThread) return "ready";
|
|
463
|
-
const internalThread = await findInternalThread(ctx.mastra, thread);
|
|
463
|
+
const internalThread = options.internalThread !== void 0 ? options.internalThread : await findInternalThread(ctx.mastra, thread);
|
|
464
464
|
if (!internalThread) {
|
|
465
465
|
if (options.requireInternalThread) throw new Error(`Could not resolve the internal Slack thread for ${thread.id}.`);
|
|
466
466
|
return "ready";
|
|
@@ -506,7 +506,8 @@ async function gateDispatch(thread, message, deps, ctx, options = { requireInter
|
|
|
506
506
|
const ownerContext = await prepareExistingSessionOwnerContext(thread, deps, ctx, {
|
|
507
507
|
expectedOrgId: sender.link.orgId,
|
|
508
508
|
senderUserId: sender.link.userId,
|
|
509
|
-
requireInternalThread: options.requireInternalThread
|
|
509
|
+
requireInternalThread: options.requireInternalThread,
|
|
510
|
+
internalThread: options.internalThread
|
|
510
511
|
});
|
|
511
512
|
if (ownerContext === "access-denied") {
|
|
512
513
|
await thread.post("This private Factory session belongs to another user.");
|
|
@@ -687,19 +688,66 @@ const createHandlers = (deps) => {
|
|
|
687
688
|
return {
|
|
688
689
|
onSubscribedMessage: async (thread, message, defaultHandler, ctx) => {
|
|
689
690
|
if (/^aside\b/i.test(message.text)) return ingestAside(thread, message, deps);
|
|
691
|
+
const internalThread = await findInternalThread(ctx.mastra, thread);
|
|
692
|
+
if (!message.isMention && internalThread?.metadata?.[MUTED_KEY] === "true") return;
|
|
690
693
|
try {
|
|
691
|
-
if (!await gateDispatch(thread, message, deps, ctx, {
|
|
694
|
+
if (!await gateDispatch(thread, message, deps, ctx, {
|
|
695
|
+
requireInternalThread: true,
|
|
696
|
+
internalThread
|
|
697
|
+
})) return;
|
|
692
698
|
} catch (error) {
|
|
693
699
|
await reportPreDispatchError(thread, message, ctx, error);
|
|
694
700
|
return;
|
|
695
701
|
}
|
|
702
|
+
const command = message.isMention ? muteCommand(message) : void 0;
|
|
703
|
+
if (command && internalThread) return setThreadMuted(thread, internalThread, message, ctx, command === "mute");
|
|
696
704
|
acknowledgeReceipt(thread, deps);
|
|
697
705
|
await defaultHandler(thread, message);
|
|
698
706
|
},
|
|
699
|
-
onMention:
|
|
707
|
+
onMention: async (thread, message, defaultHandler, ctx) => {
|
|
708
|
+
if (muteCommand(message)) return;
|
|
709
|
+
await newSessionChatHandler(thread, message, defaultHandler, ctx);
|
|
710
|
+
},
|
|
700
711
|
onDirectMessage: newSessionChatHandler
|
|
701
712
|
};
|
|
702
713
|
};
|
|
714
|
+
/** Thread metadata key; one Factory runs one Slack app, so this is per bot. */
|
|
715
|
+
const MUTED_KEY = "factory_muted";
|
|
716
|
+
/**
|
|
717
|
+
* `mute` or `unmute` when that word, with the mentions taken out, is the whole
|
|
718
|
+
* message. Reads Slack's raw text, where a mention is still `<@U…>`: the
|
|
719
|
+
* normalized `message.text` spells it as the bot's display name, which can
|
|
720
|
+
* hold spaces and punctuation (`@Mastra Factory(Caleb)`).
|
|
721
|
+
*/
|
|
722
|
+
function muteCommand(message) {
|
|
723
|
+
const raw = message.raw;
|
|
724
|
+
const rest = (typeof raw?.text === "string" ? raw.text : message.text).replace(/<@[^>]+>/g, " ").replace(/(?<!\w)@[\w.-]+/g, " ").trim().replace(/[.!]+$/, "").toLowerCase();
|
|
725
|
+
return rest === "mute" || rest === "unmute" ? rest : void 0;
|
|
726
|
+
}
|
|
727
|
+
/**
|
|
728
|
+
* Stored on the Mastra thread next to `channel_subscribed`, so a mute holds
|
|
729
|
+
* across deploys and replicas. Callers run the sender's link and session
|
|
730
|
+
* access gate first. A reaction confirms it without posting.
|
|
731
|
+
*/
|
|
732
|
+
async function setThreadMuted(thread, internal, message, ctx, muted) {
|
|
733
|
+
const store = await ctx.mastra?.getStorage()?.getStore("memory");
|
|
734
|
+
if (!store) return;
|
|
735
|
+
await store.patchThread({
|
|
736
|
+
id: internal.id,
|
|
737
|
+
metadata: {
|
|
738
|
+
...internal.metadata,
|
|
739
|
+
[MUTED_KEY]: String(muted)
|
|
740
|
+
}
|
|
741
|
+
});
|
|
742
|
+
try {
|
|
743
|
+
await thread.adapter.addReaction(thread.id, message.id, muted ? "mute" : "loud_sound");
|
|
744
|
+
} catch (error) {
|
|
745
|
+
console.warn("[slack] mute reaction failed", {
|
|
746
|
+
thread: thread.id,
|
|
747
|
+
error
|
|
748
|
+
});
|
|
749
|
+
}
|
|
750
|
+
}
|
|
703
751
|
function createSlackChannelsConfig(deps) {
|
|
704
752
|
const adapter = createSlackAdapter({
|
|
705
753
|
...deps.slack,
|