Threads: add Slack/Discord thread sessions

This commit is contained in:
Shadow
2026-01-07 09:02:20 -06:00
committed by Peter Steinberger
parent 422477499c
commit 7e5cef29a0
17 changed files with 670 additions and 27 deletions

View File

@@ -25,7 +25,7 @@ import {
type ClawdbotConfig,
loadConfig,
} from "../config/config.js";
import { resolveSessionTranscriptPath } from "../config/sessions.js";
import { resolveSessionFilePath } from "../config/sessions.js";
import { logVerbose } from "../globals.js";
import { clearCommandLane, getQueueSize } from "../process/command-queue.js";
import { defaultRuntime } from "../runtime.js";
@@ -646,6 +646,11 @@ export async function getReplyFromConfig(
isNewSession,
prefixedBodyBase,
});
const threadStarterBody = ctx.ThreadStarterBody?.trim();
const threadStarterNote =
isNewSession && threadStarterBody
? `[Thread starter - for context]\n${threadStarterBody}`
: undefined;
const skillResult = await ensureSkillSnapshot({
sessionEntry,
sessionStore,
@@ -661,10 +666,10 @@ export async function getReplyFromConfig(
systemSent = skillResult.systemSent;
const skillsSnapshot = skillResult.skillsSnapshot;
const prefixedBody = transcribedText
? [prefixedBodyBase, `Transcript:\n${transcribedText}`]
? [threadStarterNote, prefixedBodyBase, `Transcript:\n${transcribedText}`]
.filter(Boolean)
.join("\n\n")
: prefixedBodyBase;
: [threadStarterNote, prefixedBodyBase].filter(Boolean).join("\n\n");
const mediaNote = ctx.MediaPath?.length
? `[media attached: ${ctx.MediaPath}${ctx.MediaType ? ` (${ctx.MediaType})` : ""}${ctx.MediaUrl ? ` | ${ctx.MediaUrl}` : ""}]`
: undefined;
@@ -689,12 +694,12 @@ export async function getReplyFromConfig(
resolvedThinkLevel = await modelState.resolveDefaultThinkingLevel();
}
const sessionIdFinal = sessionId ?? crypto.randomUUID();
const sessionFile = resolveSessionTranscriptPath(sessionIdFinal);
const sessionFile = resolveSessionFilePath(sessionIdFinal, sessionEntry);
const queueBodyBase = transcribedText
? [baseBodyFinal, `Transcript:\n${transcribedText}`]
? [threadStarterNote, baseBodyFinal, `Transcript:\n${transcribedText}`]
.filter(Boolean)
.join("\n\n")
: baseBodyFinal;
: [threadStarterNote, baseBodyFinal].filter(Boolean).join("\n\n");
const queuedBody = mediaNote
? [mediaNote, mediaReplyHint, queueBodyBase]
.filter(Boolean)

View File

@@ -14,7 +14,7 @@ import {
} from "../../agents/pi-embedded.js";
import type { ClawdbotConfig } from "../../config/config.js";
import {
resolveSessionTranscriptPath,
resolveSessionFilePath,
type SessionEntry,
type SessionScope,
saveSessionStore,
@@ -509,7 +509,7 @@ export async function handleCommands(params: {
sessionId,
sessionKey,
messageProvider: command.provider,
sessionFile: resolveSessionTranscriptPath(sessionId),
sessionFile: resolveSessionFilePath(sessionId, sessionEntry),
workspaceDir,
config: cfg,
skillsSnapshot: sessionEntry.skillsSnapshot,

View File

@@ -0,0 +1,82 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import type { ClawdbotConfig } from "../../config/config.js";
import { saveSessionStore } from "../../config/sessions.js";
import { initSessionState } from "./session.js";
describe("initSessionState thread forking", () => {
it("forks a new session from the parent session file", async () => {
const root = await fs.mkdtemp(
path.join(os.tmpdir(), "clawdbot-thread-session-"),
);
const sessionsDir = path.join(root, "sessions");
await fs.mkdir(sessionsDir, { recursive: true });
const parentSessionId = "parent-session";
const parentSessionFile = path.join(sessionsDir, "parent.jsonl");
const header = {
type: "session",
version: 3,
id: parentSessionId,
timestamp: new Date().toISOString(),
cwd: process.cwd(),
};
const message = {
type: "message",
id: "m1",
parentId: null,
timestamp: new Date().toISOString(),
message: { role: "user", content: "Parent prompt" },
};
await fs.writeFile(
parentSessionFile,
`${JSON.stringify(header)}\n${JSON.stringify(message)}\n`,
"utf-8",
);
const storePath = path.join(root, "sessions.json");
const parentSessionKey = "slack:channel:C1";
await saveSessionStore(storePath, {
[parentSessionKey]: {
sessionId: parentSessionId,
sessionFile: parentSessionFile,
updatedAt: Date.now(),
},
});
const cfg = {
session: { store: storePath },
} as ClawdbotConfig;
const threadSessionKey = "slack:thread:C1:123";
const threadLabel = "Slack thread #general: starter";
const result = await initSessionState({
ctx: {
Body: "Thread reply",
SessionKey: threadSessionKey,
ParentSessionKey: parentSessionKey,
ThreadLabel: threadLabel,
},
cfg,
commandAuthorized: true,
});
expect(result.sessionKey).toBe(threadSessionKey);
expect(result.sessionEntry.sessionId).not.toBe(parentSessionId);
expect(result.sessionEntry.sessionFile).toBeTruthy();
expect(result.sessionEntry.displayName).toBe(threadLabel);
const newSessionFile = result.sessionEntry.sessionFile!;
const [headerLine] = (await fs.readFile(newSessionFile, "utf-8"))
.split(/\r?\n/)
.filter((line) => line.trim().length > 0);
const parsedHeader = JSON.parse(headerLine) as {
parentSession?: string;
};
expect(parsedHeader.parentSession).toBe(parentSessionFile);
});
});

View File

@@ -1,5 +1,11 @@
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import {
CURRENT_SESSION_VERSION,
SessionManager,
} from "@mariozechner/pi-coding-agent";
import type { ClawdbotConfig } from "../../config/config.js";
import {
buildGroupDisplayName,
@@ -9,6 +15,7 @@ import {
loadSessionStore,
resolveAgentIdFromSessionKey,
resolveGroupSessionKey,
resolveSessionFilePath,
resolveSessionKey,
resolveStorePath,
type SessionEntry,
@@ -36,6 +43,45 @@ export type SessionInitResult = {
triggerBodyNormalized: string;
};
function forkSessionFromParent(params: {
parentEntry: SessionEntry;
}): { sessionId: string; sessionFile: string } | null {
const parentSessionFile = resolveSessionFilePath(
params.parentEntry.sessionId,
params.parentEntry,
);
if (!parentSessionFile || !fs.existsSync(parentSessionFile)) return null;
try {
const manager = SessionManager.open(parentSessionFile);
const leafId = manager.getLeafId();
if (leafId) {
const sessionFile =
manager.createBranchedSession(leafId) ?? manager.getSessionFile();
const sessionId = manager.getSessionId();
if (sessionFile && sessionId) return { sessionId, sessionFile };
}
const sessionId = crypto.randomUUID();
const timestamp = new Date().toISOString();
const fileTimestamp = timestamp.replace(/[:.]/g, "-");
const sessionFile = path.join(
manager.getSessionDir(),
`${fileTimestamp}_${sessionId}.jsonl`,
);
const header = {
type: "session",
version: CURRENT_SESSION_VERSION,
id: sessionId,
timestamp,
cwd: manager.getCwd(),
parentSession: parentSessionFile,
};
fs.writeFileSync(sessionFile, `${JSON.stringify(header)}\n`, "utf-8");
return { sessionId, sessionFile };
} catch {
return null;
}
}
export async function initSessionState(params: {
ctx: MsgContext;
cfg: ClawdbotConfig;
@@ -189,6 +235,26 @@ export async function initSessionState(params: {
} else if (!sessionEntry.chatType) {
sessionEntry.chatType = "direct";
}
const threadLabel = ctx.ThreadLabel?.trim();
if (threadLabel) {
sessionEntry.displayName = threadLabel;
}
const parentSessionKey = ctx.ParentSessionKey?.trim();
if (
isNewSession &&
parentSessionKey &&
parentSessionKey !== sessionKey &&
sessionStore[parentSessionKey]
) {
const forked = forkSessionFromParent({
parentEntry: sessionStore[parentSessionKey],
});
if (forked) {
sessionId = forked.sessionId;
sessionEntry.sessionId = forked.sessionId;
sessionEntry.sessionFile = forked.sessionFile;
}
}
sessionStore[sessionKey] = sessionEntry;
await saveSessionStore(storePath, sessionStore);

View File

@@ -16,7 +16,7 @@ import {
import type { ClawdbotConfig } from "../config/config.js";
import {
resolveMainSessionKey,
resolveSessionTranscriptPath,
resolveSessionFilePath,
type SessionEntry,
type SessionScope,
} from "../config/sessions.js";
@@ -185,6 +185,7 @@ const formatQueueDetails = (queue?: QueueStatus) => {
const readUsageFromSessionLog = (
sessionId?: string,
sessionEntry?: SessionEntry,
):
| {
input: number;
@@ -194,9 +195,9 @@ const readUsageFromSessionLog = (
model?: string;
}
| undefined => {
// Transcripts always live at: ~/.clawdbot/sessions/<SessionId>.jsonl
// Transcripts are stored at the session file path (fallback: ~/.clawdbot/sessions/<SessionId>.jsonl)
if (!sessionId) return undefined;
const logPath = resolveSessionTranscriptPath(sessionId);
const logPath = resolveSessionFilePath(sessionId, sessionEntry);
if (!fs.existsSync(logPath)) return undefined;
try {
@@ -264,7 +265,7 @@ export function buildStatusMessage(args: StatusArgs): string {
// Prefer prompt-size tokens from the session transcript when it looks larger
// (cached prompt tokens are often missing from agent meta/store).
if (args.includeTranscriptUsage) {
const logUsage = readUsageFromSessionLog(entry?.sessionId);
const logUsage = readUsageFromSessionLog(entry?.sessionId, entry);
if (logUsage) {
const candidate = logUsage.promptTokens || logUsage.total;
if (!totalTokens || totalTokens === 0 || candidate > totalTokens) {

View File

@@ -15,10 +15,13 @@ export type MsgContext = {
SessionKey?: string;
/** Provider account id (multi-account). */
AccountId?: string;
ParentSessionKey?: string;
MessageSid?: string;
ReplyToId?: string;
ReplyToBody?: string;
ReplyToSender?: string;
ThreadStarterBody?: string;
ThreadLabel?: string;
MediaPath?: string;
MediaUrl?: string;
MediaType?: string;