Threads: add Slack/Discord thread sessions
This commit is contained in:
committed by
Peter Steinberger
parent
422477499c
commit
7e5cef29a0
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
82
src/auto-reply/reply/session.test.ts
Normal file
82
src/auto-reply/reply/session.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user