opencode-manager 0.3.1 → 0.4.1

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.
@@ -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
+ }
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Fuzzy search utilities using fast-fuzzy.
3
+ * Extracted from TUI for reuse in CLI.
4
+ */
5
+ import { Searcher, MatchData, FullOptions } from "fast-fuzzy"
6
+
7
+ /**
8
+ * A search candidate with an item and searchable text.
9
+ */
10
+ export type SearchCandidate<T> = {
11
+ item: T
12
+ searchText: string
13
+ }
14
+
15
+ /**
16
+ * A search result with the matched item and score.
17
+ */
18
+ export type SearchResult<T> = {
19
+ item: T
20
+ score: number
21
+ }
22
+
23
+ /**
24
+ * Options for fuzzy search.
25
+ */
26
+ export type FuzzySearchOptions = {
27
+ /** Maximum number of results to return (default: 200) */
28
+ limit?: number
29
+ }
30
+
31
+ // Type for the searcher options with keySelector
32
+ type SearcherOptions<T> = FullOptions<SearchCandidate<T>> & {
33
+ keySelector: (c: SearchCandidate<T>) => string
34
+ }
35
+
36
+ /**
37
+ * Creates a fuzzy searcher for items with searchable text.
38
+ *
39
+ * @param candidates - Array of search candidates with items and search text
40
+ * @returns A Searcher instance configured for the candidates
41
+ */
42
+ export function createSearcher<T>(
43
+ candidates: SearchCandidate<T>[]
44
+ ): Searcher<SearchCandidate<T>, SearcherOptions<T>> {
45
+ return new Searcher(candidates, {
46
+ keySelector: (c: SearchCandidate<T>) => c.searchText,
47
+ })
48
+ }
49
+
50
+ /**
51
+ * Performs a fuzzy search on the given candidates.
52
+ *
53
+ * @param candidates - Array of search candidates
54
+ * @param query - The search query string
55
+ * @param options - Optional search options
56
+ * @returns Array of search results sorted by score (descending)
57
+ */
58
+ export function fuzzySearch<T>(
59
+ candidates: SearchCandidate<T>[],
60
+ query: string,
61
+ options?: FuzzySearchOptions
62
+ ): SearchResult<T>[] {
63
+ const q = query.trim()
64
+ if (!q) {
65
+ // No query - return all items with score 1
66
+ return candidates.map((c) => ({ item: c.item, score: 1 }))
67
+ }
68
+
69
+ const searcher = createSearcher(candidates)
70
+ const results = searcher.search(q, { returnMatchData: true }) as MatchData<SearchCandidate<T>>[]
71
+
72
+ const mapped: SearchResult<T>[] = results.map((match) => ({
73
+ item: match.item.item,
74
+ score: match.score,
75
+ }))
76
+
77
+ // Sort by score descending
78
+ mapped.sort((a, b) => b.score - a.score)
79
+
80
+ // Apply limit
81
+ const limit = options?.limit ?? 200
82
+ if (mapped.length > limit) {
83
+ return mapped.slice(0, limit)
84
+ }
85
+
86
+ return mapped
87
+ }
88
+
89
+ /**
90
+ * Performs fuzzy search and returns only the matched items (not scores).
91
+ *
92
+ * @param candidates - Array of search candidates
93
+ * @param query - The search query string
94
+ * @param options - Optional search options
95
+ * @returns Array of matched items sorted by score (descending)
96
+ */
97
+ export function fuzzySearchItems<T>(
98
+ candidates: SearchCandidate<T>[],
99
+ query: string,
100
+ options?: FuzzySearchOptions
101
+ ): T[] {
102
+ return fuzzySearch(candidates, query, options).map((r) => r.item)
103
+ }
104
+
105
+ /**
106
+ * Builds a search text string from multiple fields.
107
+ * Joins all fields with spaces and normalizes whitespace.
108
+ *
109
+ * @param fields - Array of string fields to combine
110
+ * @returns A normalized search text string
111
+ */
112
+ export function buildSearchText(...fields: (string | null | undefined)[]): string {
113
+ return fields
114
+ .filter((f): f is string => f != null && f !== "")
115
+ .join(" ")
116
+ .replace(/\s+/g, " ")
117
+ .trim()
118
+ }
119
+
120
+ /**
121
+ * Options for tokenized search.
122
+ */
123
+ export type TokenizedSearchOptions = {
124
+ /** Maximum number of results to return (default: 200) */
125
+ limit?: number
126
+ }
127
+
128
+ /**
129
+ * Performs tokenized substring search on items.
130
+ * Matches TUI project search semantics:
131
+ * - Query is split on whitespace into tokens
132
+ * - Each token must be found in at least one of the searchable fields
133
+ * - Matching is case-insensitive substring matching
134
+ *
135
+ * @param items - Array of items to search
136
+ * @param query - The search query string
137
+ * @param getFields - Function to extract searchable fields from an item
138
+ * @param options - Optional search options
139
+ * @returns Array of items that match all tokens
140
+ */
141
+ export function tokenizedSearch<T>(
142
+ items: T[],
143
+ query: string,
144
+ getFields: (item: T) => (string | null | undefined)[],
145
+ options?: TokenizedSearchOptions
146
+ ): T[] {
147
+ const q = query?.trim().toLowerCase() ?? ""
148
+ if (!q) {
149
+ const limit = options?.limit ?? 200
150
+ return items.slice(0, limit)
151
+ }
152
+
153
+ const tokens = q.split(/\s+/).filter(Boolean)
154
+ if (tokens.length === 0) {
155
+ const limit = options?.limit ?? 200
156
+ return items.slice(0, limit)
157
+ }
158
+
159
+ const matched = items.filter((item) => {
160
+ const fields = getFields(item).map((f) => (f || "").toLowerCase())
161
+ return tokens.every((tok) => fields.some((field) => field.includes(tok)))
162
+ })
163
+
164
+ const limit = options?.limit ?? 200
165
+ if (matched.length > limit) {
166
+ return matched.slice(0, limit)
167
+ }
168
+
169
+ return matched
170
+ }