mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-20 05:04:44 +00:00
mom: turn-based context, timestamp fixes, system prompt improvements (#68)
Breaking Changes: - Timestamps now use Slack format - run migrate-timestamps.ts for existing logs Added: - Channel/user ID mappings in system prompt - Skills documentation in system prompt - Debug last_prompt.txt output - Bash working directory info - Token-efficient log queries filtering tool calls Changed: - Turn-based message context (groups consecutive bot messages as one turn) - Messages sorted by Slack timestamp - Condensed system prompt (~5k → ~2.7k chars) - Simplified user prompt - Selective logging (skip UI status labels) Fixed: - Duplicate message logging from app_mention handler - Username obfuscation in thread messages
This commit is contained in:
parent
330e044b55
commit
9ebee631be
6 changed files with 619 additions and 239 deletions
|
|
@ -1957,6 +1957,23 @@ export const MODELS = {
|
||||||
} satisfies Model<"anthropic-messages">,
|
} satisfies Model<"anthropic-messages">,
|
||||||
},
|
},
|
||||||
openrouter: {
|
openrouter: {
|
||||||
|
"prime-intellect/intellect-3": {
|
||||||
|
id: "prime-intellect/intellect-3",
|
||||||
|
name: "Prime Intellect: INTELLECT-3",
|
||||||
|
api: "openai-completions",
|
||||||
|
provider: "openrouter",
|
||||||
|
baseUrl: "https://openrouter.ai/api/v1",
|
||||||
|
reasoning: true,
|
||||||
|
input: ["text"],
|
||||||
|
cost: {
|
||||||
|
input: 0.19999999999999998,
|
||||||
|
output: 1.1,
|
||||||
|
cacheRead: 0,
|
||||||
|
cacheWrite: 0,
|
||||||
|
},
|
||||||
|
contextWindow: 131072,
|
||||||
|
maxTokens: 131072,
|
||||||
|
} satisfies Model<"openai-completions">,
|
||||||
"tngtech/tng-r1t-chimera:free": {
|
"tngtech/tng-r1t-chimera:free": {
|
||||||
id: "tngtech/tng-r1t-chimera:free",
|
id: "tngtech/tng-r1t-chimera:free",
|
||||||
name: "TNG: R1T Chimera (free)",
|
name: "TNG: R1T Chimera (free)",
|
||||||
|
|
@ -2238,8 +2255,8 @@ export const MODELS = {
|
||||||
reasoning: true,
|
reasoning: true,
|
||||||
input: ["text"],
|
input: ["text"],
|
||||||
cost: {
|
cost: {
|
||||||
input: 0.24,
|
input: 0.255,
|
||||||
output: 0.96,
|
output: 1.02,
|
||||||
cacheRead: 0,
|
cacheRead: 0,
|
||||||
cacheWrite: 0,
|
cacheWrite: 0,
|
||||||
},
|
},
|
||||||
|
|
@ -4983,23 +5000,6 @@ export const MODELS = {
|
||||||
contextWindow: 32768,
|
contextWindow: 32768,
|
||||||
maxTokens: 4096,
|
maxTokens: 4096,
|
||||||
} satisfies Model<"openai-completions">,
|
} satisfies Model<"openai-completions">,
|
||||||
"cohere/command-r-08-2024": {
|
|
||||||
id: "cohere/command-r-08-2024",
|
|
||||||
name: "Cohere: Command R (08-2024)",
|
|
||||||
api: "openai-completions",
|
|
||||||
provider: "openrouter",
|
|
||||||
baseUrl: "https://openrouter.ai/api/v1",
|
|
||||||
reasoning: false,
|
|
||||||
input: ["text"],
|
|
||||||
cost: {
|
|
||||||
input: 0.15,
|
|
||||||
output: 0.6,
|
|
||||||
cacheRead: 0,
|
|
||||||
cacheWrite: 0,
|
|
||||||
},
|
|
||||||
contextWindow: 128000,
|
|
||||||
maxTokens: 4000,
|
|
||||||
} satisfies Model<"openai-completions">,
|
|
||||||
"cohere/command-r-plus-08-2024": {
|
"cohere/command-r-plus-08-2024": {
|
||||||
id: "cohere/command-r-plus-08-2024",
|
id: "cohere/command-r-plus-08-2024",
|
||||||
name: "Cohere: Command R+ (08-2024)",
|
name: "Cohere: Command R+ (08-2024)",
|
||||||
|
|
@ -5017,6 +5017,23 @@ export const MODELS = {
|
||||||
contextWindow: 128000,
|
contextWindow: 128000,
|
||||||
maxTokens: 4000,
|
maxTokens: 4000,
|
||||||
} satisfies Model<"openai-completions">,
|
} satisfies Model<"openai-completions">,
|
||||||
|
"cohere/command-r-08-2024": {
|
||||||
|
id: "cohere/command-r-08-2024",
|
||||||
|
name: "Cohere: Command R (08-2024)",
|
||||||
|
api: "openai-completions",
|
||||||
|
provider: "openrouter",
|
||||||
|
baseUrl: "https://openrouter.ai/api/v1",
|
||||||
|
reasoning: false,
|
||||||
|
input: ["text"],
|
||||||
|
cost: {
|
||||||
|
input: 0.15,
|
||||||
|
output: 0.6,
|
||||||
|
cacheRead: 0,
|
||||||
|
cacheWrite: 0,
|
||||||
|
},
|
||||||
|
contextWindow: 128000,
|
||||||
|
maxTokens: 4000,
|
||||||
|
} satisfies Model<"openai-completions">,
|
||||||
"sao10k/l3.1-euryale-70b": {
|
"sao10k/l3.1-euryale-70b": {
|
||||||
id: "sao10k/l3.1-euryale-70b",
|
id: "sao10k/l3.1-euryale-70b",
|
||||||
name: "Sao10K: Llama 3.1 Euryale 70B v2.2",
|
name: "Sao10K: Llama 3.1 Euryale 70B v2.2",
|
||||||
|
|
@ -5153,9 +5170,9 @@ export const MODELS = {
|
||||||
contextWindow: 131072,
|
contextWindow: 131072,
|
||||||
maxTokens: 16384,
|
maxTokens: 16384,
|
||||||
} satisfies Model<"openai-completions">,
|
} satisfies Model<"openai-completions">,
|
||||||
"openai/gpt-4o-mini-2024-07-18": {
|
"openai/gpt-4o-mini": {
|
||||||
id: "openai/gpt-4o-mini-2024-07-18",
|
id: "openai/gpt-4o-mini",
|
||||||
name: "OpenAI: GPT-4o-mini (2024-07-18)",
|
name: "OpenAI: GPT-4o-mini",
|
||||||
api: "openai-completions",
|
api: "openai-completions",
|
||||||
provider: "openrouter",
|
provider: "openrouter",
|
||||||
baseUrl: "https://openrouter.ai/api/v1",
|
baseUrl: "https://openrouter.ai/api/v1",
|
||||||
|
|
@ -5170,9 +5187,9 @@ export const MODELS = {
|
||||||
contextWindow: 128000,
|
contextWindow: 128000,
|
||||||
maxTokens: 16384,
|
maxTokens: 16384,
|
||||||
} satisfies Model<"openai-completions">,
|
} satisfies Model<"openai-completions">,
|
||||||
"openai/gpt-4o-mini": {
|
"openai/gpt-4o-mini-2024-07-18": {
|
||||||
id: "openai/gpt-4o-mini",
|
id: "openai/gpt-4o-mini-2024-07-18",
|
||||||
name: "OpenAI: GPT-4o-mini",
|
name: "OpenAI: GPT-4o-mini (2024-07-18)",
|
||||||
api: "openai-completions",
|
api: "openai-completions",
|
||||||
provider: "openrouter",
|
provider: "openrouter",
|
||||||
baseUrl: "https://openrouter.ai/api/v1",
|
baseUrl: "https://openrouter.ai/api/v1",
|
||||||
|
|
@ -5272,23 +5289,6 @@ export const MODELS = {
|
||||||
contextWindow: 128000,
|
contextWindow: 128000,
|
||||||
maxTokens: 4096,
|
maxTokens: 4096,
|
||||||
} satisfies Model<"openai-completions">,
|
} satisfies Model<"openai-completions">,
|
||||||
"openai/gpt-4o-2024-05-13": {
|
|
||||||
id: "openai/gpt-4o-2024-05-13",
|
|
||||||
name: "OpenAI: GPT-4o (2024-05-13)",
|
|
||||||
api: "openai-completions",
|
|
||||||
provider: "openrouter",
|
|
||||||
baseUrl: "https://openrouter.ai/api/v1",
|
|
||||||
reasoning: false,
|
|
||||||
input: ["text", "image"],
|
|
||||||
cost: {
|
|
||||||
input: 5,
|
|
||||||
output: 15,
|
|
||||||
cacheRead: 0,
|
|
||||||
cacheWrite: 0,
|
|
||||||
},
|
|
||||||
contextWindow: 128000,
|
|
||||||
maxTokens: 4096,
|
|
||||||
} satisfies Model<"openai-completions">,
|
|
||||||
"openai/gpt-4o": {
|
"openai/gpt-4o": {
|
||||||
id: "openai/gpt-4o",
|
id: "openai/gpt-4o",
|
||||||
name: "OpenAI: GPT-4o",
|
name: "OpenAI: GPT-4o",
|
||||||
|
|
@ -5323,22 +5323,22 @@ export const MODELS = {
|
||||||
contextWindow: 128000,
|
contextWindow: 128000,
|
||||||
maxTokens: 64000,
|
maxTokens: 64000,
|
||||||
} satisfies Model<"openai-completions">,
|
} satisfies Model<"openai-completions">,
|
||||||
"meta-llama/llama-3-70b-instruct": {
|
"openai/gpt-4o-2024-05-13": {
|
||||||
id: "meta-llama/llama-3-70b-instruct",
|
id: "openai/gpt-4o-2024-05-13",
|
||||||
name: "Meta: Llama 3 70B Instruct",
|
name: "OpenAI: GPT-4o (2024-05-13)",
|
||||||
api: "openai-completions",
|
api: "openai-completions",
|
||||||
provider: "openrouter",
|
provider: "openrouter",
|
||||||
baseUrl: "https://openrouter.ai/api/v1",
|
baseUrl: "https://openrouter.ai/api/v1",
|
||||||
reasoning: false,
|
reasoning: false,
|
||||||
input: ["text"],
|
input: ["text", "image"],
|
||||||
cost: {
|
cost: {
|
||||||
input: 0.3,
|
input: 5,
|
||||||
output: 0.39999999999999997,
|
output: 15,
|
||||||
cacheRead: 0,
|
cacheRead: 0,
|
||||||
cacheWrite: 0,
|
cacheWrite: 0,
|
||||||
},
|
},
|
||||||
contextWindow: 8192,
|
contextWindow: 128000,
|
||||||
maxTokens: 16384,
|
maxTokens: 4096,
|
||||||
} satisfies Model<"openai-completions">,
|
} satisfies Model<"openai-completions">,
|
||||||
"meta-llama/llama-3-8b-instruct": {
|
"meta-llama/llama-3-8b-instruct": {
|
||||||
id: "meta-llama/llama-3-8b-instruct",
|
id: "meta-llama/llama-3-8b-instruct",
|
||||||
|
|
@ -5357,6 +5357,23 @@ export const MODELS = {
|
||||||
contextWindow: 8192,
|
contextWindow: 8192,
|
||||||
maxTokens: 16384,
|
maxTokens: 16384,
|
||||||
} satisfies Model<"openai-completions">,
|
} satisfies Model<"openai-completions">,
|
||||||
|
"meta-llama/llama-3-70b-instruct": {
|
||||||
|
id: "meta-llama/llama-3-70b-instruct",
|
||||||
|
name: "Meta: Llama 3 70B Instruct",
|
||||||
|
api: "openai-completions",
|
||||||
|
provider: "openrouter",
|
||||||
|
baseUrl: "https://openrouter.ai/api/v1",
|
||||||
|
reasoning: false,
|
||||||
|
input: ["text"],
|
||||||
|
cost: {
|
||||||
|
input: 0.3,
|
||||||
|
output: 0.39999999999999997,
|
||||||
|
cacheRead: 0,
|
||||||
|
cacheWrite: 0,
|
||||||
|
},
|
||||||
|
contextWindow: 8192,
|
||||||
|
maxTokens: 16384,
|
||||||
|
} satisfies Model<"openai-completions">,
|
||||||
"mistralai/mixtral-8x22b-instruct": {
|
"mistralai/mixtral-8x22b-instruct": {
|
||||||
id: "mistralai/mixtral-8x22b-instruct",
|
id: "mistralai/mixtral-8x22b-instruct",
|
||||||
name: "Mistral: Mixtral 8x22B Instruct",
|
name: "Mistral: Mixtral 8x22B Instruct",
|
||||||
|
|
@ -5442,23 +5459,6 @@ export const MODELS = {
|
||||||
contextWindow: 128000,
|
contextWindow: 128000,
|
||||||
maxTokens: 4096,
|
maxTokens: 4096,
|
||||||
} satisfies Model<"openai-completions">,
|
} satisfies Model<"openai-completions">,
|
||||||
"openai/gpt-3.5-turbo-0613": {
|
|
||||||
id: "openai/gpt-3.5-turbo-0613",
|
|
||||||
name: "OpenAI: GPT-3.5 Turbo (older v0613)",
|
|
||||||
api: "openai-completions",
|
|
||||||
provider: "openrouter",
|
|
||||||
baseUrl: "https://openrouter.ai/api/v1",
|
|
||||||
reasoning: false,
|
|
||||||
input: ["text"],
|
|
||||||
cost: {
|
|
||||||
input: 1,
|
|
||||||
output: 2,
|
|
||||||
cacheRead: 0,
|
|
||||||
cacheWrite: 0,
|
|
||||||
},
|
|
||||||
contextWindow: 4095,
|
|
||||||
maxTokens: 4096,
|
|
||||||
} satisfies Model<"openai-completions">,
|
|
||||||
"openai/gpt-4-turbo-preview": {
|
"openai/gpt-4-turbo-preview": {
|
||||||
id: "openai/gpt-4-turbo-preview",
|
id: "openai/gpt-4-turbo-preview",
|
||||||
name: "OpenAI: GPT-4 Turbo Preview",
|
name: "OpenAI: GPT-4 Turbo Preview",
|
||||||
|
|
@ -5476,6 +5476,23 @@ export const MODELS = {
|
||||||
contextWindow: 128000,
|
contextWindow: 128000,
|
||||||
maxTokens: 4096,
|
maxTokens: 4096,
|
||||||
} satisfies Model<"openai-completions">,
|
} satisfies Model<"openai-completions">,
|
||||||
|
"openai/gpt-3.5-turbo-0613": {
|
||||||
|
id: "openai/gpt-3.5-turbo-0613",
|
||||||
|
name: "OpenAI: GPT-3.5 Turbo (older v0613)",
|
||||||
|
api: "openai-completions",
|
||||||
|
provider: "openrouter",
|
||||||
|
baseUrl: "https://openrouter.ai/api/v1",
|
||||||
|
reasoning: false,
|
||||||
|
input: ["text"],
|
||||||
|
cost: {
|
||||||
|
input: 1,
|
||||||
|
output: 2,
|
||||||
|
cacheRead: 0,
|
||||||
|
cacheWrite: 0,
|
||||||
|
},
|
||||||
|
contextWindow: 4095,
|
||||||
|
maxTokens: 4096,
|
||||||
|
} satisfies Model<"openai-completions">,
|
||||||
"mistralai/mistral-small": {
|
"mistralai/mistral-small": {
|
||||||
id: "mistralai/mistral-small",
|
id: "mistralai/mistral-small",
|
||||||
name: "Mistral Small",
|
name: "Mistral Small",
|
||||||
|
|
@ -5578,23 +5595,6 @@ export const MODELS = {
|
||||||
contextWindow: 8191,
|
contextWindow: 8191,
|
||||||
maxTokens: 4096,
|
maxTokens: 4096,
|
||||||
} satisfies Model<"openai-completions">,
|
} satisfies Model<"openai-completions">,
|
||||||
"openai/gpt-4": {
|
|
||||||
id: "openai/gpt-4",
|
|
||||||
name: "OpenAI: GPT-4",
|
|
||||||
api: "openai-completions",
|
|
||||||
provider: "openrouter",
|
|
||||||
baseUrl: "https://openrouter.ai/api/v1",
|
|
||||||
reasoning: false,
|
|
||||||
input: ["text"],
|
|
||||||
cost: {
|
|
||||||
input: 30,
|
|
||||||
output: 60,
|
|
||||||
cacheRead: 0,
|
|
||||||
cacheWrite: 0,
|
|
||||||
},
|
|
||||||
contextWindow: 8191,
|
|
||||||
maxTokens: 4096,
|
|
||||||
} satisfies Model<"openai-completions">,
|
|
||||||
"openai/gpt-3.5-turbo": {
|
"openai/gpt-3.5-turbo": {
|
||||||
id: "openai/gpt-3.5-turbo",
|
id: "openai/gpt-3.5-turbo",
|
||||||
name: "OpenAI: GPT-3.5 Turbo",
|
name: "OpenAI: GPT-3.5 Turbo",
|
||||||
|
|
@ -5612,6 +5612,23 @@ export const MODELS = {
|
||||||
contextWindow: 16385,
|
contextWindow: 16385,
|
||||||
maxTokens: 4096,
|
maxTokens: 4096,
|
||||||
} satisfies Model<"openai-completions">,
|
} satisfies Model<"openai-completions">,
|
||||||
|
"openai/gpt-4": {
|
||||||
|
id: "openai/gpt-4",
|
||||||
|
name: "OpenAI: GPT-4",
|
||||||
|
api: "openai-completions",
|
||||||
|
provider: "openrouter",
|
||||||
|
baseUrl: "https://openrouter.ai/api/v1",
|
||||||
|
reasoning: false,
|
||||||
|
input: ["text"],
|
||||||
|
cost: {
|
||||||
|
input: 30,
|
||||||
|
output: 60,
|
||||||
|
cacheRead: 0,
|
||||||
|
cacheWrite: 0,
|
||||||
|
},
|
||||||
|
contextWindow: 8191,
|
||||||
|
maxTokens: 4096,
|
||||||
|
} satisfies Model<"openai-completions">,
|
||||||
"openrouter/auto": {
|
"openrouter/auto": {
|
||||||
id: "openrouter/auto",
|
id: "openrouter/auto",
|
||||||
name: "OpenRouter: Auto Router",
|
name: "OpenRouter: Auto Router",
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,48 @@
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Breaking Changes
|
||||||
|
|
||||||
|
- Timestamps now use Slack format (seconds.microseconds) and messages are sorted by `ts` field
|
||||||
|
- **Migration required**: Run `npx tsx scripts/migrate-timestamps.ts ./data` to fix existing logs
|
||||||
|
- Without migration, message context will be incorrectly ordered
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Channel and user ID mappings in system prompt
|
||||||
|
- Fetches all channels bot is member of and all workspace users at startup
|
||||||
|
- Mom can now reference channels by name and mention users properly
|
||||||
|
- Skills documentation in system prompt
|
||||||
|
- Explains custom CLI tools pattern with SKILL.md files
|
||||||
|
- Encourages mom to create reusable tools for recurring tasks
|
||||||
|
- Debug output: writes `last_prompt.txt` to channel directory with full context
|
||||||
|
- Bash working directory info in system prompt (/ for Docker, cwd for host)
|
||||||
|
- Token-efficient log queries that filter out tool calls/results for summaries
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Turn-based message context instead of raw line count (#68)
|
||||||
|
- Groups consecutive bot messages (tool calls/results) as single turn
|
||||||
|
- "50 turns" now means ~50 conversation exchanges, not 50 log lines
|
||||||
|
- Prevents tool-heavy runs from pushing out conversation context
|
||||||
|
- Messages sorted by Slack timestamp before building context
|
||||||
|
- Fixes out-of-order issues from async attachment downloads
|
||||||
|
- Added monotonic counter for sub-millisecond ordering
|
||||||
|
- Condensed system prompt from ~5k to ~2.7k chars
|
||||||
|
- More concise workspace layout (tree format)
|
||||||
|
- Clearer log query examples (conversation-only vs full details)
|
||||||
|
- Removed redundant guidelines section
|
||||||
|
- User prompt simplified: removed duplicate "Current message" (already in history)
|
||||||
|
- Tool status labels (`_→ label_`) no longer logged to jsonl
|
||||||
|
- Thread messages and thinking no longer double-logged
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Duplicate message logging: removed redundant log from app_mention handler
|
||||||
|
- Username obfuscation in thread messages to prevent unwanted pings
|
||||||
|
- Handles @username, bare username, and <@USERID> formats
|
||||||
|
- Escapes special regex characters in usernames
|
||||||
|
|
||||||
## [0.10.1] - 2025-11-27
|
## [0.10.1] - 2025-11-27
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
|
||||||
121
packages/mom/scripts/migrate-timestamps.ts
Normal file
121
packages/mom/scripts/migrate-timestamps.ts
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
#!/usr/bin/env npx tsx
|
||||||
|
/**
|
||||||
|
* Migrate log.jsonl timestamps from milliseconds to Slack format (seconds.microseconds)
|
||||||
|
*
|
||||||
|
* Usage: npx tsx scripts/migrate-timestamps.ts <data-dir>
|
||||||
|
* Example: npx tsx scripts/migrate-timestamps.ts ./data
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readFileSync, writeFileSync, readdirSync, statSync, existsSync } from "fs";
|
||||||
|
import { join } from "path";
|
||||||
|
|
||||||
|
function isMillisecondTimestamp(ts: string): boolean {
|
||||||
|
// Slack timestamps are seconds.microseconds, like "1764279530.533489"
|
||||||
|
// Millisecond timestamps are just big numbers, like "1764279320398"
|
||||||
|
//
|
||||||
|
// Key insight:
|
||||||
|
// - Slack ts from 2025: ~1.7 billion (10 digits before decimal)
|
||||||
|
// - Millisecond ts from 2025: ~1.7 trillion (13 digits)
|
||||||
|
|
||||||
|
// If it has a decimal and the integer part is < 10^12, it's Slack format
|
||||||
|
if (ts.includes(".")) {
|
||||||
|
const intPart = parseInt(ts.split(".")[0], 10);
|
||||||
|
return intPart > 1e12; // Unlikely to have decimal AND be millis, but check anyway
|
||||||
|
}
|
||||||
|
|
||||||
|
// No decimal - check if it's too big to be seconds
|
||||||
|
const num = parseInt(ts, 10);
|
||||||
|
return num > 1e12; // If > 1 trillion, it's milliseconds
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertToSlackTs(msTs: string): string {
|
||||||
|
const ms = parseInt(msTs, 10);
|
||||||
|
const seconds = Math.floor(ms / 1000);
|
||||||
|
const micros = (ms % 1000) * 1000;
|
||||||
|
return `${seconds}.${micros.toString().padStart(6, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function migrateFile(filePath: string): { total: number; migrated: number } {
|
||||||
|
const content = readFileSync(filePath, "utf-8");
|
||||||
|
const lines = content.split("\n").filter(Boolean);
|
||||||
|
|
||||||
|
let migrated = 0;
|
||||||
|
const newLines: string[] = [];
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(line);
|
||||||
|
if (msg.ts && isMillisecondTimestamp(msg.ts)) {
|
||||||
|
const oldTs = msg.ts;
|
||||||
|
msg.ts = convertToSlackTs(msg.ts);
|
||||||
|
console.log(` Converted: ${oldTs} -> ${msg.ts}`);
|
||||||
|
migrated++;
|
||||||
|
}
|
||||||
|
newLines.push(JSON.stringify(msg));
|
||||||
|
} catch (e) {
|
||||||
|
// Keep malformed lines as-is
|
||||||
|
console.log(` Warning: Could not parse line: ${line.substring(0, 50)}...`);
|
||||||
|
newLines.push(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (migrated > 0) {
|
||||||
|
writeFileSync(filePath, newLines.join("\n") + "\n", "utf-8");
|
||||||
|
}
|
||||||
|
|
||||||
|
return { total: lines.length, migrated };
|
||||||
|
}
|
||||||
|
|
||||||
|
function findLogFiles(dir: string): string[] {
|
||||||
|
const logFiles: string[] = [];
|
||||||
|
|
||||||
|
if (!existsSync(dir)) {
|
||||||
|
console.error(`Directory not found: ${dir}`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = readdirSync(dir);
|
||||||
|
for (const entry of entries) {
|
||||||
|
const fullPath = join(dir, entry);
|
||||||
|
const stat = statSync(fullPath);
|
||||||
|
|
||||||
|
if (stat.isDirectory()) {
|
||||||
|
// Check for log.jsonl in subdirectory
|
||||||
|
const logPath = join(fullPath, "log.jsonl");
|
||||||
|
if (existsSync(logPath)) {
|
||||||
|
logFiles.push(logPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return logFiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main
|
||||||
|
const dataDir = process.argv[2];
|
||||||
|
if (!dataDir) {
|
||||||
|
console.error("Usage: npx tsx scripts/migrate-timestamps.ts <data-dir>");
|
||||||
|
console.error("Example: npx tsx scripts/migrate-timestamps.ts ./data");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Scanning for log.jsonl files in: ${dataDir}\n`);
|
||||||
|
|
||||||
|
const logFiles = findLogFiles(dataDir);
|
||||||
|
if (logFiles.length === 0) {
|
||||||
|
console.log("No log.jsonl files found.");
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
let totalMigrated = 0;
|
||||||
|
let totalMessages = 0;
|
||||||
|
|
||||||
|
for (const logFile of logFiles) {
|
||||||
|
console.log(`Processing: ${logFile}`);
|
||||||
|
const { total, migrated } = migrateFile(logFile);
|
||||||
|
totalMessages += total;
|
||||||
|
totalMigrated += migrated;
|
||||||
|
console.log(` ${migrated}/${total} messages migrated\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Done! Migrated ${totalMigrated}/${totalMessages} total messages across ${logFiles.length} files.`);
|
||||||
|
|
@ -1,17 +1,39 @@
|
||||||
import { Agent, type AgentEvent, ProviderTransport } from "@mariozechner/pi-agent-core";
|
import { Agent, type AgentEvent, ProviderTransport } from "@mariozechner/pi-agent-core";
|
||||||
import { getModel } from "@mariozechner/pi-ai";
|
import { getModel } from "@mariozechner/pi-ai";
|
||||||
import { existsSync, readFileSync } from "fs";
|
import { existsSync, readFileSync } from "fs";
|
||||||
import { mkdir } from "fs/promises";
|
import { mkdir, writeFile } from "fs/promises";
|
||||||
import { join } from "path";
|
import { join } from "path";
|
||||||
import * as log from "./log.js";
|
import * as log from "./log.js";
|
||||||
import { createExecutor, type SandboxConfig } from "./sandbox.js";
|
import { createExecutor, type SandboxConfig } from "./sandbox.js";
|
||||||
import type { SlackContext } from "./slack.js";
|
import type { ChannelInfo, SlackContext, UserInfo } from "./slack.js";
|
||||||
import type { ChannelStore } from "./store.js";
|
import type { ChannelStore } from "./store.js";
|
||||||
import { createMomTools, setUploadFunction } from "./tools/index.js";
|
import { createMomTools, setUploadFunction } from "./tools/index.js";
|
||||||
|
|
||||||
// Hardcoded model for now
|
// Hardcoded model for now
|
||||||
const model = getModel("anthropic", "claude-sonnet-4-5");
|
const model = getModel("anthropic", "claude-sonnet-4-5");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert Date.now() to Slack timestamp format (seconds.microseconds)
|
||||||
|
* Uses a monotonic counter to ensure ordering even within the same millisecond
|
||||||
|
*/
|
||||||
|
let lastTsMs = 0;
|
||||||
|
let tsCounter = 0;
|
||||||
|
|
||||||
|
function toSlackTs(): string {
|
||||||
|
const now = Date.now();
|
||||||
|
if (now === lastTsMs) {
|
||||||
|
// Same millisecond - increment counter for sub-ms ordering
|
||||||
|
tsCounter++;
|
||||||
|
} else {
|
||||||
|
// New millisecond - reset counter
|
||||||
|
lastTsMs = now;
|
||||||
|
tsCounter = 0;
|
||||||
|
}
|
||||||
|
const seconds = Math.floor(now / 1000);
|
||||||
|
const micros = (now % 1000) * 1000 + tsCounter; // ms to micros + counter
|
||||||
|
return `${seconds}.${micros.toString().padStart(6, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AgentRunner {
|
export interface AgentRunner {
|
||||||
run(ctx: SlackContext, channelDir: string, store: ChannelStore): Promise<{ stopReason: string }>;
|
run(ctx: SlackContext, channelDir: string, store: ChannelStore): Promise<{ stopReason: string }>;
|
||||||
abort(): void;
|
abort(): void;
|
||||||
|
|
@ -25,7 +47,17 @@ function getAnthropicApiKey(): string {
|
||||||
return key;
|
return key;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getRecentMessages(channelDir: string, count: number): string {
|
interface LogMessage {
|
||||||
|
date?: string;
|
||||||
|
ts?: string;
|
||||||
|
user?: string;
|
||||||
|
userName?: string;
|
||||||
|
text?: string;
|
||||||
|
attachments?: Array<{ local: string }>;
|
||||||
|
isBot?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRecentMessages(channelDir: string, turnCount: number): string {
|
||||||
const logPath = join(channelDir, "log.jsonl");
|
const logPath = join(channelDir, "log.jsonl");
|
||||||
if (!existsSync(logPath)) {
|
if (!existsSync(logPath)) {
|
||||||
return "(no message history yet)";
|
return "(no message history yet)";
|
||||||
|
|
@ -33,23 +65,72 @@ function getRecentMessages(channelDir: string, count: number): string {
|
||||||
|
|
||||||
const content = readFileSync(logPath, "utf-8");
|
const content = readFileSync(logPath, "utf-8");
|
||||||
const lines = content.trim().split("\n").filter(Boolean);
|
const lines = content.trim().split("\n").filter(Boolean);
|
||||||
const recentLines = lines.slice(-count);
|
|
||||||
|
|
||||||
if (recentLines.length === 0) {
|
if (lines.length === 0) {
|
||||||
return "(no message history yet)";
|
return "(no message history yet)";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Format as TSV for more concise system prompt
|
// Parse all messages and sort by Slack timestamp
|
||||||
const formatted: string[] = [];
|
// (attachment downloads can cause out-of-order logging)
|
||||||
for (const line of recentLines) {
|
const messages: LogMessage[] = [];
|
||||||
|
for (const line of lines) {
|
||||||
try {
|
try {
|
||||||
const msg = JSON.parse(line);
|
messages.push(JSON.parse(line));
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
messages.sort((a, b) => {
|
||||||
|
const tsA = parseFloat(a.ts || "0");
|
||||||
|
const tsB = parseFloat(b.ts || "0");
|
||||||
|
return tsA - tsB;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Group into "turns" - a turn is either:
|
||||||
|
// - A single user message (isBot: false)
|
||||||
|
// - A sequence of consecutive bot messages (isBot: true) coalesced into one turn
|
||||||
|
// We walk backwards to get the last N turns
|
||||||
|
const turns: LogMessage[][] = [];
|
||||||
|
let currentTurn: LogMessage[] = [];
|
||||||
|
let lastWasBot: boolean | null = null;
|
||||||
|
|
||||||
|
for (let i = messages.length - 1; i >= 0; i--) {
|
||||||
|
const msg = messages[i];
|
||||||
|
const isBot = msg.isBot === true;
|
||||||
|
|
||||||
|
if (lastWasBot === null) {
|
||||||
|
// First message
|
||||||
|
currentTurn.unshift(msg);
|
||||||
|
lastWasBot = isBot;
|
||||||
|
} else if (isBot && lastWasBot) {
|
||||||
|
// Consecutive bot messages - same turn
|
||||||
|
currentTurn.unshift(msg);
|
||||||
|
} else {
|
||||||
|
// Transition - save current turn and start new one
|
||||||
|
turns.unshift(currentTurn);
|
||||||
|
currentTurn = [msg];
|
||||||
|
lastWasBot = isBot;
|
||||||
|
|
||||||
|
// Stop if we have enough turns
|
||||||
|
if (turns.length >= turnCount) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't forget the last turn we were building
|
||||||
|
if (currentTurn.length > 0 && turns.length < turnCount) {
|
||||||
|
turns.unshift(currentTurn);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flatten turns back to messages and format as TSV
|
||||||
|
const formatted: string[] = [];
|
||||||
|
for (const turn of turns) {
|
||||||
|
for (const msg of turn) {
|
||||||
const date = (msg.date || "").substring(0, 19);
|
const date = (msg.date || "").substring(0, 19);
|
||||||
const user = msg.userName || msg.user;
|
const user = msg.userName || msg.user || "";
|
||||||
const text = msg.text || "";
|
const text = msg.text || "";
|
||||||
const attachments = (msg.attachments || []).map((a: { local: string }) => a.local).join(",");
|
const attachments = (msg.attachments || []).map((a) => a.local).join(",");
|
||||||
formatted.push(`${date}\t${user}\t${text}\t${attachments}`);
|
formatted.push(`${date}\t${user}\t${text}\t${attachments}`);
|
||||||
} catch (error) {}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return formatted.join("\n");
|
return formatted.join("\n");
|
||||||
|
|
@ -96,151 +177,112 @@ function buildSystemPrompt(
|
||||||
channelId: string,
|
channelId: string,
|
||||||
memory: string,
|
memory: string,
|
||||||
sandboxConfig: SandboxConfig,
|
sandboxConfig: SandboxConfig,
|
||||||
|
channels: ChannelInfo[],
|
||||||
|
users: UserInfo[],
|
||||||
): string {
|
): string {
|
||||||
const channelPath = `${workspacePath}/${channelId}`;
|
const channelPath = `${workspacePath}/${channelId}`;
|
||||||
const isDocker = sandboxConfig.type === "docker";
|
const isDocker = sandboxConfig.type === "docker";
|
||||||
|
|
||||||
|
// Format channel mappings
|
||||||
|
const channelMappings =
|
||||||
|
channels.length > 0 ? channels.map((c) => `${c.id}\t#${c.name}`).join("\n") : "(no channels loaded)";
|
||||||
|
|
||||||
|
// Format user mappings
|
||||||
|
const userMappings =
|
||||||
|
users.length > 0 ? users.map((u) => `${u.id}\t@${u.userName}\t${u.displayName}`).join("\n") : "(no users loaded)";
|
||||||
|
|
||||||
const envDescription = isDocker
|
const envDescription = isDocker
|
||||||
? `You are running inside a Docker container (Alpine Linux).
|
? `You are running inside a Docker container (Alpine Linux).
|
||||||
|
- Bash working directory: / (use cd or absolute paths)
|
||||||
- Install tools with: apk add <package>
|
- Install tools with: apk add <package>
|
||||||
- Your changes persist across sessions
|
- Your changes persist across sessions`
|
||||||
- You have full control over this container`
|
|
||||||
: `You are running directly on the host machine.
|
: `You are running directly on the host machine.
|
||||||
- Be careful with system modifications
|
- Bash working directory: ${process.cwd()}
|
||||||
- Use the system's package manager if needed`;
|
- Be careful with system modifications`;
|
||||||
|
|
||||||
const currentDate = new Date().toISOString().split("T")[0]; // YYYY-MM-DD
|
const currentDate = new Date().toISOString().split("T")[0]; // YYYY-MM-DD
|
||||||
const currentDateTime = new Date().toISOString(); // Full ISO 8601
|
const currentDateTime = new Date().toISOString(); // Full ISO 8601
|
||||||
|
|
||||||
return `You are mom, a helpful Slack bot assistant.
|
return `You are mom, a Slack bot assistant. Be concise. No emojis.
|
||||||
|
|
||||||
## Current Date and Time
|
## Context
|
||||||
- Date: ${currentDate}
|
- Date: ${currentDate} (${currentDateTime})
|
||||||
- Full timestamp: ${currentDateTime}
|
- You receive the last 50 conversation turns. If you need older context, search log.jsonl.
|
||||||
- Use this when working with dates or searching logs
|
|
||||||
|
|
||||||
## Communication Style
|
## Slack Formatting (mrkdwn, NOT Markdown)
|
||||||
- Be concise and professional
|
Bold: *text*, Italic: _text_, Code: \`code\`, Block: \`\`\`code\`\`\`, Links: <url|text>
|
||||||
- Do not use emojis unless the user communicates informally with you
|
Do NOT use **double asterisks** or [markdown](links).
|
||||||
- Get to the point quickly
|
|
||||||
- If you need clarification, ask directly
|
|
||||||
- Use Slack's mrkdwn format (NOT standard Markdown):
|
|
||||||
- Bold: *text* (single asterisks)
|
|
||||||
- Italic: _text_
|
|
||||||
- Strikethrough: ~text~
|
|
||||||
- Code: \`code\`
|
|
||||||
- Code block: \`\`\`code\`\`\`
|
|
||||||
- Links: <url|text>
|
|
||||||
- Do NOT use **double asterisks** or [markdown](links)
|
|
||||||
|
|
||||||
## Your Environment
|
## Slack IDs
|
||||||
|
Channels: ${channelMappings}
|
||||||
|
|
||||||
|
Users: ${userMappings}
|
||||||
|
|
||||||
|
When mentioning users, use <@username> format (e.g., <@mario>).
|
||||||
|
|
||||||
|
## Environment
|
||||||
${envDescription}
|
${envDescription}
|
||||||
|
|
||||||
## Your Workspace
|
## Workspace Layout
|
||||||
Your working directory is: ${channelPath}
|
${workspacePath}/
|
||||||
|
├── MEMORY.md # Global memory (all channels)
|
||||||
|
├── skills/ # Global CLI tools you create
|
||||||
|
└── ${channelId}/ # This channel
|
||||||
|
├── MEMORY.md # Channel-specific memory
|
||||||
|
├── log.jsonl # Full message history
|
||||||
|
├── attachments/ # User-shared files
|
||||||
|
├── scratch/ # Your working directory
|
||||||
|
└── skills/ # Channel-specific tools
|
||||||
|
|
||||||
### Directory Structure
|
## Skills (Custom CLI Tools)
|
||||||
- ${workspacePath}/ - Root workspace (shared across all channels)
|
You can create reusable CLI tools for recurring tasks (email, APIs, data processing, etc.).
|
||||||
- MEMORY.md - GLOBAL memory visible to all channels (write global info here)
|
Store in \`${workspacePath}/skills/<name>/\` or \`${channelPath}/skills/<name>/\`.
|
||||||
- ${channelId}/ - This channel's directory
|
Each skill needs a \`SKILL.md\` documenting usage. Read it before using a skill.
|
||||||
- MEMORY.md - CHANNEL-SPECIFIC memory (only visible in this channel)
|
List skills in global memory so you remember them.
|
||||||
- scratch/ - Your working directory for files, repos, etc.
|
|
||||||
- log.jsonl - Message history in JSONL format (one JSON object per line)
|
|
||||||
- attachments/ - Files shared by users (managed by system, read-only)
|
|
||||||
|
|
||||||
### Message History Format
|
## Memory
|
||||||
Each line in log.jsonl contains:
|
Write to MEMORY.md files to persist context across conversations.
|
||||||
{
|
- Global (${workspacePath}/MEMORY.md): skills, preferences, project info
|
||||||
"date": "2025-11-26T10:44:00.123Z", // ISO 8601 - easy to grep by date!
|
- Channel (${channelPath}/MEMORY.md): channel-specific decisions, ongoing work
|
||||||
"ts": "1732619040.123456", // Slack timestamp or epoch ms
|
Update when you learn something important or when asked to remember something.
|
||||||
"user": "U123ABC", // User ID or "bot"
|
|
||||||
"userName": "mario", // User handle (optional)
|
|
||||||
"text": "message text",
|
|
||||||
"isBot": false
|
|
||||||
}
|
|
||||||
|
|
||||||
**⚠️ CRITICAL: Efficient Log Queries (Avoid Context Overflow)**
|
### Current Memory
|
||||||
|
|
||||||
Log files can be VERY LARGE (100K+ lines). The problem is getting too MANY messages, not message length.
|
|
||||||
Each message can be up to 10k chars - that's fine. Use head/tail to LIMIT NUMBER OF MESSAGES (10-50 at a time).
|
|
||||||
|
|
||||||
**Install jq first (if not already):**
|
|
||||||
\`\`\`bash
|
|
||||||
${isDocker ? "apk add jq" : "# jq should be available, or install via package manager"}
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
**Essential query patterns:**
|
|
||||||
\`\`\`bash
|
|
||||||
# Last N messages (compact JSON output)
|
|
||||||
tail -20 log.jsonl | jq -c '{date: .date[0:19], user: (.userName // .user), text, attachments: [(.attachments // [])[].local]}'
|
|
||||||
|
|
||||||
# Or TSV format (easier to read)
|
|
||||||
tail -20 log.jsonl | jq -r '[.date[0:19], (.userName // .user), .text, ((.attachments // []) | map(.local) | join(","))] | @tsv'
|
|
||||||
|
|
||||||
# Search by date (LIMIT with head/tail!)
|
|
||||||
grep '"date":"2025-11-26' log.jsonl | tail -30 | jq -c '{date: .date[0:19], user: (.userName // .user), text, attachments: [(.attachments // [])[].local]}'
|
|
||||||
|
|
||||||
# Messages from specific user (count first, then limit)
|
|
||||||
grep '"userName":"mario"' log.jsonl | wc -l # Check count first
|
|
||||||
grep '"userName":"mario"' log.jsonl | tail -20 | jq -c '{date: .date[0:19], user: .userName, text, attachments: [(.attachments // [])[].local]}'
|
|
||||||
|
|
||||||
# Only count (when you just need the number)
|
|
||||||
grep '"isBot":false' log.jsonl | wc -l
|
|
||||||
|
|
||||||
# Messages with attachments only (limit!)
|
|
||||||
grep '"attachments":[{' log.jsonl | tail -10 | jq -r '[.date[0:16], (.userName // .user), .text, (.attachments | map(.local) | join(","))] | @tsv'
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
**KEY RULE:** Always pipe through 'head -N' or 'tail -N' to limit results BEFORE parsing with jq!
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
**Date filtering:**
|
|
||||||
- Today: grep '"date":"${currentDate}' log.jsonl
|
|
||||||
- Yesterday: grep '"date":"2025-11-25' log.jsonl
|
|
||||||
- Date range: grep '"date":"2025-11-(26|27|28)' log.jsonl
|
|
||||||
- Time range: grep -E '"date":"2025-11-26T(09|10|11):' log.jsonl
|
|
||||||
|
|
||||||
### Working Memory System
|
|
||||||
You can maintain working memory across conversations by writing MEMORY.md files.
|
|
||||||
|
|
||||||
**IMPORTANT PATH RULES:**
|
|
||||||
- Global memory (all channels): ${workspacePath}/MEMORY.md
|
|
||||||
- Channel memory (this channel only): ${channelPath}/MEMORY.md
|
|
||||||
|
|
||||||
**What to remember:**
|
|
||||||
- Project details and architecture → Global memory
|
|
||||||
- User preferences and coding style → Global memory
|
|
||||||
- Channel-specific context → Channel memory
|
|
||||||
- Recurring tasks and patterns → Appropriate memory file
|
|
||||||
- Credentials locations (never actual secrets) → Global memory
|
|
||||||
- Decisions made and their rationale → Appropriate memory file
|
|
||||||
|
|
||||||
**When to update:**
|
|
||||||
- After learning something important that will help in future conversations
|
|
||||||
- When user asks you to remember something
|
|
||||||
- When you discover project structure or conventions
|
|
||||||
|
|
||||||
### Current Working Memory
|
|
||||||
${memory}
|
${memory}
|
||||||
|
|
||||||
|
## Log Queries (CRITICAL: limit output to avoid context overflow)
|
||||||
|
Format: \`{"date":"...","ts":"...","user":"...","userName":"...","text":"...","isBot":false}\`
|
||||||
|
The log contains user messages AND your tool calls/results. Filter appropriately.
|
||||||
|
${isDocker ? "Install jq: apk add jq" : ""}
|
||||||
|
|
||||||
|
**Conversation only (excludes tool calls/results) - use for summaries:**
|
||||||
|
\`\`\`bash
|
||||||
|
# Recent conversation (no [Tool] or [Tool Result] lines)
|
||||||
|
grep -v '"text":"\\[Tool' log.jsonl | tail -30 | jq -c '{date: .date[0:19], user: (.userName // .user), text}'
|
||||||
|
|
||||||
|
# Yesterday's conversation
|
||||||
|
grep '"date":"2025-11-26' log.jsonl | grep -v '"text":"\\[Tool' | jq -c '{date: .date[0:19], user: (.userName // .user), text}'
|
||||||
|
|
||||||
|
# Specific user's messages
|
||||||
|
grep '"userName":"mario"' log.jsonl | grep -v '"text":"\\[Tool' | tail -20 | jq -c '{date: .date[0:19], text}'
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
**Full details (includes tool calls) - use when you need technical context:**
|
||||||
|
\`\`\`bash
|
||||||
|
# Raw recent entries
|
||||||
|
tail -20 log.jsonl | jq -c '{date: .date[0:19], user: (.userName // .user), text}'
|
||||||
|
|
||||||
|
# Count all messages
|
||||||
|
wc -l log.jsonl
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
## Tools
|
## Tools
|
||||||
You have access to: bash, read, edit, write, attach tools.
|
- bash: Run shell commands (primary tool). Install packages as needed.
|
||||||
- bash: Run shell commands (this is your main tool)
|
|
||||||
- read: Read files
|
- read: Read files
|
||||||
- edit: Edit files surgically
|
|
||||||
- write: Create/overwrite files
|
- write: Create/overwrite files
|
||||||
- attach: Share a file with the user in Slack
|
- edit: Surgical file edits
|
||||||
|
- attach: Share files to Slack
|
||||||
|
|
||||||
Each tool requires a "label" parameter - brief description shown to the user.
|
Each tool requires a "label" parameter (shown to user).
|
||||||
|
|
||||||
## Guidelines
|
|
||||||
- Be concise and helpful
|
|
||||||
- Use bash for most operations
|
|
||||||
- If you need a tool, install it
|
|
||||||
- If you need credentials, ask the user
|
|
||||||
|
|
||||||
## CRITICAL
|
|
||||||
- DO NOT USE EMOJIS. KEEP YOUR RESPONSES AS SHORT AS POSSIBLE.
|
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -324,7 +366,20 @@ export function createAgentRunner(sandboxConfig: SandboxConfig): AgentRunner {
|
||||||
const workspacePath = executor.getWorkspacePath(channelDir.replace(`/${channelId}`, ""));
|
const workspacePath = executor.getWorkspacePath(channelDir.replace(`/${channelId}`, ""));
|
||||||
const recentMessages = getRecentMessages(channelDir, 50);
|
const recentMessages = getRecentMessages(channelDir, 50);
|
||||||
const memory = getMemory(channelDir);
|
const memory = getMemory(channelDir);
|
||||||
const systemPrompt = buildSystemPrompt(workspacePath, channelId, memory, sandboxConfig);
|
const systemPrompt = buildSystemPrompt(
|
||||||
|
workspacePath,
|
||||||
|
channelId,
|
||||||
|
memory,
|
||||||
|
sandboxConfig,
|
||||||
|
ctx.channels,
|
||||||
|
ctx.users,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Debug: log context sizes
|
||||||
|
log.logInfo(
|
||||||
|
`Context sizes - system: ${systemPrompt.length} chars, messages: ${recentMessages.length} chars, memory: ${memory.length} chars`,
|
||||||
|
);
|
||||||
|
log.logInfo(`Channels: ${ctx.channels.length}, Users: ${ctx.users.length}`);
|
||||||
|
|
||||||
// Set up file upload function for the attach tool
|
// Set up file upload function for the attach tool
|
||||||
// For Docker, we need to translate paths back to host
|
// For Docker, we need to translate paths back to host
|
||||||
|
|
@ -415,10 +470,13 @@ export function createAgentRunner(sandboxConfig: SandboxConfig): AgentRunner {
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
// Enqueue a message that may need splitting
|
// Enqueue a message that may need splitting
|
||||||
enqueueMessage(text: string, target: "main" | "thread", errorContext: string): void {
|
enqueueMessage(text: string, target: "main" | "thread", errorContext: string, log = true): void {
|
||||||
const parts = splitForSlack(text);
|
const parts = splitForSlack(text);
|
||||||
for (const part of parts) {
|
for (const part of parts) {
|
||||||
this.enqueue(() => (target === "main" ? ctx.respond(part) : ctx.respondInThread(part)), errorContext);
|
this.enqueue(
|
||||||
|
() => (target === "main" ? ctx.respond(part, log) : ctx.respondInThread(part)),
|
||||||
|
errorContext,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
flush(): Promise<void> {
|
flush(): Promise<void> {
|
||||||
|
|
@ -446,7 +504,7 @@ export function createAgentRunner(sandboxConfig: SandboxConfig): AgentRunner {
|
||||||
// Log to jsonl
|
// Log to jsonl
|
||||||
await store.logMessage(ctx.message.channel, {
|
await store.logMessage(ctx.message.channel, {
|
||||||
date: new Date().toISOString(),
|
date: new Date().toISOString(),
|
||||||
ts: Date.now().toString(),
|
ts: toSlackTs(),
|
||||||
user: "bot",
|
user: "bot",
|
||||||
text: `[Tool] ${event.toolName}: ${JSON.stringify(event.args)}`,
|
text: `[Tool] ${event.toolName}: ${JSON.stringify(event.args)}`,
|
||||||
attachments: [],
|
attachments: [],
|
||||||
|
|
@ -454,7 +512,7 @@ export function createAgentRunner(sandboxConfig: SandboxConfig): AgentRunner {
|
||||||
});
|
});
|
||||||
|
|
||||||
// Show label in main message only
|
// Show label in main message only
|
||||||
queue.enqueue(() => ctx.respond(`_→ ${label}_`), "tool label");
|
queue.enqueue(() => ctx.respond(`_→ ${label}_`, false), "tool label");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -475,7 +533,7 @@ export function createAgentRunner(sandboxConfig: SandboxConfig): AgentRunner {
|
||||||
// Log to jsonl
|
// Log to jsonl
|
||||||
await store.logMessage(ctx.message.channel, {
|
await store.logMessage(ctx.message.channel, {
|
||||||
date: new Date().toISOString(),
|
date: new Date().toISOString(),
|
||||||
ts: Date.now().toString(),
|
ts: toSlackTs(),
|
||||||
user: "bot",
|
user: "bot",
|
||||||
text: `[Tool Result] ${event.toolName}: ${event.isError ? "ERROR: " : ""}${truncate(resultStr, 1000)}`,
|
text: `[Tool Result] ${event.toolName}: ${event.isError ? "ERROR: " : ""}${truncate(resultStr, 1000)}`,
|
||||||
attachments: [],
|
attachments: [],
|
||||||
|
|
@ -500,11 +558,11 @@ export function createAgentRunner(sandboxConfig: SandboxConfig): AgentRunner {
|
||||||
|
|
||||||
threadMessage += "*Result:*\n```\n" + resultStr + "\n```";
|
threadMessage += "*Result:*\n```\n" + resultStr + "\n```";
|
||||||
|
|
||||||
queue.enqueueMessage(threadMessage, "thread", "tool result thread");
|
queue.enqueueMessage(threadMessage, "thread", "tool result thread", false);
|
||||||
|
|
||||||
// Show brief error in main message if failed
|
// Show brief error in main message if failed
|
||||||
if (event.isError) {
|
if (event.isError) {
|
||||||
queue.enqueue(() => ctx.respond(`_Error: ${truncate(resultStr, 200)}_`), "tool error");
|
queue.enqueue(() => ctx.respond(`_Error: ${truncate(resultStr, 200)}_`, false), "tool error");
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
@ -560,14 +618,14 @@ export function createAgentRunner(sandboxConfig: SandboxConfig): AgentRunner {
|
||||||
for (const thinking of thinkingParts) {
|
for (const thinking of thinkingParts) {
|
||||||
log.logThinking(logCtx, thinking);
|
log.logThinking(logCtx, thinking);
|
||||||
queue.enqueueMessage(`_${thinking}_`, "main", "thinking main");
|
queue.enqueueMessage(`_${thinking}_`, "main", "thinking main");
|
||||||
queue.enqueueMessage(`_${thinking}_`, "thread", "thinking thread");
|
queue.enqueueMessage(`_${thinking}_`, "thread", "thinking thread", false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Post text to main message and thread
|
// Post text to main message and thread
|
||||||
if (text.trim()) {
|
if (text.trim()) {
|
||||||
log.logResponse(logCtx, text);
|
log.logResponse(logCtx, text);
|
||||||
queue.enqueueMessage(text, "main", "response main");
|
queue.enqueueMessage(text, "main", "response main");
|
||||||
queue.enqueueMessage(text, "thread", "response thread");
|
queue.enqueueMessage(text, "thread", "response thread", false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
@ -576,12 +634,18 @@ export function createAgentRunner(sandboxConfig: SandboxConfig): AgentRunner {
|
||||||
|
|
||||||
// Run the agent with user's message
|
// Run the agent with user's message
|
||||||
// Prepend recent messages to the user prompt (not system prompt) for better caching
|
// Prepend recent messages to the user prompt (not system prompt) for better caching
|
||||||
|
// The current message is already the last entry in recentMessages
|
||||||
const userPrompt =
|
const userPrompt =
|
||||||
`Recent conversation history (last 50 messages):\n` +
|
`Conversation history (last 50 turns). Respond to the last message.\n` +
|
||||||
`Format: date TAB user TAB text TAB attachments\n\n` +
|
`Format: date TAB user TAB text TAB attachments\n\n` +
|
||||||
`${recentMessages}\n\n` +
|
recentMessages;
|
||||||
`---\n\n` +
|
// Debug: write full context to file
|
||||||
`Current message: ${ctx.message.text || "(attached files)"}`;
|
const toolDefs = tools.map((t) => ({ name: t.name, description: t.description, parameters: t.parameters }));
|
||||||
|
const debugPrompt =
|
||||||
|
`=== SYSTEM PROMPT (${systemPrompt.length} chars) ===\n\n${systemPrompt}\n\n` +
|
||||||
|
`=== TOOL DEFINITIONS (${JSON.stringify(toolDefs).length} chars) ===\n\n${JSON.stringify(toolDefs, null, 2)}\n\n` +
|
||||||
|
`=== USER PROMPT (${userPrompt.length} chars) ===\n\n${userPrompt}`;
|
||||||
|
await writeFile(join(channelDir, "last_prompt.txt"), debugPrompt, "utf-8");
|
||||||
|
|
||||||
await agent.prompt(userPrompt);
|
await agent.prompt(userPrompt);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -160,6 +160,10 @@ export function logStopRequest(ctx: LogContext): void {
|
||||||
}
|
}
|
||||||
|
|
||||||
// System
|
// System
|
||||||
|
export function logInfo(message: string): void {
|
||||||
|
console.log(chalk.blue(`${timestamp()} [system] ${message}`));
|
||||||
|
}
|
||||||
|
|
||||||
export function logWarning(message: string, details?: string): void {
|
export function logWarning(message: string, details?: string): void {
|
||||||
console.log(chalk.yellow(`${timestamp()} [system] ⚠ ${message}`));
|
console.log(chalk.yellow(`${timestamp()} [system] ⚠ ${message}`));
|
||||||
if (details) {
|
if (details) {
|
||||||
|
|
|
||||||
|
|
@ -19,8 +19,12 @@ export interface SlackContext {
|
||||||
message: SlackMessage;
|
message: SlackMessage;
|
||||||
channelName?: string; // channel name for logging (e.g., #dev-team)
|
channelName?: string; // channel name for logging (e.g., #dev-team)
|
||||||
store: ChannelStore;
|
store: ChannelStore;
|
||||||
/** Send/update the main message (accumulates text) */
|
/** All channels the bot is a member of */
|
||||||
respond(text: string): Promise<void>;
|
channels: ChannelInfo[];
|
||||||
|
/** All known users in the workspace */
|
||||||
|
users: UserInfo[];
|
||||||
|
/** Send/update the main message (accumulates text). Set log=false to skip logging. */
|
||||||
|
respond(text: string, log?: boolean): Promise<void>;
|
||||||
/** Replace the entire message text (not append) */
|
/** Replace the entire message text (not append) */
|
||||||
replaceMessage(text: string): Promise<void>;
|
replaceMessage(text: string): Promise<void>;
|
||||||
/** Post a message in the thread under the main message (for verbose details) */
|
/** Post a message in the thread under the main message (for verbose details) */
|
||||||
|
|
@ -44,6 +48,17 @@ export interface MomBotConfig {
|
||||||
workingDir: string; // directory for channel data and attachments
|
workingDir: string; // directory for channel data and attachments
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ChannelInfo {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserInfo {
|
||||||
|
id: string;
|
||||||
|
userName: string;
|
||||||
|
displayName: string;
|
||||||
|
}
|
||||||
|
|
||||||
export class MomBot {
|
export class MomBot {
|
||||||
private socketClient: SocketModeClient;
|
private socketClient: SocketModeClient;
|
||||||
private webClient: WebClient;
|
private webClient: WebClient;
|
||||||
|
|
@ -51,6 +66,7 @@ export class MomBot {
|
||||||
private botUserId: string | null = null;
|
private botUserId: string | null = null;
|
||||||
public readonly store: ChannelStore;
|
public readonly store: ChannelStore;
|
||||||
private userCache: Map<string, { userName: string; displayName: string }> = new Map();
|
private userCache: Map<string, { userName: string; displayName: string }> = new Map();
|
||||||
|
private channelCache: Map<string, string> = new Map(); // id -> name
|
||||||
|
|
||||||
constructor(handler: MomHandler, config: MomBotConfig) {
|
constructor(handler: MomHandler, config: MomBotConfig) {
|
||||||
this.handler = handler;
|
this.handler = handler;
|
||||||
|
|
@ -64,6 +80,113 @@ export class MomBot {
|
||||||
this.setupEventHandlers();
|
this.setupEventHandlers();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch all channels the bot is a member of
|
||||||
|
*/
|
||||||
|
private async fetchChannels(): Promise<void> {
|
||||||
|
try {
|
||||||
|
let cursor: string | undefined;
|
||||||
|
do {
|
||||||
|
const result = await this.webClient.conversations.list({
|
||||||
|
types: "public_channel,private_channel",
|
||||||
|
exclude_archived: true,
|
||||||
|
limit: 200,
|
||||||
|
cursor,
|
||||||
|
});
|
||||||
|
|
||||||
|
const channels = result.channels as Array<{ id?: string; name?: string; is_member?: boolean }> | undefined;
|
||||||
|
if (channels) {
|
||||||
|
for (const channel of channels) {
|
||||||
|
if (channel.id && channel.name && channel.is_member) {
|
||||||
|
this.channelCache.set(channel.id, channel.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cursor = result.response_metadata?.next_cursor;
|
||||||
|
} while (cursor);
|
||||||
|
} catch (error) {
|
||||||
|
log.logWarning("Failed to fetch channels", String(error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch all workspace users
|
||||||
|
*/
|
||||||
|
private async fetchUsers(): Promise<void> {
|
||||||
|
try {
|
||||||
|
let cursor: string | undefined;
|
||||||
|
do {
|
||||||
|
const result = await this.webClient.users.list({
|
||||||
|
limit: 200,
|
||||||
|
cursor,
|
||||||
|
});
|
||||||
|
|
||||||
|
const members = result.members as
|
||||||
|
| Array<{ id?: string; name?: string; real_name?: string; deleted?: boolean }>
|
||||||
|
| undefined;
|
||||||
|
if (members) {
|
||||||
|
for (const user of members) {
|
||||||
|
if (user.id && user.name && !user.deleted) {
|
||||||
|
this.userCache.set(user.id, {
|
||||||
|
userName: user.name,
|
||||||
|
displayName: user.real_name || user.name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cursor = result.response_metadata?.next_cursor;
|
||||||
|
} while (cursor);
|
||||||
|
} catch (error) {
|
||||||
|
log.logWarning("Failed to fetch users", String(error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all known channels (id -> name)
|
||||||
|
*/
|
||||||
|
getChannels(): ChannelInfo[] {
|
||||||
|
return Array.from(this.channelCache.entries()).map(([id, name]) => ({ id, name }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all known users
|
||||||
|
*/
|
||||||
|
getUsers(): UserInfo[] {
|
||||||
|
return Array.from(this.userCache.entries()).map(([id, { userName, displayName }]) => ({
|
||||||
|
id,
|
||||||
|
userName,
|
||||||
|
displayName,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obfuscate usernames and user IDs in text to prevent pinging people
|
||||||
|
* e.g., "nate" -> "n_a_t_e", "@mario" -> "@m_a_r_i_o", "<@U123>" -> "<@U_1_2_3>"
|
||||||
|
*/
|
||||||
|
private obfuscateUsernames(text: string): string {
|
||||||
|
let result = text;
|
||||||
|
|
||||||
|
// Obfuscate user IDs like <@U16LAL8LS>
|
||||||
|
result = result.replace(/<@([A-Z0-9]+)>/gi, (_match, id) => {
|
||||||
|
return `<@${id.split("").join("_")}>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Obfuscate usernames
|
||||||
|
for (const { userName } of this.userCache.values()) {
|
||||||
|
// Escape special regex characters in username
|
||||||
|
const escaped = userName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
// Match @username, <@username>, or bare username (case insensitive, word boundary)
|
||||||
|
const pattern = new RegExp(`(<@|@)?(\\b${escaped}\\b)`, "gi");
|
||||||
|
result = result.replace(pattern, (_match, prefix, name) => {
|
||||||
|
const obfuscated = name.split("").join("_");
|
||||||
|
return (prefix || "") + obfuscated;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
private async getUserInfo(userId: string): Promise<{ userName: string; displayName: string }> {
|
private async getUserInfo(userId: string): Promise<{ userName: string; displayName: string }> {
|
||||||
if (this.userCache.has(userId)) {
|
if (this.userCache.has(userId)) {
|
||||||
return this.userCache.get(userId)!;
|
return this.userCache.get(userId)!;
|
||||||
|
|
@ -85,6 +208,7 @@ export class MomBot {
|
||||||
|
|
||||||
private setupEventHandlers(): void {
|
private setupEventHandlers(): void {
|
||||||
// Handle @mentions in channels
|
// Handle @mentions in channels
|
||||||
|
// Note: We don't log here - the message event handler logs all messages
|
||||||
this.socketClient.on("app_mention", async ({ event, ack }) => {
|
this.socketClient.on("app_mention", async ({ event, ack }) => {
|
||||||
await ack();
|
await ack();
|
||||||
|
|
||||||
|
|
@ -96,9 +220,6 @@ export class MomBot {
|
||||||
files?: Array<{ name: string; url_private_download?: string; url_private?: string }>;
|
files?: Array<{ name: string; url_private_download?: string; url_private?: string }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Log the mention (message event may not fire for app_mention)
|
|
||||||
await this.logMessage(slackEvent);
|
|
||||||
|
|
||||||
const ctx = await this.createContext(slackEvent);
|
const ctx = await this.createContext(slackEvent);
|
||||||
await this.handler.onChannelMention(ctx);
|
await this.handler.onChannelMention(ctx);
|
||||||
});
|
});
|
||||||
|
|
@ -221,7 +342,9 @@ export class MomBot {
|
||||||
},
|
},
|
||||||
channelName,
|
channelName,
|
||||||
store: this.store,
|
store: this.store,
|
||||||
respond: async (responseText: string) => {
|
channels: this.getChannels(),
|
||||||
|
users: this.getUsers(),
|
||||||
|
respond: async (responseText: string, log = true) => {
|
||||||
// Queue updates to avoid race conditions
|
// Queue updates to avoid race conditions
|
||||||
updatePromise = updatePromise.then(async () => {
|
updatePromise = updatePromise.then(async () => {
|
||||||
if (isThinking) {
|
if (isThinking) {
|
||||||
|
|
@ -252,8 +375,10 @@ export class MomBot {
|
||||||
messageTs = result.ts as string;
|
messageTs = result.ts as string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log the response
|
// Log the response if requested
|
||||||
await this.store.logBotResponse(event.channel, responseText, messageTs!);
|
if (log) {
|
||||||
|
await this.store.logBotResponse(event.channel, responseText, messageTs!);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
await updatePromise;
|
await updatePromise;
|
||||||
|
|
@ -265,11 +390,13 @@ export class MomBot {
|
||||||
// No main message yet, just skip
|
// No main message yet, just skip
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Obfuscate usernames to avoid pinging people in thread details
|
||||||
|
const obfuscatedText = this.obfuscateUsernames(threadText);
|
||||||
// Post in thread under the main message
|
// Post in thread under the main message
|
||||||
await this.webClient.chat.postMessage({
|
await this.webClient.chat.postMessage({
|
||||||
channel: event.channel,
|
channel: event.channel,
|
||||||
thread_ts: messageTs,
|
thread_ts: messageTs,
|
||||||
text: threadText,
|
text: obfuscatedText,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
await updatePromise;
|
await updatePromise;
|
||||||
|
|
@ -343,6 +470,11 @@ export class MomBot {
|
||||||
async start(): Promise<void> {
|
async start(): Promise<void> {
|
||||||
const auth = await this.webClient.auth.test();
|
const auth = await this.webClient.auth.test();
|
||||||
this.botUserId = auth.user_id as string;
|
this.botUserId = auth.user_id as string;
|
||||||
|
|
||||||
|
// Fetch channels and users in parallel
|
||||||
|
await Promise.all([this.fetchChannels(), this.fetchUsers()]);
|
||||||
|
log.logInfo(`Loaded ${this.channelCache.size} channels, ${this.userCache.size} users`);
|
||||||
|
|
||||||
await this.socketClient.start();
|
await this.socketClient.start();
|
||||||
log.logConnected();
|
log.logConnected();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue