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.
@@ -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
+ }
@@ -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
+ }