@sovovs/bycli 2.1.37 → 2.1.39

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/clis/ima/ax.js ADDED
@@ -0,0 +1,656 @@
1
+ import { execFileSync } from 'node:child_process';
2
+
3
+ import { normalizeArticleUrl } from './utils.js';
4
+
5
+ const OPEN_KNOWLEDGE_APPLESCRIPT = [
6
+ 'tell application "System Events" to tell process "ima.copilot"',
7
+ 'set frontmost to true',
8
+ 'if exists front window then',
9
+ 'repeat with itemRef in (entire contents of front window)',
10
+ 'try',
11
+ 'if description of itemRef is "知识库" then click itemRef',
12
+ 'end try',
13
+ 'end repeat',
14
+ 'end if',
15
+ 'end tell',
16
+ 'delay 1',
17
+ ];
18
+
19
+ const AX_KNOWLEDGE_SCRIPT = String.raw`
20
+ import Cocoa
21
+ import ApplicationServices
22
+
23
+ struct DriverError: Error {
24
+ let code: String
25
+ let message: String
26
+ }
27
+
28
+ struct Candidate {
29
+ let name: String
30
+ let id: String
31
+ let element: AXUIElement
32
+ }
33
+
34
+ let query = CommandLine.arguments.dropFirst().first ?? ""
35
+ let bundleID = "com.tencent.imamac"
36
+
37
+ func attribute(_ element: AXUIElement, _ name: CFString) -> AnyObject? {
38
+ var value: CFTypeRef?
39
+ guard AXUIElementCopyAttributeValue(element, name, &value) == .success else { return nil }
40
+ return value
41
+ }
42
+
43
+ func stringAttribute(_ element: AXUIElement, _ name: CFString) -> String? {
44
+ if let value = attribute(element, name) as? String { return value }
45
+ if let value = attribute(element, name) as? URL { return value.absoluteString }
46
+ return nil
47
+ }
48
+
49
+ func elementAttribute(_ element: AXUIElement, _ name: CFString) -> AXUIElement? {
50
+ guard let value = attribute(element, name) else { return nil }
51
+ return (value as! AXUIElement)
52
+ }
53
+
54
+ func children(_ element: AXUIElement) -> [AXUIElement] {
55
+ return attribute(element, kAXChildrenAttribute as CFString) as? [AXUIElement] ?? []
56
+ }
57
+
58
+ func role(_ element: AXUIElement) -> String {
59
+ return stringAttribute(element, kAXRoleAttribute as CFString) ?? ""
60
+ }
61
+
62
+ func label(_ element: AXUIElement) -> String? {
63
+ for key in [kAXTitleAttribute, kAXDescriptionAttribute, kAXValueAttribute, kAXHelpAttribute] {
64
+ if let value = stringAttribute(element, key as CFString), !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
65
+ return value.trimmingCharacters(in: .whitespacesAndNewlines)
66
+ }
67
+ }
68
+ return nil
69
+ }
70
+
71
+ func descendants(_ root: AXUIElement, limit: Int = 12000) -> [AXUIElement] {
72
+ var result: [AXUIElement] = []
73
+ var queue = children(root)
74
+ var seen = Set<CFHashCode>()
75
+ while !queue.isEmpty && result.count < limit {
76
+ let item = queue.removeFirst()
77
+ let hash = CFHash(item)
78
+ if seen.contains(hash) { continue }
79
+ seen.insert(hash)
80
+ result.append(item)
81
+ queue.append(contentsOf: children(item))
82
+ }
83
+ return result
84
+ }
85
+
86
+ func actions(_ element: AXUIElement) -> [String] {
87
+ var names: CFArray?
88
+ guard AXUIElementCopyActionNames(element, &names) == .success else { return [] }
89
+ return names as? [String] ?? []
90
+ }
91
+
92
+ func pause(_ seconds: TimeInterval) {
93
+ RunLoop.current.run(until: Date().addingTimeInterval(seconds))
94
+ }
95
+
96
+ func clickCenter(_ element: AXUIElement) -> Bool {
97
+ guard let positionValue = attribute(element, kAXPositionAttribute as CFString),
98
+ let sizeValue = attribute(element, kAXSizeAttribute as CFString) else { return false }
99
+ let rawPosition = positionValue as! AXValue
100
+ let rawSize = sizeValue as! AXValue
101
+ var position = CGPoint.zero
102
+ var size = CGSize.zero
103
+ guard AXValueGetValue(rawPosition, .cgPoint, &position),
104
+ AXValueGetValue(rawSize, .cgSize, &size), size.width > 1, size.height > 1 else { return false }
105
+ let point = CGPoint(x: position.x + size.width / 2, y: position.y + size.height / 2)
106
+ guard let down = CGEvent(mouseEventSource: nil, mouseType: .leftMouseDown, mouseCursorPosition: point, mouseButton: .left),
107
+ let up = CGEvent(mouseEventSource: nil, mouseType: .leftMouseUp, mouseCursorPosition: point, mouseButton: .left) else { return false }
108
+ down.post(tap: CGEventTapLocation.cghidEventTap)
109
+ up.post(tap: CGEventTapLocation.cghidEventTap)
110
+ return true
111
+ }
112
+
113
+ func frame(_ element: AXUIElement) -> CGRect? {
114
+ guard let positionValue = attribute(element, kAXPositionAttribute as CFString),
115
+ let sizeValue = attribute(element, kAXSizeAttribute as CFString) else { return nil }
116
+ var position = CGPoint.zero
117
+ var size = CGSize.zero
118
+ guard AXValueGetValue(positionValue as! AXValue, .cgPoint, &position),
119
+ AXValueGetValue(sizeValue as! AXValue, .cgSize, &size) else { return nil }
120
+ return CGRect(origin: position, size: size)
121
+ }
122
+
123
+ func actionable(_ element: AXUIElement) -> AXUIElement? {
124
+ var current: AXUIElement? = element
125
+ for _ in 0..<7 {
126
+ guard let item = current else { return nil }
127
+ if actions(item).contains(kAXPressAction as String) { return item }
128
+ current = elementAttribute(item, kAXParentAttribute as CFString)
129
+ }
130
+ return nil
131
+ }
132
+
133
+ @discardableResult
134
+ func press(_ element: AXUIElement) -> Bool {
135
+ if let target = actionable(element), AXUIElementPerformAction(target, kAXPressAction as CFString) == .success {
136
+ return true
137
+ }
138
+ var current: AXUIElement? = element
139
+ for _ in 0..<5 {
140
+ guard let item = current else { return false }
141
+ if role(item) == (kAXGroupRole as String), clickCenter(item) { return true }
142
+ current = elementAttribute(item, kAXParentAttribute as CFString)
143
+ }
144
+ return clickCenter(element)
145
+ }
146
+
147
+ func waitUntil(_ timeout: TimeInterval, _ condition: () -> Bool) -> Bool {
148
+ let deadline = Date().addingTimeInterval(timeout)
149
+ repeat {
150
+ if condition() { return true }
151
+ RunLoop.current.run(until: Date().addingTimeInterval(0.15))
152
+ } while Date() < deadline
153
+ return false
154
+ }
155
+
156
+ func windows(_ app: AXUIElement) -> [AXUIElement] {
157
+ let all = attribute(app, kAXWindowsAttribute as CFString) as? [AXUIElement] ?? []
158
+ return all.filter { !children($0).isEmpty }
159
+ .sorted { descendants($0, limit: 300).count > descendants($1, limit: 300).count }
160
+ }
161
+
162
+ func knowledgeWindow(_ app: AXUIElement) -> AXUIElement? {
163
+ return windows(app).first(where: { !pageKnowledgeID($0).isEmpty })
164
+ }
165
+
166
+ func publicURL(in root: AXUIElement) -> String? {
167
+ func acceptable(_ value: String?) -> String? {
168
+ guard let value, value.hasPrefix("http://") || value.hasPrefix("https://") else { return nil }
169
+ return value.contains("ima.qq.com") || value.contains("ima.copilot") ? nil : value
170
+ }
171
+ if let value = acceptable(stringAttribute(root, kAXURLAttribute as CFString)) { return value }
172
+ for element in descendants(root, limit: 4000) {
173
+ let elementRole = role(element)
174
+ let description = stringAttribute(element, kAXDescriptionAttribute as CFString) ?? ""
175
+ let isDocumentLocation = elementRole == "AXWebArea"
176
+ || (elementRole == (kAXTextFieldRole as String) && description.contains("地址"))
177
+ guard isDocumentLocation else { continue }
178
+ if let value = acceptable(stringAttribute(element, kAXURLAttribute as CFString)) { return value }
179
+ if let value = acceptable(stringAttribute(element, kAXValueAttribute as CFString)) { return value }
180
+ }
181
+ return nil
182
+ }
183
+
184
+ func windowSignature(_ window: AXUIElement) -> String {
185
+ let title = label(window) ?? ""
186
+ let document = stringAttribute(window, kAXDocumentAttribute as CFString) ?? ""
187
+ return "\(title)|\(document)"
188
+ }
189
+
190
+ func readPublicURL(
191
+ app: AXUIElement,
192
+ excluding known: Set<String>,
193
+ knowledgeBeforeOpen: AXUIElement,
194
+ expectsURL: Bool
195
+ ) -> (AXUIElement, String?, Bool)? {
196
+ var fallback: (AXUIElement, String?, Bool)? = nil
197
+ let deadline = Date().addingTimeInterval(expectsURL ? 12 : 2)
198
+ repeat {
199
+ for window in windows(app).reversed()
200
+ where !known.contains(windowSignature(window)) && pageKnowledgeID(window).isEmpty {
201
+ if let url = publicURL(in: window) {
202
+ return (window, url, CFEqual(window, knowledgeBeforeOpen))
203
+ }
204
+ let title = label(window) ?? ""
205
+ if !title.isEmpty { fallback = (window, nil, CFEqual(window, knowledgeBeforeOpen)) }
206
+ }
207
+ pause(0.15)
208
+ } while Date() < deadline
209
+ return fallback
210
+ }
211
+
212
+ func closeArticlePage(app: AXUIElement, _ window: AXUIElement, reusedKnowledgeWindow: Bool) -> Bool {
213
+ let openedSignature = windowSignature(window)
214
+ _ = AXUIElementPerformAction(window, kAXRaiseAction as CFString)
215
+ if let owner = elementAttribute(window, kAXParentAttribute as CFString) {
216
+ _ = AXUIElementSetAttributeValue(owner, kAXFocusedWindowAttribute as CFString, window)
217
+ }
218
+ pause(0.2)
219
+ let keyCode: CGKeyCode = reusedKnowledgeWindow ? 33 : 13 // Command-[ goes back; Command-W closes a new tab/window.
220
+ guard let down = CGEvent(keyboardEventSource: nil, virtualKey: keyCode, keyDown: true),
221
+ let up = CGEvent(keyboardEventSource: nil, virtualKey: keyCode, keyDown: false) else { return false }
222
+ down.flags = .maskCommand
223
+ up.flags = .maskCommand
224
+ down.post(tap: .cghidEventTap)
225
+ up.post(tap: .cghidEventTap)
226
+ return waitUntil(6) {
227
+ knowledgeWindow(app) != nil && !windows(app).contains(where: { windowSignature($0) == openedSignature })
228
+ }
229
+ }
230
+
231
+ func pageKnowledgeID(_ window: AXUIElement) -> String {
232
+ for element in [window] + descendants(window, limit: 4000) {
233
+ guard let value = stringAttribute(element, kAXURLAttribute as CFString),
234
+ let range = value.range(of: "knowledgeBaseId=") else { continue }
235
+ let suffix = value[range.upperBound...]
236
+ return String(suffix.prefix { $0 != "&" && $0 != "#" })
237
+ }
238
+ return ""
239
+ }
240
+
241
+ func pageKnowledgeName(_ window: AXUIElement) -> String {
242
+ for element in descendants(window, limit: 4000) {
243
+ guard let value = stringAttribute(element, kAXURLAttribute as CFString),
244
+ value.contains("knowledgeBaseId="), let name = label(element) else { continue }
245
+ return name
246
+ }
247
+ return ""
248
+ }
249
+
250
+ func exactElements(_ root: AXUIElement, _ text: String) -> [AXUIElement] {
251
+ return descendants(root).filter { label($0) == text }
252
+ }
253
+
254
+ func sidebarTextElements(_ window: AXUIElement) -> [AXUIElement] {
255
+ let windowFrame = frame(window) ?? .zero
256
+ return descendants(window).filter { element in
257
+ guard role(element) == (kAXStaticTextRole as String),
258
+ let text = label(element), text.count > 0, text.count < 100,
259
+ let itemFrame = frame(element) else { return false }
260
+ let inSidebar = itemFrame.midX < windowFrame.minX + min(430, windowFrame.width * 0.35)
261
+ return inSidebar && !["个人知识库", "共享知识库", "订阅知识库"].contains(text)
262
+ }
263
+ }
264
+
265
+ func navigateToKnowledge(app: AXUIElement) throws -> AXUIElement {
266
+ if let existing = knowledgeWindow(app) { return existing }
267
+ guard !windows(app).isEmpty else {
268
+ throw DriverError(code: "IMA_NOT_RUNNING", message: "ima is running but has no visible window")
269
+ }
270
+ var knowledgeTab: AXUIElement? = nil
271
+ var navigationWindow: AXUIElement? = nil
272
+ _ = waitUntil(10) {
273
+ for window in windows(app) {
274
+ if let tab = exactElements(window, "知识库").first {
275
+ knowledgeTab = tab
276
+ navigationWindow = window
277
+ return true
278
+ }
279
+ }
280
+ return false
281
+ }
282
+ if let tab = knowledgeTab {
283
+ if let window = navigationWindow {
284
+ _ = AXUIElementPerformAction(window, kAXRaiseAction as CFString)
285
+ _ = AXUIElementSetAttributeValue(app, kAXFocusedWindowAttribute as CFString, window)
286
+ pause(0.2)
287
+ }
288
+ _ = clickCenter(tab)
289
+ _ = waitUntil(6) { knowledgeWindow(app) != nil }
290
+ }
291
+ if let selected = knowledgeWindow(app) { return selected }
292
+ throw DriverError(code: "KNOWLEDGE_NOT_FOUND", message: "ima did not open its knowledge-base page")
293
+ }
294
+
295
+ func selectKnowledge(app: AXUIElement, window: AXUIElement) throws -> Candidate {
296
+ let currentID = pageKnowledgeID(window)
297
+ let currentName = pageKnowledgeName(window)
298
+ if query == currentID {
299
+ return Candidate(name: currentName, id: currentID, element: window)
300
+ }
301
+ // Exact visible-name lookup is restricted to knowledge-base rows.
302
+ let exactMatches = sidebarTextElements(window).filter { label($0) == query }
303
+ if exactMatches.count > 1 {
304
+ throw DriverError(code: "AMBIGUOUS_KNOWLEDGE", message: "Multiple knowledge bases are named '\(query)'; use an ID")
305
+ }
306
+ if query == currentName {
307
+ return Candidate(name: currentName, id: currentID, element: window)
308
+ }
309
+ if let element = exactMatches.first, press(element) {
310
+ _ = waitUntil(4) {
311
+ guard let current = knowledgeWindow(app) else { return false }
312
+ return pageKnowledgeName(current) == query
313
+ }
314
+ let current = knowledgeWindow(app) ?? window
315
+ let candidate = Candidate(name: pageKnowledgeName(current), id: pageKnowledgeID(current), element: element)
316
+ if query == candidate.name { return candidate }
317
+ }
318
+
319
+ // ID lookup: visit sidebar rows and compare the ID exposed by ima's page URL.
320
+ let visible = sidebarTextElements(window)
321
+ var checked = Set<String>()
322
+ for element in visible {
323
+ guard let name = label(element) else { continue }
324
+ let nodeID = (attribute(element, "ChromeAXNodeId" as CFString) as? NSNumber)?.stringValue
325
+ let identity = nodeID ?? "\(CFHash(element)):\(name)"
326
+ guard !checked.contains(identity), press(element) else { continue }
327
+ checked.insert(identity)
328
+ pause(0.8)
329
+ let current = knowledgeWindow(app) ?? window
330
+ let candidate = Candidate(name: pageKnowledgeName(current), id: pageKnowledgeID(current), element: element)
331
+ if query == candidate.name || query == candidate.id { return candidate }
332
+ }
333
+ throw DriverError(code: "KNOWLEDGE_NOT_FOUND", message: "No knowledge base exactly matches '\(query)'")
334
+ }
335
+
336
+ let datePattern = try! NSRegularExpression(pattern: #"(?:\d{4}[-/.])?\d{1,2}[-/.]\d{1,2}|\d{1,2}/\d{1,2}"#)
337
+ let folderPattern = try! NSRegularExpression(pattern: #"^\d+\s*项(?:\s|$)"#)
338
+ let knownTypes = ["公众号", "网页", "PDF", "文档", "文件", "笔记", "视频", "音频", "图片"]
339
+
340
+ func firstMatch(_ regex: NSRegularExpression, _ value: String) -> Bool {
341
+ return regex.firstMatch(in: value, range: NSRange(value.startIndex..., in: value)) != nil
342
+ }
343
+
344
+ func textValues(_ element: AXUIElement) -> [String] {
345
+ var values: [String] = []
346
+ for item in [element] + descendants(element, limit: 100) {
347
+ if let value = label(item), !values.contains(value) { values.append(value) }
348
+ }
349
+ return values
350
+ }
351
+
352
+ struct Row {
353
+ let element: AXUIElement
354
+ let identity: String
355
+ let bounds: CGRect
356
+ let title: String
357
+ let metadata: String
358
+ let isFolder: Bool
359
+ }
360
+
361
+ struct FolderTarget {
362
+ let title: String
363
+ let identity: String
364
+ let ordinal: Int
365
+ }
366
+
367
+ func rows(in window: AXUIElement) -> [Row] {
368
+ var result: [Row] = []
369
+ let groups = descendants(window)
370
+ .filter { role($0) == (kAXGroupRole as String) }
371
+ .sorted { descendants($0, limit: 100).count < descendants($1, limit: 100).count }
372
+ for element in groups {
373
+ let values = textValues(element).filter { $0.count < 240 }
374
+ guard values.count >= 2 && values.count <= 7 else { continue }
375
+ let folderMetadata = values.first(where: { firstMatch(folderPattern, $0) })
376
+ let rowType = values.first(where: { value in knownTypes.contains(where: { value.hasPrefix($0) }) })
377
+ let rowDate = values.first(where: { firstMatch(datePattern, $0) })
378
+ let metadata: String
379
+ let isFolder: Bool
380
+ if let folderMetadata {
381
+ metadata = folderMetadata
382
+ isFolder = true
383
+ } else if let rowType, let rowDate {
384
+ metadata = "\(rowType) \(rowDate)"
385
+ isFolder = false
386
+ } else {
387
+ continue
388
+ }
389
+ guard let title = values.first(where: {
390
+ $0 != metadata && !knownTypes.contains($0) && !firstMatch(datePattern, $0)
391
+ && !$0.hasPrefix("/") && $0 != "没有更多内容了"
392
+ }) else { continue }
393
+ let bounds = frame(element) ?? .zero
394
+ let overlapsAcceptedRow = result.contains { accepted in
395
+ guard accepted.title == title && accepted.metadata == metadata,
396
+ !accepted.bounds.isEmpty && !bounds.isEmpty else { return false }
397
+ return accepted.bounds.contains(CGPoint(x: bounds.midX, y: bounds.midY))
398
+ || bounds.contains(CGPoint(x: accepted.bounds.midX, y: accepted.bounds.midY))
399
+ }
400
+ if overlapsAcceptedRow { continue }
401
+ let nodeID = (attribute(element, "ChromeAXNodeId" as CFString) as? NSNumber)?.stringValue
402
+ let identity = nodeID ?? "\(CFHash(element)):\(title):\(metadata)"
403
+ result.append(Row(
404
+ element: element,
405
+ identity: identity,
406
+ bounds: bounds,
407
+ title: title,
408
+ metadata: metadata,
409
+ isFolder: isFolder
410
+ ))
411
+ }
412
+ return result
413
+ }
414
+
415
+ func scrollPage(_ window: AXUIElement) -> Bool {
416
+ for element in descendants(window).reversed() where role(element) == (kAXScrollAreaRole as String) {
417
+ let action = "AXScrollDownByPage" as CFString
418
+ if actions(element).contains(action as String), AXUIElementPerformAction(element, action) == .success { return true }
419
+ if let bar = elementAttribute(element, kAXVerticalScrollBarAttribute as CFString),
420
+ let current = attribute(bar, kAXValueAttribute as CFString) as? NSNumber {
421
+ let next = min(1, current.doubleValue + 0.72)
422
+ if next > current.doubleValue + 0.001 {
423
+ return AXUIElementSetAttributeValue(bar, kAXValueAttribute as CFString, NSNumber(value: next)) == .success
424
+ }
425
+ }
426
+ }
427
+ return false
428
+ }
429
+
430
+ func scrollToTop(_ window: AXUIElement) {
431
+ for element in descendants(window) where role(element) == (kAXScrollAreaRole as String) {
432
+ if let bar = elementAttribute(element, kAXVerticalScrollBarAttribute as CFString) {
433
+ _ = AXUIElementSetAttributeValue(bar, kAXValueAttribute as CFString, NSNumber(value: 0))
434
+ }
435
+ }
436
+ pause(0.5)
437
+ }
438
+
439
+ func findFolderRow(app: AXUIElement, target: FolderTarget) -> Row? {
440
+ guard let initial = knowledgeWindow(app) else { return nil }
441
+ scrollToTop(initial)
442
+ var visitedPages = Set<String>()
443
+ var fallbackSeen = Set<String>()
444
+ var matchingTitleSeen = 0
445
+ while let window = knowledgeWindow(app) {
446
+ let folders = rows(in: window).filter { $0.isFolder }
447
+ let pageIdentity = rows(in: window).map(\.identity).sorted().joined(separator: "|")
448
+ if visitedPages.contains(pageIdentity) { return nil }
449
+ visitedPages.insert(pageIdentity)
450
+ if let match = folders.first(where: { $0.identity == target.identity }) { return match }
451
+ let newTitleMatches = folders.filter {
452
+ $0.title == target.title && !fallbackSeen.contains($0.identity)
453
+ }
454
+ if target.ordinal >= matchingTitleSeen
455
+ && target.ordinal < matchingTitleSeen + newTitleMatches.count {
456
+ return newTitleMatches[target.ordinal - matchingTitleSeen]
457
+ }
458
+ for match in newTitleMatches { fallbackSeen.insert(match.identity) }
459
+ matchingTitleSeen += newTitleMatches.count
460
+ if !scrollPage(window) { return nil }
461
+ pause(0.8)
462
+ }
463
+ return nil
464
+ }
465
+
466
+ func scrapeCurrentFolder(
467
+ app: AXUIElement,
468
+ knowledge: Candidate,
469
+ folderPath: [String],
470
+ folderTargets: inout [FolderTarget]
471
+ ) throws -> [[String: Any]] {
472
+ var output: [[String: Any]] = []
473
+ var seen = Set<String>()
474
+ var idleRounds = 0
475
+ while idleRounds < 2 {
476
+ guard let window = knowledgeWindow(app) else { break }
477
+ let pageRows = rows(in: window)
478
+ var foldersAdded = 0
479
+ for folder in pageRows where folder.isFolder
480
+ && !folderTargets.contains(where: { $0.identity == folder.identity }) {
481
+ let ordinal = folderTargets.filter { $0.title == folder.title }.count
482
+ folderTargets.append(FolderTarget(title: folder.title, identity: folder.identity, ordinal: ordinal))
483
+ foldersAdded += 1
484
+ }
485
+ var added = 0
486
+ for row in pageRows where !row.isFolder && !seen.contains(row.identity) {
487
+ seen.insert(row.identity)
488
+ added += 1
489
+ let parts = row.metadata.split(separator: " ").map(String.init)
490
+ let contentType = parts.first ?? ""
491
+ let addedDate = parts.first(where: { firstMatch(datePattern, $0) }) ?? ""
492
+ let before = Set(windows(app).map(windowSignature))
493
+ guard let knowledgeBeforeOpen = knowledgeWindow(app) else {
494
+ throw DriverError(code: "KNOWLEDGE_WINDOW_LOST", message: "Knowledge-base window disappeared")
495
+ }
496
+ var url: String? = nil
497
+ let expectsURL = contentType == "公众号" || contentType == "网页"
498
+ if press(row.element), let opened = readPublicURL(
499
+ app: app,
500
+ excluding: before,
501
+ knowledgeBeforeOpen: knowledgeBeforeOpen,
502
+ expectsURL: expectsURL
503
+ ) {
504
+ url = opened.1
505
+ if !closeArticlePage(app: app, opened.0, reusedKnowledgeWindow: opened.2) {
506
+ throw DriverError(code: "TAB_CLOSE_FAILED", message: "Could not close article tab '\(row.title)' safely")
507
+ }
508
+ }
509
+ output.append([
510
+ "knowledgeBaseId": knowledge.id,
511
+ "knowledgeBase": knowledge.name,
512
+ "folderPath": folderPath,
513
+ "title": row.title,
514
+ "url": url ?? NSNull(),
515
+ "contentType": contentType,
516
+ "addedDate": addedDate
517
+ ])
518
+ }
519
+ if added == 0 && foldersAdded == 0 { idleRounds += 1 } else { idleRounds = 0 }
520
+ guard let current = knowledgeWindow(app), scrollPage(current) else { break }
521
+ pause(0.8)
522
+ }
523
+ return output
524
+ }
525
+
526
+ func scrapeFolderRecursive(app: AXUIElement, knowledge: Candidate, folderPath: [String], depth: Int = 0) throws -> [[String: Any]] {
527
+ guard depth < 20, knowledgeWindow(app) != nil else { return [] }
528
+ var folderTargets: [FolderTarget] = []
529
+ var output = try scrapeCurrentFolder(
530
+ app: app,
531
+ knowledge: knowledge,
532
+ folderPath: folderPath,
533
+ folderTargets: &folderTargets
534
+ )
535
+ for folderTarget in folderTargets {
536
+ guard let folder = findFolderRow(app: app, target: folderTarget), press(folder.element) else {
537
+ throw DriverError(code: "FOLDER_NAVIGATION_FAILED", message: "Could not open folder '\(folderTarget.title)'")
538
+ }
539
+ pause(1)
540
+ output.append(contentsOf: try scrapeFolderRecursive(
541
+ app: app,
542
+ knowledge: knowledge,
543
+ folderPath: folderPath + [folderTarget.title],
544
+ depth: depth + 1
545
+ ))
546
+ let parentName = folderPath.last ?? knowledge.name
547
+ guard let childWindow = knowledgeWindow(app),
548
+ let parentCrumb = exactElements(childWindow, parentName).last,
549
+ press(parentCrumb) else {
550
+ throw DriverError(code: "FOLDER_NAVIGATION_FAILED", message: "Could not return to folder '\(parentName)'")
551
+ }
552
+ pause(1)
553
+ }
554
+ return output
555
+ }
556
+
557
+ func scrapeKnowledge(app: AXUIElement, knowledge: Candidate) throws -> [[String: Any]] {
558
+ return try scrapeFolderRecursive(app: app, knowledge: knowledge, folderPath: [])
559
+ }
560
+
561
+ func emit(_ value: [String: Any]) {
562
+ let data = try! JSONSerialization.data(withJSONObject: value, options: [])
563
+ print(String(data: data, encoding: .utf8)!)
564
+ }
565
+
566
+ do {
567
+ guard !query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
568
+ throw DriverError(code: "EMPTY_QUERY", message: "Knowledge-base name or ID is required")
569
+ }
570
+ guard AXIsProcessTrusted() else {
571
+ throw DriverError(code: "ACCESSIBILITY_PERMISSION_REQUIRED", message: "Enable Accessibility access for the terminal running bycli")
572
+ }
573
+ let runningApps = NSRunningApplication.runningApplications(withBundleIdentifier: bundleID)
574
+ guard !runningApps.isEmpty else {
575
+ throw DriverError(code: "IMA_NOT_RUNNING", message: "Open ima before running this command")
576
+ }
577
+ var selected: (NSRunningApplication, AXUIElement)? = nil
578
+ for running in runningApps where !running.isTerminated {
579
+ let candidate = AXUIElementCreateApplication(running.processIdentifier)
580
+ if !windows(candidate).isEmpty { selected = (running, candidate); break }
581
+ }
582
+ guard let (running, app) = selected else {
583
+ throw DriverError(code: "IMA_NOT_RUNNING", message: "ima is running but has no visible window")
584
+ }
585
+ running.activate()
586
+ let window = try navigateToKnowledge(app: app)
587
+ let knowledge = try selectKnowledge(app: app, window: window)
588
+ guard let contentWindow = knowledgeWindow(app),
589
+ descendants(contentWindow).contains(where: { role($0) == "AXWebArea" }) else {
590
+ throw DriverError(
591
+ code: "ACCESSIBILITY_CONTENT_UNAVAILABLE",
592
+ message: "ima's article list is not exposed to macOS Accessibility; quit ima and reopen it with --force-renderer-accessibility"
593
+ )
594
+ }
595
+ let items = try scrapeKnowledge(app: app, knowledge: knowledge)
596
+ emit(["ok": true, "knowledgeBaseId": knowledge.id, "knowledgeBase": knowledge.name, "items": items])
597
+ } catch let error as DriverError {
598
+ emit(["ok": false, "code": error.code, "message": error.message])
599
+ } catch {
600
+ emit(["ok": false, "code": "AX_DRIVER_FAILED", "message": String(describing: error)])
601
+ }
602
+ `;
603
+
604
+ export function parseDriverEnvelope(output) {
605
+ const lines = String(output).trim().split(/\r?\n/).reverse();
606
+ let envelope;
607
+ for (const line of lines) {
608
+ try {
609
+ envelope = JSON.parse(line);
610
+ break;
611
+ } catch {
612
+ // Swift may print compiler diagnostics around the single JSON result.
613
+ }
614
+ }
615
+ if (!envelope || typeof envelope !== 'object') throw new Error('ima driver returned invalid JSON');
616
+ if (typeof envelope.ok !== 'boolean') throw new Error('ima driver response is missing ok flag');
617
+ return envelope;
618
+ }
619
+
620
+ export function readKnowledgeBase(query) {
621
+ if (process.platform !== 'darwin') {
622
+ return { ok: false, code: 'UNSUPPORTED_PLATFORM', message: 'The ima adapter currently requires macOS' };
623
+ }
624
+ try {
625
+ execFileSync('pgrep', ['-x', 'ima.copilot'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
626
+ } catch {
627
+ execFileSync('open', ['-a', 'ima.copilot', '--args', '--force-renderer-accessibility'], {
628
+ encoding: 'utf8',
629
+ stdio: ['ignore', 'pipe', 'pipe'],
630
+ });
631
+ execFileSync('osascript', ['-e', 'delay 2'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
632
+ }
633
+ try {
634
+ execFileSync('osascript', OPEN_KNOWLEDGE_APPLESCRIPT.flatMap((line) => ['-e', line]), {
635
+ encoding: 'utf8',
636
+ timeout: 15_000,
637
+ stdio: ['ignore', 'pipe', 'pipe'],
638
+ });
639
+ } catch {
640
+ // The Swift driver below returns the actionable typed error.
641
+ }
642
+ const output = execFileSync('swift', ['-', String(query)], {
643
+ input: AX_KNOWLEDGE_SCRIPT,
644
+ encoding: 'utf8',
645
+ timeout: 30 * 60 * 1000,
646
+ maxBuffer: 50 * 1024 * 1024,
647
+ stdio: ['pipe', 'pipe', 'pipe'],
648
+ });
649
+ const envelope = parseDriverEnvelope(output);
650
+ if (Array.isArray(envelope.items)) {
651
+ envelope.items = envelope.items.map((item) => ({ ...item, url: normalizeArticleUrl(item.url) }));
652
+ }
653
+ return envelope;
654
+ }
655
+
656
+ export const __test__ = { AX_KNOWLEDGE_SCRIPT, OPEN_KNOWLEDGE_APPLESCRIPT };