kanna-code 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,10 @@
1
- import { existsSync, readdirSync, statSync } from "node:fs"
1
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"
2
2
  import { homedir } from "node:os"
3
3
  import path from "node:path"
4
+ import type { AgentProvider } from "../shared/types"
5
+ import { resolveLocalPath } from "./paths"
6
+
7
+ const LOG_PREFIX = "[kanna discovery]"
4
8
 
5
9
  export interface DiscoveredProject {
6
10
  localPath: string
@@ -8,6 +12,15 @@ export interface DiscoveredProject {
8
12
  modifiedAt: number
9
13
  }
10
14
 
15
+ export interface ProviderDiscoveredProject extends DiscoveredProject {
16
+ provider: AgentProvider
17
+ }
18
+
19
+ export interface ProjectDiscoveryAdapter {
20
+ provider: AgentProvider
21
+ scan(homeDir?: string): ProviderDiscoveredProject[]
22
+ }
23
+
11
24
  function resolveEncodedClaudePath(folderName: string) {
12
25
  const segments = folderName.replace(/^-/, "").split("-").filter(Boolean)
13
26
  let currentPath = ""
@@ -38,28 +51,290 @@ function resolveEncodedClaudePath(folderName: string) {
38
51
  return currentPath || "/"
39
52
  }
40
53
 
41
- export function discoverClaudeProjects(homeDir: string = homedir()): DiscoveredProject[] {
42
- const projectsDir = path.join(homeDir, ".claude", "projects")
43
- if (!existsSync(projectsDir)) {
54
+ function normalizeExistingDirectory(localPath: string) {
55
+ try {
56
+ const normalized = resolveLocalPath(localPath)
57
+ if (!statSync(normalized).isDirectory()) {
58
+ return null
59
+ }
60
+ return normalized
61
+ } catch {
62
+ return null
63
+ }
64
+ }
65
+
66
+ function mergeDiscoveredProjects(projects: Iterable<DiscoveredProject>): DiscoveredProject[] {
67
+ const merged = new Map<string, DiscoveredProject>()
68
+
69
+ for (const project of projects) {
70
+ const existing = merged.get(project.localPath)
71
+ if (!existing || project.modifiedAt > existing.modifiedAt) {
72
+ merged.set(project.localPath, {
73
+ localPath: project.localPath,
74
+ title: project.title || path.basename(project.localPath) || project.localPath,
75
+ modifiedAt: project.modifiedAt,
76
+ })
77
+ continue
78
+ }
79
+
80
+ if (!existing.title && project.title) {
81
+ existing.title = project.title
82
+ }
83
+ }
84
+
85
+ return [...merged.values()].sort((a, b) => b.modifiedAt - a.modifiedAt)
86
+ }
87
+
88
+ export class ClaudeProjectDiscoveryAdapter implements ProjectDiscoveryAdapter {
89
+ readonly provider = "claude" as const
90
+
91
+ scan(homeDir: string = homedir()): ProviderDiscoveredProject[] {
92
+ const projectsDir = path.join(homeDir, ".claude", "projects")
93
+ if (!existsSync(projectsDir)) {
94
+ console.log(`${LOG_PREFIX} provider=claude status=missing root=${projectsDir}`)
95
+ return []
96
+ }
97
+
98
+ const entries = readdirSync(projectsDir, { withFileTypes: true })
99
+ const projects: ProviderDiscoveredProject[] = []
100
+ let directoryEntries = 0
101
+ let skippedMissing = 0
102
+
103
+ for (const entry of entries) {
104
+ if (!entry.isDirectory()) continue
105
+ directoryEntries += 1
106
+
107
+ const resolvedPath = resolveEncodedClaudePath(entry.name)
108
+ const normalizedPath = normalizeExistingDirectory(resolvedPath)
109
+ if (!normalizedPath) {
110
+ skippedMissing += 1
111
+ continue
112
+ }
113
+
114
+ const stat = statSync(path.join(projectsDir, entry.name))
115
+ projects.push({
116
+ provider: this.provider,
117
+ localPath: normalizedPath,
118
+ title: path.basename(normalizedPath) || normalizedPath,
119
+ modifiedAt: stat.mtimeMs,
120
+ })
121
+ }
122
+
123
+ const mergedProjects = mergeDiscoveredProjects(projects).map((project) => ({
124
+ provider: this.provider,
125
+ ...project,
126
+ }))
127
+
128
+ console.log(
129
+ `${LOG_PREFIX} provider=claude scanned=${directoryEntries} valid=${projects.length} deduped=${mergedProjects.length} skipped_missing=${skippedMissing} samples=${mergedProjects.slice(0, 5).map((project) => project.localPath).join(", ") || "-"}`
130
+ )
131
+
132
+ return mergedProjects
133
+ }
134
+ }
135
+
136
+ function parseJsonRecord(line: string): Record<string, unknown> | null {
137
+ try {
138
+ const parsed = JSON.parse(line)
139
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
140
+ return null
141
+ }
142
+ return parsed as Record<string, unknown>
143
+ } catch {
144
+ return null
145
+ }
146
+ }
147
+
148
+ function readCodexSessionIndex(indexPath: string) {
149
+ const updatedAtById = new Map<string, number>()
150
+ if (!existsSync(indexPath)) {
151
+ return updatedAtById
152
+ }
153
+
154
+ for (const line of readFileSync(indexPath, "utf8").split("\n")) {
155
+ if (!line.trim()) continue
156
+ const record = parseJsonRecord(line)
157
+ if (!record) continue
158
+
159
+ const id = typeof record.id === "string" ? record.id : null
160
+ const updatedAt = typeof record.updated_at === "string" ? Date.parse(record.updated_at) : Number.NaN
161
+ if (!id || Number.isNaN(updatedAt)) continue
162
+
163
+ const existing = updatedAtById.get(id)
164
+ if (existing === undefined || updatedAt > existing) {
165
+ updatedAtById.set(id, updatedAt)
166
+ }
167
+ }
168
+
169
+ return updatedAtById
170
+ }
171
+
172
+ function collectCodexSessionFiles(directory: string): string[] {
173
+ if (!existsSync(directory)) {
44
174
  return []
45
175
  }
46
176
 
47
- const entries = readdirSync(projectsDir, { withFileTypes: true })
48
- const projects: DiscoveredProject[] = []
177
+ const files: string[] = []
178
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
179
+ const fullPath = path.join(directory, entry.name)
180
+ if (entry.isDirectory()) {
181
+ files.push(...collectCodexSessionFiles(fullPath))
182
+ continue
183
+ }
184
+ if (entry.isFile() && entry.name.endsWith(".jsonl")) {
185
+ files.push(fullPath)
186
+ }
187
+ }
188
+ return files
189
+ }
190
+
191
+ function readCodexConfiguredProjects(configPath: string) {
192
+ const projects = new Map<string, number>()
193
+ if (!existsSync(configPath)) {
194
+ return projects
195
+ }
196
+
197
+ const configMtime = statSync(configPath).mtimeMs
198
+ for (const line of readFileSync(configPath, "utf8").split("\n")) {
199
+ const match = line.match(/^\[projects\."(.+)"\]$/)
200
+ if (!match?.[1]) continue
201
+ projects.set(match[1], configMtime)
202
+ }
203
+
204
+ return projects
205
+ }
206
+
207
+ function readCodexSessionMetadata(sessionsDir: string) {
208
+ const metadataById = new Map<string, { cwd: string; modifiedAt: number }>()
209
+
210
+ for (const sessionFile of collectCodexSessionFiles(sessionsDir)) {
211
+ const fileStat = statSync(sessionFile)
212
+ const firstLine = readFileSync(sessionFile, "utf8").split("\n", 1)[0]
213
+ if (!firstLine?.trim()) continue
214
+
215
+ const record = parseJsonRecord(firstLine)
216
+ if (!record || record.type !== "session_meta") continue
49
217
 
50
- for (const entry of entries) {
51
- if (!entry.isDirectory()) continue
218
+ const payload = record.payload
219
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) continue
52
220
 
53
- const resolvedPath = resolveEncodedClaudePath(entry.name)
54
- if (!existsSync(resolvedPath)) continue
221
+ const payloadRecord = payload as Record<string, unknown>
222
+ const sessionId = typeof payloadRecord.id === "string" ? payloadRecord.id : null
223
+ const cwd = typeof payloadRecord.cwd === "string" ? payloadRecord.cwd : null
224
+ if (!sessionId || !cwd) continue
55
225
 
56
- const stat = statSync(path.join(projectsDir, entry.name))
57
- projects.push({
58
- localPath: resolvedPath,
59
- title: path.basename(resolvedPath) || resolvedPath,
60
- modifiedAt: stat.mtimeMs,
61
- })
226
+ const recordTimestamp = typeof record.timestamp === "string" ? Date.parse(record.timestamp) : Number.NaN
227
+ const payloadTimestamp = typeof payloadRecord.timestamp === "string" ? Date.parse(payloadRecord.timestamp) : Number.NaN
228
+ const modifiedAt = [recordTimestamp, payloadTimestamp, fileStat.mtimeMs].find((value) => !Number.isNaN(value)) ?? fileStat.mtimeMs
229
+
230
+ metadataById.set(sessionId, { cwd, modifiedAt })
62
231
  }
63
232
 
64
- return projects.sort((a, b) => b.modifiedAt - a.modifiedAt)
233
+ return metadataById
234
+ }
235
+
236
+ export class CodexProjectDiscoveryAdapter implements ProjectDiscoveryAdapter {
237
+ readonly provider = "codex" as const
238
+
239
+ scan(homeDir: string = homedir()): ProviderDiscoveredProject[] {
240
+ const indexPath = path.join(homeDir, ".codex", "session_index.jsonl")
241
+ const sessionsDir = path.join(homeDir, ".codex", "sessions")
242
+ const configPath = path.join(homeDir, ".codex", "config.toml")
243
+ const updatedAtById = readCodexSessionIndex(indexPath)
244
+ const metadataById = readCodexSessionMetadata(sessionsDir)
245
+ const configuredProjects = readCodexConfiguredProjects(configPath)
246
+ const projects: ProviderDiscoveredProject[] = []
247
+ let skippedMissingMeta = 0
248
+ let skippedRelative = 0
249
+ let skippedMissingPath = 0
250
+ let fallbackSessionTimestamps = 0
251
+ let configProjectsIncluded = 0
252
+
253
+ if (!existsSync(indexPath) || !existsSync(sessionsDir) || !existsSync(configPath)) {
254
+ console.log(
255
+ `${LOG_PREFIX} provider=codex status=missing index_exists=${existsSync(indexPath)} sessions_exists=${existsSync(sessionsDir)} config_exists=${existsSync(configPath)}`
256
+ )
257
+ }
258
+
259
+ for (const [sessionId, metadata] of metadataById.entries()) {
260
+ const modifiedAt = updatedAtById.get(sessionId) ?? metadata.modifiedAt
261
+ const cwd = metadata.cwd
262
+ if (!updatedAtById.has(sessionId)) {
263
+ fallbackSessionTimestamps += 1
264
+ }
265
+ if (!cwd) {
266
+ skippedMissingMeta += 1
267
+ continue
268
+ }
269
+ if (!path.isAbsolute(cwd)) {
270
+ skippedRelative += 1
271
+ continue
272
+ }
273
+
274
+ const normalizedPath = normalizeExistingDirectory(cwd)
275
+ if (!normalizedPath) {
276
+ skippedMissingPath += 1
277
+ continue
278
+ }
279
+
280
+ projects.push({
281
+ provider: this.provider,
282
+ localPath: normalizedPath,
283
+ title: path.basename(normalizedPath) || normalizedPath,
284
+ modifiedAt,
285
+ })
286
+ }
287
+
288
+ for (const [configuredPath, modifiedAt] of configuredProjects.entries()) {
289
+ if (!path.isAbsolute(configuredPath)) {
290
+ skippedRelative += 1
291
+ continue
292
+ }
293
+
294
+ const normalizedPath = normalizeExistingDirectory(configuredPath)
295
+ if (!normalizedPath) {
296
+ skippedMissingPath += 1
297
+ continue
298
+ }
299
+
300
+ configProjectsIncluded += 1
301
+ projects.push({
302
+ provider: this.provider,
303
+ localPath: normalizedPath,
304
+ title: path.basename(normalizedPath) || normalizedPath,
305
+ modifiedAt,
306
+ })
307
+ }
308
+
309
+ const mergedProjects = mergeDiscoveredProjects(projects).map((project) => ({
310
+ provider: this.provider,
311
+ ...project,
312
+ }))
313
+
314
+ console.log(
315
+ `${LOG_PREFIX} provider=codex indexed_sessions=${updatedAtById.size} session_meta=${metadataById.size} config_projects=${configuredProjects.size} valid=${projects.length} deduped=${mergedProjects.length} fallback_session_timestamps=${fallbackSessionTimestamps} config_projects_included=${configProjectsIncluded} skipped_missing_meta=${skippedMissingMeta} skipped_relative=${skippedRelative} skipped_missing_path=${skippedMissingPath} samples=${mergedProjects.slice(0, 5).map((project) => project.localPath).join(", ") || "-"}`
316
+ )
317
+
318
+ return mergedProjects
319
+ }
320
+ }
321
+
322
+ export const DEFAULT_PROJECT_DISCOVERY_ADAPTERS: ProjectDiscoveryAdapter[] = [
323
+ new ClaudeProjectDiscoveryAdapter(),
324
+ new CodexProjectDiscoveryAdapter(),
325
+ ]
326
+
327
+ export function discoverProjects(
328
+ homeDir: string = homedir(),
329
+ adapters: ProjectDiscoveryAdapter[] = DEFAULT_PROJECT_DISCOVERY_ADAPTERS
330
+ ): DiscoveredProject[] {
331
+ const mergedProjects = mergeDiscoveredProjects(
332
+ adapters.flatMap((adapter) => adapter.scan(homeDir).map(({ provider: _provider, ...project }) => project))
333
+ )
334
+
335
+ console.log(
336
+ `${LOG_PREFIX} aggregate providers=${adapters.map((adapter) => adapter.provider).join(",")} total=${mergedProjects.length} samples=${mergedProjects.slice(0, 10).map((project) => project.localPath).join(", ") || "-"}`
337
+ )
338
+
339
+ return mergedProjects
65
340
  }
@@ -1,43 +1,36 @@
1
- import { query } from "@anthropic-ai/claude-agent-sdk"
1
+ import { QuickResponseAdapter } from "./quick-response"
2
2
 
3
- export async function generateTitleForChat(messageContent: string): Promise<string | null> {
4
- try {
5
- const q = query({
6
- prompt: `Generate a short, descriptive title (under 30 chars) for a conversation that starts with this message. Return JSON matching the schema.\n\n${messageContent}`,
7
- options: {
8
- model: "haiku",
9
- tools: [],
10
- systemPrompt: "",
11
- effort: "low",
12
- permissionMode: "bypassPermissions",
13
- outputFormat: {
14
- type: "json_schema",
15
- schema: {
16
- type: "object",
17
- properties: {
18
- title: { type: "string" },
19
- },
20
- required: ["title"],
21
- additionalProperties: false,
22
- },
23
- },
24
- env: { ...process.env },
25
- },
26
- })
3
+ const TITLE_SCHEMA = {
4
+ type: "object",
5
+ properties: {
6
+ title: { type: "string" },
7
+ },
8
+ required: ["title"],
9
+ additionalProperties: false,
10
+ } as const
27
11
 
28
- try {
29
- for await (const message of q) {
30
- if ("result" in message) {
31
- const output = (message as Record<string, unknown>).structured_output as { title?: string } | undefined
32
- return typeof output?.title === "string" ? output.title.slice(0, 80) : null
33
- }
34
- }
35
- } finally {
36
- q.close()
37
- }
12
+ function normalizeGeneratedTitle(value: unknown): string | null {
13
+ if (typeof value !== "string") return null
14
+ const normalized = value.replace(/\s+/g, " ").trim().slice(0, 80)
15
+ if (!normalized || normalized === "New Chat") return null
16
+ return normalized
17
+ }
18
+
19
+ export async function generateTitleForChat(
20
+ messageContent: string,
21
+ cwd: string,
22
+ adapter = new QuickResponseAdapter()
23
+ ): Promise<string | null> {
24
+ const result = await adapter.generateStructured<string>({
25
+ cwd,
26
+ task: "conversation title generation",
27
+ prompt: `Generate a short, descriptive title (under 30 chars) for a conversation that starts with this message.\n\n${messageContent}`,
28
+ schema: TITLE_SCHEMA,
29
+ parse: (value) => {
30
+ const output = value && typeof value === "object" ? value as { title?: unknown } : {}
31
+ return normalizeGeneratedTitle(output.title)
32
+ },
33
+ })
38
34
 
39
- return null
40
- } catch {
41
- return null
42
- }
35
+ return result
43
36
  }
@@ -0,0 +1,86 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import { generateTitleForChat } from "./generate-title"
3
+ import { QuickResponseAdapter } from "./quick-response"
4
+
5
+ describe("QuickResponseAdapter", () => {
6
+ test("returns the Claude structured result when it validates", async () => {
7
+ const adapter = new QuickResponseAdapter({
8
+ runClaudeStructured: async () => ({ title: "Claude title" }),
9
+ runCodexStructured: async () => ({ title: "Codex title" }),
10
+ })
11
+
12
+ const result = await adapter.generateStructured({
13
+ cwd: "/tmp/project",
14
+ task: "title generation",
15
+ prompt: "Generate a title",
16
+ schema: {
17
+ type: "object",
18
+ properties: {
19
+ title: { type: "string" },
20
+ },
21
+ required: ["title"],
22
+ additionalProperties: false,
23
+ },
24
+ parse: (value) => {
25
+ const output = value && typeof value === "object" ? value as { title?: unknown } : {}
26
+ return typeof output.title === "string" ? output.title : null
27
+ },
28
+ })
29
+
30
+ expect(result).toBe("Claude title")
31
+ })
32
+
33
+ test("falls back to Codex when Claude fails validation", async () => {
34
+ const adapter = new QuickResponseAdapter({
35
+ runClaudeStructured: async () => ({ bad: true }),
36
+ runCodexStructured: async () => ({ title: "Codex title" }),
37
+ })
38
+
39
+ const result = await adapter.generateStructured({
40
+ cwd: "/tmp/project",
41
+ task: "title generation",
42
+ prompt: "Generate a title",
43
+ schema: {
44
+ type: "object",
45
+ properties: {
46
+ title: { type: "string" },
47
+ },
48
+ required: ["title"],
49
+ additionalProperties: false,
50
+ },
51
+ parse: (value) => {
52
+ const output = value && typeof value === "object" ? value as { title?: unknown } : {}
53
+ return typeof output.title === "string" ? output.title : null
54
+ },
55
+ })
56
+
57
+ expect(result).toBe("Codex title")
58
+ })
59
+ })
60
+
61
+ describe("generateTitleForChat", () => {
62
+ test("sanitizes generated titles", async () => {
63
+ const title = await generateTitleForChat(
64
+ "hello",
65
+ "/tmp/project",
66
+ new QuickResponseAdapter({
67
+ runClaudeStructured: async () => ({ title: " Example\nTitle " }),
68
+ })
69
+ )
70
+
71
+ expect(title).toBe("Example Title")
72
+ })
73
+
74
+ test("rejects invalid generated titles", async () => {
75
+ const title = await generateTitleForChat(
76
+ "hello",
77
+ "/tmp/project",
78
+ new QuickResponseAdapter({
79
+ runClaudeStructured: async () => ({ title: " " }),
80
+ runCodexStructured: async () => ({ title: "New Chat" }),
81
+ })
82
+ )
83
+
84
+ expect(title).toBeNull()
85
+ })
86
+ })
@@ -0,0 +1,124 @@
1
+ import { query } from "@anthropic-ai/claude-agent-sdk"
2
+ import { CodexAppServerManager } from "./codex-app-server"
3
+
4
+ type JsonSchema = {
5
+ type: "object"
6
+ properties: Record<string, unknown>
7
+ required?: readonly string[]
8
+ additionalProperties?: boolean
9
+ }
10
+
11
+ export interface StructuredQuickResponseArgs<T> {
12
+ cwd: string
13
+ task: string
14
+ prompt: string
15
+ schema: JsonSchema
16
+ parse: (value: unknown) => T | null
17
+ }
18
+
19
+ interface QuickResponseAdapterArgs {
20
+ codexManager?: CodexAppServerManager
21
+ runClaudeStructured?: (args: Omit<StructuredQuickResponseArgs<unknown>, "parse">) => Promise<unknown | null>
22
+ runCodexStructured?: (args: Omit<StructuredQuickResponseArgs<unknown>, "parse">) => Promise<unknown | null>
23
+ }
24
+
25
+ function parseJsonText(value: string): unknown | null {
26
+ const trimmed = value.trim()
27
+ if (!trimmed) return null
28
+
29
+ const candidates = [trimmed]
30
+ const fencedMatch = trimmed.match(/```(?:json)?\s*([\s\S]*?)\s*```/i)
31
+ if (fencedMatch?.[1]) {
32
+ candidates.unshift(fencedMatch[1].trim())
33
+ }
34
+
35
+ for (const candidate of candidates) {
36
+ try {
37
+ return JSON.parse(candidate)
38
+ } catch {
39
+ continue
40
+ }
41
+ }
42
+
43
+ return null
44
+ }
45
+
46
+ async function runClaudeStructured(args: Omit<StructuredQuickResponseArgs<unknown>, "parse">): Promise<unknown | null> {
47
+ const q = query({
48
+ prompt: args.prompt,
49
+ options: {
50
+ model: "haiku",
51
+ tools: [],
52
+ systemPrompt: "",
53
+ effort: "low",
54
+ permissionMode: "bypassPermissions",
55
+ outputFormat: {
56
+ type: "json_schema",
57
+ schema: args.schema,
58
+ },
59
+ env: { ...process.env },
60
+ },
61
+ })
62
+
63
+ try {
64
+ for await (const message of q) {
65
+ if ("result" in message) {
66
+ return (message as Record<string, unknown>).structured_output ?? null
67
+ }
68
+ }
69
+ return null
70
+ } finally {
71
+ q.close()
72
+ }
73
+ }
74
+
75
+ async function runCodexStructured(
76
+ codexManager: CodexAppServerManager,
77
+ args: Omit<StructuredQuickResponseArgs<unknown>, "parse">
78
+ ): Promise<unknown | null> {
79
+ const response = await codexManager.generateStructured({
80
+ cwd: args.cwd,
81
+ prompt: `${args.prompt}\n\nReturn JSON only that matches this schema:\n${JSON.stringify(args.schema, null, 2)}`,
82
+ })
83
+ if (typeof response !== "string") return null
84
+ return parseJsonText(response)
85
+ }
86
+
87
+ export class QuickResponseAdapter {
88
+ private readonly codexManager: CodexAppServerManager
89
+ private readonly runClaudeStructured: (args: Omit<StructuredQuickResponseArgs<unknown>, "parse">) => Promise<unknown | null>
90
+ private readonly runCodexStructured: (args: Omit<StructuredQuickResponseArgs<unknown>, "parse">) => Promise<unknown | null>
91
+
92
+ constructor(args: QuickResponseAdapterArgs = {}) {
93
+ this.codexManager = args.codexManager ?? new CodexAppServerManager()
94
+ this.runClaudeStructured = args.runClaudeStructured ?? runClaudeStructured
95
+ this.runCodexStructured = args.runCodexStructured ?? ((structuredArgs) =>
96
+ runCodexStructured(this.codexManager, structuredArgs))
97
+ }
98
+
99
+ async generateStructured<T>(args: StructuredQuickResponseArgs<T>): Promise<T | null> {
100
+ const request = {
101
+ cwd: args.cwd,
102
+ task: args.task,
103
+ prompt: args.prompt,
104
+ schema: args.schema,
105
+ }
106
+
107
+ const claudeResult = await this.tryProvider(args.parse, () => this.runClaudeStructured(request))
108
+ if (claudeResult !== null) return claudeResult
109
+
110
+ return await this.tryProvider(args.parse, () => this.runCodexStructured(request))
111
+ }
112
+
113
+ private async tryProvider<T>(
114
+ parse: (value: unknown) => T | null,
115
+ run: () => Promise<unknown | null>
116
+ ): Promise<T | null> {
117
+ try {
118
+ const result = await run()
119
+ return result === null ? null : parse(result)
120
+ } catch {
121
+ return null
122
+ }
123
+ }
124
+ }
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, test } from "bun:test"
2
- import { deriveChatSnapshot, deriveSidebarData } from "./read-models"
2
+ import { deriveChatSnapshot, deriveLocalProjectsSnapshot, deriveSidebarData } from "./read-models"
3
3
  import { createEmptyState } from "./events"
4
4
 
5
5
  describe("read models", () => {
@@ -60,4 +60,46 @@ describe("read models", () => {
60
60
  "gpt-5.3-codex-spark",
61
61
  ])
62
62
  })
63
+
64
+ test("prefers saved project metadata over discovered entries for the same path", () => {
65
+ const state = createEmptyState()
66
+ state.projectsById.set("project-1", {
67
+ id: "project-1",
68
+ localPath: "/tmp/project",
69
+ title: "Saved Project",
70
+ createdAt: 1,
71
+ updatedAt: 50,
72
+ })
73
+ state.projectIdsByPath.set("/tmp/project", "project-1")
74
+ state.chatsById.set("chat-1", {
75
+ id: "chat-1",
76
+ projectId: "project-1",
77
+ title: "Chat",
78
+ createdAt: 1,
79
+ updatedAt: 75,
80
+ provider: "codex",
81
+ planMode: false,
82
+ sessionToken: null,
83
+ lastMessageAt: 100,
84
+ lastTurnOutcome: null,
85
+ })
86
+
87
+ const snapshot = deriveLocalProjectsSnapshot(state, [
88
+ {
89
+ localPath: "/tmp/project",
90
+ title: "Discovered Project",
91
+ modifiedAt: 10,
92
+ },
93
+ ], "Local Machine")
94
+
95
+ expect(snapshot.projects).toEqual([
96
+ {
97
+ localPath: "/tmp/project",
98
+ title: "Saved Project",
99
+ source: "saved",
100
+ lastOpenedAt: 100,
101
+ chatCount: 1,
102
+ },
103
+ ])
104
+ })
63
105
  })
@@ -2,7 +2,7 @@ import path from "node:path"
2
2
  import { APP_NAME } from "../shared/branding"
3
3
  import { EventStore } from "./event-store"
4
4
  import { AgentCoordinator } from "./agent"
5
- import { discoverClaudeProjects, type DiscoveredProject } from "./discovery"
5
+ import { discoverProjects, type DiscoveredProject } from "./discovery"
6
6
  import { getMachineDisplayName } from "./machine-name"
7
7
  import { createWsRouter, type ClientState } from "./ws-router"
8
8
 
@@ -18,7 +18,7 @@ export async function startKannaServer(options: StartKannaServerOptions = {}) {
18
18
  let discoveredProjects: DiscoveredProject[] = []
19
19
 
20
20
  async function refreshDiscovery() {
21
- discoveredProjects = discoverClaudeProjects()
21
+ discoveredProjects = discoverProjects()
22
22
  return discoveredProjects
23
23
  }
24
24