Merge pull request #1271 from Whoaa512/feat/session-picker-mvp
feat: session picker MVP - fuzzy search, derived titles, relative time
This commit is contained in:
@@ -1,6 +1,5 @@
|
||||
import type { ReasoningLevel, ThinkLevel } from "../auto-reply/thinking.js";
|
||||
import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js";
|
||||
import { formatCliCommand } from "../cli/command-format.js";
|
||||
import { listDeliverableMessageChannels } from "../utils/message-channel.js";
|
||||
import type { ResolvedTimeFormat } from "./date-time.js";
|
||||
import type { EmbeddedContextFile } from "./pi-embedded-helpers.js";
|
||||
@@ -86,22 +85,8 @@ function buildMessagingSection(params: {
|
||||
messageChannelOptions: string;
|
||||
inlineButtonsEnabled: boolean;
|
||||
runtimeChannel?: string;
|
||||
channelActions?: string[];
|
||||
}) {
|
||||
if (params.isMinimal) return [];
|
||||
|
||||
// Build channel-specific action description
|
||||
let actionsDescription: string;
|
||||
if (params.channelActions && params.channelActions.length > 0 && params.runtimeChannel) {
|
||||
// Include "send" as a base action plus channel-specific actions
|
||||
const allActions = new Set(["send", ...params.channelActions]);
|
||||
const actionList = Array.from(allActions).sort().join(", ");
|
||||
actionsDescription = `- Use \`message\` for proactive sends + channel actions. Current channel (${params.runtimeChannel}) supports: ${actionList}.`;
|
||||
} else {
|
||||
actionsDescription =
|
||||
"- Use `message` for proactive sends + channel actions (send, react, edit, delete, etc.).";
|
||||
}
|
||||
|
||||
return [
|
||||
"## Messaging",
|
||||
"- Reply in current session → automatically routes to the source channel (Signal, Telegram, etc.)",
|
||||
@@ -111,7 +96,7 @@ function buildMessagingSection(params: {
|
||||
? [
|
||||
"",
|
||||
"### message tool",
|
||||
actionsDescription,
|
||||
"- Use `message` for proactive sends + channel actions (polls, reactions, etc.).",
|
||||
"- For `action=send`, include `to` and `message`.",
|
||||
`- If multiple channels are configured, pass \`channel\` (${params.messageChannelOptions}).`,
|
||||
`- If you use \`message\` (\`action=send\`) to deliver your user-visible reply, respond with ONLY: ${SILENT_REPLY_TOKEN} (avoid duplicate replies).`,
|
||||
@@ -139,7 +124,7 @@ function buildDocsSection(params: { docsPath?: string; isMinimal: boolean; readT
|
||||
"Community: https://discord.com/invite/clawd",
|
||||
"Find new skills: https://clawdhub.com",
|
||||
"For Clawdbot behavior, commands, config, or architecture: consult local docs first.",
|
||||
`When diagnosing issues, run \`${formatCliCommand("clawdbot status")}\` yourself when possible; only ask the user if you lack access (e.g., sandboxed).`,
|
||||
"When diagnosing issues, run `clawdbot status` yourself when possible; only ask the user if you lack access (e.g., sandboxed).",
|
||||
"",
|
||||
];
|
||||
}
|
||||
@@ -170,10 +155,9 @@ export function buildAgentSystemPrompt(params: {
|
||||
arch?: string;
|
||||
node?: string;
|
||||
model?: string;
|
||||
defaultModel?: string;
|
||||
channel?: string;
|
||||
capabilities?: string[];
|
||||
/** Supported message actions for the current channel (e.g., react, edit, unsend) */
|
||||
channelActions?: string[];
|
||||
};
|
||||
sandboxInfo?: {
|
||||
enabled: boolean;
|
||||
@@ -381,11 +365,11 @@ export function buildAgentSystemPrompt(params: {
|
||||
"## Clawdbot CLI Quick Reference",
|
||||
"Clawdbot is controlled via subcommands. Do not invent commands.",
|
||||
"To manage the Gateway daemon service (start/stop/restart):",
|
||||
`- ${formatCliCommand("clawdbot daemon status")}`,
|
||||
`- ${formatCliCommand("clawdbot daemon start")}`,
|
||||
`- ${formatCliCommand("clawdbot daemon stop")}`,
|
||||
`- ${formatCliCommand("clawdbot daemon restart")}`,
|
||||
`If unsure, ask the user to run \`${formatCliCommand("clawdbot help")}\` (or \`${formatCliCommand("clawdbot daemon --help")}\`) and paste the output.`,
|
||||
"- clawdbot daemon status",
|
||||
"- clawdbot daemon start",
|
||||
"- clawdbot daemon stop",
|
||||
"- clawdbot daemon restart",
|
||||
"If unsure, ask the user to run `clawdbot help` (or `clawdbot daemon --help`) and paste the output.",
|
||||
"",
|
||||
...skillsSection,
|
||||
...memorySection,
|
||||
@@ -484,7 +468,6 @@ export function buildAgentSystemPrompt(params: {
|
||||
messageChannelOptions,
|
||||
inlineButtonsEnabled,
|
||||
runtimeChannel,
|
||||
channelActions: runtimeInfo?.channelActions,
|
||||
}),
|
||||
];
|
||||
|
||||
|
||||
@@ -3,81 +3,92 @@ import { Type } from "@sinclair/typebox";
|
||||
import { NonEmptyString, SessionLabelString } from "./primitives.js";
|
||||
|
||||
export const SessionsListParamsSchema = Type.Object(
|
||||
{
|
||||
limit: Type.Optional(Type.Integer({ minimum: 1 })),
|
||||
activeMinutes: Type.Optional(Type.Integer({ minimum: 1 })),
|
||||
includeGlobal: Type.Optional(Type.Boolean()),
|
||||
includeUnknown: Type.Optional(Type.Boolean()),
|
||||
label: Type.Optional(SessionLabelString),
|
||||
spawnedBy: Type.Optional(NonEmptyString),
|
||||
agentId: Type.Optional(NonEmptyString),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
{
|
||||
limit: Type.Optional(Type.Integer({ minimum: 1 })),
|
||||
activeMinutes: Type.Optional(Type.Integer({ minimum: 1 })),
|
||||
includeGlobal: Type.Optional(Type.Boolean()),
|
||||
includeUnknown: Type.Optional(Type.Boolean()),
|
||||
/**
|
||||
* Read first 8KB of each session transcript to derive title from first user message.
|
||||
* Performs a file read per session - use `limit` to bound result set on large stores.
|
||||
*/
|
||||
includeDerivedTitles: Type.Optional(Type.Boolean()),
|
||||
/**
|
||||
* Read last 16KB of each session transcript to extract most recent message preview.
|
||||
* Performs a file read per session - use `limit` to bound result set on large stores.
|
||||
*/
|
||||
includeLastMessage: Type.Optional(Type.Boolean()),
|
||||
label: Type.Optional(SessionLabelString),
|
||||
spawnedBy: Type.Optional(NonEmptyString),
|
||||
agentId: Type.Optional(NonEmptyString),
|
||||
search: Type.Optional(Type.String()),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const SessionsResolveParamsSchema = Type.Object(
|
||||
{
|
||||
key: Type.Optional(NonEmptyString),
|
||||
label: Type.Optional(SessionLabelString),
|
||||
agentId: Type.Optional(NonEmptyString),
|
||||
spawnedBy: Type.Optional(NonEmptyString),
|
||||
includeGlobal: Type.Optional(Type.Boolean()),
|
||||
includeUnknown: Type.Optional(Type.Boolean()),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
{
|
||||
key: Type.Optional(NonEmptyString),
|
||||
label: Type.Optional(SessionLabelString),
|
||||
agentId: Type.Optional(NonEmptyString),
|
||||
spawnedBy: Type.Optional(NonEmptyString),
|
||||
includeGlobal: Type.Optional(Type.Boolean()),
|
||||
includeUnknown: Type.Optional(Type.Boolean()),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const SessionsPatchParamsSchema = Type.Object(
|
||||
{
|
||||
key: NonEmptyString,
|
||||
label: Type.Optional(Type.Union([SessionLabelString, Type.Null()])),
|
||||
thinkingLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
||||
verboseLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
||||
reasoningLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
||||
responseUsage: Type.Optional(
|
||||
Type.Union([
|
||||
Type.Literal("off"),
|
||||
Type.Literal("tokens"),
|
||||
Type.Literal("full"),
|
||||
// Backward compat with older clients/stores.
|
||||
Type.Literal("on"),
|
||||
Type.Null(),
|
||||
]),
|
||||
),
|
||||
elevatedLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
||||
execHost: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
||||
execSecurity: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
||||
execAsk: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
||||
execNode: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
||||
model: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
||||
spawnedBy: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
||||
sendPolicy: Type.Optional(
|
||||
Type.Union([Type.Literal("allow"), Type.Literal("deny"), Type.Null()]),
|
||||
),
|
||||
groupActivation: Type.Optional(
|
||||
Type.Union([Type.Literal("mention"), Type.Literal("always"), Type.Null()]),
|
||||
),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
{
|
||||
key: NonEmptyString,
|
||||
label: Type.Optional(Type.Union([SessionLabelString, Type.Null()])),
|
||||
thinkingLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
||||
verboseLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
||||
reasoningLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
||||
responseUsage: Type.Optional(
|
||||
Type.Union([
|
||||
Type.Literal("off"),
|
||||
Type.Literal("tokens"),
|
||||
Type.Literal("full"),
|
||||
// Backward compat with older clients/stores.
|
||||
Type.Literal("on"),
|
||||
Type.Null(),
|
||||
]),
|
||||
),
|
||||
elevatedLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
||||
execHost: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
||||
execSecurity: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
||||
execAsk: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
||||
execNode: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
||||
model: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
||||
spawnedBy: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
||||
sendPolicy: Type.Optional(
|
||||
Type.Union([Type.Literal("allow"), Type.Literal("deny"), Type.Null()]),
|
||||
),
|
||||
groupActivation: Type.Optional(
|
||||
Type.Union([Type.Literal("mention"), Type.Literal("always"), Type.Null()]),
|
||||
),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const SessionsResetParamsSchema = Type.Object(
|
||||
{ key: NonEmptyString },
|
||||
{ additionalProperties: false },
|
||||
{ key: NonEmptyString },
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const SessionsDeleteParamsSchema = Type.Object(
|
||||
{
|
||||
key: NonEmptyString,
|
||||
deleteTranscript: Type.Optional(Type.Boolean()),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
{
|
||||
key: NonEmptyString,
|
||||
deleteTranscript: Type.Optional(Type.Boolean()),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const SessionsCompactParamsSchema = Type.Object(
|
||||
{
|
||||
key: NonEmptyString,
|
||||
maxLines: Type.Optional(Type.Integer({ minimum: 1 })),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
{
|
||||
key: NonEmptyString,
|
||||
maxLines: Type.Optional(Type.Integer({ minimum: 1 })),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
343
src/gateway/session-utils.fs.test.ts
Normal file
343
src/gateway/session-utils.fs.test.ts
Normal file
@@ -0,0 +1,343 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||
import {
|
||||
readFirstUserMessageFromTranscript,
|
||||
readLastMessagePreviewFromTranscript,
|
||||
} from "./session-utils.fs.js";
|
||||
|
||||
describe("readFirstUserMessageFromTranscript", () => {
|
||||
let tmpDir: string;
|
||||
let storePath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-session-fs-test-"));
|
||||
storePath = path.join(tmpDir, "sessions.json");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("returns null when transcript file does not exist", () => {
|
||||
const result = readFirstUserMessageFromTranscript("nonexistent-session", storePath);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("returns first user message from transcript with string content", () => {
|
||||
const sessionId = "test-session-1";
|
||||
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
|
||||
const lines = [
|
||||
JSON.stringify({ type: "session", version: 1, id: sessionId }),
|
||||
JSON.stringify({ message: { role: "user", content: "Hello world" } }),
|
||||
JSON.stringify({ message: { role: "assistant", content: "Hi there" } }),
|
||||
];
|
||||
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
|
||||
|
||||
const result = readFirstUserMessageFromTranscript(sessionId, storePath);
|
||||
expect(result).toBe("Hello world");
|
||||
});
|
||||
|
||||
test("returns first user message from transcript with array content", () => {
|
||||
const sessionId = "test-session-2";
|
||||
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
|
||||
const lines = [
|
||||
JSON.stringify({ type: "session", version: 1, id: sessionId }),
|
||||
JSON.stringify({
|
||||
message: {
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "Array message content" }],
|
||||
},
|
||||
}),
|
||||
];
|
||||
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
|
||||
|
||||
const result = readFirstUserMessageFromTranscript(sessionId, storePath);
|
||||
expect(result).toBe("Array message content");
|
||||
});
|
||||
|
||||
test("returns first user message from transcript with input_text content", () => {
|
||||
const sessionId = "test-session-2b";
|
||||
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
|
||||
const lines = [
|
||||
JSON.stringify({ type: "session", version: 1, id: sessionId }),
|
||||
JSON.stringify({
|
||||
message: {
|
||||
role: "user",
|
||||
content: [{ type: "input_text", text: "Input text content" }],
|
||||
},
|
||||
}),
|
||||
];
|
||||
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
|
||||
|
||||
const result = readFirstUserMessageFromTranscript(sessionId, storePath);
|
||||
expect(result).toBe("Input text content");
|
||||
});
|
||||
test("skips non-user messages to find first user message", () => {
|
||||
const sessionId = "test-session-3";
|
||||
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
|
||||
const lines = [
|
||||
JSON.stringify({ type: "session", version: 1, id: sessionId }),
|
||||
JSON.stringify({ message: { role: "system", content: "System prompt" } }),
|
||||
JSON.stringify({ message: { role: "assistant", content: "Greeting" } }),
|
||||
JSON.stringify({ message: { role: "user", content: "First user question" } }),
|
||||
];
|
||||
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
|
||||
|
||||
const result = readFirstUserMessageFromTranscript(sessionId, storePath);
|
||||
expect(result).toBe("First user question");
|
||||
});
|
||||
|
||||
test("returns null when no user messages exist", () => {
|
||||
const sessionId = "test-session-4";
|
||||
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
|
||||
const lines = [
|
||||
JSON.stringify({ type: "session", version: 1, id: sessionId }),
|
||||
JSON.stringify({ message: { role: "system", content: "System prompt" } }),
|
||||
JSON.stringify({ message: { role: "assistant", content: "Greeting" } }),
|
||||
];
|
||||
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
|
||||
|
||||
const result = readFirstUserMessageFromTranscript(sessionId, storePath);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("handles malformed JSON lines gracefully", () => {
|
||||
const sessionId = "test-session-5";
|
||||
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
|
||||
const lines = [
|
||||
"not valid json",
|
||||
JSON.stringify({ message: { role: "user", content: "Valid message" } }),
|
||||
];
|
||||
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
|
||||
|
||||
const result = readFirstUserMessageFromTranscript(sessionId, storePath);
|
||||
expect(result).toBe("Valid message");
|
||||
});
|
||||
|
||||
test("uses sessionFile parameter when provided", () => {
|
||||
const sessionId = "test-session-6";
|
||||
const customPath = path.join(tmpDir, "custom-transcript.jsonl");
|
||||
const lines = [
|
||||
JSON.stringify({ type: "session", version: 1, id: sessionId }),
|
||||
JSON.stringify({ message: { role: "user", content: "Custom file message" } }),
|
||||
];
|
||||
fs.writeFileSync(customPath, lines.join("\n"), "utf-8");
|
||||
|
||||
const result = readFirstUserMessageFromTranscript(sessionId, storePath, customPath);
|
||||
expect(result).toBe("Custom file message");
|
||||
});
|
||||
|
||||
test("trims whitespace from message content", () => {
|
||||
const sessionId = "test-session-7";
|
||||
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
|
||||
const lines = [JSON.stringify({ message: { role: "user", content: " Padded message " } })];
|
||||
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
|
||||
|
||||
const result = readFirstUserMessageFromTranscript(sessionId, storePath);
|
||||
expect(result).toBe("Padded message");
|
||||
});
|
||||
|
||||
test("returns null for empty content", () => {
|
||||
const sessionId = "test-session-8";
|
||||
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
|
||||
const lines = [
|
||||
JSON.stringify({ message: { role: "user", content: "" } }),
|
||||
JSON.stringify({ message: { role: "user", content: "Second message" } }),
|
||||
];
|
||||
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
|
||||
|
||||
const result = readFirstUserMessageFromTranscript(sessionId, storePath);
|
||||
expect(result).toBe("Second message");
|
||||
});
|
||||
});
|
||||
|
||||
describe("readLastMessagePreviewFromTranscript", () => {
|
||||
let tmpDir: string;
|
||||
let storePath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-session-fs-test-"));
|
||||
storePath = path.join(tmpDir, "sessions.json");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("returns null when transcript file does not exist", () => {
|
||||
const result = readLastMessagePreviewFromTranscript("nonexistent-session", storePath);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for empty file", () => {
|
||||
const sessionId = "test-last-empty";
|
||||
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
|
||||
fs.writeFileSync(transcriptPath, "", "utf-8");
|
||||
|
||||
const result = readLastMessagePreviewFromTranscript(sessionId, storePath);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("returns last user message from transcript", () => {
|
||||
const sessionId = "test-last-user";
|
||||
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
|
||||
const lines = [
|
||||
JSON.stringify({ message: { role: "user", content: "First user" } }),
|
||||
JSON.stringify({ message: { role: "assistant", content: "First assistant" } }),
|
||||
JSON.stringify({ message: { role: "user", content: "Last user message" } }),
|
||||
];
|
||||
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
|
||||
|
||||
const result = readLastMessagePreviewFromTranscript(sessionId, storePath);
|
||||
expect(result).toBe("Last user message");
|
||||
});
|
||||
|
||||
test("returns last assistant message from transcript", () => {
|
||||
const sessionId = "test-last-assistant";
|
||||
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
|
||||
const lines = [
|
||||
JSON.stringify({ message: { role: "user", content: "User question" } }),
|
||||
JSON.stringify({ message: { role: "assistant", content: "Final assistant reply" } }),
|
||||
];
|
||||
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
|
||||
|
||||
const result = readLastMessagePreviewFromTranscript(sessionId, storePath);
|
||||
expect(result).toBe("Final assistant reply");
|
||||
});
|
||||
|
||||
test("skips system messages to find last user/assistant", () => {
|
||||
const sessionId = "test-last-skip-system";
|
||||
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
|
||||
const lines = [
|
||||
JSON.stringify({ message: { role: "user", content: "Real last" } }),
|
||||
JSON.stringify({ message: { role: "system", content: "System at end" } }),
|
||||
];
|
||||
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
|
||||
|
||||
const result = readLastMessagePreviewFromTranscript(sessionId, storePath);
|
||||
expect(result).toBe("Real last");
|
||||
});
|
||||
|
||||
test("returns null when no user/assistant messages exist", () => {
|
||||
const sessionId = "test-last-no-match";
|
||||
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
|
||||
const lines = [
|
||||
JSON.stringify({ type: "session", version: 1, id: sessionId }),
|
||||
JSON.stringify({ message: { role: "system", content: "Only system" } }),
|
||||
];
|
||||
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
|
||||
|
||||
const result = readLastMessagePreviewFromTranscript(sessionId, storePath);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("handles malformed JSON lines gracefully", () => {
|
||||
const sessionId = "test-last-malformed";
|
||||
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
|
||||
const lines = [
|
||||
JSON.stringify({ message: { role: "user", content: "Valid first" } }),
|
||||
"not valid json at end",
|
||||
];
|
||||
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
|
||||
|
||||
const result = readLastMessagePreviewFromTranscript(sessionId, storePath);
|
||||
expect(result).toBe("Valid first");
|
||||
});
|
||||
|
||||
test("handles array content format", () => {
|
||||
const sessionId = "test-last-array";
|
||||
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
|
||||
const lines = [
|
||||
JSON.stringify({
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "Array content response" }],
|
||||
},
|
||||
}),
|
||||
];
|
||||
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
|
||||
|
||||
const result = readLastMessagePreviewFromTranscript(sessionId, storePath);
|
||||
expect(result).toBe("Array content response");
|
||||
});
|
||||
|
||||
test("handles output_text content format", () => {
|
||||
const sessionId = "test-last-output-text";
|
||||
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
|
||||
const lines = [
|
||||
JSON.stringify({
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [{ type: "output_text", text: "Output text response" }],
|
||||
},
|
||||
}),
|
||||
];
|
||||
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
|
||||
|
||||
const result = readLastMessagePreviewFromTranscript(sessionId, storePath);
|
||||
expect(result).toBe("Output text response");
|
||||
});
|
||||
test("uses sessionFile parameter when provided", () => {
|
||||
const sessionId = "test-last-custom";
|
||||
const customPath = path.join(tmpDir, "custom-last.jsonl");
|
||||
const lines = [JSON.stringify({ message: { role: "user", content: "Custom file last" } })];
|
||||
fs.writeFileSync(customPath, lines.join("\n"), "utf-8");
|
||||
|
||||
const result = readLastMessagePreviewFromTranscript(sessionId, storePath, customPath);
|
||||
expect(result).toBe("Custom file last");
|
||||
});
|
||||
|
||||
test("trims whitespace from message content", () => {
|
||||
const sessionId = "test-last-trim";
|
||||
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
|
||||
const lines = [
|
||||
JSON.stringify({ message: { role: "assistant", content: " Padded response " } }),
|
||||
];
|
||||
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
|
||||
|
||||
const result = readLastMessagePreviewFromTranscript(sessionId, storePath);
|
||||
expect(result).toBe("Padded response");
|
||||
});
|
||||
|
||||
test("skips empty content to find previous message", () => {
|
||||
const sessionId = "test-last-skip-empty";
|
||||
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
|
||||
const lines = [
|
||||
JSON.stringify({ message: { role: "assistant", content: "Has content" } }),
|
||||
JSON.stringify({ message: { role: "user", content: "" } }),
|
||||
];
|
||||
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
|
||||
|
||||
const result = readLastMessagePreviewFromTranscript(sessionId, storePath);
|
||||
expect(result).toBe("Has content");
|
||||
});
|
||||
|
||||
test("reads from end of large file (16KB window)", () => {
|
||||
const sessionId = "test-last-large";
|
||||
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
|
||||
const padding = JSON.stringify({ message: { role: "user", content: "x".repeat(500) } });
|
||||
const lines: string[] = [];
|
||||
for (let i = 0; i < 50; i++) {
|
||||
lines.push(padding);
|
||||
}
|
||||
lines.push(JSON.stringify({ message: { role: "assistant", content: "Last in large file" } }));
|
||||
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
|
||||
|
||||
const result = readLastMessagePreviewFromTranscript(sessionId, storePath);
|
||||
expect(result).toBe("Last in large file");
|
||||
});
|
||||
|
||||
test("handles valid UTF-8 content", () => {
|
||||
const sessionId = "test-last-utf8";
|
||||
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
|
||||
const validLine = JSON.stringify({
|
||||
message: { role: "user", content: "Valid UTF-8: 你好世界 🌍" },
|
||||
});
|
||||
fs.writeFileSync(transcriptPath, validLine, "utf-8");
|
||||
|
||||
const result = readLastMessagePreviewFromTranscript(sessionId, storePath);
|
||||
expect(result).toBe("Valid UTF-8: 你好世界 🌍");
|
||||
});
|
||||
});
|
||||
@@ -79,3 +79,113 @@ export function capArrayByJsonBytes<T>(
|
||||
const next = start > 0 ? items.slice(start) : items;
|
||||
return { items: next, bytes };
|
||||
}
|
||||
|
||||
const MAX_LINES_TO_SCAN = 10;
|
||||
|
||||
type TranscriptMessage = {
|
||||
role?: string;
|
||||
content?: string | Array<{ type: string; text?: string }>;
|
||||
};
|
||||
|
||||
function extractTextFromContent(content: TranscriptMessage["content"]): string | null {
|
||||
if (typeof content === "string") return content.trim() || null;
|
||||
if (!Array.isArray(content)) return null;
|
||||
for (const part of content) {
|
||||
if (!part || typeof part.text !== "string") continue;
|
||||
if (part.type === "text" || part.type === "output_text" || part.type === "input_text") {
|
||||
const trimmed = part.text.trim();
|
||||
if (trimmed) return trimmed;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function readFirstUserMessageFromTranscript(
|
||||
sessionId: string,
|
||||
storePath: string | undefined,
|
||||
sessionFile?: string,
|
||||
agentId?: string,
|
||||
): string | null {
|
||||
const candidates = resolveSessionTranscriptCandidates(sessionId, storePath, sessionFile, agentId);
|
||||
const filePath = candidates.find((p) => fs.existsSync(p));
|
||||
if (!filePath) return null;
|
||||
|
||||
let fd: number | null = null;
|
||||
try {
|
||||
fd = fs.openSync(filePath, "r");
|
||||
const buf = Buffer.alloc(8192);
|
||||
const bytesRead = fs.readSync(fd, buf, 0, buf.length, 0);
|
||||
if (bytesRead === 0) return null;
|
||||
const chunk = buf.toString("utf-8", 0, bytesRead);
|
||||
const lines = chunk.split(/\r?\n/).slice(0, MAX_LINES_TO_SCAN);
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
try {
|
||||
const parsed = JSON.parse(line);
|
||||
const msg = parsed?.message as TranscriptMessage | undefined;
|
||||
if (msg?.role === "user") {
|
||||
const text = extractTextFromContent(msg.content);
|
||||
if (text) return text;
|
||||
}
|
||||
} catch {
|
||||
// skip malformed lines
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// file read error
|
||||
} finally {
|
||||
if (fd !== null) fs.closeSync(fd);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const LAST_MSG_MAX_BYTES = 16384;
|
||||
const LAST_MSG_MAX_LINES = 20;
|
||||
|
||||
export function readLastMessagePreviewFromTranscript(
|
||||
sessionId: string,
|
||||
storePath: string | undefined,
|
||||
sessionFile?: string,
|
||||
agentId?: string,
|
||||
): string | null {
|
||||
const candidates = resolveSessionTranscriptCandidates(sessionId, storePath, sessionFile, agentId);
|
||||
const filePath = candidates.find((p) => fs.existsSync(p));
|
||||
if (!filePath) return null;
|
||||
|
||||
let fd: number | null = null;
|
||||
try {
|
||||
fd = fs.openSync(filePath, "r");
|
||||
const stat = fs.fstatSync(fd);
|
||||
const size = stat.size;
|
||||
if (size === 0) return null;
|
||||
|
||||
const readStart = Math.max(0, size - LAST_MSG_MAX_BYTES);
|
||||
const readLen = Math.min(size, LAST_MSG_MAX_BYTES);
|
||||
const buf = Buffer.alloc(readLen);
|
||||
fs.readSync(fd, buf, 0, readLen, readStart);
|
||||
|
||||
const chunk = buf.toString("utf-8");
|
||||
const lines = chunk.split(/\r?\n/).filter((l) => l.trim());
|
||||
const tailLines = lines.slice(-LAST_MSG_MAX_LINES);
|
||||
|
||||
for (let i = tailLines.length - 1; i >= 0; i--) {
|
||||
const line = tailLines[i];
|
||||
try {
|
||||
const parsed = JSON.parse(line);
|
||||
const msg = parsed?.message as TranscriptMessage | undefined;
|
||||
if (msg?.role === "user" || msg?.role === "assistant") {
|
||||
const text = extractTextFromContent(msg.content);
|
||||
if (text) return text;
|
||||
}
|
||||
} catch {
|
||||
// skip malformed
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// file error
|
||||
} finally {
|
||||
if (fd !== null) fs.closeSync(fd);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import type { SessionEntry } from "../config/sessions.js";
|
||||
import {
|
||||
capArrayByJsonBytes,
|
||||
classifySessionKey,
|
||||
deriveSessionTitle,
|
||||
listSessionsFromStore,
|
||||
parseGroupKey,
|
||||
resolveGatewaySessionStoreTarget,
|
||||
resolveSessionStoreKey,
|
||||
@@ -91,3 +93,242 @@ describe("gateway session utils", () => {
|
||||
expect(target.storePath).toBe(path.resolve(storeTemplate.replace("{agentId}", "ops")));
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveSessionTitle", () => {
|
||||
test("returns undefined for undefined entry", () => {
|
||||
expect(deriveSessionTitle(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
test("prefers displayName when set", () => {
|
||||
const entry = {
|
||||
sessionId: "abc123",
|
||||
updatedAt: Date.now(),
|
||||
displayName: "My Custom Session",
|
||||
subject: "Group Chat",
|
||||
} as SessionEntry;
|
||||
expect(deriveSessionTitle(entry)).toBe("My Custom Session");
|
||||
});
|
||||
|
||||
test("falls back to subject when displayName is missing", () => {
|
||||
const entry = {
|
||||
sessionId: "abc123",
|
||||
updatedAt: Date.now(),
|
||||
subject: "Dev Team Chat",
|
||||
} as SessionEntry;
|
||||
expect(deriveSessionTitle(entry)).toBe("Dev Team Chat");
|
||||
});
|
||||
|
||||
test("uses first user message when displayName and subject missing", () => {
|
||||
const entry = {
|
||||
sessionId: "abc123",
|
||||
updatedAt: Date.now(),
|
||||
} as SessionEntry;
|
||||
expect(deriveSessionTitle(entry, "Hello, how are you?")).toBe("Hello, how are you?");
|
||||
});
|
||||
|
||||
test("truncates long first user message to 60 chars with ellipsis", () => {
|
||||
const entry = {
|
||||
sessionId: "abc123",
|
||||
updatedAt: Date.now(),
|
||||
} as SessionEntry;
|
||||
const longMsg =
|
||||
"This is a very long message that exceeds sixty characters and should be truncated appropriately";
|
||||
const result = deriveSessionTitle(entry, longMsg);
|
||||
expect(result).toBeDefined();
|
||||
expect(result!.length).toBeLessThanOrEqual(60);
|
||||
expect(result!.endsWith("…")).toBe(true);
|
||||
});
|
||||
|
||||
test("truncates at word boundary when possible", () => {
|
||||
const entry = {
|
||||
sessionId: "abc123",
|
||||
updatedAt: Date.now(),
|
||||
} as SessionEntry;
|
||||
const longMsg = "This message has many words and should be truncated at a word boundary nicely";
|
||||
const result = deriveSessionTitle(entry, longMsg);
|
||||
expect(result).toBeDefined();
|
||||
expect(result!.endsWith("…")).toBe(true);
|
||||
expect(result!.includes(" ")).toBe(false);
|
||||
});
|
||||
|
||||
test("falls back to sessionId prefix with date", () => {
|
||||
const entry = {
|
||||
sessionId: "abcd1234-5678-90ef-ghij-klmnopqrstuv",
|
||||
updatedAt: new Date("2024-03-15T10:30:00Z").getTime(),
|
||||
} as SessionEntry;
|
||||
const result = deriveSessionTitle(entry);
|
||||
expect(result).toBe("abcd1234 (2024-03-15)");
|
||||
});
|
||||
|
||||
test("falls back to sessionId prefix without date when updatedAt missing", () => {
|
||||
const entry = {
|
||||
sessionId: "abcd1234-5678-90ef-ghij-klmnopqrstuv",
|
||||
updatedAt: 0,
|
||||
} as SessionEntry;
|
||||
const result = deriveSessionTitle(entry);
|
||||
expect(result).toBe("abcd1234");
|
||||
});
|
||||
|
||||
test("trims whitespace from displayName", () => {
|
||||
const entry = {
|
||||
sessionId: "abc123",
|
||||
updatedAt: Date.now(),
|
||||
displayName: " Padded Name ",
|
||||
} as SessionEntry;
|
||||
expect(deriveSessionTitle(entry)).toBe("Padded Name");
|
||||
});
|
||||
|
||||
test("ignores empty displayName and falls through", () => {
|
||||
const entry = {
|
||||
sessionId: "abc123",
|
||||
updatedAt: Date.now(),
|
||||
displayName: " ",
|
||||
subject: "Actual Subject",
|
||||
} as SessionEntry;
|
||||
expect(deriveSessionTitle(entry)).toBe("Actual Subject");
|
||||
});
|
||||
});
|
||||
|
||||
describe("listSessionsFromStore search", () => {
|
||||
const baseCfg = {
|
||||
session: { mainKey: "main" },
|
||||
agents: { list: [{ id: "main", default: true }] },
|
||||
} as ClawdbotConfig;
|
||||
|
||||
const makeStore = (): Record<string, SessionEntry> => ({
|
||||
"agent:main:work-project": {
|
||||
sessionId: "sess-work-1",
|
||||
updatedAt: Date.now(),
|
||||
displayName: "Work Project Alpha",
|
||||
label: "work",
|
||||
} as SessionEntry,
|
||||
"agent:main:personal-chat": {
|
||||
sessionId: "sess-personal-1",
|
||||
updatedAt: Date.now() - 1000,
|
||||
displayName: "Personal Chat",
|
||||
subject: "Family Reunion Planning",
|
||||
} as SessionEntry,
|
||||
"agent:main:discord:group:dev-team": {
|
||||
sessionId: "sess-discord-1",
|
||||
updatedAt: Date.now() - 2000,
|
||||
label: "discord",
|
||||
subject: "Dev Team Discussion",
|
||||
} as SessionEntry,
|
||||
});
|
||||
|
||||
test("returns all sessions when search is empty", () => {
|
||||
const store = makeStore();
|
||||
const result = listSessionsFromStore({
|
||||
cfg: baseCfg,
|
||||
storePath: "/tmp/sessions.json",
|
||||
store,
|
||||
opts: { search: "" },
|
||||
});
|
||||
expect(result.sessions.length).toBe(3);
|
||||
});
|
||||
|
||||
test("returns all sessions when search is undefined", () => {
|
||||
const store = makeStore();
|
||||
const result = listSessionsFromStore({
|
||||
cfg: baseCfg,
|
||||
storePath: "/tmp/sessions.json",
|
||||
store,
|
||||
opts: {},
|
||||
});
|
||||
expect(result.sessions.length).toBe(3);
|
||||
});
|
||||
|
||||
test("filters by displayName case-insensitively", () => {
|
||||
const store = makeStore();
|
||||
const result = listSessionsFromStore({
|
||||
cfg: baseCfg,
|
||||
storePath: "/tmp/sessions.json",
|
||||
store,
|
||||
opts: { search: "WORK PROJECT" },
|
||||
});
|
||||
expect(result.sessions.length).toBe(1);
|
||||
expect(result.sessions[0].displayName).toBe("Work Project Alpha");
|
||||
});
|
||||
|
||||
test("filters by subject", () => {
|
||||
const store = makeStore();
|
||||
const result = listSessionsFromStore({
|
||||
cfg: baseCfg,
|
||||
storePath: "/tmp/sessions.json",
|
||||
store,
|
||||
opts: { search: "reunion" },
|
||||
});
|
||||
expect(result.sessions.length).toBe(1);
|
||||
expect(result.sessions[0].subject).toBe("Family Reunion Planning");
|
||||
});
|
||||
|
||||
test("filters by label", () => {
|
||||
const store = makeStore();
|
||||
const result = listSessionsFromStore({
|
||||
cfg: baseCfg,
|
||||
storePath: "/tmp/sessions.json",
|
||||
store,
|
||||
opts: { search: "discord" },
|
||||
});
|
||||
expect(result.sessions.length).toBe(1);
|
||||
expect(result.sessions[0].label).toBe("discord");
|
||||
});
|
||||
|
||||
test("filters by sessionId", () => {
|
||||
const store = makeStore();
|
||||
const result = listSessionsFromStore({
|
||||
cfg: baseCfg,
|
||||
storePath: "/tmp/sessions.json",
|
||||
store,
|
||||
opts: { search: "sess-personal" },
|
||||
});
|
||||
expect(result.sessions.length).toBe(1);
|
||||
expect(result.sessions[0].sessionId).toBe("sess-personal-1");
|
||||
});
|
||||
|
||||
test("filters by key", () => {
|
||||
const store = makeStore();
|
||||
const result = listSessionsFromStore({
|
||||
cfg: baseCfg,
|
||||
storePath: "/tmp/sessions.json",
|
||||
store,
|
||||
opts: { search: "dev-team" },
|
||||
});
|
||||
expect(result.sessions.length).toBe(1);
|
||||
expect(result.sessions[0].key).toBe("agent:main:discord:group:dev-team");
|
||||
});
|
||||
|
||||
test("returns empty array when no matches", () => {
|
||||
const store = makeStore();
|
||||
const result = listSessionsFromStore({
|
||||
cfg: baseCfg,
|
||||
storePath: "/tmp/sessions.json",
|
||||
store,
|
||||
opts: { search: "nonexistent-term" },
|
||||
});
|
||||
expect(result.sessions.length).toBe(0);
|
||||
});
|
||||
|
||||
test("matches partial strings", () => {
|
||||
const store = makeStore();
|
||||
const result = listSessionsFromStore({
|
||||
cfg: baseCfg,
|
||||
storePath: "/tmp/sessions.json",
|
||||
store,
|
||||
opts: { search: "alpha" },
|
||||
});
|
||||
expect(result.sessions.length).toBe(1);
|
||||
expect(result.sessions[0].displayName).toBe("Work Project Alpha");
|
||||
});
|
||||
|
||||
test("trims whitespace from search query", () => {
|
||||
const store = makeStore();
|
||||
const result = listSessionsFromStore({
|
||||
cfg: baseCfg,
|
||||
storePath: "/tmp/sessions.json",
|
||||
store,
|
||||
opts: { search: " personal " },
|
||||
});
|
||||
expect(result.sessions.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,6 +22,10 @@ import {
|
||||
parseAgentSessionKey,
|
||||
} from "../routing/session-key.js";
|
||||
import { normalizeSessionDeliveryFields } from "../utils/delivery-context.js";
|
||||
import {
|
||||
readFirstUserMessageFromTranscript,
|
||||
readLastMessagePreviewFromTranscript,
|
||||
} from "./session-utils.fs.js";
|
||||
import type {
|
||||
GatewayAgentRow,
|
||||
GatewaySessionRow,
|
||||
@@ -32,6 +36,8 @@ import type {
|
||||
export {
|
||||
archiveFileOnDisk,
|
||||
capArrayByJsonBytes,
|
||||
readFirstUserMessageFromTranscript,
|
||||
readLastMessagePreviewFromTranscript,
|
||||
readSessionMessages,
|
||||
resolveSessionTranscriptCandidates,
|
||||
} from "./session-utils.fs.js";
|
||||
@@ -43,6 +49,52 @@ export type {
|
||||
SessionsPatchResult,
|
||||
} from "./session-utils.types.js";
|
||||
|
||||
const DERIVED_TITLE_MAX_LEN = 60;
|
||||
|
||||
function formatSessionIdPrefix(sessionId: string, updatedAt?: number | null): string {
|
||||
const prefix = sessionId.slice(0, 8);
|
||||
if (updatedAt && updatedAt > 0) {
|
||||
const d = new Date(updatedAt);
|
||||
const date = d.toISOString().slice(0, 10);
|
||||
return `${prefix} (${date})`;
|
||||
}
|
||||
return prefix;
|
||||
}
|
||||
|
||||
function truncateTitle(text: string, maxLen: number): string {
|
||||
if (text.length <= maxLen) return text;
|
||||
const cut = text.slice(0, maxLen - 1);
|
||||
const lastSpace = cut.lastIndexOf(" ");
|
||||
if (lastSpace > maxLen * 0.6) return cut.slice(0, lastSpace) + "…";
|
||||
return cut + "…";
|
||||
}
|
||||
|
||||
export function deriveSessionTitle(
|
||||
entry: SessionEntry | undefined,
|
||||
firstUserMessage?: string | null,
|
||||
): string | undefined {
|
||||
if (!entry) return undefined;
|
||||
|
||||
if (entry.displayName?.trim()) {
|
||||
return entry.displayName.trim();
|
||||
}
|
||||
|
||||
if (entry.subject?.trim()) {
|
||||
return entry.subject.trim();
|
||||
}
|
||||
|
||||
if (firstUserMessage?.trim()) {
|
||||
const normalized = firstUserMessage.replace(/\s+/g, " ").trim();
|
||||
return truncateTitle(normalized, DERIVED_TITLE_MAX_LEN);
|
||||
}
|
||||
|
||||
if (entry.sessionId) {
|
||||
return formatSessionIdPrefix(entry.sessionId, entry.updatedAt);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function loadSessionEntry(sessionKey: string) {
|
||||
const cfg = loadConfig();
|
||||
const sessionCfg = cfg.session;
|
||||
@@ -341,9 +393,12 @@ export function listSessionsFromStore(params: {
|
||||
|
||||
const includeGlobal = opts.includeGlobal === true;
|
||||
const includeUnknown = opts.includeUnknown === true;
|
||||
const includeDerivedTitles = opts.includeDerivedTitles === true;
|
||||
const includeLastMessage = opts.includeLastMessage === true;
|
||||
const spawnedBy = typeof opts.spawnedBy === "string" ? opts.spawnedBy : "";
|
||||
const label = typeof opts.label === "string" ? opts.label.trim() : "";
|
||||
const agentId = typeof opts.agentId === "string" ? normalizeAgentId(opts.agentId) : "";
|
||||
const search = typeof opts.search === "string" ? opts.search.trim().toLowerCase() : "";
|
||||
const activeMinutes =
|
||||
typeof opts.activeMinutes === "number" && Number.isFinite(opts.activeMinutes)
|
||||
? Math.max(1, Math.floor(opts.activeMinutes))
|
||||
@@ -400,6 +455,7 @@ export function listSessionsFromStore(params: {
|
||||
const deliveryFields = normalizeSessionDeliveryFields(entry);
|
||||
return {
|
||||
key,
|
||||
entry,
|
||||
kind: classifySessionKey(key, entry),
|
||||
label: entry?.label,
|
||||
displayName,
|
||||
@@ -429,10 +485,17 @@ export function listSessionsFromStore(params: {
|
||||
lastChannel: deliveryFields.lastChannel ?? entry?.lastChannel,
|
||||
lastTo: deliveryFields.lastTo ?? entry?.lastTo,
|
||||
lastAccountId: deliveryFields.lastAccountId ?? entry?.lastAccountId,
|
||||
} satisfies GatewaySessionRow;
|
||||
};
|
||||
})
|
||||
.sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
|
||||
|
||||
if (search) {
|
||||
sessions = sessions.filter((s) => {
|
||||
const fields = [s.displayName, s.label, s.subject, s.sessionId, s.key];
|
||||
return fields.some((f) => typeof f === "string" && f.toLowerCase().includes(search));
|
||||
});
|
||||
}
|
||||
|
||||
if (activeMinutes !== undefined) {
|
||||
const cutoff = now - activeMinutes * 60_000;
|
||||
sessions = sessions.filter((s) => (s.updatedAt ?? 0) >= cutoff);
|
||||
@@ -443,11 +506,36 @@ export function listSessionsFromStore(params: {
|
||||
sessions = sessions.slice(0, limit);
|
||||
}
|
||||
|
||||
const finalSessions: GatewaySessionRow[] = sessions.map((s) => {
|
||||
const { entry, ...rest } = s;
|
||||
let derivedTitle: string | undefined;
|
||||
let lastMessagePreview: string | undefined;
|
||||
if (entry?.sessionId) {
|
||||
if (includeDerivedTitles) {
|
||||
const firstUserMsg = readFirstUserMessageFromTranscript(
|
||||
entry.sessionId,
|
||||
storePath,
|
||||
entry.sessionFile,
|
||||
);
|
||||
derivedTitle = deriveSessionTitle(entry, firstUserMsg);
|
||||
}
|
||||
if (includeLastMessage) {
|
||||
const lastMsg = readLastMessagePreviewFromTranscript(
|
||||
entry.sessionId,
|
||||
storePath,
|
||||
entry.sessionFile,
|
||||
);
|
||||
if (lastMsg) lastMessagePreview = lastMsg;
|
||||
}
|
||||
}
|
||||
return { ...rest, derivedTitle, lastMessagePreview } satisfies GatewaySessionRow;
|
||||
});
|
||||
|
||||
return {
|
||||
ts: now,
|
||||
path: storePath,
|
||||
count: sessions.length,
|
||||
count: finalSessions.length,
|
||||
defaults: getSessionDefaults(cfg),
|
||||
sessions,
|
||||
sessions: finalSessions,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ export type GatewaySessionRow = {
|
||||
kind: "direct" | "group" | "global" | "unknown";
|
||||
label?: string;
|
||||
displayName?: string;
|
||||
derivedTitle?: string;
|
||||
lastMessagePreview?: string;
|
||||
channel?: string;
|
||||
subject?: string;
|
||||
groupChannel?: string;
|
||||
|
||||
143
src/tui/components/filterable-select-list.ts
Normal file
143
src/tui/components/filterable-select-list.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import {
|
||||
Input,
|
||||
matchesKey,
|
||||
type SelectItem,
|
||||
SelectList,
|
||||
type SelectListTheme,
|
||||
getEditorKeybindings,
|
||||
} from "@mariozechner/pi-tui";
|
||||
import type { Component } from "@mariozechner/pi-tui";
|
||||
import chalk from "chalk";
|
||||
import { fuzzyFilterLower, prepareSearchItems } from "./fuzzy-filter.js";
|
||||
|
||||
export interface FilterableSelectItem extends SelectItem {
|
||||
/** Additional searchable fields beyond label */
|
||||
searchText?: string;
|
||||
/** Pre-computed lowercase search text (label + description + searchText) for filtering */
|
||||
searchTextLower?: string;
|
||||
}
|
||||
|
||||
export interface FilterableSelectListTheme extends SelectListTheme {
|
||||
filterLabel: (text: string) => string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Combines text input filtering with a select list.
|
||||
* User types to filter, arrows/j/k to navigate, Enter to select, Escape to clear/cancel.
|
||||
*/
|
||||
export class FilterableSelectList implements Component {
|
||||
private input: Input;
|
||||
private selectList: SelectList;
|
||||
private allItems: FilterableSelectItem[];
|
||||
private maxVisible: number;
|
||||
private theme: FilterableSelectListTheme;
|
||||
private filterText = "";
|
||||
|
||||
onSelect?: (item: SelectItem) => void;
|
||||
onCancel?: () => void;
|
||||
|
||||
constructor(items: FilterableSelectItem[], maxVisible: number, theme: FilterableSelectListTheme) {
|
||||
this.allItems = prepareSearchItems(items);
|
||||
this.maxVisible = maxVisible;
|
||||
this.theme = theme;
|
||||
this.input = new Input();
|
||||
this.selectList = new SelectList(this.allItems, maxVisible, theme);
|
||||
}
|
||||
|
||||
private applyFilter(): void {
|
||||
const queryLower = this.filterText.toLowerCase();
|
||||
if (!queryLower.trim()) {
|
||||
this.selectList = new SelectList(this.allItems, this.maxVisible, this.theme);
|
||||
return;
|
||||
}
|
||||
const filtered = fuzzyFilterLower(this.allItems, queryLower);
|
||||
this.selectList = new SelectList(filtered, this.maxVisible, this.theme);
|
||||
}
|
||||
|
||||
invalidate(): void {
|
||||
this.input.invalidate();
|
||||
this.selectList.invalidate();
|
||||
}
|
||||
|
||||
render(width: number): string[] {
|
||||
const lines: string[] = [];
|
||||
|
||||
// Filter input row
|
||||
const filterLabel = this.theme.filterLabel("Filter: ");
|
||||
const inputLines = this.input.render(width - 8);
|
||||
const inputText = inputLines[0] ?? "";
|
||||
lines.push(filterLabel + inputText);
|
||||
|
||||
// Separator
|
||||
lines.push(chalk.dim("─".repeat(width)));
|
||||
|
||||
// Select list
|
||||
const listLines = this.selectList.render(width);
|
||||
lines.push(...listLines);
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
handleInput(keyData: string): void {
|
||||
const allowVimNav = !this.filterText.trim();
|
||||
|
||||
// Navigation: arrows, vim j/k, or ctrl+p/ctrl+n
|
||||
if (
|
||||
matchesKey(keyData, "up") ||
|
||||
matchesKey(keyData, "ctrl+p") ||
|
||||
(allowVimNav && keyData === "k")
|
||||
) {
|
||||
this.selectList.handleInput("\x1b[A");
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
matchesKey(keyData, "down") ||
|
||||
matchesKey(keyData, "ctrl+n") ||
|
||||
(allowVimNav && keyData === "j")
|
||||
) {
|
||||
this.selectList.handleInput("\x1b[B");
|
||||
return;
|
||||
}
|
||||
|
||||
// Enter selects
|
||||
if (matchesKey(keyData, "enter")) {
|
||||
const selected = this.selectList.getSelectedItem();
|
||||
if (selected) {
|
||||
this.onSelect?.(selected);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Escape: clear filter or cancel
|
||||
const kb = getEditorKeybindings();
|
||||
if (kb.matches(keyData, "selectCancel")) {
|
||||
if (this.filterText) {
|
||||
this.filterText = "";
|
||||
this.input.setValue("");
|
||||
this.applyFilter();
|
||||
} else {
|
||||
this.onCancel?.();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// All other input goes to filter
|
||||
const prevValue = this.input.getValue();
|
||||
this.input.handleInput(keyData);
|
||||
const newValue = this.input.getValue();
|
||||
|
||||
if (newValue !== prevValue) {
|
||||
this.filterText = newValue;
|
||||
this.applyFilter();
|
||||
}
|
||||
}
|
||||
|
||||
getSelectedItem(): SelectItem | null {
|
||||
return this.selectList.getSelectedItem();
|
||||
}
|
||||
|
||||
getFilterText(): string {
|
||||
return this.filterText;
|
||||
}
|
||||
}
|
||||
114
src/tui/components/fuzzy-filter.ts
Normal file
114
src/tui/components/fuzzy-filter.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Shared fuzzy filtering utilities for select list components.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Word boundary characters for matching.
|
||||
*/
|
||||
const WORD_BOUNDARY_CHARS = /[\s\-_./:#@]/;
|
||||
|
||||
/**
|
||||
* Check if position is at a word boundary.
|
||||
*/
|
||||
export function isWordBoundary(text: string, index: number): boolean {
|
||||
return index === 0 || WORD_BOUNDARY_CHARS.test(text[index - 1] ?? "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Find index where query matches at a word boundary in text.
|
||||
* Returns null if no match.
|
||||
*/
|
||||
export function findWordBoundaryIndex(text: string, query: string): number | null {
|
||||
if (!query) return null;
|
||||
const textLower = text.toLowerCase();
|
||||
const queryLower = query.toLowerCase();
|
||||
const maxIndex = textLower.length - queryLower.length;
|
||||
if (maxIndex < 0) return null;
|
||||
for (let i = 0; i <= maxIndex; i++) {
|
||||
if (textLower.startsWith(queryLower, i) && isWordBoundary(textLower, i)) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fuzzy match with pre-lowercased inputs (avoids toLowerCase on every keystroke).
|
||||
* Returns score (lower = better) or null if no match.
|
||||
*/
|
||||
export function fuzzyMatchLower(queryLower: string, textLower: string): number | null {
|
||||
if (queryLower.length === 0) return 0;
|
||||
if (queryLower.length > textLower.length) return null;
|
||||
|
||||
let queryIndex = 0;
|
||||
let score = 0;
|
||||
let lastMatchIndex = -1;
|
||||
let consecutiveMatches = 0;
|
||||
|
||||
for (let i = 0; i < textLower.length && queryIndex < queryLower.length; i++) {
|
||||
if (textLower[i] === queryLower[queryIndex]) {
|
||||
const isAtWordBoundary = isWordBoundary(textLower, i);
|
||||
if (lastMatchIndex === i - 1) {
|
||||
consecutiveMatches++;
|
||||
score -= consecutiveMatches * 5; // Reward consecutive matches
|
||||
} else {
|
||||
consecutiveMatches = 0;
|
||||
if (lastMatchIndex >= 0) score += (i - lastMatchIndex - 1) * 2; // Penalize gaps
|
||||
}
|
||||
if (isAtWordBoundary) score -= 10; // Reward word boundary matches
|
||||
score += i * 0.1; // Slight penalty for later matches
|
||||
lastMatchIndex = i;
|
||||
queryIndex++;
|
||||
}
|
||||
}
|
||||
return queryIndex < queryLower.length ? null : score;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter items using pre-lowercased searchTextLower field.
|
||||
* Supports space-separated tokens (all must match).
|
||||
*/
|
||||
export function fuzzyFilterLower<T extends { searchTextLower?: string }>(
|
||||
items: T[],
|
||||
queryLower: string,
|
||||
): T[] {
|
||||
const trimmed = queryLower.trim();
|
||||
if (!trimmed) return items;
|
||||
|
||||
const tokens = trimmed.split(/\s+/).filter((t) => t.length > 0);
|
||||
if (tokens.length === 0) return items;
|
||||
|
||||
const results: { item: T; score: number }[] = [];
|
||||
for (const item of items) {
|
||||
const text = item.searchTextLower ?? "";
|
||||
let totalScore = 0;
|
||||
let allMatch = true;
|
||||
for (const token of tokens) {
|
||||
const score = fuzzyMatchLower(token, text);
|
||||
if (score !== null) {
|
||||
totalScore += score;
|
||||
} else {
|
||||
allMatch = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (allMatch) results.push({ item, score: totalScore });
|
||||
}
|
||||
results.sort((a, b) => a.score - b.score);
|
||||
return results.map((r) => r.item);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare items for fuzzy filtering by pre-computing lowercase search text.
|
||||
*/
|
||||
export function prepareSearchItems<T extends { label?: string; description?: string; searchText?: string }>(
|
||||
items: T[],
|
||||
): (T & { searchTextLower: string })[] {
|
||||
return items.map((item) => {
|
||||
const parts: string[] = [];
|
||||
if (item.label) parts.push(item.label);
|
||||
if (item.description) parts.push(item.description);
|
||||
if (item.searchText) parts.push(item.searchText);
|
||||
return { ...item, searchTextLower: parts.join(" ").toLowerCase() };
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
type Component,
|
||||
fuzzyFilter,
|
||||
getEditorKeybindings,
|
||||
Input,
|
||||
isKeyRelease,
|
||||
matchesKey,
|
||||
@@ -9,6 +10,7 @@ import {
|
||||
truncateToWidth,
|
||||
} from "@mariozechner/pi-tui";
|
||||
import { visibleWidth } from "../../terminal/ansi.js";
|
||||
import { findWordBoundaryIndex } from "./fuzzy-filter.js";
|
||||
|
||||
export interface SearchableSelectListTheme extends SelectListTheme {
|
||||
searchPrompt: (text: string) => string;
|
||||
@@ -80,7 +82,7 @@ export class SearchableSelectList implements Component {
|
||||
continue;
|
||||
}
|
||||
// Tier 2: Word-boundary prefix in label (score 100-199)
|
||||
const wordBoundaryIndex = this.findWordBoundaryIndex(label, q);
|
||||
const wordBoundaryIndex = findWordBoundaryIndex(label, q);
|
||||
if (wordBoundaryIndex !== null) {
|
||||
wordBoundary.push({ item, score: wordBoundaryIndex });
|
||||
continue;
|
||||
@@ -111,28 +113,6 @@ export class SearchableSelectList implements Component {
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if query matches at a word boundary in text.
|
||||
* E.g., "gpt" matches "openai/gpt-4" at the "gpt" word boundary.
|
||||
*/
|
||||
private matchesWordBoundary(text: string, query: string): boolean {
|
||||
return this.findWordBoundaryIndex(text, query) !== null;
|
||||
}
|
||||
|
||||
private findWordBoundaryIndex(text: string, query: string): number | null {
|
||||
if (!query) return null;
|
||||
const maxIndex = text.length - query.length;
|
||||
if (maxIndex < 0) return null;
|
||||
for (let i = 0; i <= maxIndex; i++) {
|
||||
if (text.startsWith(query, i)) {
|
||||
if (i === 0 || /[\s\-_./:]/.test(text[i - 1] ?? "")) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private escapeRegex(str: string): string {
|
||||
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
@@ -258,14 +238,24 @@ export class SearchableSelectList implements Component {
|
||||
handleInput(keyData: string): void {
|
||||
if (isKeyRelease(keyData)) return;
|
||||
|
||||
const allowVimNav = !this.searchInput.getValue().trim();
|
||||
|
||||
// Navigation keys
|
||||
if (matchesKey(keyData, "up") || matchesKey(keyData, "ctrl+p")) {
|
||||
if (
|
||||
matchesKey(keyData, "up") ||
|
||||
matchesKey(keyData, "ctrl+p") ||
|
||||
(allowVimNav && keyData === "k")
|
||||
) {
|
||||
this.selectedIndex = Math.max(0, this.selectedIndex - 1);
|
||||
this.notifySelectionChange();
|
||||
return;
|
||||
}
|
||||
|
||||
if (matchesKey(keyData, "down") || matchesKey(keyData, "ctrl+n")) {
|
||||
if (
|
||||
matchesKey(keyData, "down") ||
|
||||
matchesKey(keyData, "ctrl+n") ||
|
||||
(allowVimNav && keyData === "j")
|
||||
) {
|
||||
this.selectedIndex = Math.min(this.filteredItems.length - 1, this.selectedIndex + 1);
|
||||
this.notifySelectionChange();
|
||||
return;
|
||||
@@ -279,7 +269,8 @@ export class SearchableSelectList implements Component {
|
||||
return;
|
||||
}
|
||||
|
||||
if (matchesKey(keyData, "escape")) {
|
||||
const kb = getEditorKeybindings();
|
||||
if (kb.matches(keyData, "selectCancel")) {
|
||||
if (this.onCancel) {
|
||||
this.onCancel();
|
||||
}
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import { type SelectItem, SelectList, type SettingItem, SettingsList } from "@mariozechner/pi-tui";
|
||||
import { searchableSelectListTheme, selectListTheme, settingsListTheme } from "../theme/theme.js";
|
||||
import {
|
||||
filterableSelectListTheme,
|
||||
searchableSelectListTheme,
|
||||
selectListTheme,
|
||||
settingsListTheme,
|
||||
} from "../theme/theme.js";
|
||||
import {
|
||||
FilterableSelectList,
|
||||
type FilterableSelectItem,
|
||||
} from "./filterable-select-list.js";
|
||||
import { SearchableSelectList } from "./searchable-select-list.js";
|
||||
|
||||
export function createSelectList(items: SelectItem[], maxVisible = 7) {
|
||||
@@ -10,6 +19,10 @@ export function createSearchableSelectList(items: SelectItem[], maxVisible = 7)
|
||||
return new SearchableSelectList(items, maxVisible, searchableSelectListTheme);
|
||||
}
|
||||
|
||||
export function createFilterableSelectList(items: FilterableSelectItem[], maxVisible = 7) {
|
||||
return new FilterableSelectList(items, maxVisible, filterableSelectListTheme);
|
||||
}
|
||||
|
||||
export function createSettingsList(
|
||||
items: SettingItem[],
|
||||
onChange: (id: string, value: string) => void,
|
||||
|
||||
@@ -64,6 +64,8 @@ export type GatewaySessionList = {
|
||||
lastProvider?: string;
|
||||
lastTo?: string;
|
||||
lastAccountId?: string;
|
||||
derivedTitle?: string;
|
||||
lastMessagePreview?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
@@ -183,6 +185,8 @@ export class GatewayChatClient {
|
||||
activeMinutes: opts?.activeMinutes,
|
||||
includeGlobal: opts?.includeGlobal,
|
||||
includeUnknown: opts?.includeUnknown,
|
||||
includeDerivedTitles: opts?.includeDerivedTitles,
|
||||
includeLastMessage: opts?.includeLastMessage,
|
||||
agentId: opts?.agentId,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -106,6 +106,11 @@ export const selectListTheme: SelectListTheme = {
|
||||
noMatch: (text) => fg(palette.dim)(text),
|
||||
};
|
||||
|
||||
export const filterableSelectListTheme = {
|
||||
...selectListTheme,
|
||||
filterLabel: (text: string) => fg(palette.dim)(text),
|
||||
};
|
||||
|
||||
export const settingsListTheme: SettingsListTheme = {
|
||||
label: (text, selected) =>
|
||||
selected ? chalk.bold(fg(palette.accent)(text)) : fg(palette.text)(text),
|
||||
|
||||
@@ -5,9 +5,14 @@ import {
|
||||
resolveResponseUsageMode,
|
||||
} from "../auto-reply/thinking.js";
|
||||
import { normalizeAgentId } from "../routing/session-key.js";
|
||||
import { formatRelativeTime } from "../utils/time-format.js";
|
||||
import { helpText, parseCommand } from "./commands.js";
|
||||
import type { ChatLog } from "./components/chat-log.js";
|
||||
import { createSearchableSelectList, createSettingsList } from "./components/selectors.js";
|
||||
import {
|
||||
createFilterableSelectList,
|
||||
createSearchableSelectList,
|
||||
createSettingsList,
|
||||
} from "./components/selectors.js";
|
||||
import type { GatewayChatClient } from "./gateway-chat.js";
|
||||
import { formatStatusSummary } from "./tui-status-summary.js";
|
||||
import type {
|
||||
@@ -134,16 +139,37 @@ export function createCommandHandlers(context: CommandHandlerContext) {
|
||||
const result = await client.listSessions({
|
||||
includeGlobal: false,
|
||||
includeUnknown: false,
|
||||
includeDerivedTitles: true,
|
||||
includeLastMessage: true,
|
||||
agentId: state.currentAgentId,
|
||||
});
|
||||
const items = result.sessions.map((session) => ({
|
||||
value: session.key,
|
||||
label: session.displayName
|
||||
? `${session.displayName} (${formatSessionKey(session.key)})`
|
||||
: formatSessionKey(session.key),
|
||||
description: session.updatedAt ? new Date(session.updatedAt).toLocaleString() : "",
|
||||
}));
|
||||
const selector = createSearchableSelectList(items, 9);
|
||||
const items = result.sessions.map((session) => {
|
||||
const title = session.derivedTitle ?? session.displayName;
|
||||
const formattedKey = formatSessionKey(session.key);
|
||||
// Avoid redundant "title (key)" when title matches key
|
||||
const label = title && title !== formattedKey ? `${title} (${formattedKey})` : formattedKey;
|
||||
// Build description: time + message preview
|
||||
const timePart = session.updatedAt ? formatRelativeTime(session.updatedAt) : "";
|
||||
const preview = session.lastMessagePreview?.replace(/\s+/g, " ").trim();
|
||||
const description =
|
||||
timePart && preview ? `${timePart} · ${preview}` : (preview ?? timePart);
|
||||
return {
|
||||
value: session.key,
|
||||
label,
|
||||
description,
|
||||
searchText: [
|
||||
session.displayName,
|
||||
session.label,
|
||||
session.subject,
|
||||
session.sessionId,
|
||||
session.key,
|
||||
session.lastMessagePreview,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" "),
|
||||
};
|
||||
});
|
||||
const selector = createFilterableSelectList(items, 9);
|
||||
selector.onSelect = (item) => {
|
||||
void (async () => {
|
||||
closeOverlay();
|
||||
|
||||
15
src/utils/time-format.ts
Normal file
15
src/utils/time-format.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export function formatRelativeTime(timestamp: number): string {
|
||||
const now = Date.now();
|
||||
const diff = now - timestamp;
|
||||
const seconds = Math.floor(diff / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const days = Math.floor(hours / 24);
|
||||
|
||||
if (seconds < 60) return "just now";
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
if (days === 1) return "Yesterday";
|
||||
if (days < 7) return `${days}d ago`;
|
||||
return new Date(timestamp).toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
||||
}
|
||||
Reference in New Issue
Block a user