Merge pull request #8191 from Kilo-Org/feat/local-recall-tool

feat(cli): add local recall tool for cross-worktrees session search
This commit is contained in:
Marian Alexandru Alecu
2026-04-08 20:28:20 +03:00
committed by GitHub
17 changed files with 775 additions and 43 deletions
+1
View File
@@ -187,6 +187,7 @@ export namespace Agent {
"*": "allow",
bash, // kilocode_change
doom_loop: "ask",
recall: "ask", // kilocode_change
external_directory: {
"*": "ask",
...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])),
+89 -15
View File
@@ -73,32 +73,63 @@ export const SessionListCommand = cmd({
command: "list",
describe: "list sessions",
builder: (yargs: Argv) => {
return yargs
.option("max-count", {
alias: "n",
describe: "limit to N most recent sessions",
type: "number",
})
.option("format", {
describe: "output format",
type: "string",
choices: ["table", "json"],
default: "table",
})
// kilocode_change start
return (
yargs
.option("max-count", {
alias: "n",
describe: "limit to N most recent sessions",
type: "number",
})
.option("format", {
describe: "output format",
type: "string",
choices: ["table", "json"],
default: "table",
})
// kilocode_change end
// kilocode_change start
.option("all", {
alias: "a",
describe: "list sessions from all projects",
type: "boolean",
default: false,
})
.option("search", {
alias: "s",
describe: "filter sessions by title",
type: "string",
})
)
// kilocode_change end
},
// kilocode_change start
handler: async (args) => {
await bootstrap(process.cwd(), async () => {
const sessions = [...Session.list({ roots: true, limit: args.maxCount })]
// kilocode_change end
// kilocode_change start
const sessions = args.all
? [...Session.listGlobal({ roots: true, limit: args.maxCount, search: args.search })]
: [...Session.list({ roots: true, limit: args.maxCount, search: args.search })]
// kilocode_change end
// kilocode_change start
if (sessions.length === 0) {
return
}
// kilocode_change end
// kilocode_change start
let output: string
if (args.format === "json") {
output = formatSessionJSON(sessions)
output = args.all
? formatGlobalSessionJSON(sessions as Session.GlobalInfo[])
: formatSessionJSON(sessions as Session.Info[])
} else {
output = formatSessionTable(sessions)
output = args.all
? formatGlobalSessionTable(sessions as Session.GlobalInfo[])
: formatSessionTable(sessions as Session.Info[])
// kilocode_change end
}
const shouldPaginate = process.stdout.isTTY && !args.maxCount && args.format === "table"
@@ -144,6 +175,7 @@ function formatSessionTable(sessions: Session.Info[]): string {
return lines.join(EOL)
}
// kilocode_change start
function formatSessionJSON(sessions: Session.Info[]): string {
const jsonData = sessions.map((session) => ({
id: session.id,
@@ -155,3 +187,45 @@ function formatSessionJSON(sessions: Session.Info[]): string {
}))
return JSON.stringify(jsonData, null, 2)
}
// kilocode_change end
// kilocode_change start
function formatGlobalSessionTable(sessions: Session.GlobalInfo[]): string {
const lines: string[] = []
const maxIdWidth = Math.max(20, ...sessions.map((s) => s.id.length))
const maxTitleWidth = Math.max(25, ...sessions.map((s) => s.title.length))
const maxProjectWidth = Math.max(
10,
...sessions.map((s) => (s.project?.name ?? s.project?.worktree ?? "unknown").length),
)
const header = `Session ID${" ".repeat(maxIdWidth - 10)} Title${" ".repeat(maxTitleWidth - 5)} Project${" ".repeat(maxProjectWidth - 7)} Updated`
lines.push(header)
lines.push("─".repeat(header.length))
for (const session of sessions) {
const truncatedTitle = Locale.truncate(session.title, maxTitleWidth)
const project = Locale.truncate(session.project?.name ?? session.project?.worktree ?? "unknown", maxProjectWidth)
const timeStr = Locale.todayTimeOrDateTime(session.time.updated)
const line = `${session.id.padEnd(maxIdWidth)} ${truncatedTitle.padEnd(maxTitleWidth)} ${project.padEnd(maxProjectWidth)} ${timeStr}`
lines.push(line)
}
return lines.join(EOL)
}
function formatGlobalSessionJSON(sessions: Session.GlobalInfo[]): string {
const jsonData = sessions.map((session) => ({
id: session.id,
title: session.title,
updated: session.time.updated,
created: session.time.created,
projectId: session.projectID,
directory: session.directory,
project: session.project
? { id: session.project.id, name: session.project.name, worktree: session.project.worktree }
: null,
}))
return JSON.stringify(jsonData, null, 2)
}
// kilocode_change end
@@ -2,15 +2,15 @@ import { useDialog } from "@tui/ui/dialog"
import { DialogSelect } from "@tui/ui/dialog-select"
import { useRoute } from "@tui/context/route"
import { useSync } from "@tui/context/sync"
import { createMemo, createSignal, createResource, onMount, Show } from "solid-js"
import { createMemo, createSignal, createResource, onMount } from "solid-js" // kilocode_change
import { Locale } from "@/util/locale"
import { useKeybind } from "../context/keybind"
import { useTheme } from "../context/theme"
import { useSDK } from "../context/sdk"
import { DialogSessionRename } from "./dialog-session-rename"
import { useKV } from "../context/kv"
import { createDebouncedSignal } from "../util/signal"
import { Spinner } from "./spinner"
import path from "path" // kilocode_change
export function DialogSessionList() {
const dialog = useDialog()
@@ -19,23 +19,44 @@ export function DialogSessionList() {
const keybind = useKeybind()
const { theme } = useTheme()
const sdk = useSDK()
const kv = useKV()
const [toDelete, setToDelete] = createSignal<string>()
const [search, setSearch] = createDebouncedSignal("", 150)
const [global, setGlobal] = createSignal(true) // kilocode_change - show all worktrees by default
const [searchResults] = createResource(search, async (query) => {
if (!query) return undefined
const result = await sdk.client.session.list({ search: query, limit: 30 })
return result.data ?? []
})
// kilocode_change start - always fetch from experimental endpoint (returns GlobalSession with worktree info)
const [searchResults, searchActions] = createResource(
() => search(),
async (query) => {
const result = await sdk.client.experimental.session.list(
{
search: query || undefined,
roots: true,
worktrees: true,
limit: 30,
},
{ throwOnError: true },
)
return result.data ?? []
},
)
// kilocode_change end
const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined))
const sessions = createMemo(() => searchResults() ?? sync.data.session)
// kilocode_change start - client-side worktree filtering when global is off
const sessions = createMemo(() => {
const all = searchResults() ?? []
if (global()) return all
const root = sync.data.path.worktree
if (!root || root === "/") return all
return all.filter((s) => s.directory === root || s.directory.startsWith(root + path.sep))
})
// kilocode_change end
const options = createMemo(() => {
const today = new Date().toDateString()
const all = global() // kilocode_change
return sessions()
.filter((x) => x.parentID === undefined)
.toSorted((a, b) => b.time.updated - a.time.updated)
@@ -50,6 +71,7 @@ export function DialogSessionList() {
const isWorking = status?.type === "busy"
return {
title: isDeleting ? `Press ${keybind.print("session_delete")} again to confirm` : x.title,
description: all && x.worktreeName ? `(${x.worktreeName})` : undefined, // kilocode_change - worktree label
bg: isDeleting ? theme.error : undefined,
value: x.id,
category,
@@ -65,7 +87,7 @@ export function DialogSessionList() {
return (
<DialogSelect
title="Sessions"
title={global() ? "Sessions (all worktrees)" : "Sessions (current worktree)"} // kilocode_change
options={options()}
skipFilter={true}
current={currentSessionID()}
@@ -86,10 +108,13 @@ export function DialogSessionList() {
title: "delete",
onTrigger: async (option) => {
if (toDelete() === option.value) {
sdk.client.session.delete({
// kilocode_change start
await sdk.client.session.delete({
sessionID: option.value,
})
// kilocode_change end
setToDelete(undefined)
void searchActions.refetch() // kilocode_change
return
}
setToDelete(option.value)
@@ -97,11 +122,30 @@ export function DialogSessionList() {
},
{
keybind: keybind.all.session_rename?.[0],
title: "rename",
title: "rename", // kilocode_change
// kilocode_change start
onTrigger: async (option) => {
dialog.replace(() => <DialogSessionRename session={option.value} />)
const item = sessions().find((x) => x.id === option.value)
dialog.replace(() => (
<DialogSessionRename
session={option.value}
title={item?.title}
onConfirm={() => {
void searchActions.refetch()
}}
/>
))
},
},
{
keybind: { name: "a", ctrl: true, meta: false, shift: false, leader: false },
title: global() ? "current" : "all",
onTrigger: async () => {
setToDelete(undefined)
setGlobal((v) => !v)
},
},
// kilocode_change end
]}
/>
)
@@ -6,6 +6,8 @@ import { useSDK } from "../context/sdk"
interface DialogSessionRenameProps {
session: string
title?: string // kilocode_change
onConfirm?: () => void // kilocode_change
}
export function DialogSessionRename(props: DialogSessionRenameProps) {
@@ -17,12 +19,16 @@ export function DialogSessionRename(props: DialogSessionRenameProps) {
return (
<DialogPrompt
title="Rename Session"
value={session()?.title}
value={session()?.title ?? props.title} // kilocode_change
onConfirm={(value) => {
sdk.client.session.update({
sessionID: props.session,
title: value,
})
// kilocode_change start
sdk.client.session
.update({
sessionID: props.session,
title: value,
})
.then(() => props.onConfirm?.())
// kilocode_change end
dialog.clear()
}}
onCancel={() => dialog.clear()}
@@ -0,0 +1,35 @@
// kilocode_change - new file
import { Instance } from "../project/instance"
import { Project } from "../project/project"
import { Filesystem } from "../util/filesystem"
import { git } from "../util/git"
export namespace WorktreeFamily {
export async function list() {
if (Instance.project.vcs !== "git") {
return [Filesystem.resolve(Instance.directory)]
}
const listed = await git(["worktree", "list", "--porcelain"], {
cwd: Instance.worktree,
})
if (listed.exitCode === 0) {
const dirs = listed
.text()
.split("\n")
.map((line) => line.trim())
.flatMap((line) => {
if (!line.startsWith("worktree ")) return []
return [Filesystem.resolve(line.slice("worktree ".length).trim())]
})
if (dirs.length > 0) {
return [...new Set(dirs)]
}
}
const dirs = [Instance.worktree, ...(await Project.sandboxes(Instance.project.id))]
return [...new Set(dirs.map((dir) => Filesystem.resolve(dir)))]
}
}
@@ -13,8 +13,11 @@ import { lazy } from "../../util/lazy"
import { Snapshot } from "../../snapshot" // kilocode_change
import { Review } from "../../kilocode/review/review" // kilocode_change
import { WorktreeDiff } from "../../kilocode/review/worktree-diff" // kilocode_change
import { WorktreeFamily } from "../../kilocode/worktree-family" // kilocode_change
import { Log } from "../../util/log" // kilocode_change
import { WorkspaceRoutes } from "./workspace"
import { Filesystem } from "../../util/filesystem" // kilocode_change
import path from "path" // kilocode_change
export const ExperimentalRoutes = lazy(() =>
new Hono()
@@ -326,7 +329,14 @@ export const ExperimentalRoutes = lazy(() =>
validator(
"query",
z.object({
// kilocode_change start
projectID: z.string().optional().meta({ description: "Filter sessions by project ID" }),
directory: z.string().optional().meta({ description: "Filter sessions by project directory" }),
worktrees: z.coerce
.boolean()
.optional()
.meta({ description: "Restrict sessions to the current repo worktree family or current directory" }),
// kilocode_change end
roots: z.coerce.boolean().optional().meta({ description: "Only return root sessions (no parentID)" }),
start: z.coerce
.number()
@@ -343,10 +353,19 @@ export const ExperimentalRoutes = lazy(() =>
),
async (c) => {
const query = c.req.valid("query")
const limit = query.limit ?? 100
const limit = query.limit ?? 100 // kilocode_change
// kilocode_change start
const projectID = query.worktrees && !query.projectID ? Instance.project.id : query.projectID
// kilocode_change end
const directories = query.worktrees ? await WorktreeFamily.list() : undefined // kilocode_change
// kilocode_change start - sort longest-first so most specific worktree matches first
const sorted = directories ? [...directories].sort((a, b) => b.length - a.length) : undefined
// kilocode_change end
const sessions: Session.GlobalInfo[] = []
for await (const session of Session.listGlobal({
projectID, // kilocode_change
directory: query.directory,
directories, // kilocode_change
roots: query.roots,
start: query.start,
cursor: query.cursor,
@@ -354,6 +373,13 @@ export const ExperimentalRoutes = lazy(() =>
limit: limit + 1,
archived: query.archived,
})) {
// kilocode_change start - resolve worktree folder name for each session
if (sorted) {
const root = sorted.find((d) => Filesystem.contains(d, session.directory))
sessions.push({ ...session, worktreeName: path.basename(root ?? session.directory) })
continue
}
// kilocode_change end
sessions.push(session)
}
const hasMore = sessions.length > limit
+53 -5
View File
@@ -117,6 +117,25 @@ export namespace Session {
return `${title} (fork #1)`
}
// kilocode_change start
function family(id: string) {
const row = Database.use((db) =>
db.select({ worktree: ProjectTable.worktree }).from(ProjectTable).where(eq(ProjectTable.id, id)).get(),
)
const root = row?.worktree ? Filesystem.resolve(row.worktree) : undefined
if (!root || root === "/") return [id]
const ids = Database.use((db) =>
db
.select({ id: ProjectTable.id })
.from(ProjectTable)
.where(eq(ProjectTable.worktree, root))
.all()
.map((item) => item.id),
)
return ids.length ? ids : [id]
}
// kilocode_change end
export const Info = z
.object({
id: Identifier.schema("session"),
@@ -185,6 +204,7 @@ export namespace Session {
export const GlobalInfo = Info.extend({
project: ProjectInfo.nullable(),
worktreeName: z.string().optional(), // kilocode_change - basename of the specific worktree directory
}).meta({
ref: "GlobalSession",
})
@@ -621,8 +641,11 @@ export namespace Session {
}
}
// kilocode_change start
export function* listGlobal(input?: {
projectID?: string
directory?: string
directories?: string[]
roots?: boolean
start?: number
cursor?: number
@@ -630,7 +653,18 @@ export namespace Session {
limit?: number
archived?: boolean
}) {
const conditions: SQL[] = []
const conditions: SQL[] = [] // kilocode_change
// kilocode_change start
if (input?.projectID) {
const ids = family(input.projectID)
if (ids.length === 1 && ids[0] === input.projectID) {
conditions.push(eq(SessionTable.project_id, input.projectID))
} else {
conditions.push(inArray(SessionTable.project_id, ids))
}
}
// kilocode_change end
if (input?.directory) {
// kilocode_change start: vscode uri.fsPath gives lowercase drive letter on Windows; resolve() canonicalises to match stored path
@@ -653,7 +687,9 @@ export namespace Session {
conditions.push(isNull(SessionTable.time_archived))
}
const limit = input?.limit ?? 100
const limit = input?.limit ?? 100 // kilocode_change
// kilocode_change start
const dirs = [...new Set((input?.directories ?? []).map((dir) => Filesystem.resolve(dir)))]
const rows = Database.use((db) => {
const query =
@@ -663,10 +699,19 @@ export namespace Session {
.from(SessionTable)
.where(and(...conditions))
: db.select().from(SessionTable)
return query.orderBy(desc(SessionTable.time_updated), desc(SessionTable.id)).limit(limit).all()
const sorted = query.orderBy(desc(SessionTable.time_updated), desc(SessionTable.id))
return dirs.length ? sorted.all() : sorted.limit(limit).all()
})
const ids = [...new Set(rows.map((row) => row.project_id))]
const list =
dirs.length > 0
? rows.filter((row) => {
const dir = Filesystem.resolve(row.directory)
return dirs.some((root) => Filesystem.contains(root, dir))
})
: rows
const ids = [...new Set(list.slice(0, limit).map((row) => row.project_id))]
const projects = new Map<string, ProjectInfo>()
if (ids.length > 0) {
@@ -685,11 +730,14 @@ export namespace Session {
})
}
}
// kilocode_change end
for (const row of rows) {
// kilocode_change start
for (const row of list.slice(0, limit)) {
const project = projects.get(row.project_id) ?? null
yield { ...fromRow(row), project }
}
// kilocode_change end
}
export const children = fn(Identifier.schema("session"), async (parentID) => {
+150
View File
@@ -0,0 +1,150 @@
// kilocode_change - new file
import z from "zod"
import { Tool } from "./tool"
import { Instance } from "../project/instance"
import { Locale } from "../util/locale"
import { Filesystem } from "../util/filesystem" // kilocode_change
import { WorktreeFamily } from "../kilocode/worktree-family" // kilocode_change
import DESCRIPTION from "./recall.txt"
export const RecallTool = Tool.define("kilo_local_recall", {
description: DESCRIPTION,
parameters: z.object({
mode: z.enum(["search", "read"]).describe("'search' to find sessions by title, 'read' to get a session transcript"),
query: z.string().optional().describe("Search query to match against session titles (required for search mode)"),
sessionID: z.string().optional().describe("Session ID to read the transcript of (required for read mode)"),
limit: z.number().optional().describe("Maximum number of search results to return (default: 20, max: 50)"),
}),
async execute(params, ctx) {
if (params.mode === "search") {
return search(params, ctx)
}
return read(params, ctx)
},
})
async function search(params: { query?: string; limit?: number }, ctx: Tool.Context) {
if (!params.query) {
throw new Error("The 'query' parameter is required when mode is 'search'")
}
await ctx.ask({
permission: "recall",
patterns: ["search"],
always: ["search"],
metadata: {
mode: "search",
query: params.query,
},
})
const limit = Math.min(params.limit ?? 20, 50)
const dirs = await WorktreeFamily.list() // kilocode_change
const { Session } = await import("../session/index") // kilocode_change
const results: Array<{
id: string
title: string
directory: string
updated: string
}> = []
for (const session of Session.listGlobal({
projectID: Instance.project.id, // kilocode_change
directories: dirs, // kilocode_change
search: params.query,
roots: true,
limit,
})) {
results.push({
id: session.id,
title: session.title,
directory: session.directory,
updated: Locale.todayTimeOrDateTime(session.time.updated),
})
}
if (results.length === 0) {
return {
title: `Search: "${params.query}" (no results)`,
output: `No sessions found matching "${params.query}".`,
metadata: {},
}
}
const lines = results.map((r) => `- **${r.title}**\n ID: ${r.id} | Updated: ${r.updated} | Dir: ${r.directory}`)
return {
title: `Search: "${params.query}" (${results.length} results)`,
output: lines.join("\n"),
metadata: {},
}
}
async function read(params: { sessionID?: string }, ctx: Tool.Context) {
if (!params.sessionID) {
throw new Error("The 'sessionID' parameter is required when mode is 'read'")
}
const { Session } = await import("../session/index") // kilocode_change
const session = await Session.get(params.sessionID).catch(() => {
throw new Error(`Session "${params.sessionID}" not found. Use search mode first to find valid session IDs.`)
})
const dirs = await WorktreeFamily.list() // kilocode_change
// kilocode_change start
const dir = Filesystem.resolve(session.directory)
if (!dirs.some((root) => Filesystem.contains(root, dir))) {
throw new Error(
`Session "${params.sessionID}" belongs to a different workspace and cannot be read from this directory.`,
)
}
// kilocode_change end
const cross = session.projectID !== Instance.project.id
if (cross) {
await ctx.ask({
permission: "recall",
patterns: [session.directory],
always: [session.directory],
metadata: {
sessionID: session.id,
title: session.title,
directory: session.directory,
},
})
}
const msgs = await Session.messages({ sessionID: session.id })
const lines: string[] = [
`# Session: ${session.title}`,
`Directory: ${session.directory}`,
`Created: ${Locale.todayTimeOrDateTime(session.time.created)}`,
"",
]
for (const msg of msgs) {
if (msg.info.role === "user") {
lines.push("## User")
for (const part of msg.parts) {
if (part.type === "text") lines.push(part.text)
}
lines.push("")
}
if (msg.info.role === "assistant") {
lines.push("## Assistant")
for (const part of msg.parts) {
if (part.type === "text") lines.push(part.text)
if (part.type === "tool" && part.state.status === "completed") {
lines.push(`[Tool: ${part.tool}] ${part.state.title}`)
}
}
lines.push("")
}
}
return {
title: `Read: ${session.title}`,
output: lines.join("\n"),
metadata: {},
}
}
+12
View File
@@ -0,0 +1,12 @@
Search and read past conversations from the current project on this machine, including its git worktrees. Use this to recall previous work, find how something was implemented before, or retrieve context from another worktree in the same repo.
Two modes:
1. **Search** - Find sessions by title keyword in the current project and its worktrees. Returns a list of matching sessions with their title, directory, and last updated time. Use this first to locate relevant conversations.
2. **Read** - Retrieve the full transcript of a specific session by ID. Returns the conversation messages (user prompts and assistant responses) so you can understand what was discussed and done.
Usage notes:
- Search matches against session titles using case-insensitive substring matching
- Results are limited to the current project/worktree family
- Reading a session from a different project is rejected
- Use search mode first to find session IDs, then read mode to get the full conversation
- Session transcripts can be large; prefer searching first to narrow down which session to read
+2
View File
@@ -29,6 +29,7 @@ import { LspTool } from "./lsp"
import { Truncate } from "./truncation"
import { ApplyPatchTool } from "./apply_patch"
import { RecallTool } from "./recall" // kilocode_change
import { Glob } from "../util/glob"
import { pathToFileURL } from "url"
@@ -118,6 +119,7 @@ export namespace ToolRegistry {
CodeSearchTool,
...(config.experimental?.codebase_search === true ? [CodebaseSearchTool] : []), // kilocode_change
SkillTool,
RecallTool, // kilocode_change
ApplyPatchTool,
...(Flag.KILO_EXPERIMENTAL_LSP_TOOL ? [LspTool] : []),
...(config.experimental?.batch_tool === true ? [BatchTool] : []),
@@ -0,0 +1,92 @@
// kilocode_change - new file
import { afterEach, describe, expect, mock, test } from "bun:test"
import { $ } from "bun"
import path from "path"
import { Config } from "../../src/config/config"
import { Instance } from "../../src/project/instance"
import { Log } from "../../src/util/log"
import { resetDatabase } from "../fixture/db"
import { tmpdir } from "../fixture/fixture"
mock.module("@/kilo-sessions/remote-sender", () => ({
RemoteSender: {
create() {
return {
handle() {},
dispose() {},
}
},
},
}))
Log.init({ print: false })
afterEach(async () => {
await resetDatabase()
})
describe("experimental.session.list", () => {
test("filters sessions by repo worktree family even when project IDs drift", async () => {
await using first = await tmpdir({ git: true })
await using second = await tmpdir({ git: true })
const worktree = path.join(first.path, "..", path.basename(first.path) + "-worktree")
try {
await $`git worktree add ${worktree} -b test-branch-${Date.now()}`.cwd(first.path).quiet()
await Bun.write(path.join(first.path, ".git", "opencode"), "stale-project-id")
const share = Config.get
Config.get = async () => ({ share: "manual" }) as Awaited<ReturnType<typeof Config.get>>
try {
const { Server } = await import("../../src/server/server")
const { Session } = await import("../../src/session/index")
const root = await Instance.provide({
directory: first.path,
fn: async () => ({
app: Server.App(),
project: await Server.App().request("/project/current", {
headers: { "x-kilo-directory": first.path },
}),
session: await Session.create({ title: "root-session" }),
}),
})
const branch = await Instance.provide({
directory: worktree,
fn: async () => Session.create({ title: "worktree-session" }),
})
await Instance.provide({
directory: second.path,
fn: async () => Session.create({ title: "other-project-session" }),
})
const app = root.app
const project = await root.project.json()
const response = await app.request(
`/experimental/session?projectID=${encodeURIComponent(project.id)}&roots=true&worktrees=true`,
{
headers: { "x-kilo-directory": first.path },
},
)
expect(response.status).toBe(200)
const body = await response.json()
const ids = body.map((item: { id: string }) => item.id)
const dirs = body.map((item: { directory: string }) => item.directory)
expect(root.session.projectID).not.toBe(branch.projectID)
expect(project.id).toBe(root.session.projectID)
expect(ids).toContain(root.session.id)
expect(ids).toContain(branch.id)
expect(dirs).toContain(worktree)
expect(body.some((item: { title: string }) => item.title === "other-project-session")).toBe(false)
} finally {
Config.get = share
}
} finally {
await $`git worktree remove ${worktree}`.cwd(first.path).quiet().nothrow()
}
})
})
@@ -1,14 +1,33 @@
import { describe, expect, test } from "bun:test"
// kilocode_change - new file
import { $ } from "bun"
import { afterEach, describe, expect, mock, test } from "bun:test"
import path from "path"
import { Instance } from "../../src/project/instance"
import { Project } from "../../src/project/project"
import { Session } from "../../src/session"
import { Log } from "../../src/util/log"
import { resetDatabase } from "../fixture/db"
import { tmpdir } from "../fixture/fixture"
mock.module("@/kilo-sessions/remote-sender", () => ({
RemoteSender: {
create() {
return {
handle() {},
dispose() {},
}
},
},
}))
Log.init({ print: false })
afterEach(async () => {
await resetDatabase()
})
describe("Session.listGlobal", () => {
test("lists sessions across projects with project metadata", async () => {
const { Session } = await import("../../src/session/index")
await using first = await tmpdir({ git: true })
await using second = await tmpdir({ git: true })
@@ -40,6 +59,7 @@ describe("Session.listGlobal", () => {
})
test("excludes archived sessions by default", async () => {
const { Session } = await import("../../src/session/index")
await using tmp = await tmpdir({ git: true })
const archived = await Instance.provide({
@@ -64,6 +84,7 @@ describe("Session.listGlobal", () => {
})
test("supports cursor pagination", async () => {
const { Session } = await import("../../src/session/index")
await using tmp = await tmpdir({ git: true })
const first = await Instance.provide({
@@ -78,12 +99,48 @@ describe("Session.listGlobal", () => {
const page = [...Session.listGlobal({ directory: tmp.path, limit: 1 })]
expect(page.length).toBe(1)
expect(page[0].id).toBe(second.id)
expect(page[0]!.id).toBe(second.id)
const next = [...Session.listGlobal({ directory: tmp.path, limit: 10, cursor: page[0].time.updated })]
const next = [...Session.listGlobal({ directory: tmp.path, limit: 10, cursor: page[0]!.time.updated })]
const ids = next.map((session) => session.id)
expect(ids).toContain(first.id)
expect(ids).not.toContain(second.id)
})
test("filters by project family across worktrees when project IDs drift", async () => {
const { Session } = await import("../../src/session/index")
await using first = await tmpdir({ git: true })
await using second = await tmpdir({ git: true })
const worktree = path.join(first.path, "..", path.basename(first.path) + "-worktree")
try {
await $`git worktree add ${worktree} -b test-branch-${Date.now()}`.cwd(first.path).quiet()
await Bun.write(path.join(first.path, ".git", "opencode"), "stale-project-id")
const root = await Instance.provide({
directory: first.path,
fn: async () => Session.create({ title: "root-session" }),
})
const branch = await Instance.provide({
directory: worktree,
fn: async () => Session.create({ title: "worktree-session" }),
})
const other = await Instance.provide({
directory: second.path,
fn: async () => Session.create({ title: "other-session" }),
})
const sessions = [...Session.listGlobal({ projectID: root.projectID, roots: true, limit: 200 })]
const ids = sessions.map((session) => session.id)
expect(root.projectID).not.toBe(branch.projectID)
expect(ids).toContain(root.id)
expect(ids).toContain(branch.id)
expect(ids).not.toContain(other.id)
expect(sessions.find((session) => session.id === branch.id)?.directory).toBe(worktree)
} finally {
await $`git worktree remove ${worktree}`.cwd(first.path).quiet().nothrow()
}
})
})
+148
View File
@@ -0,0 +1,148 @@
// kilocode_change - new file
import { afterEach, describe, expect, mock, test } from "bun:test"
import { $ } from "bun"
import path from "path"
import { Instance } from "../../src/project/instance"
import { Config } from "../../src/config/config"
import { RecallTool } from "../../src/tool/recall"
import { resetDatabase } from "../fixture/db"
import { tmpdir } from "../fixture/fixture"
import type { Tool } from "../../src/tool/tool"
mock.module("@/kilo-sessions/remote-sender", () => ({
RemoteSender: {
create() {
return {
handle() {},
dispose() {},
}
},
},
}))
const ctx: Tool.Context = {
sessionID: "ses_test",
messageID: "msg_test",
callID: "call_test",
agent: "code",
abort: AbortSignal.any([]),
messages: [],
metadata: () => {},
ask: async () => {},
}
afterEach(async () => {
await resetDatabase()
})
describe("tool.recall", () => {
test("search is limited to the current project worktrees", async () => {
await using first = await tmpdir({ git: true })
await using second = await tmpdir({ git: true })
const worktree = path.join(first.path, "..", path.basename(first.path) + "-worktree")
try {
await $`git worktree add ${worktree} -b test-branch-${Date.now()}`.cwd(first.path).quiet()
await Bun.write(path.join(first.path, ".git", "opencode"), "stale-project-id")
const share = Config.get
Config.get = async () => ({ share: "manual" }) as Awaited<ReturnType<typeof Config.get>>
try {
const { Session } = await import("../../src/session/index")
await Instance.provide({
directory: first.path,
fn: async () => Session.create({ title: "search-target root" }),
})
await Instance.provide({
directory: worktree,
fn: async () => Session.create({ title: "search-target worktree" }),
})
await Instance.provide({
directory: second.path,
fn: async () => Session.create({ title: "search-target other" }),
})
const result = await Instance.provide({
directory: first.path,
fn: async () => {
const tool = await RecallTool.init()
return tool.execute({ mode: "search", query: "search-target" }, ctx)
},
})
expect(result.output).toContain("search-target root")
expect(result.output).toContain("search-target worktree")
expect(result.output).not.toContain("search-target other")
} finally {
Config.get = share
}
} finally {
await $`git worktree remove ${worktree}`.cwd(first.path).quiet().nothrow()
}
})
test("read rejects sessions from another project", async () => {
await using first = await tmpdir({ git: true })
await using second = await tmpdir({ git: true })
const share = Config.get
Config.get = async () => ({ share: "manual" }) as Awaited<ReturnType<typeof Config.get>>
try {
const { Session } = await import("../../src/session/index")
const session = await Instance.provide({
directory: second.path,
fn: async () => Session.create({ title: "other-project-session" }),
})
const err = await Instance.provide({
directory: first.path,
fn: async () => {
const tool = await RecallTool.init()
return tool.execute({ mode: "read", sessionID: session.id }, ctx).catch((error) => error as Error)
},
})
expect(err).toBeInstanceOf(Error)
expect((err as Error).message).toContain("belongs to a different workspace")
} finally {
Config.get = share
}
})
test("read allows sessions from sibling worktrees when project IDs drift", async () => {
await using first = await tmpdir({ git: true })
const worktree = path.join(first.path, "..", path.basename(first.path) + "-worktree")
try {
await $`git worktree add ${worktree} -b test-branch-${Date.now()}`.cwd(first.path).quiet()
await Bun.write(path.join(first.path, ".git", "opencode"), "stale-project-id")
const share = Config.get
Config.get = async () => ({ share: "manual" }) as Awaited<ReturnType<typeof Config.get>>
try {
const { Session } = await import("../../src/session/index")
const session = await Instance.provide({
directory: worktree,
fn: async () => Session.create({ title: "worktree readable" }),
})
const result = await Instance.provide({
directory: first.path,
fn: async () => {
const tool = await RecallTool.init()
return tool.execute({ mode: "read", sessionID: session.id }, ctx)
},
})
expect(result.output).toContain("# Session: worktree readable")
} finally {
Config.get = share
}
} finally {
await $`git worktree remove ${worktree}`.cwd(first.path).quiet().nothrow()
}
})
})
+4
View File
@@ -1098,6 +1098,8 @@ export class Session extends HeyApiClient {
parameters?: {
directory?: string
workspace?: string
projectID?: string
worktrees?: boolean
roots?: boolean
start?: number
cursor?: number
@@ -1114,6 +1116,8 @@ export class Session extends HeyApiClient {
args: [
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
{ in: "query", key: "projectID" },
{ in: "query", key: "worktrees" },
{ in: "query", key: "roots" },
{ in: "query", key: "start" },
{ in: "query", key: "cursor" },
+9
View File
@@ -1780,6 +1780,7 @@ export type GlobalSession = {
diff?: string
}
project: ProjectSummary | null
worktreeName?: string
}
export type McpResource = {
@@ -2866,6 +2867,14 @@ export type ExperimentalSessionListData = {
*/
directory?: string
workspace?: string
/**
* Filter sessions by project ID
*/
projectID?: string
/**
* Restrict sessions to the current repo worktree family or current directory
*/
worktrees?: boolean
/**
* Only return root sessions (no parentID)
*/
+19
View File
@@ -1833,6 +1833,22 @@
"type": "string"
}
},
{
"in": "query",
"name": "projectID",
"schema": {
"type": "string"
},
"description": "Filter sessions by project ID"
},
{
"in": "query",
"name": "worktrees",
"schema": {
"type": "boolean"
},
"description": "Restrict sessions to the current repo worktree family or current directory"
},
{
"in": "query",
"name": "roots",
@@ -14253,6 +14269,9 @@
"type": "null"
}
]
},
"worktreeName": {
"type": "string"
}
},
"required": ["id", "slug", "projectID", "directory", "title", "version", "time", "project"]
+5
View File
@@ -37,6 +37,11 @@ const base = baseIdx !== -1 ? args[baseIdx + 1] : "origin/main"
function run(cmd: string, args: string[]) {
const result = spawnSync(cmd, args, { cwd: ROOT, encoding: "utf8" })
if (result.status !== 0) {
const msg = result.stderr?.trim() || result.stdout?.trim() || "unknown error"
console.error(`Command failed: ${cmd} ${args.join(" ")}\n${msg}`)
process.exit(1)
}
return result.stdout?.trim() ?? ""
}