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.
- package/README.md +32 -10
- package/dist/client/assets/{index-C-sGbl7X.js → index-Byzgv_-q.js} +2 -2
- package/dist/client/index.html +1 -1
- package/package.json +1 -1
- package/src/server/agent.test.ts +127 -1
- package/src/server/agent.ts +24 -8
- package/src/server/codex-app-server.test.ts +50 -0
- package/src/server/codex-app-server.ts +51 -0
- package/src/server/discovery.test.ts +211 -0
- package/src/server/discovery.ts +292 -17
- package/src/server/generate-title.ts +32 -39
- package/src/server/quick-response.test.ts +86 -0
- package/src/server/quick-response.ts +124 -0
- package/src/server/read-models.test.ts +43 -1
- package/src/server/server.ts +2 -2
package/src/server/discovery.ts
CHANGED
|
@@ -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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
|
48
|
-
const
|
|
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
|
-
|
|
51
|
-
if (!
|
|
218
|
+
const payload = record.payload
|
|
219
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload)) continue
|
|
52
220
|
|
|
53
|
-
const
|
|
54
|
-
|
|
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
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
|
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 {
|
|
1
|
+
import { QuickResponseAdapter } from "./quick-response"
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
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
|
})
|
package/src/server/server.ts
CHANGED
|
@@ -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 {
|
|
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 =
|
|
21
|
+
discoveredProjects = discoverProjects()
|
|
22
22
|
return discoveredProjects
|
|
23
23
|
}
|
|
24
24
|
|