@oh-my-pi/pi-mom 1.337.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/src/store.ts ADDED
@@ -0,0 +1,234 @@
1
+ import { existsSync, mkdirSync, readFileSync } from "node:fs";
2
+ import { appendFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import * as log from "./log.js";
5
+
6
+ export interface Attachment {
7
+ original: string; // original filename from uploader
8
+ local: string; // path relative to working dir (e.g., "C12345/attachments/1732531234567_file.png")
9
+ }
10
+
11
+ export interface LoggedMessage {
12
+ date: string; // ISO 8601 date (e.g., "2025-11-26T10:44:00.000Z") for easy grepping
13
+ ts: string; // slack timestamp or epoch ms
14
+ user: string; // user ID (or "bot" for bot responses)
15
+ userName?: string; // handle (e.g., "mario")
16
+ displayName?: string; // display name (e.g., "Mario Zechner")
17
+ text: string;
18
+ attachments: Attachment[];
19
+ isBot: boolean;
20
+ }
21
+
22
+ export interface ChannelStoreConfig {
23
+ workingDir: string;
24
+ botToken: string; // needed for authenticated file downloads
25
+ }
26
+
27
+ interface PendingDownload {
28
+ channelId: string;
29
+ localPath: string; // relative path
30
+ url: string;
31
+ }
32
+
33
+ export class ChannelStore {
34
+ private workingDir: string;
35
+ private botToken: string;
36
+ private pendingDownloads: PendingDownload[] = [];
37
+ private isDownloading = false;
38
+ // Track recently logged message timestamps to prevent duplicates
39
+ // Key: "channelId:ts", automatically cleaned up after 60 seconds
40
+ private recentlyLogged = new Map<string, number>();
41
+
42
+ constructor(config: ChannelStoreConfig) {
43
+ this.workingDir = config.workingDir;
44
+ this.botToken = config.botToken;
45
+
46
+ // Ensure working directory exists
47
+ if (!existsSync(this.workingDir)) {
48
+ mkdirSync(this.workingDir, { recursive: true });
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Get or create the directory for a channel/DM
54
+ */
55
+ getChannelDir(channelId: string): string {
56
+ const dir = join(this.workingDir, channelId);
57
+ if (!existsSync(dir)) {
58
+ mkdirSync(dir, { recursive: true });
59
+ }
60
+ return dir;
61
+ }
62
+
63
+ /**
64
+ * Generate a unique local filename for an attachment
65
+ */
66
+ generateLocalFilename(originalName: string, timestamp: string): string {
67
+ // Convert slack timestamp (1234567890.123456) to milliseconds
68
+ const ts = Math.floor(parseFloat(timestamp) * 1000);
69
+ // Sanitize original name (remove problematic characters)
70
+ const sanitized = originalName.replace(/[^a-zA-Z0-9._-]/g, "_");
71
+ return `${ts}_${sanitized}`;
72
+ }
73
+
74
+ /**
75
+ * Process attachments from a Slack message event
76
+ * Returns attachment metadata and queues downloads
77
+ */
78
+ processAttachments(
79
+ channelId: string,
80
+ files: Array<{ name?: string; url_private_download?: string; url_private?: string }>,
81
+ timestamp: string,
82
+ ): Attachment[] {
83
+ const attachments: Attachment[] = [];
84
+
85
+ for (const file of files) {
86
+ const url = file.url_private_download || file.url_private;
87
+ if (!url) continue;
88
+ if (!file.name) {
89
+ log.logWarning("Attachment missing name, skipping", url);
90
+ continue;
91
+ }
92
+
93
+ const filename = this.generateLocalFilename(file.name, timestamp);
94
+ const localPath = `${channelId}/attachments/${filename}`;
95
+
96
+ attachments.push({
97
+ original: file.name,
98
+ local: localPath,
99
+ });
100
+
101
+ // Queue for background download
102
+ this.pendingDownloads.push({ channelId, localPath, url });
103
+ }
104
+
105
+ // Trigger background download
106
+ this.processDownloadQueue();
107
+
108
+ return attachments;
109
+ }
110
+
111
+ /**
112
+ * Log a message to the channel's log.jsonl
113
+ * Returns false if message was already logged (duplicate)
114
+ */
115
+ async logMessage(channelId: string, message: LoggedMessage): Promise<boolean> {
116
+ // Check for duplicate (same channel + timestamp)
117
+ const dedupeKey = `${channelId}:${message.ts}`;
118
+ if (this.recentlyLogged.has(dedupeKey)) {
119
+ return false; // Already logged
120
+ }
121
+
122
+ // Mark as logged and schedule cleanup after 60 seconds
123
+ this.recentlyLogged.set(dedupeKey, Date.now());
124
+ setTimeout(() => this.recentlyLogged.delete(dedupeKey), 60000);
125
+
126
+ const logPath = join(this.getChannelDir(channelId), "log.jsonl");
127
+
128
+ // Ensure message has a date field
129
+ if (!message.date) {
130
+ // Parse timestamp to get date
131
+ let date: Date;
132
+ if (message.ts.includes(".")) {
133
+ // Slack timestamp format (1234567890.123456)
134
+ date = new Date(parseFloat(message.ts) * 1000);
135
+ } else {
136
+ // Epoch milliseconds
137
+ date = new Date(parseInt(message.ts, 10));
138
+ }
139
+ message.date = date.toISOString();
140
+ }
141
+
142
+ const line = `${JSON.stringify(message)}\n`;
143
+ await appendFile(logPath, line, "utf-8");
144
+ return true;
145
+ }
146
+
147
+ /**
148
+ * Log a bot response
149
+ */
150
+ async logBotResponse(channelId: string, text: string, ts: string): Promise<void> {
151
+ await this.logMessage(channelId, {
152
+ date: new Date().toISOString(),
153
+ ts,
154
+ user: "bot",
155
+ text,
156
+ attachments: [],
157
+ isBot: true,
158
+ });
159
+ }
160
+
161
+ /**
162
+ * Get the timestamp of the last logged message for a channel
163
+ * Returns null if no log exists
164
+ */
165
+ getLastTimestamp(channelId: string): string | null {
166
+ const logPath = join(this.workingDir, channelId, "log.jsonl");
167
+ if (!existsSync(logPath)) {
168
+ return null;
169
+ }
170
+
171
+ try {
172
+ const content = readFileSync(logPath, "utf-8");
173
+ const lines = content.trim().split("\n");
174
+ if (lines.length === 0 || lines[0] === "") {
175
+ return null;
176
+ }
177
+ const lastLine = lines[lines.length - 1];
178
+ const message = JSON.parse(lastLine) as LoggedMessage;
179
+ return message.ts;
180
+ } catch {
181
+ return null;
182
+ }
183
+ }
184
+
185
+ /**
186
+ * Process the download queue in the background
187
+ */
188
+ private async processDownloadQueue(): Promise<void> {
189
+ if (this.isDownloading || this.pendingDownloads.length === 0) return;
190
+
191
+ this.isDownloading = true;
192
+
193
+ while (this.pendingDownloads.length > 0) {
194
+ const item = this.pendingDownloads.shift();
195
+ if (!item) break;
196
+
197
+ try {
198
+ await this.downloadAttachment(item.localPath, item.url);
199
+ // Success - could add success logging here if we have context
200
+ } catch (error) {
201
+ const errorMsg = error instanceof Error ? error.message : String(error);
202
+ log.logWarning(`Failed to download attachment`, `${item.localPath}: ${errorMsg}`);
203
+ }
204
+ }
205
+
206
+ this.isDownloading = false;
207
+ }
208
+
209
+ /**
210
+ * Download a single attachment
211
+ */
212
+ private async downloadAttachment(localPath: string, url: string): Promise<void> {
213
+ const filePath = join(this.workingDir, localPath);
214
+
215
+ // Ensure directory exists
216
+ const dir = join(this.workingDir, localPath.substring(0, localPath.lastIndexOf("/")));
217
+ if (!existsSync(dir)) {
218
+ mkdirSync(dir, { recursive: true });
219
+ }
220
+
221
+ const response = await fetch(url, {
222
+ headers: {
223
+ Authorization: `Bearer ${this.botToken}`,
224
+ },
225
+ });
226
+
227
+ if (!response.ok) {
228
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
229
+ }
230
+
231
+ const buffer = await response.arrayBuffer();
232
+ await Bun.write(filePath, new Uint8Array(buffer));
233
+ }
234
+ }
@@ -0,0 +1,47 @@
1
+ import type { AgentTool } from "@oh-my-pi/pi-agent-core";
2
+ import { Type } from "@sinclair/typebox";
3
+ import { basename, resolve as resolvePath } from "path";
4
+
5
+ // This will be set by the agent before running
6
+ let uploadFn: ((filePath: string, title?: string) => Promise<void>) | null = null;
7
+
8
+ export function setUploadFunction(fn: (filePath: string, title?: string) => Promise<void>): void {
9
+ uploadFn = fn;
10
+ }
11
+
12
+ const attachSchema = Type.Object({
13
+ label: Type.String({ description: "Brief description of what you're sharing (shown to user)" }),
14
+ path: Type.String({ description: "Path to the file to attach" }),
15
+ title: Type.Optional(Type.String({ description: "Title for the file (defaults to filename)" })),
16
+ });
17
+
18
+ export const attachTool: AgentTool<typeof attachSchema> = {
19
+ name: "attach",
20
+ label: "attach",
21
+ description:
22
+ "Attach a file to your response. Use this to share files, images, or documents with the user. Only files from /workspace/ can be attached.",
23
+ parameters: attachSchema,
24
+ execute: async (
25
+ _toolCallId: string,
26
+ { path, title }: { label: string; path: string; title?: string },
27
+ signal?: AbortSignal,
28
+ ) => {
29
+ if (!uploadFn) {
30
+ throw new Error("Upload function not configured");
31
+ }
32
+
33
+ if (signal?.aborted) {
34
+ throw new Error("Operation aborted");
35
+ }
36
+
37
+ const absolutePath = resolvePath(path);
38
+ const fileName = title || basename(absolutePath);
39
+
40
+ await uploadFn(absolutePath, fileName);
41
+
42
+ return {
43
+ content: [{ type: "text" as const, text: `Attached file: ${fileName}` }],
44
+ details: undefined,
45
+ };
46
+ },
47
+ };
@@ -0,0 +1,99 @@
1
+ import { tmpdir } from "node:os";
2
+ import { join } from "node:path";
3
+ import type { AgentTool } from "@oh-my-pi/pi-agent-core";
4
+ import { Type } from "@sinclair/typebox";
5
+ import type { Executor } from "../sandbox.js";
6
+ import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, type TruncationResult, truncateTail } from "./truncate.js";
7
+
8
+ /**
9
+ * Generate a unique temp file path for bash output
10
+ */
11
+ function getTempFilePath(): string {
12
+ const bytes = crypto.getRandomValues(new Uint8Array(8));
13
+ const id = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
14
+ return join(tmpdir(), `mom-bash-${id}.log`);
15
+ }
16
+
17
+ const bashSchema = Type.Object({
18
+ label: Type.String({ description: "Brief description of what this command does (shown to user)" }),
19
+ command: Type.String({ description: "Bash command to execute" }),
20
+ timeout: Type.Optional(Type.Number({ description: "Timeout in seconds (optional, no default timeout)" })),
21
+ });
22
+
23
+ interface BashToolDetails {
24
+ truncation?: TruncationResult;
25
+ fullOutputPath?: string;
26
+ }
27
+
28
+ export function createBashTool(executor: Executor): AgentTool<typeof bashSchema> {
29
+ return {
30
+ name: "bash",
31
+ label: "bash",
32
+ description: `Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last ${DEFAULT_MAX_LINES} lines or ${
33
+ DEFAULT_MAX_BYTES / 1024
34
+ }KB (whichever is hit first). If truncated, full output is saved to a temp file. Optionally provide a timeout in seconds.`,
35
+ parameters: bashSchema,
36
+ execute: async (
37
+ _toolCallId: string,
38
+ { command, timeout }: { label: string; command: string; timeout?: number },
39
+ signal?: AbortSignal,
40
+ ) => {
41
+ // Track output for potential temp file writing
42
+ let tempFilePath: string | undefined;
43
+
44
+ const result = await executor.exec(command, { timeout, signal });
45
+ let output = "";
46
+ if (result.stdout) output += result.stdout;
47
+ if (result.stderr) {
48
+ if (output) output += "\n";
49
+ output += result.stderr;
50
+ }
51
+
52
+ const totalBytes = Buffer.byteLength(output, "utf-8");
53
+
54
+ // Write to temp file if output exceeds limit
55
+ if (totalBytes > DEFAULT_MAX_BYTES) {
56
+ tempFilePath = getTempFilePath();
57
+ await Bun.write(tempFilePath, output);
58
+ }
59
+
60
+ // Apply tail truncation
61
+ const truncation = truncateTail(output);
62
+ let outputText = truncation.content || "(no output)";
63
+
64
+ // Build details with truncation info
65
+ let details: BashToolDetails | undefined;
66
+
67
+ if (truncation.truncated) {
68
+ details = {
69
+ truncation,
70
+ fullOutputPath: tempFilePath,
71
+ };
72
+
73
+ // Build actionable notice
74
+ const startLine = truncation.totalLines - truncation.outputLines + 1;
75
+ const endLine = truncation.totalLines;
76
+
77
+ if (truncation.lastLinePartial) {
78
+ // Edge case: last line alone > 50KB
79
+ const lastLineSize = formatSize(Buffer.byteLength(output.split("\n").pop() || "", "utf-8"));
80
+ outputText += `\n\n[Showing last ${formatSize(
81
+ truncation.outputBytes,
82
+ )} of line ${endLine} (line is ${lastLineSize}). Full output: ${tempFilePath}]`;
83
+ } else if (truncation.truncatedBy === "lines") {
84
+ outputText += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines}. Full output: ${tempFilePath}]`;
85
+ } else {
86
+ outputText += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines} (${formatSize(
87
+ DEFAULT_MAX_BYTES,
88
+ )} limit). Full output: ${tempFilePath}]`;
89
+ }
90
+ }
91
+
92
+ if (result.code !== 0) {
93
+ throw new Error(`${outputText}\n\nCommand exited with code ${result.code}`.trim());
94
+ }
95
+
96
+ return { content: [{ type: "text", text: outputText }], details };
97
+ },
98
+ };
99
+ }
@@ -0,0 +1,165 @@
1
+ import type { AgentTool } from "@oh-my-pi/pi-agent-core";
2
+ import { Type } from "@sinclair/typebox";
3
+ import * as Diff from "diff";
4
+ import type { Executor } from "../sandbox.js";
5
+
6
+ /**
7
+ * Generate a unified diff string with line numbers and context
8
+ */
9
+ function generateDiffString(oldContent: string, newContent: string, contextLines = 4): string {
10
+ const parts = Diff.diffLines(oldContent, newContent);
11
+ const output: string[] = [];
12
+
13
+ const oldLines = oldContent.split("\n");
14
+ const newLines = newContent.split("\n");
15
+ const maxLineNum = Math.max(oldLines.length, newLines.length);
16
+ const lineNumWidth = String(maxLineNum).length;
17
+
18
+ let oldLineNum = 1;
19
+ let newLineNum = 1;
20
+ let lastWasChange = false;
21
+
22
+ for (let i = 0; i < parts.length; i++) {
23
+ const part = parts[i];
24
+ const raw = part.value.split("\n");
25
+ if (raw[raw.length - 1] === "") {
26
+ raw.pop();
27
+ }
28
+
29
+ if (part.added || part.removed) {
30
+ for (const line of raw) {
31
+ if (part.added) {
32
+ const lineNum = String(newLineNum).padStart(lineNumWidth, " ");
33
+ output.push(`+${lineNum} ${line}`);
34
+ newLineNum++;
35
+ } else {
36
+ const lineNum = String(oldLineNum).padStart(lineNumWidth, " ");
37
+ output.push(`-${lineNum} ${line}`);
38
+ oldLineNum++;
39
+ }
40
+ }
41
+ lastWasChange = true;
42
+ } else {
43
+ const nextPartIsChange = i < parts.length - 1 && (parts[i + 1].added || parts[i + 1].removed);
44
+
45
+ if (lastWasChange || nextPartIsChange) {
46
+ let linesToShow = raw;
47
+ let skipStart = 0;
48
+ let skipEnd = 0;
49
+
50
+ if (!lastWasChange) {
51
+ skipStart = Math.max(0, raw.length - contextLines);
52
+ linesToShow = raw.slice(skipStart);
53
+ }
54
+
55
+ if (!nextPartIsChange && linesToShow.length > contextLines) {
56
+ skipEnd = linesToShow.length - contextLines;
57
+ linesToShow = linesToShow.slice(0, contextLines);
58
+ }
59
+
60
+ if (skipStart > 0) {
61
+ output.push(` ${"".padStart(lineNumWidth, " ")} ...`);
62
+ }
63
+
64
+ for (const line of linesToShow) {
65
+ const lineNum = String(oldLineNum).padStart(lineNumWidth, " ");
66
+ output.push(` ${lineNum} ${line}`);
67
+ oldLineNum++;
68
+ newLineNum++;
69
+ }
70
+
71
+ if (skipEnd > 0) {
72
+ output.push(` ${"".padStart(lineNumWidth, " ")} ...`);
73
+ }
74
+
75
+ oldLineNum += skipStart + skipEnd;
76
+ newLineNum += skipStart + skipEnd;
77
+ } else {
78
+ oldLineNum += raw.length;
79
+ newLineNum += raw.length;
80
+ }
81
+
82
+ lastWasChange = false;
83
+ }
84
+ }
85
+
86
+ return output.join("\n");
87
+ }
88
+
89
+ const editSchema = Type.Object({
90
+ label: Type.String({ description: "Brief description of the edit you're making (shown to user)" }),
91
+ path: Type.String({ description: "Path to the file to edit (relative or absolute)" }),
92
+ oldText: Type.String({ description: "Exact text to find and replace (must match exactly)" }),
93
+ newText: Type.String({ description: "New text to replace the old text with" }),
94
+ });
95
+
96
+ export function createEditTool(executor: Executor): AgentTool<typeof editSchema> {
97
+ return {
98
+ name: "edit",
99
+ label: "edit",
100
+ description:
101
+ "Edit a file by replacing exact text. The oldText must match exactly (including whitespace). Use this for precise, surgical edits.",
102
+ parameters: editSchema,
103
+ execute: async (
104
+ _toolCallId: string,
105
+ { path, oldText, newText }: { label: string; path: string; oldText: string; newText: string },
106
+ signal?: AbortSignal,
107
+ ) => {
108
+ // Read the file
109
+ const readResult = await executor.exec(`cat ${shellEscape(path)}`, { signal });
110
+ if (readResult.code !== 0) {
111
+ throw new Error(readResult.stderr || `File not found: ${path}`);
112
+ }
113
+
114
+ const content = readResult.stdout;
115
+
116
+ // Check if old text exists
117
+ if (!content.includes(oldText)) {
118
+ throw new Error(
119
+ `Could not find the exact text in ${path}. The old text must match exactly including all whitespace and newlines.`,
120
+ );
121
+ }
122
+
123
+ // Count occurrences
124
+ const occurrences = content.split(oldText).length - 1;
125
+
126
+ if (occurrences > 1) {
127
+ throw new Error(
128
+ `Found ${occurrences} occurrences of the text in ${path}. The text must be unique. Please provide more context to make it unique.`,
129
+ );
130
+ }
131
+
132
+ // Perform replacement
133
+ const index = content.indexOf(oldText);
134
+ const newContent = content.substring(0, index) + newText + content.substring(index + oldText.length);
135
+
136
+ if (content === newContent) {
137
+ throw new Error(
138
+ `No changes made to ${path}. The replacement produced identical content. This might indicate an issue with special characters or the text not existing as expected.`,
139
+ );
140
+ }
141
+
142
+ // Write the file back
143
+ const writeResult = await executor.exec(`printf '%s' ${shellEscape(newContent)} > ${shellEscape(path)}`, {
144
+ signal,
145
+ });
146
+ if (writeResult.code !== 0) {
147
+ throw new Error(writeResult.stderr || `Failed to write file: ${path}`);
148
+ }
149
+
150
+ return {
151
+ content: [
152
+ {
153
+ type: "text",
154
+ text: `Successfully replaced text in ${path}. Changed ${oldText.length} characters to ${newText.length} characters.`,
155
+ },
156
+ ],
157
+ details: { diff: generateDiffString(content, newContent) },
158
+ };
159
+ },
160
+ };
161
+ }
162
+
163
+ function shellEscape(s: string): string {
164
+ return `'${s.replace(/'/g, "'\\''")}'`;
165
+ }
@@ -0,0 +1,19 @@
1
+ import type { AgentTool } from "@oh-my-pi/pi-agent-core";
2
+ import type { Executor } from "../sandbox.js";
3
+ import { attachTool } from "./attach.js";
4
+ import { createBashTool } from "./bash.js";
5
+ import { createEditTool } from "./edit.js";
6
+ import { createReadTool } from "./read.js";
7
+ import { createWriteTool } from "./write.js";
8
+
9
+ export { setUploadFunction } from "./attach.js";
10
+
11
+ export function createMomTools(executor: Executor): AgentTool<any>[] {
12
+ return [
13
+ createReadTool(executor),
14
+ createBashTool(executor),
15
+ createEditTool(executor),
16
+ createWriteTool(executor),
17
+ attachTool,
18
+ ];
19
+ }