opencode-manager 0.3.1 → 0.4.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/PROJECT-SUMMARY.md +104 -24
- package/README.md +335 -7
- package/bun.lock +17 -1
- package/manage_opencode_projects.py +71 -66
- package/package.json +6 -3
- package/src/bin/opencode-manager.ts +133 -3
- package/src/cli/backup.ts +324 -0
- package/src/cli/commands/chat.ts +322 -0
- package/src/cli/commands/projects.ts +222 -0
- package/src/cli/commands/sessions.ts +495 -0
- package/src/cli/commands/tokens.ts +168 -0
- package/src/cli/commands/tui.ts +36 -0
- package/src/cli/errors.ts +259 -0
- package/src/cli/formatters/json.ts +184 -0
- package/src/cli/formatters/ndjson.ts +71 -0
- package/src/cli/formatters/table.ts +837 -0
- package/src/cli/index.ts +169 -0
- package/src/cli/output.ts +661 -0
- package/src/cli/resolvers.ts +249 -0
- package/src/lib/clipboard.ts +37 -0
- package/src/lib/opencode-data.ts +380 -1
- package/src/lib/search.ts +170 -0
- package/src/{opencode-tui.tsx → tui/app.tsx} +739 -105
- package/src/tui/args.ts +92 -0
- package/src/tui/index.tsx +46 -0
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI resolver helpers for converting user-provided IDs to records.
|
|
3
|
+
*
|
|
4
|
+
* These helpers provide consistent ID resolution across all CLI commands,
|
|
5
|
+
* supporting both exact matches and flexible matching patterns.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
loadProjectRecords,
|
|
10
|
+
loadSessionRecords,
|
|
11
|
+
type LoadOptions,
|
|
12
|
+
type SessionLoadOptions,
|
|
13
|
+
type ProjectRecord,
|
|
14
|
+
type SessionRecord,
|
|
15
|
+
} from "../lib/opencode-data"
|
|
16
|
+
import { NotFoundError, projectNotFound, sessionNotFound } from "./errors"
|
|
17
|
+
|
|
18
|
+
// ========================
|
|
19
|
+
// Session Resolution
|
|
20
|
+
// ========================
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Options for resolving session IDs.
|
|
24
|
+
*/
|
|
25
|
+
export interface ResolveSessionOptions extends SessionLoadOptions {
|
|
26
|
+
/**
|
|
27
|
+
* If true, allow partial prefix matching when exact match fails.
|
|
28
|
+
* Requires the prefix to match exactly one session uniquely.
|
|
29
|
+
* Defaults to false.
|
|
30
|
+
*/
|
|
31
|
+
allowPrefix?: boolean
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Result of a session resolution attempt.
|
|
36
|
+
*/
|
|
37
|
+
export interface ResolveSessionResult {
|
|
38
|
+
session: SessionRecord
|
|
39
|
+
/** How the session was matched */
|
|
40
|
+
matchType: "exact" | "prefix"
|
|
41
|
+
/** All sessions that were loaded (for reuse) */
|
|
42
|
+
allSessions: SessionRecord[]
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Find a session by exact ID from a pre-loaded list of sessions.
|
|
47
|
+
*
|
|
48
|
+
* @param sessions - Pre-loaded session records
|
|
49
|
+
* @param sessionId - Session ID to find
|
|
50
|
+
* @returns The matching session
|
|
51
|
+
* @throws NotFoundError if session doesn't exist
|
|
52
|
+
*/
|
|
53
|
+
export function findSessionById(
|
|
54
|
+
sessions: SessionRecord[],
|
|
55
|
+
sessionId: string
|
|
56
|
+
): SessionRecord {
|
|
57
|
+
const session = sessions.find((s) => s.sessionId === sessionId)
|
|
58
|
+
if (!session) {
|
|
59
|
+
sessionNotFound(sessionId)
|
|
60
|
+
}
|
|
61
|
+
return session
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Find sessions matching a prefix from a pre-loaded list.
|
|
66
|
+
*
|
|
67
|
+
* @param sessions - Pre-loaded session records
|
|
68
|
+
* @param prefix - Session ID prefix to match
|
|
69
|
+
* @returns Array of matching sessions
|
|
70
|
+
*/
|
|
71
|
+
export function findSessionsByPrefix(
|
|
72
|
+
sessions: SessionRecord[],
|
|
73
|
+
prefix: string
|
|
74
|
+
): SessionRecord[] {
|
|
75
|
+
return sessions.filter((s) => s.sessionId.startsWith(prefix))
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Resolve a session ID to a session record, loading data as needed.
|
|
80
|
+
*
|
|
81
|
+
* Supports exact matching and optional prefix matching.
|
|
82
|
+
*
|
|
83
|
+
* @param sessionId - Session ID or prefix to resolve
|
|
84
|
+
* @param options - Resolution options including root and projectId filters
|
|
85
|
+
* @returns Resolution result with session and metadata
|
|
86
|
+
* @throws NotFoundError if no session matches
|
|
87
|
+
* @throws NotFoundError if prefix matches multiple sessions (ambiguous)
|
|
88
|
+
*/
|
|
89
|
+
export async function resolveSessionId(
|
|
90
|
+
sessionId: string,
|
|
91
|
+
options: ResolveSessionOptions = {}
|
|
92
|
+
): Promise<ResolveSessionResult> {
|
|
93
|
+
const sessions = await loadSessionRecords({
|
|
94
|
+
root: options.root,
|
|
95
|
+
projectId: options.projectId,
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
// Try exact match first
|
|
99
|
+
const exactMatch = sessions.find((s) => s.sessionId === sessionId)
|
|
100
|
+
if (exactMatch) {
|
|
101
|
+
return {
|
|
102
|
+
session: exactMatch,
|
|
103
|
+
matchType: "exact",
|
|
104
|
+
allSessions: sessions,
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Try prefix match if allowed
|
|
109
|
+
if (options.allowPrefix) {
|
|
110
|
+
const prefixMatches = findSessionsByPrefix(sessions, sessionId)
|
|
111
|
+
|
|
112
|
+
if (prefixMatches.length === 1) {
|
|
113
|
+
return {
|
|
114
|
+
session: prefixMatches[0],
|
|
115
|
+
matchType: "prefix",
|
|
116
|
+
allSessions: sessions,
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (prefixMatches.length > 1) {
|
|
121
|
+
throw new NotFoundError(
|
|
122
|
+
`Ambiguous session ID prefix "${sessionId}" matches ${prefixMatches.length} sessions: ${prefixMatches
|
|
123
|
+
.slice(0, 3)
|
|
124
|
+
.map((s) => s.sessionId)
|
|
125
|
+
.join(", ")}${prefixMatches.length > 3 ? "..." : ""}`,
|
|
126
|
+
"session"
|
|
127
|
+
)
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// No match found
|
|
132
|
+
sessionNotFound(sessionId)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ========================
|
|
136
|
+
// Project Resolution
|
|
137
|
+
// ========================
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Options for resolving project IDs.
|
|
141
|
+
*/
|
|
142
|
+
export interface ResolveProjectOptions extends LoadOptions {
|
|
143
|
+
/**
|
|
144
|
+
* If true, allow partial prefix matching when exact match fails.
|
|
145
|
+
* Requires the prefix to match exactly one project uniquely.
|
|
146
|
+
* Defaults to false.
|
|
147
|
+
*/
|
|
148
|
+
allowPrefix?: boolean
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Result of a project resolution attempt.
|
|
153
|
+
*/
|
|
154
|
+
export interface ResolveProjectResult {
|
|
155
|
+
project: ProjectRecord
|
|
156
|
+
/** How the project was matched */
|
|
157
|
+
matchType: "exact" | "prefix"
|
|
158
|
+
/** All projects that were loaded (for reuse) */
|
|
159
|
+
allProjects: ProjectRecord[]
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Find a project by exact ID from a pre-loaded list of projects.
|
|
164
|
+
*
|
|
165
|
+
* @param projects - Pre-loaded project records
|
|
166
|
+
* @param projectId - Project ID to find
|
|
167
|
+
* @returns The matching project
|
|
168
|
+
* @throws NotFoundError if project doesn't exist
|
|
169
|
+
*/
|
|
170
|
+
export function findProjectById(
|
|
171
|
+
projects: ProjectRecord[],
|
|
172
|
+
projectId: string
|
|
173
|
+
): ProjectRecord {
|
|
174
|
+
const project = projects.find((p) => p.projectId === projectId)
|
|
175
|
+
if (!project) {
|
|
176
|
+
projectNotFound(projectId)
|
|
177
|
+
}
|
|
178
|
+
return project
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Find projects matching a prefix from a pre-loaded list.
|
|
183
|
+
*
|
|
184
|
+
* @param projects - Pre-loaded project records
|
|
185
|
+
* @param prefix - Project ID prefix to match
|
|
186
|
+
* @returns Array of matching projects
|
|
187
|
+
*/
|
|
188
|
+
export function findProjectsByPrefix(
|
|
189
|
+
projects: ProjectRecord[],
|
|
190
|
+
prefix: string
|
|
191
|
+
): ProjectRecord[] {
|
|
192
|
+
return projects.filter((p) => p.projectId.startsWith(prefix))
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Resolve a project ID to a project record, loading data as needed.
|
|
197
|
+
*
|
|
198
|
+
* Supports exact matching and optional prefix matching.
|
|
199
|
+
*
|
|
200
|
+
* @param projectId - Project ID or prefix to resolve
|
|
201
|
+
* @param options - Resolution options including root
|
|
202
|
+
* @returns Resolution result with project and metadata
|
|
203
|
+
* @throws NotFoundError if no project matches
|
|
204
|
+
* @throws NotFoundError if prefix matches multiple projects (ambiguous)
|
|
205
|
+
*/
|
|
206
|
+
export async function resolveProjectId(
|
|
207
|
+
projectId: string,
|
|
208
|
+
options: ResolveProjectOptions = {}
|
|
209
|
+
): Promise<ResolveProjectResult> {
|
|
210
|
+
const projects = await loadProjectRecords({
|
|
211
|
+
root: options.root,
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
// Try exact match first
|
|
215
|
+
const exactMatch = projects.find((p) => p.projectId === projectId)
|
|
216
|
+
if (exactMatch) {
|
|
217
|
+
return {
|
|
218
|
+
project: exactMatch,
|
|
219
|
+
matchType: "exact",
|
|
220
|
+
allProjects: projects,
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Try prefix match if allowed
|
|
225
|
+
if (options.allowPrefix) {
|
|
226
|
+
const prefixMatches = findProjectsByPrefix(projects, projectId)
|
|
227
|
+
|
|
228
|
+
if (prefixMatches.length === 1) {
|
|
229
|
+
return {
|
|
230
|
+
project: prefixMatches[0],
|
|
231
|
+
matchType: "prefix",
|
|
232
|
+
allProjects: projects,
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (prefixMatches.length > 1) {
|
|
237
|
+
throw new NotFoundError(
|
|
238
|
+
`Ambiguous project ID prefix "${projectId}" matches ${prefixMatches.length} projects: ${prefixMatches
|
|
239
|
+
.slice(0, 3)
|
|
240
|
+
.map((p) => p.projectId)
|
|
241
|
+
.join(", ")}${prefixMatches.length > 3 ? "..." : ""}`,
|
|
242
|
+
"project"
|
|
243
|
+
)
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// No match found
|
|
248
|
+
projectNotFound(projectId)
|
|
249
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { exec } from "node:child_process"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Copy text to the system clipboard.
|
|
5
|
+
* Uses pbcopy on macOS and xclip on Linux.
|
|
6
|
+
*
|
|
7
|
+
* @param text The text to copy to clipboard
|
|
8
|
+
* @returns Promise that resolves when copy is complete, rejects on error
|
|
9
|
+
*/
|
|
10
|
+
export function copyToClipboard(text: string): Promise<void> {
|
|
11
|
+
return new Promise((resolve, reject) => {
|
|
12
|
+
const cmd =
|
|
13
|
+
process.platform === "darwin" ? "pbcopy" : "xclip -selection clipboard"
|
|
14
|
+
const proc = exec(cmd, (error) => {
|
|
15
|
+
if (error) {
|
|
16
|
+
reject(error)
|
|
17
|
+
} else {
|
|
18
|
+
resolve()
|
|
19
|
+
}
|
|
20
|
+
})
|
|
21
|
+
proc.stdin?.write(text)
|
|
22
|
+
proc.stdin?.end()
|
|
23
|
+
})
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Copy text to clipboard, logging errors to console.
|
|
28
|
+
* This is a fire-and-forget version for use in contexts where
|
|
29
|
+
* error handling is not critical.
|
|
30
|
+
*
|
|
31
|
+
* @param text The text to copy to clipboard
|
|
32
|
+
*/
|
|
33
|
+
export function copyToClipboardSync(text: string): void {
|
|
34
|
+
copyToClipboard(text).catch((error) => {
|
|
35
|
+
console.error("Failed to copy to clipboard:", error)
|
|
36
|
+
})
|
|
37
|
+
}
|
package/src/lib/opencode-data.ts
CHANGED
|
@@ -27,6 +27,39 @@ export type AggregateTokenSummary = {
|
|
|
27
27
|
unknownSessions?: number
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
// ========================
|
|
31
|
+
// Chat History Types
|
|
32
|
+
// ========================
|
|
33
|
+
|
|
34
|
+
export type PartType = "text" | "subtask" | "tool" | "unknown"
|
|
35
|
+
|
|
36
|
+
export interface ChatPart {
|
|
37
|
+
partId: string
|
|
38
|
+
messageId: string
|
|
39
|
+
type: PartType
|
|
40
|
+
text: string // extracted human-readable content
|
|
41
|
+
toolName?: string // for tool parts
|
|
42
|
+
toolStatus?: string // "running" | "completed" | "error"
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type ChatRole = "user" | "assistant" | "unknown"
|
|
46
|
+
|
|
47
|
+
export interface ChatMessage {
|
|
48
|
+
sessionId: string
|
|
49
|
+
messageId: string
|
|
50
|
+
role: ChatRole
|
|
51
|
+
createdAt: Date | null
|
|
52
|
+
parentId?: string // for threading (assistant → user)
|
|
53
|
+
tokens?: TokenBreakdown // only on assistant messages
|
|
54
|
+
|
|
55
|
+
// Parts are loaded lazily for performance.
|
|
56
|
+
parts: ChatPart[] | null
|
|
57
|
+
|
|
58
|
+
// Computed for display
|
|
59
|
+
previewText: string // placeholder until parts load; then first N chars of combined parts
|
|
60
|
+
totalChars: number | null // null until parts load
|
|
61
|
+
}
|
|
62
|
+
|
|
30
63
|
export interface ProjectRecord {
|
|
31
64
|
index: number
|
|
32
65
|
bucket: ProjectBucket
|
|
@@ -566,7 +599,7 @@ function parseMessageTokens(tokens: MessageTokens | null | undefined): TokenBrea
|
|
|
566
599
|
return breakdown
|
|
567
600
|
}
|
|
568
601
|
|
|
569
|
-
async function loadSessionMessagePaths(sessionId: string, root: string): Promise<string[] | null> {
|
|
602
|
+
export async function loadSessionMessagePaths(sessionId: string, root: string): Promise<string[] | null> {
|
|
570
603
|
// Primary path: storage/message/<sessionId>
|
|
571
604
|
const primaryPath = join(root, 'storage', 'message', sessionId)
|
|
572
605
|
if (await pathExists(primaryPath)) {
|
|
@@ -732,3 +765,349 @@ async function computeAggregateTokenSummary(
|
|
|
732
765
|
unknownSessions,
|
|
733
766
|
}
|
|
734
767
|
}
|
|
768
|
+
|
|
769
|
+
// ========================
|
|
770
|
+
// Chat History Loading
|
|
771
|
+
// ========================
|
|
772
|
+
|
|
773
|
+
/**
|
|
774
|
+
* Load paths for part files associated with a message.
|
|
775
|
+
* Tries primary storage first, falls back to legacy layout.
|
|
776
|
+
*
|
|
777
|
+
* @returns Array of full paths to part JSON files, or null if neither directory exists.
|
|
778
|
+
*/
|
|
779
|
+
export async function loadMessagePartPaths(messageId: string, root: string): Promise<string[] | null> {
|
|
780
|
+
// Primary path: storage/part/<messageId>
|
|
781
|
+
const primaryPath = join(root, 'storage', 'part', messageId)
|
|
782
|
+
if (await pathExists(primaryPath)) {
|
|
783
|
+
try {
|
|
784
|
+
const entries = await fs.readdir(primaryPath)
|
|
785
|
+
return entries
|
|
786
|
+
.filter((e) => e.endsWith('.json'))
|
|
787
|
+
.map((e) => join(primaryPath, e))
|
|
788
|
+
} catch {
|
|
789
|
+
return null
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
// Legacy fallback: storage/session/part/<messageId>
|
|
794
|
+
const legacyPath = join(root, 'storage', 'session', 'part', messageId)
|
|
795
|
+
if (await pathExists(legacyPath)) {
|
|
796
|
+
try {
|
|
797
|
+
const entries = await fs.readdir(legacyPath)
|
|
798
|
+
return entries
|
|
799
|
+
.filter((e) => e.endsWith('.json'))
|
|
800
|
+
.map((e) => join(legacyPath, e))
|
|
801
|
+
} catch {
|
|
802
|
+
return null
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
return null
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
/**
|
|
810
|
+
* Safely convert a value to display text with optional truncation.
|
|
811
|
+
*/
|
|
812
|
+
function toDisplayText(value: unknown, maxChars = 10_000): string {
|
|
813
|
+
let full = ""
|
|
814
|
+
if (value == null) {
|
|
815
|
+
full = ""
|
|
816
|
+
} else if (typeof value === "string") {
|
|
817
|
+
full = value
|
|
818
|
+
} else {
|
|
819
|
+
try {
|
|
820
|
+
full = JSON.stringify(value, null, 2)
|
|
821
|
+
} catch {
|
|
822
|
+
full = String(value)
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
if (full.length <= maxChars) {
|
|
827
|
+
return full
|
|
828
|
+
}
|
|
829
|
+
return `${full.slice(0, maxChars)}\n[... truncated, ${full.length} chars total]`
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
/**
|
|
833
|
+
* Extract human-readable content from a part object.
|
|
834
|
+
*/
|
|
835
|
+
function extractPartContent(part: unknown): { text: string; toolName?: string; toolStatus?: string } {
|
|
836
|
+
const p = part as Record<string, unknown>
|
|
837
|
+
const type = typeof p.type === "string" ? p.type : "unknown"
|
|
838
|
+
|
|
839
|
+
switch (type) {
|
|
840
|
+
case "text":
|
|
841
|
+
return { text: toDisplayText(p.text) }
|
|
842
|
+
|
|
843
|
+
case "subtask":
|
|
844
|
+
return { text: toDisplayText(p.prompt ?? p.description ?? "") }
|
|
845
|
+
|
|
846
|
+
case "tool": {
|
|
847
|
+
const state = (p.state ?? {}) as Record<string, unknown>
|
|
848
|
+
const toolName = typeof p.tool === "string" ? p.tool : "unknown"
|
|
849
|
+
const status = typeof state.status === "string" ? state.status : "unknown"
|
|
850
|
+
|
|
851
|
+
// Prefer output when present; otherwise show a prompt-like input summary.
|
|
852
|
+
if ("output" in state) {
|
|
853
|
+
return { text: toDisplayText(state.output), toolName, toolStatus: status }
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
const input = (state.input ?? {}) as Record<string, unknown>
|
|
857
|
+
const prompt = input.prompt ?? `[tool:${toolName}]`
|
|
858
|
+
return { text: toDisplayText(prompt), toolName, toolStatus: status }
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
default:
|
|
862
|
+
// Unknown part type: attempt a safe JSON preview, then fall back to a label.
|
|
863
|
+
return { text: toDisplayText(part) || `[${type} part]` }
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
interface RawMessagePayload {
|
|
868
|
+
id?: string
|
|
869
|
+
sessionID?: string
|
|
870
|
+
role?: string
|
|
871
|
+
time?: { created?: number }
|
|
872
|
+
parentID?: string
|
|
873
|
+
tokens?: MessageTokens | null
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
/**
|
|
877
|
+
* Load chat message index for a session (metadata only, no parts).
|
|
878
|
+
* Returns an array of ChatMessage stubs with parts set to null.
|
|
879
|
+
*/
|
|
880
|
+
export async function loadSessionChatIndex(
|
|
881
|
+
sessionId: string,
|
|
882
|
+
root: string = DEFAULT_ROOT
|
|
883
|
+
): Promise<ChatMessage[]> {
|
|
884
|
+
const normalizedRoot = resolve(root)
|
|
885
|
+
const messagePaths = await loadSessionMessagePaths(sessionId, normalizedRoot)
|
|
886
|
+
|
|
887
|
+
if (messagePaths === null || messagePaths.length === 0) {
|
|
888
|
+
return []
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
const messages: ChatMessage[] = []
|
|
892
|
+
|
|
893
|
+
for (const msgPath of messagePaths) {
|
|
894
|
+
const payload = await readJsonFile<RawMessagePayload>(msgPath)
|
|
895
|
+
if (!payload || !payload.id) {
|
|
896
|
+
// Skip malformed entries
|
|
897
|
+
continue
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
const role: ChatRole =
|
|
901
|
+
payload.role === "user" ? "user" :
|
|
902
|
+
payload.role === "assistant" ? "assistant" :
|
|
903
|
+
"unknown"
|
|
904
|
+
|
|
905
|
+
const createdAt = msToDate(payload.time?.created)
|
|
906
|
+
|
|
907
|
+
// Parse tokens for assistant messages
|
|
908
|
+
let tokens: TokenBreakdown | undefined
|
|
909
|
+
if (role === "assistant" && payload.tokens) {
|
|
910
|
+
const parsed = parseMessageTokens(payload.tokens)
|
|
911
|
+
if (parsed) {
|
|
912
|
+
tokens = parsed
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
messages.push({
|
|
917
|
+
sessionId,
|
|
918
|
+
messageId: payload.id,
|
|
919
|
+
role,
|
|
920
|
+
createdAt,
|
|
921
|
+
parentId: payload.parentID,
|
|
922
|
+
tokens,
|
|
923
|
+
parts: null,
|
|
924
|
+
previewText: "[loading...]",
|
|
925
|
+
totalChars: null,
|
|
926
|
+
})
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
// Sort by createdAt, with stable fallback on messageId for ties/missing timestamps
|
|
930
|
+
messages.sort((a, b) => {
|
|
931
|
+
const aTime = a.createdAt?.getTime() ?? 0
|
|
932
|
+
const bTime = b.createdAt?.getTime() ?? 0
|
|
933
|
+
if (aTime !== bTime) {
|
|
934
|
+
return aTime - bTime // ascending (oldest first)
|
|
935
|
+
}
|
|
936
|
+
return a.messageId.localeCompare(b.messageId)
|
|
937
|
+
})
|
|
938
|
+
|
|
939
|
+
return messages
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
/**
|
|
943
|
+
* Load all parts for a message and extract readable content.
|
|
944
|
+
*/
|
|
945
|
+
export async function loadMessageParts(
|
|
946
|
+
messageId: string,
|
|
947
|
+
root: string = DEFAULT_ROOT
|
|
948
|
+
): Promise<ChatPart[]> {
|
|
949
|
+
const normalizedRoot = resolve(root)
|
|
950
|
+
const partPaths = await loadMessagePartPaths(messageId, normalizedRoot)
|
|
951
|
+
|
|
952
|
+
if (partPaths === null || partPaths.length === 0) {
|
|
953
|
+
return []
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
const parts: ChatPart[] = []
|
|
957
|
+
|
|
958
|
+
// Sort part paths by filename for deterministic order
|
|
959
|
+
const sortedPaths = [...partPaths].sort((a, b) => {
|
|
960
|
+
const aName = a.split('/').pop() ?? ''
|
|
961
|
+
const bName = b.split('/').pop() ?? ''
|
|
962
|
+
return aName.localeCompare(bName)
|
|
963
|
+
})
|
|
964
|
+
|
|
965
|
+
for (const partPath of sortedPaths) {
|
|
966
|
+
try {
|
|
967
|
+
const raw = await readJsonFile<Record<string, unknown>>(partPath)
|
|
968
|
+
if (!raw || !raw.id) {
|
|
969
|
+
// Skip malformed part files
|
|
970
|
+
continue
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
const partId = typeof raw.id === "string" ? raw.id : String(raw.id)
|
|
974
|
+
const typeRaw = typeof raw.type === "string" ? raw.type : "unknown"
|
|
975
|
+
const type: PartType =
|
|
976
|
+
typeRaw === "text" ? "text" :
|
|
977
|
+
typeRaw === "subtask" ? "subtask" :
|
|
978
|
+
typeRaw === "tool" ? "tool" :
|
|
979
|
+
"unknown"
|
|
980
|
+
|
|
981
|
+
const extracted = extractPartContent(raw)
|
|
982
|
+
|
|
983
|
+
parts.push({
|
|
984
|
+
partId,
|
|
985
|
+
messageId,
|
|
986
|
+
type,
|
|
987
|
+
text: extracted.text,
|
|
988
|
+
toolName: extracted.toolName,
|
|
989
|
+
toolStatus: extracted.toolStatus,
|
|
990
|
+
})
|
|
991
|
+
} catch {
|
|
992
|
+
// Skip files that fail to parse
|
|
993
|
+
continue
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
return parts
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
const PREVIEW_CHARS = 200
|
|
1001
|
+
|
|
1002
|
+
/**
|
|
1003
|
+
* Hydrate a ChatMessage with its parts, computing previewText and totalChars.
|
|
1004
|
+
*/
|
|
1005
|
+
export async function hydrateChatMessageParts(
|
|
1006
|
+
message: ChatMessage,
|
|
1007
|
+
root: string = DEFAULT_ROOT
|
|
1008
|
+
): Promise<ChatMessage> {
|
|
1009
|
+
const parts = await loadMessageParts(message.messageId, root)
|
|
1010
|
+
|
|
1011
|
+
// Combine all part texts for total chars and preview
|
|
1012
|
+
const combinedText = parts.map(p => p.text).join('\n\n')
|
|
1013
|
+
const totalChars = combinedText.length
|
|
1014
|
+
|
|
1015
|
+
let previewText: string
|
|
1016
|
+
if (combinedText.length === 0) {
|
|
1017
|
+
previewText = "[no content]"
|
|
1018
|
+
} else if (combinedText.length <= PREVIEW_CHARS) {
|
|
1019
|
+
previewText = combinedText.replace(/\n/g, ' ').trim()
|
|
1020
|
+
} else {
|
|
1021
|
+
previewText = combinedText.slice(0, PREVIEW_CHARS).replace(/\n/g, ' ').trim() + "..."
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
return {
|
|
1025
|
+
...message,
|
|
1026
|
+
parts,
|
|
1027
|
+
previewText,
|
|
1028
|
+
totalChars,
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
// ========================
|
|
1033
|
+
// Cross-Session Chat Search
|
|
1034
|
+
// ========================
|
|
1035
|
+
|
|
1036
|
+
export interface ChatSearchResult {
|
|
1037
|
+
sessionId: string
|
|
1038
|
+
sessionTitle: string
|
|
1039
|
+
projectId: string
|
|
1040
|
+
messageId: string
|
|
1041
|
+
role: ChatRole
|
|
1042
|
+
matchedText: string // snippet around the match
|
|
1043
|
+
fullText: string // full part text for display
|
|
1044
|
+
partType: PartType
|
|
1045
|
+
createdAt: Date | null
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
/**
|
|
1049
|
+
* Search across all chat content in specified sessions.
|
|
1050
|
+
* Returns matching messages with context snippets.
|
|
1051
|
+
*/
|
|
1052
|
+
export async function searchSessionsChat(
|
|
1053
|
+
sessions: SessionRecord[],
|
|
1054
|
+
query: string,
|
|
1055
|
+
root: string = DEFAULT_ROOT,
|
|
1056
|
+
options: { maxResults?: number } = {}
|
|
1057
|
+
): Promise<ChatSearchResult[]> {
|
|
1058
|
+
const normalizedRoot = resolve(root)
|
|
1059
|
+
const queryLower = query.toLowerCase().trim()
|
|
1060
|
+
const maxResults = options.maxResults ?? 100
|
|
1061
|
+
const results: ChatSearchResult[] = []
|
|
1062
|
+
|
|
1063
|
+
if (!queryLower) {
|
|
1064
|
+
return results
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
for (const session of sessions) {
|
|
1068
|
+
if (results.length >= maxResults) break
|
|
1069
|
+
|
|
1070
|
+
// Load messages for this session
|
|
1071
|
+
const messages = await loadSessionChatIndex(session.sessionId, normalizedRoot)
|
|
1072
|
+
|
|
1073
|
+
for (const message of messages) {
|
|
1074
|
+
if (results.length >= maxResults) break
|
|
1075
|
+
|
|
1076
|
+
// Load parts to search content
|
|
1077
|
+
const parts = await loadMessageParts(message.messageId, normalizedRoot)
|
|
1078
|
+
|
|
1079
|
+
for (const part of parts) {
|
|
1080
|
+
if (results.length >= maxResults) break
|
|
1081
|
+
|
|
1082
|
+
const textLower = part.text.toLowerCase()
|
|
1083
|
+
const matchIndex = textLower.indexOf(queryLower)
|
|
1084
|
+
|
|
1085
|
+
if (matchIndex !== -1) {
|
|
1086
|
+
// Create a snippet around the match
|
|
1087
|
+
const snippetStart = Math.max(0, matchIndex - 50)
|
|
1088
|
+
const snippetEnd = Math.min(part.text.length, matchIndex + query.length + 50)
|
|
1089
|
+
let snippet = part.text.slice(snippetStart, snippetEnd)
|
|
1090
|
+
if (snippetStart > 0) snippet = "..." + snippet
|
|
1091
|
+
if (snippetEnd < part.text.length) snippet = snippet + "..."
|
|
1092
|
+
|
|
1093
|
+
results.push({
|
|
1094
|
+
sessionId: session.sessionId,
|
|
1095
|
+
sessionTitle: session.title || session.sessionId,
|
|
1096
|
+
projectId: session.projectId,
|
|
1097
|
+
messageId: message.messageId,
|
|
1098
|
+
role: message.role,
|
|
1099
|
+
matchedText: snippet.replace(/\n/g, ' '),
|
|
1100
|
+
fullText: part.text,
|
|
1101
|
+
partType: part.type,
|
|
1102
|
+
createdAt: message.createdAt,
|
|
1103
|
+
})
|
|
1104
|
+
|
|
1105
|
+
// Only one result per message to avoid duplicates
|
|
1106
|
+
break
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
return results
|
|
1113
|
+
}
|