oc-tweaks 0.7.1 → 0.8.2

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.
Files changed (2) hide show
  1. package/dist/index.js +2494 -0
  2. package/package.json +3 -3
package/dist/index.js CHANGED
@@ -419,6 +419,2499 @@ var compactionPlugin = async () => {
419
419
  })
420
420
  };
421
421
  };
422
+ // src/plugins/insights.ts
423
+ import { tool } from "@opencode-ai/plugin";
424
+
425
+ // src/insights/handler.ts
426
+ import { mkdir as mkdir3, readdir as readdir2 } from "node:fs/promises";
427
+ import { dirname as dirname2 } from "node:path";
428
+
429
+ // src/insights/aggregator.ts
430
+ var OVERLAP_WINDOW_MS = 30 * 60 * 1000;
431
+ var MAX_SESSION_SUMMARIES = 50;
432
+ function incrementCounter(record, key, amount = 1) {
433
+ record[key] = (record[key] || 0) + amount;
434
+ }
435
+ function incrementEntries(target, source) {
436
+ for (const [key, count] of Object.entries(source)) {
437
+ if (count > 0) {
438
+ incrementCounter(target, key, count);
439
+ }
440
+ }
441
+ }
442
+ function toDateOnly(timestamp) {
443
+ if (!Number.isFinite(timestamp) || timestamp <= 0)
444
+ return "";
445
+ return new Date(timestamp).toISOString().slice(0, 10);
446
+ }
447
+ function calculateMedian(values) {
448
+ if (values.length === 0)
449
+ return 0;
450
+ const sorted = [...values].sort((a, b) => a - b);
451
+ const middle = Math.floor(sorted.length / 2);
452
+ return sorted[middle] ?? 0;
453
+ }
454
+ function buildSessionSummary(session, sessionFacets) {
455
+ return {
456
+ id: session.id,
457
+ date: toDateOnly(session.time_created),
458
+ summary: sessionFacets?.brief_summary || session.title,
459
+ goal: sessionFacets?.underlying_goal
460
+ };
461
+ }
462
+ function detectMultiClauding(sessions) {
463
+ const allSessionMessages = [];
464
+ for (const session of sessions) {
465
+ for (const timestamp of session.user_message_timestamps) {
466
+ if (Number.isFinite(timestamp)) {
467
+ allSessionMessages.push({ ts: timestamp, sessionId: session.id });
468
+ }
469
+ }
470
+ }
471
+ allSessionMessages.sort((a, b) => a.ts - b.ts);
472
+ const multiClaudeSessionPairs = new Set;
473
+ const messagesDuringMulticlaude = new Set;
474
+ let windowStart = 0;
475
+ const sessionLastIndex = new Map;
476
+ for (let i = 0;i < allSessionMessages.length; i += 1) {
477
+ const message = allSessionMessages[i];
478
+ while (windowStart < i && message.ts - allSessionMessages[windowStart].ts > OVERLAP_WINDOW_MS) {
479
+ const expiring = allSessionMessages[windowStart];
480
+ if (sessionLastIndex.get(expiring.sessionId) === windowStart) {
481
+ sessionLastIndex.delete(expiring.sessionId);
482
+ }
483
+ windowStart += 1;
484
+ }
485
+ const previousIndex = sessionLastIndex.get(message.sessionId);
486
+ if (previousIndex !== undefined) {
487
+ for (let j = previousIndex + 1;j < i; j += 1) {
488
+ const between = allSessionMessages[j];
489
+ if (between.sessionId !== message.sessionId) {
490
+ const pair = [message.sessionId, between.sessionId].sort().join(":");
491
+ multiClaudeSessionPairs.add(pair);
492
+ messagesDuringMulticlaude.add(`${allSessionMessages[previousIndex].ts}:${message.sessionId}`);
493
+ messagesDuringMulticlaude.add(`${between.ts}:${between.sessionId}`);
494
+ messagesDuringMulticlaude.add(`${message.ts}:${message.sessionId}`);
495
+ break;
496
+ }
497
+ }
498
+ }
499
+ sessionLastIndex.set(message.sessionId, i);
500
+ }
501
+ const sessionsWithOverlaps = new Set;
502
+ for (const pair of multiClaudeSessionPairs) {
503
+ const [first, second] = pair.split(":");
504
+ if (first)
505
+ sessionsWithOverlaps.add(first);
506
+ if (second)
507
+ sessionsWithOverlaps.add(second);
508
+ }
509
+ return {
510
+ overlap_events: multiClaudeSessionPairs.size,
511
+ sessions_involved: sessionsWithOverlaps.size,
512
+ user_messages_during: messagesDuringMulticlaude.size
513
+ };
514
+ }
515
+ function aggregateData(sessions, facets) {
516
+ const result = {
517
+ total_sessions: sessions.length,
518
+ sessions_with_facets: facets.size,
519
+ date_range: { start: "", end: "" },
520
+ total_messages: 0,
521
+ total_duration_hours: 0,
522
+ total_input_tokens: 0,
523
+ total_output_tokens: 0,
524
+ tool_counts: {},
525
+ languages: {},
526
+ git_commits: 0,
527
+ git_pushes: 0,
528
+ projects: {},
529
+ goal_categories: {},
530
+ outcomes: {},
531
+ satisfaction: {},
532
+ helpfulness: {},
533
+ session_types: {},
534
+ friction: {},
535
+ success: {},
536
+ session_summaries: [],
537
+ total_interruptions: 0,
538
+ total_tool_errors: 0,
539
+ tool_error_categories: {},
540
+ user_response_times: [],
541
+ median_response_time: 0,
542
+ avg_response_time: 0,
543
+ sessions_using_task_agent: 0,
544
+ sessions_using_mcp: 0,
545
+ sessions_using_web_search: 0,
546
+ sessions_using_web_fetch: 0,
547
+ total_lines_added: 0,
548
+ total_lines_removed: 0,
549
+ total_files_modified: 0,
550
+ days_active: 0,
551
+ messages_per_day: 0,
552
+ message_hours: [],
553
+ multi_clauding: {
554
+ overlap_events: 0,
555
+ sessions_involved: 0,
556
+ user_messages_during: 0
557
+ }
558
+ };
559
+ const sessionDates = [];
560
+ const allResponseTimes = [];
561
+ const allMessageHours = [];
562
+ for (const session of sessions) {
563
+ const sessionDate = toDateOnly(session.time_created);
564
+ if (sessionDate) {
565
+ sessionDates.push(sessionDate);
566
+ }
567
+ result.total_messages += session.total_messages;
568
+ result.total_duration_hours += session.duration_ms / (60 * 60 * 1000);
569
+ result.total_input_tokens += session.input_tokens;
570
+ result.total_output_tokens += session.output_tokens;
571
+ result.git_commits += session.git_commits;
572
+ result.git_pushes += session.git_pushes;
573
+ result.total_interruptions += session.user_interruptions;
574
+ result.total_tool_errors += session.tool_errors;
575
+ result.total_lines_added += session.lines_added;
576
+ result.total_lines_removed += session.lines_removed;
577
+ result.total_files_modified += session.files_modified;
578
+ incrementEntries(result.tool_counts, session.tool_counts);
579
+ incrementEntries(result.languages, session.languages);
580
+ incrementEntries(result.tool_error_categories, session.tool_error_categories);
581
+ incrementCounter(result.projects, session.project_id);
582
+ allResponseTimes.push(...session.user_response_times);
583
+ allMessageHours.push(...session.message_hours);
584
+ if (session.uses_task_agent)
585
+ result.sessions_using_task_agent += 1;
586
+ if (session.uses_mcp)
587
+ result.sessions_using_mcp += 1;
588
+ if (session.uses_web_search)
589
+ result.sessions_using_web_search += 1;
590
+ if (session.uses_web_fetch)
591
+ result.sessions_using_web_fetch += 1;
592
+ const sessionFacets = facets.get(session.id);
593
+ if (sessionFacets) {
594
+ incrementEntries(result.goal_categories, sessionFacets.goal_categories);
595
+ incrementCounter(result.outcomes, sessionFacets.outcome);
596
+ incrementEntries(result.satisfaction, sessionFacets.user_satisfaction_counts);
597
+ incrementCounter(result.helpfulness, sessionFacets.claude_helpfulness);
598
+ incrementCounter(result.session_types, sessionFacets.session_type);
599
+ incrementEntries(result.friction, sessionFacets.friction_counts);
600
+ if (sessionFacets.primary_success !== "none") {
601
+ incrementCounter(result.success, sessionFacets.primary_success);
602
+ }
603
+ }
604
+ if (result.session_summaries.length < MAX_SESSION_SUMMARIES) {
605
+ result.session_summaries.push(buildSessionSummary(session, sessionFacets));
606
+ }
607
+ }
608
+ sessionDates.sort();
609
+ result.date_range = {
610
+ start: sessionDates[0] || "",
611
+ end: sessionDates[sessionDates.length - 1] || ""
612
+ };
613
+ result.user_response_times = allResponseTimes;
614
+ result.median_response_time = calculateMedian(allResponseTimes);
615
+ result.avg_response_time = allResponseTimes.length > 0 ? allResponseTimes.reduce((sum, value) => sum + value, 0) / allResponseTimes.length : 0;
616
+ const uniqueDays = new Set(sessionDates);
617
+ result.days_active = uniqueDays.size;
618
+ result.messages_per_day = result.days_active > 0 ? result.total_messages / result.days_active : 0;
619
+ result.message_hours = allMessageHours;
620
+ result.multi_clauding = detectMultiClauding(sessions);
621
+ return result;
622
+ }
623
+
624
+ // src/insights/cache.ts
625
+ import { Database } from "bun:sqlite";
626
+ import { stat } from "node:fs/promises";
627
+ var REPORT_TTL_MS = 24 * 60 * 60 * 1000;
628
+ function getHomeDir() {
629
+ return Bun.env?.HOME ?? "";
630
+ }
631
+ function getDefaultDbPath() {
632
+ return `${getHomeDir()}/.local/share/opencode/opencode.db`;
633
+ }
634
+ function getFacetsDir() {
635
+ return `${getHomeDir()}/.local/share/opencode/insights/facets`;
636
+ }
637
+ function getReportDir() {
638
+ return `${getHomeDir()}/.local/share/opencode/insights`;
639
+ }
640
+ function getReportPath() {
641
+ return `${getReportDir()}/report.html`;
642
+ }
643
+ function isValidSessionFacets(obj) {
644
+ if (!obj || typeof obj !== "object" || Array.isArray(obj))
645
+ return false;
646
+ const o = obj;
647
+ return typeof o.session_id === "string" && typeof o.underlying_goal === "string" && typeof o.outcome === "string" && typeof o.brief_summary === "string" && typeof o.claude_helpfulness === "string" && typeof o.session_type === "string" && typeof o.primary_success === "string" && typeof o.friction_detail === "string" && o.goal_categories !== null && typeof o.goal_categories === "object" && !Array.isArray(o.goal_categories) && o.user_satisfaction_counts !== null && typeof o.user_satisfaction_counts === "object" && !Array.isArray(o.user_satisfaction_counts) && o.friction_counts !== null && typeof o.friction_counts === "object" && !Array.isArray(o.friction_counts);
648
+ }
649
+ async function loadCachedFacets(sessionId) {
650
+ const facetPath = `${getFacetsDir()}/${sessionId}.json`;
651
+ try {
652
+ const file = Bun.file(facetPath);
653
+ if (!await file.exists())
654
+ return null;
655
+ const parsed = await file.json();
656
+ return isValidSessionFacets(parsed) ? parsed : null;
657
+ } catch {
658
+ return null;
659
+ }
660
+ }
661
+ async function saveFacets(facets) {
662
+ try {
663
+ await Bun.write(`${getFacetsDir()}/${facets.session_id}.json`, JSON.stringify(facets, null, 2));
664
+ } catch {
665
+ return;
666
+ }
667
+ }
668
+ async function getFileMtimeMs(path) {
669
+ try {
670
+ const info = await stat(path);
671
+ return Number.isFinite(info.mtimeMs) ? info.mtimeMs : null;
672
+ } catch {
673
+ return null;
674
+ }
675
+ }
676
+ function getLatestDbUpdate(dbPath) {
677
+ try {
678
+ const db = new Database(dbPath, { readonly: true });
679
+ const result = db.query("SELECT MAX(time_updated) AS max_time_updated FROM session").get();
680
+ db.close();
681
+ const value = result?.max_time_updated;
682
+ return typeof value === "number" ? value : value ? Number(value) : null;
683
+ } catch {
684
+ return null;
685
+ }
686
+ }
687
+ async function isReportFresh(dbPath) {
688
+ const reportPath = getReportPath();
689
+ const reportMtime = await getFileMtimeMs(reportPath);
690
+ if (reportMtime === null)
691
+ return false;
692
+ if (Date.now() - reportMtime > REPORT_TTL_MS)
693
+ return false;
694
+ const latestDbUpdate = getLatestDbUpdate(dbPath ?? getDefaultDbPath());
695
+ if (latestDbUpdate === null)
696
+ return false;
697
+ return latestDbUpdate <= reportMtime;
698
+ }
699
+
700
+ // src/insights/collector.ts
701
+ import { Database as Database2 } from "bun:sqlite";
702
+
703
+ // src/insights/constants.ts
704
+ var EXTENSION_TO_LANGUAGE = {
705
+ ".ts": "TypeScript",
706
+ ".tsx": "TypeScript",
707
+ ".js": "JavaScript",
708
+ ".jsx": "JavaScript",
709
+ ".py": "Python",
710
+ ".rb": "Ruby",
711
+ ".go": "Go",
712
+ ".rs": "Rust",
713
+ ".java": "Java",
714
+ ".md": "Markdown",
715
+ ".json": "JSON",
716
+ ".yaml": "YAML",
717
+ ".yml": "YAML",
718
+ ".sh": "Shell",
719
+ ".css": "CSS",
720
+ ".html": "HTML"
721
+ };
722
+ var MAX_FACET_EXTRACTIONS = 50;
723
+ var CONCURRENCY = 50;
724
+ var MAX_SESSIONS = 200;
725
+
726
+ // src/insights/collector.ts
727
+ var REQUEST_INTERRUPTED_MARKER = "[Request interrupted by user";
728
+ function getDefaultDbPath2() {
729
+ return `${Bun.env?.HOME ?? ""}/.local/share/opencode/opencode.db`;
730
+ }
731
+ function openDatabase(dbPath) {
732
+ return new Database2(dbPath, { readonly: true });
733
+ }
734
+ function getDbPath(dbPath) {
735
+ return dbPath || getDefaultDbPath2();
736
+ }
737
+ function resolveProjectId(db, project) {
738
+ if (project.includes("/")) {
739
+ const row = db.query("SELECT id FROM project WHERE worktree = ?").get(project);
740
+ if (row)
741
+ return row.id;
742
+ }
743
+ return project;
744
+ }
745
+ function mapSessionRow(row) {
746
+ return {
747
+ id: String(row.id),
748
+ project_id: String(row.project_id),
749
+ parent_id: row.parent_id ? String(row.parent_id) : undefined,
750
+ slug: String(row.slug),
751
+ directory: String(row.directory),
752
+ title: String(row.title),
753
+ version: String(row.version),
754
+ share_url: row.share_url ? String(row.share_url) : undefined,
755
+ summary_additions: toNumber(row.summary_additions),
756
+ summary_deletions: toNumber(row.summary_deletions),
757
+ summary_files: toNumber(row.summary_files),
758
+ summary_diffs: row.summary_diffs ? String(row.summary_diffs) : undefined,
759
+ time_created: toNumber(row.time_created),
760
+ time_updated: toNumber(row.time_updated),
761
+ time_compacting: row.time_compacting === null || row.time_compacting === undefined ? undefined : toNumber(row.time_compacting),
762
+ time_archived: row.time_archived === null || row.time_archived === undefined ? undefined : toNumber(row.time_archived),
763
+ workspace_id: row.workspace_id ? String(row.workspace_id) : undefined
764
+ };
765
+ }
766
+ function mapSessionStats(base, messages, parts) {
767
+ const stats = extractToolStats(messages, parts);
768
+ return {
769
+ ...base,
770
+ user_message_count: countHumanUserMessages(messages),
771
+ total_messages: messages.length,
772
+ duration_ms: Math.max(0, base.time_updated - base.time_created),
773
+ tool_counts: stats.toolCounts,
774
+ languages: stats.languages,
775
+ git_commits: stats.gitCommits,
776
+ git_pushes: stats.gitPushes,
777
+ input_tokens: stats.inputTokens,
778
+ output_tokens: stats.outputTokens,
779
+ user_interruptions: stats.userInterruptions,
780
+ tool_errors: stats.toolErrors,
781
+ tool_error_categories: stats.toolErrorCategories,
782
+ user_response_times: stats.userResponseTimes,
783
+ message_hours: stats.messageHours,
784
+ lines_added: stats.linesAdded,
785
+ lines_removed: stats.linesRemoved,
786
+ files_modified: stats.filesModified.size,
787
+ uses_task_agent: stats.usesTaskAgent,
788
+ uses_mcp: stats.usesMcp,
789
+ uses_web_search: stats.usesWebSearch,
790
+ uses_web_fetch: stats.usesWebFetch,
791
+ user_message_timestamps: stats.userMessageTimestamps
792
+ };
793
+ }
794
+ function toNumber(value) {
795
+ if (typeof value === "number" && Number.isFinite(value))
796
+ return value;
797
+ if (typeof value === "string" && value.trim() !== "") {
798
+ const parsed = Number(value);
799
+ if (Number.isFinite(parsed))
800
+ return parsed;
801
+ }
802
+ return 0;
803
+ }
804
+ function incrementCounter2(record, key, amount = 1) {
805
+ record[key] = (record[key] || 0) + amount;
806
+ }
807
+ function getLanguageFromPath(filePath) {
808
+ const lastDot = filePath.lastIndexOf(".");
809
+ if (lastDot === -1)
810
+ return null;
811
+ const extension = filePath.slice(lastDot).toLowerCase();
812
+ return EXTENSION_TO_LANGUAGE[extension] ?? null;
813
+ }
814
+ function isObject(value) {
815
+ return typeof value === "object" && value !== null && !Array.isArray(value);
816
+ }
817
+ function getTimeValue(message) {
818
+ const created = message.time?.created;
819
+ if (typeof created === "number" && Number.isFinite(created))
820
+ return created;
821
+ return null;
822
+ }
823
+ function hasInterruptedContent(content) {
824
+ if (typeof content === "string")
825
+ return content.includes(REQUEST_INTERRUPTED_MARKER);
826
+ if (!Array.isArray(content))
827
+ return false;
828
+ return content.some((block) => isObject(block) && block.type === "text" && typeof block.text === "string" && block.text.includes(REQUEST_INTERRUPTED_MARKER));
829
+ }
830
+ function countHumanUserMessages(messages) {
831
+ return messages.filter((message) => message.role === "user").length;
832
+ }
833
+ function parseJsonBlob(value) {
834
+ return JSON.parse(String(value));
835
+ }
836
+ function queryMessages(db, sessionId) {
837
+ const rows = db.query("SELECT data FROM message WHERE session_id = ? ORDER BY time_created ASC").all(sessionId);
838
+ return rows.map((row) => parseJsonBlob(row.data));
839
+ }
840
+ function queryParts(db, sessionId) {
841
+ const rows = db.query("SELECT data FROM part WHERE session_id = ? ORDER BY id ASC").all(sessionId);
842
+ return rows.map((row) => parseJsonBlob(row.data));
843
+ }
844
+ function isRecoverableBlobError(error) {
845
+ if (error instanceof SyntaxError)
846
+ return true;
847
+ if (!(error instanceof Error))
848
+ return false;
849
+ const sqliteCode = isObject(error) && typeof error.code === "string" ? error.code : "";
850
+ if (sqliteCode === "SQLITE_CORRUPT")
851
+ return true;
852
+ const message = error.message.toLowerCase();
853
+ return message.includes("malformed") || message.includes("corrupt");
854
+ }
855
+ function queryMessagesSafely(db, sessionId) {
856
+ try {
857
+ return queryMessages(db, sessionId);
858
+ } catch (error) {
859
+ if (isRecoverableBlobError(error)) {
860
+ return [];
861
+ }
862
+ throw error;
863
+ }
864
+ }
865
+ function queryPartsSafely(db, sessionId) {
866
+ try {
867
+ return queryParts(db, sessionId);
868
+ } catch (error) {
869
+ if (isRecoverableBlobError(error)) {
870
+ return [];
871
+ }
872
+ throw error;
873
+ }
874
+ }
875
+ function detectToolError(output, explicitError) {
876
+ if (explicitError === true)
877
+ return true;
878
+ if (typeof explicitError === "string" && explicitError.trim().length > 0)
879
+ return true;
880
+ const outputText = typeof output === "string" ? output.toLowerCase() : "";
881
+ if (!outputText)
882
+ return false;
883
+ return outputText.includes("exit code") || outputText.includes("rejected") || outputText.includes("doesn't want") || outputText.includes("string to replace") || outputText.includes("no changes") || outputText.includes("modified since") || outputText.includes("exceeds maximum") || outputText.includes("too large") || outputText.includes("file not found") || outputText.includes("does not exist");
884
+ }
885
+ function categorizeToolError(output, explicitError) {
886
+ if (typeof explicitError === "string" && explicitError.trim().length > 0) {
887
+ output = explicitError;
888
+ }
889
+ if (typeof output !== "string")
890
+ return "Other";
891
+ const lowerContent = output.toLowerCase();
892
+ if (lowerContent.includes("exit code"))
893
+ return "Command Failed";
894
+ if (lowerContent.includes("rejected") || lowerContent.includes("doesn't want")) {
895
+ return "User Rejected";
896
+ }
897
+ if (lowerContent.includes("string to replace") || lowerContent.includes("no changes")) {
898
+ return "Edit Failed";
899
+ }
900
+ if (lowerContent.includes("modified since read"))
901
+ return "File Changed";
902
+ if (lowerContent.includes("exceeds maximum") || lowerContent.includes("too large")) {
903
+ return "File Too Large";
904
+ }
905
+ if (lowerContent.includes("file not found") || lowerContent.includes("does not exist")) {
906
+ return "File Not Found";
907
+ }
908
+ return "Other";
909
+ }
910
+ function countLines(text) {
911
+ if (!text)
912
+ return 0;
913
+ return text.split(`
914
+ `).length;
915
+ }
916
+ function computeLineDiff(oldString, newString) {
917
+ const oldLines = oldString ? oldString.split(`
918
+ `) : [];
919
+ const newLines = newString ? newString.split(`
920
+ `) : [];
921
+ const lcs = Array.from({ length: oldLines.length + 1 }, () => Array.from({ length: newLines.length + 1 }, () => 0));
922
+ for (let i = oldLines.length - 1;i >= 0; i -= 1) {
923
+ for (let j = newLines.length - 1;j >= 0; j -= 1) {
924
+ if (oldLines[i] === newLines[j]) {
925
+ lcs[i][j] = lcs[i + 1][j + 1] + 1;
926
+ } else {
927
+ lcs[i][j] = Math.max(lcs[i + 1][j], lcs[i][j + 1]);
928
+ }
929
+ }
930
+ }
931
+ const common = lcs[0][0];
932
+ return {
933
+ added: newLines.length - common,
934
+ removed: oldLines.length - common
935
+ };
936
+ }
937
+ async function collectSessions(options = {}) {
938
+ const dbPath = getDbPath(options.dbPath);
939
+ const db = openDatabase(dbPath);
940
+ try {
941
+ let sql = "SELECT * FROM session";
942
+ const conditions = [];
943
+ const params = [];
944
+ if (typeof options.days === "number" && Number.isFinite(options.days)) {
945
+ conditions.push("time_created > ?");
946
+ params.push(Date.now() - options.days * 86400000);
947
+ }
948
+ if (options.project) {
949
+ const projectId = resolveProjectId(db, options.project);
950
+ conditions.push("project_id = ?");
951
+ params.push(projectId);
952
+ }
953
+ conditions.push("title NOT LIKE ?");
954
+ params.push("%[insights-internal]%");
955
+ conditions.push("(parent_id IS NULL OR parent_id = '')");
956
+ if (conditions.length > 0) {
957
+ sql += ` WHERE ${conditions.join(" AND ")}`;
958
+ }
959
+ sql += " ORDER BY time_updated DESC";
960
+ const rows = db.query(sql).all(...params);
961
+ return rows.map((row) => {
962
+ const base = mapSessionRow(row);
963
+ const messages = queryMessagesSafely(db, base.id);
964
+ const parts = queryPartsSafely(db, base.id);
965
+ return mapSessionStats(base, messages, parts);
966
+ });
967
+ } finally {
968
+ db.close();
969
+ }
970
+ }
971
+ async function collectMessages(dbPath, sessionId) {
972
+ const db = openDatabase(dbPath);
973
+ try {
974
+ return queryMessages(db, sessionId);
975
+ } finally {
976
+ db.close();
977
+ }
978
+ }
979
+ async function collectParts(dbPath, sessionId) {
980
+ const db = openDatabase(dbPath);
981
+ try {
982
+ return queryPartsSafely(db, sessionId);
983
+ } finally {
984
+ db.close();
985
+ }
986
+ }
987
+ function extractToolStats(messages, parts) {
988
+ const toolCounts = {};
989
+ const languages = {};
990
+ let gitCommits = 0;
991
+ let gitPushes = 0;
992
+ let inputTokens = 0;
993
+ let outputTokens = 0;
994
+ let userInterruptions = 0;
995
+ const userResponseTimes = [];
996
+ let toolErrors = 0;
997
+ const toolErrorCategories = {};
998
+ let usesTaskAgent = false;
999
+ let usesMcp = false;
1000
+ let usesWebSearch = false;
1001
+ let usesWebFetch = false;
1002
+ let linesAdded = 0;
1003
+ let linesRemoved = 0;
1004
+ const filesModified = new Set;
1005
+ const messageHours = [];
1006
+ const userMessageTimestamps = [];
1007
+ let lastAssistantTimestamp = null;
1008
+ for (const message of messages) {
1009
+ if (message.role === "assistant") {
1010
+ inputTokens += toNumber(message.tokens?.input);
1011
+ outputTokens += toNumber(message.tokens?.output);
1012
+ const assistantTime = getTimeValue(message);
1013
+ if (assistantTime !== null) {
1014
+ lastAssistantTimestamp = assistantTime;
1015
+ }
1016
+ continue;
1017
+ }
1018
+ if (message.role !== "user")
1019
+ continue;
1020
+ const messageTime = getTimeValue(message);
1021
+ if (messageTime !== null) {
1022
+ const createdAt = new Date(messageTime);
1023
+ messageHours.push(createdAt.getHours());
1024
+ userMessageTimestamps.push(messageTime);
1025
+ if (lastAssistantTimestamp !== null) {
1026
+ const responseTimeSec = (messageTime - lastAssistantTimestamp) / 1000;
1027
+ if (responseTimeSec > 2 && responseTimeSec < 3600) {
1028
+ userResponseTimes.push(responseTimeSec);
1029
+ }
1030
+ }
1031
+ }
1032
+ if (hasInterruptedContent(message.content)) {
1033
+ userInterruptions += 1;
1034
+ }
1035
+ }
1036
+ for (const part of parts) {
1037
+ if (part.type !== "tool" || !part.tool)
1038
+ continue;
1039
+ const toolName = part.tool;
1040
+ incrementCounter2(toolCounts, toolName);
1041
+ if (toolName === "Task" || toolName === "Agent")
1042
+ usesTaskAgent = true;
1043
+ if (toolName.startsWith("mcp__"))
1044
+ usesMcp = true;
1045
+ if (toolName === "WebSearch")
1046
+ usesWebSearch = true;
1047
+ if (toolName === "WebFetch")
1048
+ usesWebFetch = true;
1049
+ const input = isObject(part.state?.input) ? part.state?.input : {};
1050
+ const filePath = typeof input.file_path === "string" ? input.file_path : "";
1051
+ const language = filePath ? getLanguageFromPath(filePath) : null;
1052
+ if (language) {
1053
+ incrementCounter2(languages, language);
1054
+ }
1055
+ if ((toolName === "Edit" || toolName === "Write") && filePath) {
1056
+ filesModified.add(filePath);
1057
+ }
1058
+ if (toolName === "Edit") {
1059
+ const oldString = typeof input.old_string === "string" ? input.old_string : "";
1060
+ const newString = typeof input.new_string === "string" ? input.new_string : "";
1061
+ const diff = computeLineDiff(oldString, newString);
1062
+ linesAdded += diff.added;
1063
+ linesRemoved += diff.removed;
1064
+ }
1065
+ if (toolName === "Write") {
1066
+ const writeContent = typeof input.content === "string" ? input.content : "";
1067
+ linesAdded += countLines(writeContent);
1068
+ }
1069
+ const command = typeof input.command === "string" ? input.command : "";
1070
+ if (command.includes("git commit"))
1071
+ gitCommits += 1;
1072
+ if (command.includes("git push"))
1073
+ gitPushes += 1;
1074
+ const output = part.state?.output;
1075
+ const explicitError = part.state?.error;
1076
+ if (detectToolError(output, explicitError)) {
1077
+ toolErrors += 1;
1078
+ incrementCounter2(toolErrorCategories, categorizeToolError(output, explicitError));
1079
+ }
1080
+ }
1081
+ return {
1082
+ toolCounts,
1083
+ languages,
1084
+ gitCommits,
1085
+ gitPushes,
1086
+ inputTokens,
1087
+ outputTokens,
1088
+ userInterruptions,
1089
+ userResponseTimes,
1090
+ toolErrors,
1091
+ toolErrorCategories,
1092
+ usesTaskAgent,
1093
+ usesMcp,
1094
+ usesWebSearch,
1095
+ usesWebFetch,
1096
+ linesAdded,
1097
+ linesRemoved,
1098
+ filesModified,
1099
+ messageHours,
1100
+ userMessageTimestamps
1101
+ };
1102
+ }
1103
+
1104
+ // src/insights/export.ts
1105
+ function getOpenCodeVersion() {
1106
+ return process.env.OPENCODE_VERSION || "unknown";
1107
+ }
1108
+ function buildAtAGlanceSummary(insights) {
1109
+ const atAGlance = insights.at_a_glance;
1110
+ if (!atAGlance) {
1111
+ return "_No insights generated_";
1112
+ }
1113
+ const sections = [
1114
+ atAGlance.whats_working ? `**What's working:** ${atAGlance.whats_working} See _Impressive Things You Did_.` : "",
1115
+ atAGlance.whats_hindering ? `**What's hindering you:** ${atAGlance.whats_hindering} See _Where Things Go Wrong_.` : "",
1116
+ atAGlance.quick_wins ? `**Quick wins to try:** ${atAGlance.quick_wins} See _Features to Try_.` : "",
1117
+ atAGlance.ambitious_workflows ? `**Ambitious workflows:** ${atAGlance.ambitious_workflows} See _On the Horizon_.` : ""
1118
+ ].filter(Boolean);
1119
+ return `## At a Glance
1120
+
1121
+ ${sections.join(`
1122
+
1123
+ `)}`;
1124
+ }
1125
+ function buildStatsLine(data) {
1126
+ const sessionLabel = data.total_sessions_scanned && data.total_sessions_scanned > data.total_sessions ? `${data.total_sessions_scanned.toLocaleString()} sessions total · ${data.total_sessions} analyzed` : `${data.total_sessions} sessions`;
1127
+ return [
1128
+ sessionLabel,
1129
+ `${data.total_messages.toLocaleString()} messages`,
1130
+ `${Math.round(data.total_duration_hours)}h`,
1131
+ `${data.git_commits} commits`
1132
+ ].join(" · ");
1133
+ }
1134
+ function buildExportData(data, insights, facets) {
1135
+ const facetsSummary = {
1136
+ total: facets.size,
1137
+ goal_categories: {},
1138
+ outcomes: {},
1139
+ satisfaction: {},
1140
+ friction: {}
1141
+ };
1142
+ for (const facet of facets.values()) {
1143
+ for (const [category, count] of Object.entries(facet.goal_categories)) {
1144
+ if (count > 0) {
1145
+ facetsSummary.goal_categories[category] = (facetsSummary.goal_categories[category] || 0) + count;
1146
+ }
1147
+ }
1148
+ facetsSummary.outcomes[facet.outcome] = (facetsSummary.outcomes[facet.outcome] || 0) + 1;
1149
+ for (const [level, count] of Object.entries(facet.user_satisfaction_counts)) {
1150
+ if (count > 0) {
1151
+ facetsSummary.satisfaction[level] = (facetsSummary.satisfaction[level] || 0) + count;
1152
+ }
1153
+ }
1154
+ for (const [type, count] of Object.entries(facet.friction_counts)) {
1155
+ if (count > 0) {
1156
+ facetsSummary.friction[type] = (facetsSummary.friction[type] || 0) + count;
1157
+ }
1158
+ }
1159
+ }
1160
+ return {
1161
+ metadata: {
1162
+ username: process.env.USER || "unknown",
1163
+ generated_at: new Date().toISOString(),
1164
+ opencode_version: getOpenCodeVersion(),
1165
+ date_range: data.date_range,
1166
+ session_count: data.total_sessions
1167
+ },
1168
+ aggregated_data: data,
1169
+ insights,
1170
+ facets_summary: facetsSummary
1171
+ };
1172
+ }
1173
+ function buildPromptForCommand(insights, htmlPath, data, facetsDir) {
1174
+ const reportUrl = `file://${htmlPath}`;
1175
+ const header = `# OpenCode Insights
1176
+
1177
+ ${buildStatsLine(data)}
1178
+ ${data.date_range.start} to ${data.date_range.end}
1179
+ `;
1180
+ const userSummary = `${header}${buildAtAGlanceSummary(insights)}
1181
+
1182
+ Your full shareable insights report is ready: ${reportUrl}`;
1183
+ return `The user just ran /insights to generate a usage report analyzing their OpenCode sessions.
1184
+
1185
+ Here is the full insights data:
1186
+ ${JSON.stringify(insights, null, 2)}
1187
+
1188
+ Report URL: ${reportUrl}
1189
+ HTML file: ${htmlPath}
1190
+ Facets directory: ${facetsDir}
1191
+
1192
+ Here is what the user sees:
1193
+ ${userSummary}
1194
+
1195
+ Now output the following message exactly:
1196
+
1197
+ <message>
1198
+ Your shareable insights report is ready:
1199
+ ${reportUrl}
1200
+
1201
+ Want to dig into any section or try one of the suggestions?
1202
+ </message>`;
1203
+ }
1204
+
1205
+ // src/insights/prompts/facets-extraction.ts
1206
+ var FACET_EXTRACTION_PROMPT = `Analyze this OpenCode session and extract structured facets.
1207
+
1208
+ CRITICAL GUIDELINES:
1209
+
1210
+ 1. **goal_categories**: Count ONLY what the USER explicitly asked for.
1211
+ - DO NOT count the AI's autonomous codebase exploration
1212
+ - DO NOT count work the AI decided to do on its own
1213
+ - ONLY count when user says "can you...", "please...", "I need...", "let's..."
1214
+
1215
+ 2. **user_satisfaction_counts**: Base ONLY on explicit user signals.
1216
+ - "Yay!", "great!", "perfect!" → happy
1217
+ - "thanks", "looks good", "that works" → satisfied
1218
+ - "ok, now let's..." (continuing without complaint) → likely_satisfied
1219
+ - "that's not right", "try again" → dissatisfied
1220
+ - "this is broken", "I give up" → frustrated
1221
+
1222
+ 3. **friction_counts**: Be specific about what went wrong.
1223
+ - misunderstood_request: AI interpreted incorrectly
1224
+ - wrong_approach: Right goal, wrong solution method
1225
+ - buggy_code: Code didn't work correctly
1226
+ - user_rejected_action: User said no/stop to a tool call
1227
+ - excessive_changes: Over-engineered or changed too much
1228
+
1229
+ 4. If very short or just warmup, use warmup_minimal for goal_category
1230
+
1231
+ SESSION:
1232
+ {transcript}
1233
+
1234
+ RESPOND WITH ONLY A VALID JSON OBJECT matching this schema:
1235
+ {
1236
+ "session_id": "Session identifier (use the id from session metadata)",
1237
+ "underlying_goal": "What the user fundamentally wanted to achieve",
1238
+ "goal_categories": {"category_name": count},
1239
+ "outcome": "fully_achieved|mostly_achieved|partially_achieved|not_achieved|unclear_from_transcript",
1240
+ "user_satisfaction_counts": {"level": count},
1241
+ "claude_helpfulness": "unhelpful|slightly_helpful|moderately_helpful|very_helpful|essential",
1242
+ "session_type": "single_task|multi_task|iterative_refinement|exploration|quick_question",
1243
+ "friction_counts": {"friction_type": count},
1244
+ "friction_detail": "One sentence describing friction or empty string",
1245
+ "primary_success": "none|fast_accurate_search|correct_code_edits|good_explanations|proactive_help|multi_file_changes|good_debugging",
1246
+ "brief_summary": "One sentence: what user wanted and whether they got it",
1247
+ "user_instructions_to_claude": ["Instruction user gave the AI that they may want to reuse"]
1248
+ }`;
1249
+
1250
+ // src/insights/facets.ts
1251
+ var MIN_USER_MESSAGES = 2;
1252
+ var MIN_DURATION_MS = 60000;
1253
+ var MAX_TRANSCRIPT_CHARS = 30000;
1254
+ var TRANSCRIPT_CHUNK_SIZE = 25000;
1255
+ var MAX_COMPRESSED_TRANSCRIPT_CHARS = 28000;
1256
+ function isObject2(value) {
1257
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1258
+ }
1259
+ function toLimitedText(value, maxChars) {
1260
+ if (typeof value !== "string")
1261
+ return "";
1262
+ const trimmed = value.replace(/\s+/g, " ").trim();
1263
+ if (trimmed.length <= maxChars)
1264
+ return trimmed;
1265
+ return `${trimmed.slice(0, maxChars)}…`;
1266
+ }
1267
+ function safeStringify(value, maxChars) {
1268
+ if (typeof value === "string")
1269
+ return toLimitedText(value, maxChars);
1270
+ try {
1271
+ return toLimitedText(JSON.stringify(value), maxChars);
1272
+ } catch {
1273
+ return "";
1274
+ }
1275
+ }
1276
+ function getMessageText(content, maxChars) {
1277
+ if (typeof content === "string") {
1278
+ return toLimitedText(content, maxChars);
1279
+ }
1280
+ if (!Array.isArray(content))
1281
+ return "";
1282
+ const pieces = [];
1283
+ for (const block of content) {
1284
+ if (!isObject2(block))
1285
+ continue;
1286
+ if (typeof block.text === "string") {
1287
+ pieces.push(block.text);
1288
+ continue;
1289
+ }
1290
+ if (typeof block.type === "string" && block.type === "tool_use" && typeof block.name === "string") {
1291
+ pieces.push(`[ToolUse: ${block.name}]`);
1292
+ }
1293
+ }
1294
+ return toLimitedText(pieces.join(`
1295
+ `), maxChars);
1296
+ }
1297
+ function formatTranscript(sessionId, messages, parts) {
1298
+ const lines = [];
1299
+ lines.push(`Session: ${sessionId}`);
1300
+ lines.push(`Messages: ${messages.length}`);
1301
+ lines.push(`Parts: ${parts.length}`);
1302
+ lines.push("");
1303
+ for (const message of messages) {
1304
+ const role = typeof message?.role === "string" ? message.role : "unknown";
1305
+ const text = getMessageText(message?.content, role === "assistant" ? 320 : 520);
1306
+ if (text) {
1307
+ lines.push(`[${role === "user" ? "User" : role === "assistant" ? "Assistant" : "Message"}] ${text}`);
1308
+ }
1309
+ }
1310
+ if (parts.length > 0) {
1311
+ lines.push("");
1312
+ lines.push("[Tool Parts]");
1313
+ }
1314
+ for (const part of parts) {
1315
+ if (part?.type !== "tool")
1316
+ continue;
1317
+ const toolName = typeof part.tool === "string" ? part.tool : "unknown";
1318
+ const inputText = safeStringify(part.state?.input, 260);
1319
+ const outputText = safeStringify(part.state?.output, 260);
1320
+ const errorText = safeStringify(part.state?.error, 180);
1321
+ lines.push(`[Tool: ${toolName}]`);
1322
+ if (inputText)
1323
+ lines.push(` input: ${inputText}`);
1324
+ if (outputText)
1325
+ lines.push(` output: ${outputText}`);
1326
+ if (errorText)
1327
+ lines.push(` error: ${errorText}`);
1328
+ }
1329
+ return lines.join(`
1330
+ `);
1331
+ }
1332
+ function summarizeChunk(chunk, index, total) {
1333
+ const lines = chunk.split(`
1334
+ `);
1335
+ const userCount = lines.filter((line) => line.startsWith("[User]")).length;
1336
+ const assistantCount = lines.filter((line) => line.startsWith("[Assistant]")).length;
1337
+ const toolCount = lines.filter((line) => line.startsWith("[Tool:")).length;
1338
+ const head = lines.slice(0, 24).join(`
1339
+ `);
1340
+ const tail = lines.slice(-12).join(`
1341
+ `);
1342
+ return [
1343
+ `[Chunk ${index + 1}/${total}] users=${userCount} assistants=${assistantCount} tools=${toolCount}`,
1344
+ "[Head]",
1345
+ toLimitedText(head, 1500),
1346
+ "[Tail]",
1347
+ toLimitedText(tail, 1000)
1348
+ ].join(`
1349
+ `);
1350
+ }
1351
+ function compressTranscriptIfNeeded(transcript) {
1352
+ if (transcript.length <= MAX_TRANSCRIPT_CHARS)
1353
+ return transcript;
1354
+ const chunks = [];
1355
+ for (let i = 0;i < transcript.length; i += TRANSCRIPT_CHUNK_SIZE) {
1356
+ chunks.push(transcript.slice(i, i + TRANSCRIPT_CHUNK_SIZE));
1357
+ }
1358
+ const summarized = chunks.map((chunk, index) => summarizeChunk(chunk, index, chunks.length));
1359
+ const combined = [
1360
+ "[Long session transcript compressed for facet extraction]",
1361
+ `Chunk count: ${chunks.length}`,
1362
+ "",
1363
+ summarized.join(`
1364
+
1365
+ ---
1366
+
1367
+ `)
1368
+ ].join(`
1369
+ `);
1370
+ if (combined.length <= MAX_COMPRESSED_TRANSCRIPT_CHARS)
1371
+ return combined;
1372
+ return `${combined.slice(0, MAX_COMPRESSED_TRANSCRIPT_CHARS)}
1373
+ [truncated]`;
1374
+ }
1375
+ function getSessionItems(index, sessionId) {
1376
+ if (index instanceof Map) {
1377
+ const value = index.get(sessionId);
1378
+ return Array.isArray(value) ? value : [];
1379
+ }
1380
+ if (isObject2(index)) {
1381
+ const value = index[sessionId];
1382
+ return Array.isArray(value) ? value : [];
1383
+ }
1384
+ return [];
1385
+ }
1386
+ function extractTextFromPromptResponse(response) {
1387
+ const payload = isObject2(response) && "data" in response ? response.data : response;
1388
+ if (!isObject2(payload))
1389
+ return "";
1390
+ const parts = payload.parts;
1391
+ if (Array.isArray(parts)) {
1392
+ const texts = parts.filter((part) => isObject2(part) && part.type === "text" && typeof part.text === "string").map((part) => String(part.text));
1393
+ if (texts.length > 0)
1394
+ return texts.join(`
1395
+ `);
1396
+ }
1397
+ if (typeof payload.text === "string")
1398
+ return payload.text;
1399
+ return "";
1400
+ }
1401
+ function getPromptSessionId(created) {
1402
+ if (isObject2(created)) {
1403
+ if (isObject2(created.data) && typeof created.data.id === "string")
1404
+ return created.data.id;
1405
+ if (typeof created.id === "string")
1406
+ return created.id;
1407
+ }
1408
+ return null;
1409
+ }
1410
+ function isSubstantiveSession(meta) {
1411
+ const userMessageCount = typeof meta?.user_message_count === "number" ? meta.user_message_count : 0;
1412
+ const durationMs = typeof meta?.duration_ms === "number" ? meta.duration_ms : 0;
1413
+ if (userMessageCount < MIN_USER_MESSAGES)
1414
+ return false;
1415
+ if (durationMs < MIN_DURATION_MS)
1416
+ return false;
1417
+ return true;
1418
+ }
1419
+ function isMinimalSession(facets) {
1420
+ const categories = isObject2(facets?.goal_categories) ? facets.goal_categories : {};
1421
+ const positive = Object.keys(categories).filter((key) => (categories[key] ?? 0) > 0);
1422
+ return positive.length === 1 && positive[0] === "warmup_minimal";
1423
+ }
1424
+ async function extractFacetsFromAPI(client, sessionId, messages, parts) {
1425
+ try {
1426
+ const transcript = compressTranscriptIfNeeded(formatTranscript(sessionId, messages, parts));
1427
+ const promptText = FACET_EXTRACTION_PROMPT.replace("{transcript}", transcript);
1428
+ let promptSessionId = sessionId;
1429
+ if (client?.session?.create) {
1430
+ const created = await client.session.create({
1431
+ body: {
1432
+ title: `[insights-internal] facets ${sessionId}`
1433
+ }
1434
+ });
1435
+ promptSessionId = getPromptSessionId(created) ?? promptSessionId;
1436
+ }
1437
+ const response = await client?.session?.prompt({
1438
+ path: { id: promptSessionId },
1439
+ body: {
1440
+ parts: [{ type: "text", text: promptText }]
1441
+ }
1442
+ });
1443
+ const text = extractTextFromPromptResponse(response);
1444
+ const jsonMatch = text.match(/\{[\s\S]*\}/);
1445
+ if (!jsonMatch)
1446
+ return null;
1447
+ const parsed = JSON.parse(jsonMatch[0]);
1448
+ if (!isValidSessionFacets(parsed))
1449
+ return null;
1450
+ const facets = {
1451
+ ...parsed,
1452
+ session_id: sessionId
1453
+ };
1454
+ await saveFacets(facets);
1455
+ return facets;
1456
+ } catch {
1457
+ return null;
1458
+ }
1459
+ }
1460
+ async function extractAllFacets(client, sessions, messages, parts, cachedFacets) {
1461
+ const facets = new Map;
1462
+ for (const [sessionId, cached] of cachedFacets ?? new Map) {
1463
+ facets.set(sessionId, cached);
1464
+ }
1465
+ const substantiveSessions = sessions.filter(isSubstantiveSession);
1466
+ const toExtract = [];
1467
+ const cacheLookup = await Promise.all(substantiveSessions.map(async (meta) => {
1468
+ const sessionId = meta.id;
1469
+ if (facets.has(sessionId)) {
1470
+ return { sessionId, cached: facets.get(sessionId) ?? null };
1471
+ }
1472
+ const cached = await loadCachedFacets(sessionId);
1473
+ return { sessionId, cached };
1474
+ }));
1475
+ for (const item of cacheLookup) {
1476
+ if (item.cached) {
1477
+ facets.set(item.sessionId, item.cached);
1478
+ continue;
1479
+ }
1480
+ if (toExtract.length < MAX_FACET_EXTRACTIONS) {
1481
+ toExtract.push(item.sessionId);
1482
+ }
1483
+ }
1484
+ for (let i = 0;i < toExtract.length; i += CONCURRENCY) {
1485
+ const batch = toExtract.slice(i, i + CONCURRENCY);
1486
+ const results = await Promise.all(batch.map(async (sessionId) => {
1487
+ const sessionMessages = getSessionItems(messages, sessionId);
1488
+ const sessionParts = getSessionItems(parts, sessionId);
1489
+ const extracted = await extractFacetsFromAPI(client, sessionId, sessionMessages, sessionParts);
1490
+ return { sessionId, extracted };
1491
+ }));
1492
+ for (const item of results) {
1493
+ if (item.extracted) {
1494
+ facets.set(item.sessionId, item.extracted);
1495
+ }
1496
+ }
1497
+ }
1498
+ return facets;
1499
+ }
1500
+
1501
+ // src/insights/prompts/at-a-glance.ts
1502
+ var AT_A_GLANCE_PROMPT = `You're writing an "At a Glance" summary for an OpenCode usage insights report. The goal is to help users understand their usage and improve how they work with OpenCode, especially as models improve.
1503
+
1504
+ Use this 4-part structure:
1505
+
1506
+ 1. **What's working** - What is the user's unique style of interacting with OpenCode and what are some impactful things they've done? Include one or two details, but keep it high level. Don't be fluffy or overly complimentary. Don't focus on tool calls.
1507
+
1508
+ 2. **What's hindering you** - Split into (a) the AI's fault (misunderstandings, wrong approaches, bugs) and (b) user-side friction (not providing enough context, environment issues - ideally more general than just one project). Be honest but constructive.
1509
+
1510
+ 3. **Quick wins to try** - Specific features they could try from the examples below, or a compelling workflow technique. Avoid generic advice like "ask the AI to confirm before actions" or "type more context upfront".
1511
+
1512
+ 4. **Ambitious workflows for better models** - As models improve over the next 3-6 months, what should they prepare for? What workflows that seem impossible now will become possible? Draw from the on_the_horizon section below.
1513
+
1514
+ Keep each section to 2-3 not-too-long sentences. Don't overwhelm the user. Don't mention specific numerical stats. Use a coaching tone. Use second person "you".
1515
+
1516
+ RESPOND WITH ONLY A VALID JSON OBJECT:
1517
+ {
1518
+ "whats_working": "2-3 sentences about unique style and impactful work",
1519
+ "whats_hindering": "2-3 sentences split between Claude's fault and user-side friction",
1520
+ "quick_wins": "2-3 sentences with specific, compelling features or techniques to try",
1521
+ "ambitious_workflows": "2-3 sentences about preparing for more capable models"
1522
+ }
1523
+
1524
+ SESSION DATA:
1525
+ {fullContext}
1526
+
1527
+ ## Project Areas (what user works on)
1528
+ {projectAreasText}
1529
+
1530
+ ## Big Wins (impressive accomplishments)
1531
+ {bigWinsText}
1532
+
1533
+ ## Friction Categories (where things go wrong)
1534
+ {frictionText}
1535
+
1536
+ ## Features to Try
1537
+ {featuresText}
1538
+
1539
+ ## Usage Patterns to Adopt
1540
+ {patternsText}
1541
+
1542
+ ## On the Horizon (ambitious workflows for better models)
1543
+ {horizonText}`;
1544
+
1545
+ // src/insights/prompts/sections.ts
1546
+ var INSIGHT_SECTIONS = [
1547
+ {
1548
+ name: "project_areas",
1549
+ prompt: `Analyze this OpenCode usage data and identify project areas.
1550
+
1551
+ RESPOND WITH ONLY A VALID JSON OBJECT:
1552
+ {
1553
+ "areas": [
1554
+ {"name": "Area name", "session_count": N, "description": "2-3 sentences about what was worked on and how OpenCode was used."}
1555
+ ]
1556
+ }
1557
+
1558
+ Include 4-5 areas. Skip internal tool operations.`
1559
+ },
1560
+ {
1561
+ name: "interaction_style",
1562
+ prompt: `Analyze this OpenCode usage data and describe the user's interaction style.
1563
+
1564
+ RESPOND WITH ONLY A VALID JSON OBJECT:
1565
+ {
1566
+ "narrative": "2-3 paragraphs analyzing HOW the user interacts with OpenCode. Use second person 'you'. Describe patterns: iterate quickly vs detailed upfront specs? Interrupt often or let the AI run? Include specific examples. Use **bold** for key insights.",
1567
+ "key_pattern": "One sentence summary of most distinctive interaction style"
1568
+ }`
1569
+ },
1570
+ {
1571
+ name: "what_works",
1572
+ prompt: `Analyze this OpenCode usage data and identify what's working well for this user. Use second person ("you").
1573
+
1574
+ RESPOND WITH ONLY A VALID JSON OBJECT:
1575
+ {
1576
+ "intro": "1 sentence of context",
1577
+ "impressive_workflows": [
1578
+ {"title": "Short title (3-6 words)", "description": "2-3 sentences describing the impressive workflow or approach. Use 'you' not 'the user'."}
1579
+ ]
1580
+ }
1581
+
1582
+ Include 3 impressive workflows.`
1583
+ },
1584
+ {
1585
+ name: "friction_analysis",
1586
+ prompt: `Analyze this OpenCode usage data and identify friction points for this user. Use second person ("you").
1587
+
1588
+ RESPOND WITH ONLY A VALID JSON OBJECT:
1589
+ {
1590
+ "intro": "1 sentence summarizing friction patterns",
1591
+ "categories": [
1592
+ {"category": "Concrete category name", "description": "1-2 sentences explaining this category and what could be done differently. Use 'you' not 'the user'.", "examples": ["Specific example with consequence", "Another example"]}
1593
+ ]
1594
+ }
1595
+
1596
+ Include 3 friction categories with 2 examples each.`
1597
+ },
1598
+ {
1599
+ name: "suggestions",
1600
+ prompt: `Analyze this OpenCode usage data and suggest improvements.
1601
+
1602
+ ## FEATURES REFERENCE (pick from these for features_to_try):
1603
+ 1. **MCP Servers**: Connect OpenCode to external tools, databases, and APIs via Model Context Protocol.
1604
+ - How to use: Add MCP server config to your OpenCode settings
1605
+ - Good for: database queries, Slack integration, GitHub issue lookup, connecting to internal APIs
1606
+
1607
+ 2. **Custom Commands**: Reusable prompts you define as markdown files that run with a single /command.
1608
+ - How to use: Create a command file in your OpenCode commands directory with instructions, then type \`/command-name\` to run it.
1609
+ - Good for: repetitive workflows - /commit, /review, /test, /deploy, /pr, or complex multi-step workflows
1610
+
1611
+ 3. **Hooks**: Shell commands that auto-run at specific lifecycle events.
1612
+ - How to use: Add hook config to your OpenCode settings under the "hooks" key.
1613
+ - Good for: auto-formatting code, running type checks, enforcing conventions
1614
+
1615
+ 4. **Non-interactive Mode**: Run OpenCode from scripts and CI/CD without an interactive session.
1616
+ - Good for: CI/CD integration, batch code fixes, automated reviews
1617
+
1618
+ 5. **Task Agents**: OpenCode spawns focused sub-agents for complex exploration or parallel work.
1619
+ - How to use: The AI auto-invokes when helpful, or ask "use an agent to explore X"
1620
+ - Good for: codebase exploration, understanding complex systems
1621
+
1622
+ RESPOND WITH ONLY A VALID JSON OBJECT:
1623
+ {
1624
+ "claude_md_additions": [
1625
+ {"addition": "A specific line or block to add to your project instruction file (AGENTS.md) based on workflow patterns. E.g., 'Always run tests after modifying auth-related files'", "why": "1 sentence explaining why this would help based on actual sessions", "prompt_scaffold": "Instructions for where to add this. E.g., 'Add under ## Testing section'"}
1626
+ ],
1627
+ "features_to_try": [
1628
+ {"feature": "Feature name from FEATURES REFERENCE above", "one_liner": "What it does", "why_for_you": "Why this would help YOU based on your sessions", "example_code": "Actual command or config to copy"}
1629
+ ],
1630
+ "usage_patterns": [
1631
+ {"title": "Short title", "suggestion": "1-2 sentence summary", "detail": "3-4 sentences explaining how this applies to YOUR work", "copyable_prompt": "A specific prompt to copy and try"}
1632
+ ]
1633
+ }
1634
+
1635
+ IMPORTANT for claude_md_additions: PRIORITIZE instructions that appear MULTIPLE TIMES in the user data. If the user told the AI the same thing in 2+ sessions (e.g., 'always run tests', 'use TypeScript'), that's a PRIME candidate - they shouldn't have to repeat themselves.
1636
+
1637
+ IMPORTANT for features_to_try: Pick 2-3 from the FEATURES REFERENCE above. Include 2-3 items for each category.`
1638
+ },
1639
+ {
1640
+ name: "on_the_horizon",
1641
+ prompt: `Analyze this OpenCode usage data and identify future opportunities.
1642
+
1643
+ RESPOND WITH ONLY A VALID JSON OBJECT:
1644
+ {
1645
+ "intro": "1 sentence about evolving AI-assisted development",
1646
+ "opportunities": [
1647
+ {"title": "Short title (4-8 words)", "whats_possible": "2-3 ambitious sentences about autonomous workflows", "how_to_try": "1-2 sentences mentioning relevant tooling", "copyable_prompt": "Detailed prompt to try"}
1648
+ ]
1649
+ }
1650
+
1651
+ Include 3 opportunities. Think BIG - autonomous workflows, parallel agents, iterating against tests.`
1652
+ },
1653
+ {
1654
+ name: "fun_ending",
1655
+ prompt: `Analyze this OpenCode usage data and find a memorable moment.
1656
+
1657
+ RESPOND WITH ONLY A VALID JSON OBJECT:
1658
+ {
1659
+ "headline": "A memorable QUALITATIVE moment from the transcripts - not a statistic. Something human, funny, or surprising.",
1660
+ "detail": "Brief context about when/where this happened"
1661
+ }
1662
+
1663
+ Find something genuinely interesting or amusing from the session summaries.`
1664
+ }
1665
+ ];
1666
+
1667
+ // src/insights/generator.ts
1668
+ function isObject3(value) {
1669
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1670
+ }
1671
+ function getPromptSessionId2(created) {
1672
+ if (isObject3(created)) {
1673
+ if (isObject3(created.data) && typeof created.data.id === "string")
1674
+ return created.data.id;
1675
+ if (typeof created.id === "string")
1676
+ return created.id;
1677
+ }
1678
+ return null;
1679
+ }
1680
+ function extractTextFromPromptResponse2(response) {
1681
+ const payload = isObject3(response) && "data" in response ? response.data : response;
1682
+ if (!isObject3(payload))
1683
+ return "";
1684
+ const parts = payload.parts;
1685
+ if (Array.isArray(parts)) {
1686
+ const texts = parts.filter((part) => isObject3(part) && part.type === "text" && typeof part.text === "string").map((part) => String(part.text));
1687
+ if (texts.length > 0)
1688
+ return texts.join(`
1689
+ `);
1690
+ }
1691
+ if (typeof payload.text === "string")
1692
+ return payload.text;
1693
+ return "";
1694
+ }
1695
+ function parseJsonObjectFromText(text) {
1696
+ const jsonMatch = text.match(/\{[\s\S]*\}/);
1697
+ if (!jsonMatch)
1698
+ return null;
1699
+ try {
1700
+ const parsed = JSON.parse(jsonMatch[0]);
1701
+ return isObject3(parsed) ? parsed : null;
1702
+ } catch {
1703
+ return null;
1704
+ }
1705
+ }
1706
+ function topCounts(counts, limit) {
1707
+ return Object.entries(counts || {}).filter(([, count]) => Number.isFinite(count) && count > 0).sort((a, b) => b[1] - a[1]).slice(0, limit).map(([name, count]) => ({ name, count }));
1708
+ }
1709
+ function buildDataContext(data) {
1710
+ return {
1711
+ sessions: data.total_sessions_scanned ?? data.total_sessions,
1712
+ analyzed: data.total_sessions,
1713
+ date_range: data.date_range,
1714
+ messages: data.total_messages,
1715
+ hours: Number(data.total_duration_hours.toFixed(2)),
1716
+ commits: data.git_commits,
1717
+ top_tools: topCounts(data.tool_counts, 8),
1718
+ top_goals: topCounts(data.goal_categories, 8),
1719
+ outcomes: data.outcomes,
1720
+ satisfaction: data.satisfaction,
1721
+ friction: data.friction,
1722
+ success: data.success,
1723
+ languages: topCounts(data.languages, 8)
1724
+ };
1725
+ }
1726
+ function buildFullContext(data, facets) {
1727
+ const dataContext = buildDataContext(data);
1728
+ const facetItems = Array.from(facets.values());
1729
+ const sessionSummaries = facetItems.slice(0, 50).map((facet) => ({
1730
+ session_id: facet.session_id,
1731
+ brief_summary: facet.brief_summary,
1732
+ outcome: facet.outcome,
1733
+ claude_helpfulness: facet.claude_helpfulness
1734
+ }));
1735
+ const frictionDetails = facetItems.map((facet) => ({
1736
+ session_id: facet.session_id,
1737
+ friction_detail: facet.friction_detail
1738
+ })).filter((item) => item.friction_detail && item.friction_detail.trim().length > 0).slice(0, 20);
1739
+ const userInstructions = facetItems.flatMap((facet) => (facet.user_instructions_to_claude || []).map((instruction) => ({
1740
+ session_id: facet.session_id,
1741
+ instruction
1742
+ }))).filter((item) => item.instruction && item.instruction.trim().length > 0).slice(0, 15);
1743
+ return JSON.stringify({
1744
+ data_context: dataContext,
1745
+ session_summaries: sessionSummaries,
1746
+ friction_details: frictionDetails,
1747
+ user_instructions: userInstructions
1748
+ }, null, 2);
1749
+ }
1750
+ function stringifyForPrompt(value) {
1751
+ if (value === null || value === undefined)
1752
+ return "No section output available";
1753
+ if (typeof value === "string")
1754
+ return value;
1755
+ try {
1756
+ return JSON.stringify(value, null, 2);
1757
+ } catch {
1758
+ return String(value);
1759
+ }
1760
+ }
1761
+ function pickSuggestionsField(suggestions, key) {
1762
+ if (!isObject3(suggestions))
1763
+ return null;
1764
+ return suggestions[key] ?? null;
1765
+ }
1766
+ async function promptWithInternalSession(client, title, promptText) {
1767
+ let promptSessionId = `insights-internal-${Date.now()}`;
1768
+ if (client?.session?.create) {
1769
+ const created = await client.session.create({
1770
+ body: {
1771
+ title
1772
+ }
1773
+ });
1774
+ promptSessionId = getPromptSessionId2(created) ?? promptSessionId;
1775
+ }
1776
+ const response = await client?.session?.prompt({
1777
+ path: { id: promptSessionId },
1778
+ body: {
1779
+ parts: [{ type: "text", text: promptText }]
1780
+ }
1781
+ });
1782
+ return extractTextFromPromptResponse2(response);
1783
+ }
1784
+ async function generateInsightFromPrompt(client, sectionName, promptText) {
1785
+ try {
1786
+ const title = `[insights-internal] section ${sectionName}`;
1787
+ const responseText = await promptWithInternalSession(client, title, promptText);
1788
+ return parseJsonObjectFromText(responseText);
1789
+ } catch (error) {
1790
+ const reason = error instanceof Error ? error.message : String(error);
1791
+ console.warn(`[insights] section ${sectionName} failed: ${reason}`);
1792
+ return null;
1793
+ }
1794
+ }
1795
+ async function generateSectionInsight(client, section, fullContext) {
1796
+ const promptText = `${section.prompt}
1797
+
1798
+ SESSION DATA (JSON):
1799
+ ${fullContext}`;
1800
+ return generateInsightFromPrompt(client, section.name, promptText);
1801
+ }
1802
+ async function generateParallelInsights(client, data, facets, onProgress) {
1803
+ const dataContext = buildDataContext(data);
1804
+ const fullContext = buildFullContext(data, facets);
1805
+ const regularSections = INSIGHT_SECTIONS.filter((section) => section.name !== "at_a_glance");
1806
+ onProgress?.({ stage: "sections-start", completed: 0, total: regularSections.length + 1 });
1807
+ const sectionResults = await Promise.all(regularSections.map(async (section) => ({
1808
+ name: section.name,
1809
+ insight: await generateSectionInsight(client, section, fullContext)
1810
+ })));
1811
+ const insights = {};
1812
+ const sectionMap = {};
1813
+ let completedSections = 0;
1814
+ for (const { name, insight } of sectionResults) {
1815
+ sectionMap[name] = insight;
1816
+ insights[name] = insight;
1817
+ completedSections += 1;
1818
+ onProgress?.({
1819
+ stage: "section-complete",
1820
+ section: name,
1821
+ completed: completedSections,
1822
+ total: regularSections.length + 1
1823
+ });
1824
+ }
1825
+ const atAGlancePrompt = AT_A_GLANCE_PROMPT.replace("{fullContext}", `${fullContext}
1826
+
1827
+ Data Context Snapshot:
1828
+ ${stringifyForPrompt(dataContext)}`).replace("{projectAreasText}", stringifyForPrompt(sectionMap.project_areas)).replace("{bigWinsText}", stringifyForPrompt(sectionMap.what_works)).replace("{frictionText}", stringifyForPrompt(sectionMap.friction_analysis)).replace("{featuresText}", stringifyForPrompt(pickSuggestionsField(sectionMap.suggestions, "features_to_try"))).replace("{patternsText}", stringifyForPrompt(pickSuggestionsField(sectionMap.suggestions, "usage_patterns"))).replace("{horizonText}", stringifyForPrompt(sectionMap.on_the_horizon));
1829
+ const atAGlance = await generateInsightFromPrompt(client, "at_a_glance", atAGlancePrompt);
1830
+ insights.at_a_glance = atAGlance;
1831
+ onProgress?.({
1832
+ stage: "section-complete",
1833
+ section: "at_a_glance",
1834
+ completed: regularSections.length + 1,
1835
+ total: regularSections.length + 1
1836
+ });
1837
+ return insights;
1838
+ }
1839
+
1840
+ // src/insights/renderer.ts
1841
+ function escapeHtml(text) {
1842
+ if (!text)
1843
+ return "";
1844
+ return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#039;");
1845
+ }
1846
+ function escapeHtmlWithBold(text) {
1847
+ const escaped = escapeHtml(text);
1848
+ return escaped.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>");
1849
+ }
1850
+ var SATISFACTION_ORDER = [
1851
+ "frustrated",
1852
+ "dissatisfied",
1853
+ "likely_satisfied",
1854
+ "satisfied",
1855
+ "happy",
1856
+ "unsure",
1857
+ "neutral",
1858
+ "delighted"
1859
+ ];
1860
+ var OUTCOME_ORDER = [
1861
+ "not_achieved",
1862
+ "partially_achieved",
1863
+ "mostly_achieved",
1864
+ "fully_achieved",
1865
+ "unclear_from_transcript"
1866
+ ];
1867
+ var LABEL_MAP = {
1868
+ single_task: "Single Task",
1869
+ multi_task: "Multi Task",
1870
+ iterative_refinement: "Iterative Refinement",
1871
+ exploration: "Exploration",
1872
+ quick_question: "Quick Question"
1873
+ };
1874
+ function generateBarChart(data, color, maxItems = 6, fixedOrder) {
1875
+ let entries;
1876
+ if (fixedOrder) {
1877
+ entries = fixedOrder.filter((key) => (key in data) && (data[key] ?? 0) > 0).map((key) => [key, data[key] ?? 0]);
1878
+ } else {
1879
+ entries = Object.entries(data || {}).sort((a, b) => b[1] - a[1]).slice(0, maxItems);
1880
+ }
1881
+ if (entries.length === 0)
1882
+ return '<p class="empty">No data</p>';
1883
+ const maxVal = Math.max(...entries.map((e) => e[1]));
1884
+ return entries.map(([label, count]) => {
1885
+ const pct = count / maxVal * 100;
1886
+ const cleanLabel = LABEL_MAP[label] || label.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
1887
+ return `<div class="bar-row">
1888
+ <div class="bar-label">${escapeHtml(cleanLabel)}</div>
1889
+ <div class="bar-track"><div class="bar-fill" style="width:${pct}%;background:${color}"></div></div>
1890
+ <div class="bar-value">${count}</div>
1891
+ </div>`;
1892
+ }).join(`
1893
+ `);
1894
+ }
1895
+ function generateResponseTimeHistogram(times) {
1896
+ if (!times || times.length === 0)
1897
+ return '<p class="empty">No response time data</p>';
1898
+ const buckets = {
1899
+ "2-10s": 0,
1900
+ "10-30s": 0,
1901
+ "30s-1m": 0,
1902
+ "1-2m": 0,
1903
+ "2-5m": 0,
1904
+ "5-15m": 0,
1905
+ ">15m": 0
1906
+ };
1907
+ for (const t of times) {
1908
+ if (t < 10)
1909
+ buckets["2-10s"] = (buckets["2-10s"] ?? 0) + 1;
1910
+ else if (t < 30)
1911
+ buckets["10-30s"] = (buckets["10-30s"] ?? 0) + 1;
1912
+ else if (t < 60)
1913
+ buckets["30s-1m"] = (buckets["30s-1m"] ?? 0) + 1;
1914
+ else if (t < 120)
1915
+ buckets["1-2m"] = (buckets["1-2m"] ?? 0) + 1;
1916
+ else if (t < 300)
1917
+ buckets["2-5m"] = (buckets["2-5m"] ?? 0) + 1;
1918
+ else if (t < 900)
1919
+ buckets["5-15m"] = (buckets["5-15m"] ?? 0) + 1;
1920
+ else
1921
+ buckets[">15m"] = (buckets[">15m"] ?? 0) + 1;
1922
+ }
1923
+ const maxVal = Math.max(...Object.values(buckets));
1924
+ if (maxVal === 0)
1925
+ return '<p class="empty">No response time data</p>';
1926
+ return Object.entries(buckets).map(([label, count]) => {
1927
+ const pct = count / maxVal * 100;
1928
+ return `<div class="bar-row">
1929
+ <div class="bar-label">${label}</div>
1930
+ <div class="bar-track"><div class="bar-fill" style="width:${pct}%;background:#6366f1"></div></div>
1931
+ <div class="bar-value">${count}</div>
1932
+ </div>`;
1933
+ }).join(`
1934
+ `);
1935
+ }
1936
+ function generateTimeOfDayChart(messageHours) {
1937
+ if (!messageHours || messageHours.length === 0)
1938
+ return '<p class="empty">No time data</p>';
1939
+ const periods = [
1940
+ { label: "Morning (6-12)", range: [6, 7, 8, 9, 10, 11] },
1941
+ { label: "Afternoon (12-18)", range: [12, 13, 14, 15, 16, 17] },
1942
+ { label: "Evening (18-24)", range: [18, 19, 20, 21, 22, 23] },
1943
+ { label: "Night (0-6)", range: [0, 1, 2, 3, 4, 5] }
1944
+ ];
1945
+ const hourCounts = {};
1946
+ for (const h of messageHours) {
1947
+ hourCounts[h] = (hourCounts[h] || 0) + 1;
1948
+ }
1949
+ const periodCounts = periods.map((p) => ({
1950
+ label: p.label,
1951
+ count: p.range.reduce((sum, h) => sum + (hourCounts[h] || 0), 0)
1952
+ }));
1953
+ const maxVal = Math.max(...periodCounts.map((p) => p.count)) || 1;
1954
+ const barsHtml = periodCounts.map((p) => `
1955
+ <div class="bar-row">
1956
+ <div class="bar-label">${p.label}</div>
1957
+ <div class="bar-track"><div class="bar-fill" style="width:${p.count / maxVal * 100}%;background:#8b5cf6"></div></div>
1958
+ <div class="bar-value">${p.count}</div>
1959
+ </div>`).join(`
1960
+ `);
1961
+ return `<div id="hour-histogram">${barsHtml}</div>`;
1962
+ }
1963
+ function getHourCountsJson(messageHours) {
1964
+ const hourCounts = {};
1965
+ if (messageHours) {
1966
+ for (const h of messageHours) {
1967
+ hourCounts[h] = (hourCounts[h] || 0) + 1;
1968
+ }
1969
+ }
1970
+ return JSON.stringify(hourCounts);
1971
+ }
1972
+ function generateHtmlReport(data, insights) {
1973
+ const markdownToHtml = (md) => {
1974
+ if (!md)
1975
+ return "";
1976
+ return md.split(`
1977
+
1978
+ `).map((p) => {
1979
+ let html = escapeHtml(p);
1980
+ html = html.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>");
1981
+ html = html.replace(/^- /gm, "• ");
1982
+ html = html.replace(/\n/g, "<br>");
1983
+ return `<p>${html}</p>`;
1984
+ }).join(`
1985
+ `);
1986
+ };
1987
+ const atAGlance = insights.at_a_glance;
1988
+ const atAGlanceHtml = atAGlance ? `
1989
+ <div id="section-glance" class="at-a-glance">
1990
+ <div class="glance-title">At a Glance</div>
1991
+ <div class="glance-sections">
1992
+ ${atAGlance.whats_working ? `<div class="glance-section"><strong>What's working:</strong> ${escapeHtmlWithBold(atAGlance.whats_working)} <a href="#section-wins" class="see-more">Impressive Things You Did →</a></div>` : ""}
1993
+ ${atAGlance.whats_hindering ? `<div class="glance-section"><strong>What's hindering you:</strong> ${escapeHtmlWithBold(atAGlance.whats_hindering)} <a href="#section-friction" class="see-more">Where Things Go Wrong →</a></div>` : ""}
1994
+ ${atAGlance.quick_wins ? `<div class="glance-section"><strong>Quick wins to try:</strong> ${escapeHtmlWithBold(atAGlance.quick_wins)} <a href="#section-features" class="see-more">Features to Try →</a></div>` : ""}
1995
+ ${atAGlance.ambitious_workflows ? `<div class="glance-section"><strong>Ambitious workflows:</strong> ${escapeHtmlWithBold(atAGlance.ambitious_workflows)} <a href="#section-horizon" class="see-more">On the Horizon →</a></div>` : ""}
1996
+ </div>
1997
+ </div>
1998
+ ` : `<div id="section-glance" style="display:none;"></div>`;
1999
+ const projectAreas = insights.project_areas?.areas || [];
2000
+ const projectAreasHtml = projectAreas.length > 0 ? `
2001
+ <h2 id="section-projects">What You Work On</h2>
2002
+ <div class="project-areas">
2003
+ ${projectAreas.map((area) => `
2004
+ <div class="project-area">
2005
+ <div class="area-header">
2006
+ <span class="area-name">${escapeHtml(area.name)}</span>
2007
+ <span class="area-count">~${area.session_count} sessions</span>
2008
+ </div>
2009
+ <div class="area-desc">${escapeHtml(area.description)}</div>
2010
+ </div>
2011
+ `).join("")}
2012
+ </div>
2013
+ ` : `<h2 id="section-projects" style="display:none;">What You Work On</h2>`;
2014
+ const interactionStyle = insights.interaction_style;
2015
+ const interactionHtml = interactionStyle?.narrative ? `
2016
+ <h2 id="section-style">How You Use OpenCode</h2>
2017
+ <div class="narrative">
2018
+ ${markdownToHtml(interactionStyle.narrative)}
2019
+ ${interactionStyle.key_pattern ? `<div class="key-insight"><strong>Key pattern:</strong> ${escapeHtml(interactionStyle.key_pattern)}</div>` : ""}
2020
+ </div>
2021
+ ` : `<h2 id="section-style" style="display:none;">How You Use OpenCode</h2>`;
2022
+ const whatWorks = insights.what_works;
2023
+ const whatWorksHtml = whatWorks?.impressive_workflows && whatWorks.impressive_workflows.length > 0 ? `
2024
+ <h2 id="section-wins">Impressive Things You Did</h2>
2025
+ ${whatWorks.intro ? `<p class="section-intro">${escapeHtml(whatWorks.intro)}</p>` : ""}
2026
+ <div class="big-wins">
2027
+ ${whatWorks.impressive_workflows.map((wf) => `
2028
+ <div class="big-win">
2029
+ <div class="big-win-title">${escapeHtml(wf.title || "")}</div>
2030
+ <div class="big-win-desc">${escapeHtml(wf.description || "")}</div>
2031
+ </div>
2032
+ `).join("")}
2033
+ </div>
2034
+ ` : `<h2 id="section-wins" style="display:none;">Impressive Things You Did</h2>`;
2035
+ const frictionAnalysis = insights.friction_analysis;
2036
+ const frictionHtml = frictionAnalysis?.categories && frictionAnalysis.categories.length > 0 ? `
2037
+ <h2 id="section-friction">Where Things Go Wrong</h2>
2038
+ ${frictionAnalysis.intro ? `<p class="section-intro">${escapeHtml(frictionAnalysis.intro)}</p>` : ""}
2039
+ <div class="friction-categories">
2040
+ ${frictionAnalysis.categories.map((cat) => `
2041
+ <div class="friction-category">
2042
+ <div class="friction-title">${escapeHtml(cat.category || "")}</div>
2043
+ <div class="friction-desc">${escapeHtml(cat.description || "")}</div>
2044
+ ${cat.examples ? `<ul class="friction-examples">${cat.examples.map((ex) => `<li>${escapeHtml(ex)}</li>`).join("")}</ul>` : ""}
2045
+ </div>
2046
+ `).join("")}
2047
+ </div>
2048
+ ` : `<h2 id="section-friction" style="display:none;">Where Things Go Wrong</h2>`;
2049
+ const suggestions = insights.suggestions;
2050
+ const suggestionsHtml = suggestions ? `
2051
+ ${suggestions.claude_md_additions && suggestions.claude_md_additions.length > 0 || suggestions.features_to_try && suggestions.features_to_try.length > 0 ? `<h2 id="section-features">Features to Try</h2>` : `<h2 id="section-features" style="display:none;">Features to Try</h2>`}
2052
+ ${suggestions.claude_md_additions && suggestions.claude_md_additions.length > 0 ? `
2053
+ <div class="claude-md-section">
2054
+ <h3>Suggested AGENTS.md Additions</h3>
2055
+ <p style="font-size: 12px; color: var(--text-muted); margin-bottom: 12px;">Just copy this into OpenCode to add it to your AGENTS.md.</p>
2056
+ <div class="claude-md-actions">
2057
+ <button class="copy-all-btn" onclick="copyAllCheckedAgentsMd()">Copy All Checked</button>
2058
+ </div>
2059
+ ${suggestions.claude_md_additions.map((add, i) => `
2060
+ <div class="claude-md-item">
2061
+ <input type="checkbox" id="cmd-${i}" class="cmd-checkbox" checked data-text="${escapeHtml(add.prompt_scaffold || add.where || "Add to AGENTS.md")}
2062
+
2063
+ ${escapeHtml(add.addition)}">
2064
+ <label for="cmd-${i}">
2065
+ <code class="cmd-code">${escapeHtml(add.addition)}</code>
2066
+ <button class="copy-btn" onclick="copyCmdItem(${i})">Copy</button>
2067
+ </label>
2068
+ <div class="cmd-why">${escapeHtml(add.why)}</div>
2069
+ </div>
2070
+ `).join("")}
2071
+ </div>
2072
+ ` : ""}
2073
+ ${suggestions.features_to_try && suggestions.features_to_try.length > 0 ? `
2074
+ <p style="font-size: 13px; color: var(--text-muted); margin-bottom: 12px;">Just copy this into OpenCode and it'll set it up for you.</p>
2075
+ <div class="features-section">
2076
+ ${suggestions.features_to_try.map((feat) => `
2077
+ <div class="feature-card">
2078
+ <div class="feature-title">${escapeHtml(feat.feature || "")}</div>
2079
+ <div class="feature-oneliner">${escapeHtml(feat.one_liner || "")}</div>
2080
+ <div class="feature-why"><strong>Why for you:</strong> ${escapeHtml(feat.why_for_you || "")}</div>
2081
+ ${feat.example_code ? `
2082
+ <div class="feature-examples">
2083
+ <div class="feature-example">
2084
+ <div class="example-code-row">
2085
+ <code class="example-code">${escapeHtml(feat.example_code)}</code>
2086
+ <button class="copy-btn" onclick="copyText(this)">Copy</button>
2087
+ </div>
2088
+ </div>
2089
+ </div>
2090
+ ` : ""}
2091
+ </div>
2092
+ `).join("")}
2093
+ </div>
2094
+ ` : ""}
2095
+ ${suggestions.usage_patterns && suggestions.usage_patterns.length > 0 ? `
2096
+ <h2 id="section-patterns">New Ways to Use OpenCode</h2>
2097
+ <p style="font-size: 13px; color: var(--text-muted); margin-bottom: 12px;">Just copy this into OpenCode and it'll walk you through it.</p>
2098
+ <div class="patterns-section">
2099
+ ${suggestions.usage_patterns.map((pat) => `
2100
+ <div class="pattern-card">
2101
+ <div class="pattern-title">${escapeHtml(pat.title || "")}</div>
2102
+ <div class="pattern-summary">${escapeHtml(pat.suggestion || "")}</div>
2103
+ ${pat.detail ? `<div class="pattern-detail">${escapeHtml(pat.detail)}</div>` : ""}
2104
+ ${pat.copyable_prompt ? `
2105
+ <div class="copyable-prompt-section">
2106
+ <div class="prompt-label">Paste into OpenCode:</div>
2107
+ <div class="copyable-prompt-row">
2108
+ <code class="copyable-prompt">${escapeHtml(pat.copyable_prompt)}</code>
2109
+ <button class="copy-btn" onclick="copyText(this)">Copy</button>
2110
+ </div>
2111
+ </div>
2112
+ ` : ""}
2113
+ </div>
2114
+ `).join("")}
2115
+ </div>
2116
+ ` : `<h2 id="section-patterns" style="display:none;">New Ways to Use OpenCode</h2>`}
2117
+ ` : `
2118
+ <h2 id="section-features" style="display:none;">Features to Try</h2>
2119
+ <h2 id="section-patterns" style="display:none;">New Ways to Use OpenCode</h2>
2120
+ `;
2121
+ const horizonData = insights.on_the_horizon;
2122
+ const horizonHtml = horizonData?.opportunities && horizonData.opportunities.length > 0 ? `
2123
+ <h2 id="section-horizon">On the Horizon</h2>
2124
+ ${horizonData.intro ? `<p class="section-intro">${escapeHtml(horizonData.intro)}</p>` : ""}
2125
+ <div class="horizon-section">
2126
+ ${horizonData.opportunities.map((opp) => `
2127
+ <div class="horizon-card">
2128
+ <div class="horizon-title">${escapeHtml(opp.title || "")}</div>
2129
+ <div class="horizon-possible">${escapeHtml(opp.whats_possible || "")}</div>
2130
+ ${opp.how_to_try ? `<div class="horizon-tip"><strong>Getting started:</strong> ${escapeHtml(opp.how_to_try)}</div>` : ""}
2131
+ ${opp.copyable_prompt ? `<div class="pattern-prompt"><div class="prompt-label">Paste into OpenCode:</div><code>${escapeHtml(opp.copyable_prompt)}</code><button class="copy-btn" onclick="copyText(this)">Copy</button></div>` : ""}
2132
+ </div>
2133
+ `).join("")}
2134
+ </div>
2135
+ ` : `<h2 id="section-horizon" style="display:none;">On the Horizon</h2>`;
2136
+ const funEnding = insights.fun_ending;
2137
+ const funEndingHtml = funEnding?.headline ? `
2138
+ <h2 id="section-fun" style="display:none;">Fun Ending</h2>
2139
+ <div class="fun-ending">
2140
+ <div class="fun-headline">"${escapeHtml(funEnding.headline)}"</div>
2141
+ ${funEnding.detail ? `<div class="fun-detail">${escapeHtml(funEnding.detail)}</div>` : ""}
2142
+ </div>
2143
+ ` : `<div id="section-fun" style="display:none;"></div>`;
2144
+ const css = `
2145
+ :root {
2146
+ --bg-color: #f8fafc;
2147
+ --text-main: #334155;
2148
+ --text-heading: #0f172a;
2149
+ --text-muted: #64748b;
2150
+ --card-bg: #ffffff;
2151
+ --border-color: #e2e8f0;
2152
+
2153
+ --nav-bg: #f1f5f9;
2154
+ --nav-hover: #e2e8f0;
2155
+
2156
+ --glance-bg: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
2157
+ --glance-border: #f59e0b;
2158
+ --glance-title: #92400e;
2159
+ --glance-text: #78350f;
2160
+ --glance-link: #b45309;
2161
+
2162
+ --win-bg: #f0fdf4;
2163
+ --win-border: #bbf7d0;
2164
+ --win-title: #166534;
2165
+ --win-text: #15803d;
2166
+
2167
+ --frict-bg: #fef2f2;
2168
+ --frict-border: #fca5a5;
2169
+ --frict-title: #991b1b;
2170
+ --frict-text: #7f1d1d;
2171
+
2172
+ --cmd-bg: #eff6ff;
2173
+ --cmd-border: #bfdbfe;
2174
+ --cmd-title: #1e40af;
2175
+
2176
+ --code-bg: #f1f5f9;
2177
+
2178
+ --feat-bg: #f0fdf4;
2179
+ --feat-border: #86efac;
2180
+
2181
+ --pat-bg: #f0f9ff;
2182
+ --pat-border: #7dd3fc;
2183
+
2184
+ --horiz-bg: linear-gradient(135deg, #faf5ff 0%, #f5f3ff 100%);
2185
+ --horiz-border: #c4b5fd;
2186
+ --horiz-title: #5b21b6;
2187
+ --horiz-tip-bg: rgba(255,255,255,0.6);
2188
+
2189
+ --fun-bg: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
2190
+ --fun-border: #fbbf24;
2191
+ --fun-title: #78350f;
2192
+ --fun-text: #92400e;
2193
+ }
2194
+ body.dark {
2195
+ --bg-color: #0f172a;
2196
+ --text-main: #cbd5e1;
2197
+ --text-heading: #f8fafc;
2198
+ --text-muted: #94a3b8;
2199
+ --card-bg: #1e293b;
2200
+ --border-color: #334155;
2201
+
2202
+ --nav-bg: #334155;
2203
+ --nav-hover: #475569;
2204
+
2205
+ --glance-bg: linear-gradient(135deg, #78350f 0%, #92400e 100%);
2206
+ --glance-border: #b45309;
2207
+ --glance-title: #fde68a;
2208
+ --glance-text: #fef3c7;
2209
+ --glance-link: #fcd34d;
2210
+
2211
+ --win-bg: #14532d;
2212
+ --win-border: #166534;
2213
+ --win-title: #bbf7d0;
2214
+ --win-text: #dcfce7;
2215
+
2216
+ --frict-bg: #7f1d1d;
2217
+ --frict-border: #991b1b;
2218
+ --frict-title: #fca5a5;
2219
+ --frict-text: #fecaca;
2220
+
2221
+ --cmd-bg: #1e3a8a;
2222
+ --cmd-border: #1e40af;
2223
+ --cmd-title: #bfdbfe;
2224
+
2225
+ --code-bg: #0f172a;
2226
+
2227
+ --feat-bg: #064e3b;
2228
+ --feat-border: #065f46;
2229
+
2230
+ --pat-bg: #0c4a6e;
2231
+ --pat-border: #075985;
2232
+
2233
+ --horiz-bg: linear-gradient(135deg, #4c1d95 0%, #5b21b6 100%);
2234
+ --horiz-border: #7c3aed;
2235
+ --horiz-title: #ddd6fe;
2236
+ --horiz-tip-bg: rgba(15,23,42,0.6);
2237
+
2238
+ --fun-bg: linear-gradient(135deg, #78350f 0%, #92400e 100%);
2239
+ --fun-border: #b45309;
2240
+ --fun-title: #fef3c7;
2241
+ --fun-text: #fde68a;
2242
+ }
2243
+ * { box-sizing: border-box; margin: 0; padding: 0; }
2244
+ body { font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; background: var(--bg-color); color: var(--text-main); line-height: 1.65; padding: 48px 24px; transition: background 0.2s, color 0.2s; }
2245
+ .container { max-width: 800px; margin: 0 auto; position: relative; }
2246
+ .theme-toggle { position: absolute; top: 0; right: 0; background: var(--card-bg); border: 1px solid var(--border-color); color: var(--text-main); padding: 6px 12px; border-radius: 6px; cursor: pointer; font-size: 14px; }
2247
+ h1 { font-size: 32px; font-weight: 700; color: var(--text-heading); margin-bottom: 8px; }
2248
+ h2 { font-size: 20px; font-weight: 600; color: var(--text-heading); margin-top: 48px; margin-bottom: 16px; }
2249
+ .subtitle { color: var(--text-muted); font-size: 15px; margin-bottom: 32px; }
2250
+ .nav-toc { display: flex; flex-wrap: wrap; gap: 8px; margin: 24px 0 32px 0; padding: 16px; background: var(--card-bg); border-radius: 8px; border: 1px solid var(--border-color); }
2251
+ .nav-toc a { font-size: 12px; color: var(--text-muted); text-decoration: none; padding: 6px 12px; border-radius: 6px; background: var(--nav-bg); transition: all 0.15s; }
2252
+ .nav-toc a:hover { background: var(--nav-hover); color: var(--text-main); }
2253
+ .stats-row { display: flex; gap: 24px; margin-bottom: 40px; padding: 20px 0; border-top: 1px solid var(--border-color); border-bottom: 1px solid var(--border-color); flex-wrap: wrap; }
2254
+ .stat { text-align: center; }
2255
+ .stat-value { font-size: 24px; font-weight: 700; color: var(--text-heading); }
2256
+ .stat-label { font-size: 11px; color: var(--text-muted); text-transform: uppercase; }
2257
+ .at-a-glance { background: var(--glance-bg); border: 1px solid var(--glance-border); border-radius: 12px; padding: 20px 24px; margin-bottom: 32px; }
2258
+ .glance-title { font-size: 16px; font-weight: 700; color: var(--glance-title); margin-bottom: 16px; }
2259
+ .glance-sections { display: flex; flex-direction: column; gap: 12px; }
2260
+ .glance-section { font-size: 14px; color: var(--glance-text); line-height: 1.6; }
2261
+ .glance-section strong { color: var(--glance-title); }
2262
+ .see-more { color: var(--glance-link); text-decoration: none; font-size: 13px; white-space: nowrap; }
2263
+ .see-more:hover { text-decoration: underline; }
2264
+ .project-areas { display: flex; flex-direction: column; gap: 12px; margin-bottom: 32px; }
2265
+ .project-area { background: var(--card-bg); border: 1px solid var(--border-color); border-radius: 8px; padding: 16px; }
2266
+ .area-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
2267
+ .area-name { font-weight: 600; font-size: 15px; color: var(--text-heading); }
2268
+ .area-count { font-size: 12px; color: var(--text-muted); background: var(--nav-bg); padding: 2px 8px; border-radius: 4px; }
2269
+ .area-desc { font-size: 14px; color: var(--text-main); line-height: 1.5; }
2270
+ .narrative { background: var(--card-bg); border: 1px solid var(--border-color); border-radius: 8px; padding: 20px; margin-bottom: 24px; }
2271
+ .narrative p { margin-bottom: 12px; font-size: 14px; color: var(--text-main); line-height: 1.7; }
2272
+ .key-insight { background: var(--win-bg); border: 1px solid var(--win-border); border-radius: 8px; padding: 12px 16px; margin-top: 12px; font-size: 14px; color: var(--win-title); }
2273
+ .section-intro { font-size: 14px; color: var(--text-muted); margin-bottom: 16px; }
2274
+ .big-wins { display: flex; flex-direction: column; gap: 12px; margin-bottom: 24px; }
2275
+ .big-win { background: var(--win-bg); border: 1px solid var(--win-border); border-radius: 8px; padding: 16px; }
2276
+ .big-win-title { font-weight: 600; font-size: 15px; color: var(--win-title); margin-bottom: 8px; }
2277
+ .big-win-desc { font-size: 14px; color: var(--win-text); line-height: 1.5; }
2278
+ .friction-categories { display: flex; flex-direction: column; gap: 16px; margin-bottom: 24px; }
2279
+ .friction-category { background: var(--frict-bg); border: 1px solid var(--frict-border); border-radius: 8px; padding: 16px; }
2280
+ .friction-title { font-weight: 600; font-size: 15px; color: var(--frict-title); margin-bottom: 6px; }
2281
+ .friction-desc { font-size: 13px; color: var(--frict-text); margin-bottom: 10px; }
2282
+ .friction-examples { margin: 0 0 0 20px; font-size: 13px; color: var(--text-main); }
2283
+ .friction-examples li { margin-bottom: 4px; }
2284
+ .claude-md-section { background: var(--cmd-bg); border: 1px solid var(--cmd-border); border-radius: 8px; padding: 16px; margin-bottom: 20px; }
2285
+ .claude-md-section h3 { font-size: 14px; font-weight: 600; color: var(--cmd-title); margin: 0 0 12px 0; }
2286
+ .claude-md-actions { margin-bottom: 12px; padding-bottom: 12px; border-bottom: 1px solid var(--cmd-border); }
2287
+ .copy-all-btn { background: #2563eb; color: white; border: none; border-radius: 4px; padding: 6px 12px; font-size: 12px; cursor: pointer; font-weight: 500; transition: all 0.2s; }
2288
+ .copy-all-btn:hover { background: #1d4ed8; }
2289
+ .copy-all-btn.copied { background: #16a34a; }
2290
+ .claude-md-item { display: flex; flex-wrap: wrap; align-items: flex-start; gap: 8px; padding: 10px 0; border-bottom: 1px solid var(--cmd-border); }
2291
+ .claude-md-item:last-child { border-bottom: none; }
2292
+ .cmd-checkbox { margin-top: 2px; }
2293
+ .cmd-code { background: var(--card-bg); padding: 8px 12px; border-radius: 4px; font-size: 12px; color: var(--cmd-title); border: 1px solid var(--cmd-border); font-family: monospace; display: block; white-space: pre-wrap; word-break: break-word; flex: 1; }
2294
+ .cmd-why { font-size: 12px; color: var(--text-muted); width: 100%; padding-left: 24px; margin-top: 4px; }
2295
+ .features-section, .patterns-section { display: flex; flex-direction: column; gap: 12px; margin: 16px 0; }
2296
+ .feature-card { background: var(--feat-bg); border: 1px solid var(--feat-border); border-radius: 8px; padding: 16px; }
2297
+ .pattern-card { background: var(--pat-bg); border: 1px solid var(--pat-border); border-radius: 8px; padding: 16px; }
2298
+ .feature-title, .pattern-title { font-weight: 600; font-size: 15px; color: var(--text-heading); margin-bottom: 6px; }
2299
+ .feature-oneliner, .pattern-summary { font-size: 14px; color: var(--text-main); margin-bottom: 8px; }
2300
+ .feature-why, .pattern-detail { font-size: 13px; color: var(--text-main); line-height: 1.5; }
2301
+ .feature-examples { margin-top: 12px; }
2302
+ .feature-example { padding: 8px 0; border-top: 1px solid var(--feat-border); }
2303
+ .feature-example:first-child { border-top: none; }
2304
+ .example-code-row, .copyable-prompt-row { display: flex; align-items: flex-start; gap: 8px; }
2305
+ .example-code { flex: 1; background: var(--code-bg); padding: 8px 12px; border-radius: 4px; font-family: monospace; font-size: 12px; color: var(--text-main); overflow-x: auto; white-space: pre-wrap; }
2306
+ .copyable-prompt-section { margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border-color); }
2307
+ .copyable-prompt { flex: 1; background: var(--card-bg); padding: 10px 12px; border-radius: 4px; font-family: monospace; font-size: 12px; color: var(--text-main); border: 1px solid var(--border-color); white-space: pre-wrap; line-height: 1.5; }
2308
+ .pattern-prompt { background: var(--card-bg); padding: 12px; border-radius: 6px; margin-top: 12px; border: 1px solid var(--border-color); }
2309
+ .pattern-prompt code { font-family: monospace; font-size: 12px; color: var(--text-main); display: block; white-space: pre-wrap; margin-bottom: 8px; }
2310
+ .prompt-label { font-size: 11px; font-weight: 600; text-transform: uppercase; color: var(--text-muted); margin-bottom: 6px; }
2311
+ .copy-btn { background: var(--nav-bg); border: none; border-radius: 4px; padding: 4px 8px; font-size: 11px; cursor: pointer; color: var(--text-muted); flex-shrink: 0; }
2312
+ .copy-btn:hover { background: var(--nav-hover); color: var(--text-main); }
2313
+ .charts-row { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; margin: 24px 0; }
2314
+ .chart-card { background: var(--card-bg); border: 1px solid var(--border-color); border-radius: 8px; padding: 16px; }
2315
+ .chart-title { font-size: 12px; font-weight: 600; color: var(--text-muted); text-transform: uppercase; margin-bottom: 12px; }
2316
+ .bar-row { display: flex; align-items: center; margin-bottom: 6px; }
2317
+ .bar-label { width: 100px; font-size: 11px; color: var(--text-main); flex-shrink: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
2318
+ .bar-track { flex: 1; height: 6px; background: var(--nav-bg); border-radius: 3px; margin: 0 8px; }
2319
+ .bar-fill { height: 100%; border-radius: 3px; }
2320
+ .bar-value { width: 28px; font-size: 11px; font-weight: 500; color: var(--text-muted); text-align: right; }
2321
+ .empty { color: var(--text-muted); font-size: 13px; }
2322
+ .horizon-section { display: flex; flex-direction: column; gap: 16px; }
2323
+ .horizon-card { background: var(--horiz-bg); border: 1px solid var(--horiz-border); border-radius: 8px; padding: 16px; }
2324
+ .horizon-title { font-weight: 600; font-size: 15px; color: var(--horiz-title); margin-bottom: 8px; }
2325
+ .horizon-possible { font-size: 14px; color: var(--text-main); margin-bottom: 10px; line-height: 1.5; }
2326
+ .horizon-tip { font-size: 13px; color: var(--horiz-title); background: var(--horiz-tip-bg); padding: 8px 12px; border-radius: 4px; }
2327
+ .fun-ending { background: var(--fun-bg); border: 1px solid var(--fun-border); border-radius: 12px; padding: 24px; margin-top: 40px; text-align: center; }
2328
+ .fun-headline { font-size: 18px; font-weight: 600; color: var(--fun-title); margin-bottom: 8px; }
2329
+ .fun-detail { font-size: 14px; color: var(--fun-text); }
2330
+ @media (max-width: 640px) { .charts-row { grid-template-columns: 1fr; } .stats-row { justify-content: center; } }
2331
+ `;
2332
+ const hourCountsJson = getHourCountsJson(data.message_hours);
2333
+ const js = `
2334
+ function toggleTheme() {
2335
+ document.body.classList.toggle('dark');
2336
+ const isDark = document.body.classList.contains('dark');
2337
+ localStorage.setItem('opencode-insights-theme', isDark ? 'dark' : 'light');
2338
+ }
2339
+
2340
+ // Check saved theme
2341
+ if (localStorage.getItem('opencode-insights-theme') === 'dark') {
2342
+ document.body.classList.add('dark');
2343
+ } else if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches && !localStorage.getItem('opencode-insights-theme')) {
2344
+ document.body.classList.add('dark');
2345
+ }
2346
+
2347
+ function copyText(btn) {
2348
+ const code = btn.previousElementSibling;
2349
+ navigator.clipboard.writeText(code.textContent).then(() => {
2350
+ btn.textContent = 'Copied!';
2351
+ setTimeout(() => { btn.textContent = 'Copy'; }, 2000);
2352
+ });
2353
+ }
2354
+ function copyCmdItem(idx) {
2355
+ const checkbox = document.getElementById('cmd-' + idx);
2356
+ if (checkbox) {
2357
+ const text = checkbox.dataset.text;
2358
+ navigator.clipboard.writeText(text).then(() => {
2359
+ const btn = checkbox.nextElementSibling.querySelector('.copy-btn');
2360
+ if (btn) { btn.textContent = 'Copied!'; setTimeout(() => { btn.textContent = 'Copy'; }, 2000); }
2361
+ });
2362
+ }
2363
+ }
2364
+ function copyAllCheckedAgentsMd() {
2365
+ const checkboxes = document.querySelectorAll('.cmd-checkbox:checked');
2366
+ const texts = [];
2367
+ checkboxes.forEach(cb => {
2368
+ if (cb.dataset.text) { texts.push(cb.dataset.text); }
2369
+ });
2370
+ const combined = texts.join('\\n');
2371
+ const btn = document.querySelector('.copy-all-btn');
2372
+ if (btn) {
2373
+ navigator.clipboard.writeText(combined).then(() => {
2374
+ btn.textContent = 'Copied ' + texts.length + ' items!';
2375
+ btn.classList.add('copied');
2376
+ setTimeout(() => { btn.textContent = 'Copy All Checked'; btn.classList.remove('copied'); }, 2000);
2377
+ });
2378
+ }
2379
+ }
2380
+ // Timezone selector for time of day chart
2381
+ const rawHourCounts = ${hourCountsJson};
2382
+ function updateHourHistogram(offsetFromPT) {
2383
+ const periods = [
2384
+ { label: "Morning (6-12)", range: [6,7,8,9,10,11] },
2385
+ { label: "Afternoon (12-18)", range: [12,13,14,15,16,17] },
2386
+ { label: "Evening (18-24)", range: [18,19,20,21,22,23] },
2387
+ { label: "Night (0-6)", range: [0,1,2,3,4,5] }
2388
+ ];
2389
+ const adjustedCounts = {};
2390
+ for (const [hour, count] of Object.entries(rawHourCounts)) {
2391
+ const newHour = (parseInt(hour) + offsetFromPT + 24) % 24;
2392
+ adjustedCounts[newHour] = (adjustedCounts[newHour] || 0) + count;
2393
+ }
2394
+ const periodCounts = periods.map(p => ({
2395
+ label: p.label,
2396
+ count: p.range.reduce((sum, h) => sum + (adjustedCounts[h] || 0), 0)
2397
+ }));
2398
+ const maxCount = Math.max(...periodCounts.map(p => p.count)) || 1;
2399
+ const container = document.getElementById('hour-histogram');
2400
+ container.textContent = '';
2401
+ periodCounts.forEach(p => {
2402
+ const row = document.createElement('div');
2403
+ row.className = 'bar-row';
2404
+ const label = document.createElement('div');
2405
+ label.className = 'bar-label';
2406
+ label.textContent = p.label;
2407
+ const track = document.createElement('div');
2408
+ track.className = 'bar-track';
2409
+ const fill = document.createElement('div');
2410
+ fill.className = 'bar-fill';
2411
+ fill.style.width = (p.count / maxCount) * 100 + '%';
2412
+ fill.style.background = '#8b5cf6';
2413
+ track.appendChild(fill);
2414
+ const value = document.createElement('div');
2415
+ value.className = 'bar-value';
2416
+ value.textContent = p.count;
2417
+ row.appendChild(label);
2418
+ row.appendChild(track);
2419
+ row.appendChild(value);
2420
+ container.appendChild(row);
2421
+ });
2422
+ }
2423
+ document.getElementById('timezone-select').addEventListener('change', function() {
2424
+ const customInput = document.getElementById('custom-offset');
2425
+ if (this.value === 'custom') {
2426
+ customInput.style.display = 'inline-block';
2427
+ customInput.focus();
2428
+ } else {
2429
+ customInput.style.display = 'none';
2430
+ updateHourHistogram(parseInt(this.value));
2431
+ }
2432
+ });
2433
+ document.getElementById('custom-offset').addEventListener('change', function() {
2434
+ const offset = parseInt(this.value) + 8;
2435
+ updateHourHistogram(offset);
2436
+ });
2437
+ `;
2438
+ return `<!DOCTYPE html>
2439
+ <html>
2440
+ <head>
2441
+ <meta charset="utf-8">
2442
+ <title>OpenCode Insights</title>
2443
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
2444
+ <style>${css}</style>
2445
+ </head>
2446
+ <body>
2447
+ <div class="container">
2448
+ <button class="theme-toggle" onclick="toggleTheme()" title="Toggle Dark Mode">\uD83C\uDF13 Theme</button>
2449
+ <h1>OpenCode Insights</h1>
2450
+ <p class="subtitle">${data.total_messages?.toLocaleString() || 0} messages across ${data.total_sessions || 0} sessions${data.total_sessions_scanned && data.total_sessions_scanned > data.total_sessions ? ` (${data.total_sessions_scanned.toLocaleString()} total)` : ""} | ${data.date_range?.start || ""} to ${data.date_range?.end || ""}</p>
2451
+
2452
+ ${atAGlanceHtml}
2453
+
2454
+ <nav class="nav-toc">
2455
+ <a href="#section-glance">At a Glance</a>
2456
+ <a href="#section-projects">What You Work On</a>
2457
+ <a href="#section-style">How You Use OpenCode</a>
2458
+ <a href="#section-wins">Impressive Things</a>
2459
+ <a href="#section-friction">Where Things Go Wrong</a>
2460
+ <a href="#section-features">Features to Try</a>
2461
+ <a href="#section-patterns">New Usage Patterns</a>
2462
+ <a href="#section-horizon">On the Horizon</a>
2463
+ <a href="#section-fun">Fun Ending</a>
2464
+ </nav>
2465
+
2466
+ <div class="stats-row" id="section-stats">
2467
+ <div class="stat"><div class="stat-value">${data.total_messages?.toLocaleString() || 0}</div><div class="stat-label">Messages</div></div>
2468
+ <div class="stat"><div class="stat-value">+${data.total_lines_added?.toLocaleString() || 0}/-${data.total_lines_removed?.toLocaleString() || 0}</div><div class="stat-label">Lines</div></div>
2469
+ <div class="stat"><div class="stat-value">${data.total_files_modified || 0}</div><div class="stat-label">Files</div></div>
2470
+ <div class="stat"><div class="stat-value">${data.days_active || 0}</div><div class="stat-label">Days</div></div>
2471
+ <div class="stat"><div class="stat-value">${data.messages_per_day || 0}</div><div class="stat-label">Msgs/Day</div></div>
2472
+ </div>
2473
+
2474
+ ${projectAreasHtml}
2475
+
2476
+ <div class="charts-row">
2477
+ <div class="chart-card">
2478
+ <div class="chart-title">What You Wanted</div>
2479
+ ${generateBarChart(data.goal_categories, "#2563eb")}
2480
+ </div>
2481
+ <div class="chart-card">
2482
+ <div class="chart-title">Top Tools Used</div>
2483
+ ${generateBarChart(data.tool_counts, "#0891b2")}
2484
+ </div>
2485
+ </div>
2486
+
2487
+ <div class="charts-row">
2488
+ <div class="chart-card">
2489
+ <div class="chart-title">Languages</div>
2490
+ ${generateBarChart(data.languages, "#10b981")}
2491
+ </div>
2492
+ <div class="chart-card">
2493
+ <div class="chart-title">Session Types</div>
2494
+ ${generateBarChart(data.session_types || {}, "#8b5cf6")}
2495
+ </div>
2496
+ </div>
2497
+
2498
+ ${interactionHtml}
2499
+
2500
+ <!-- Response Time Distribution -->
2501
+ <div class="chart-card" style="margin: 24px 0;">
2502
+ <div class="chart-title">User Response Time Distribution</div>
2503
+ ${generateResponseTimeHistogram(data.user_response_times)}
2504
+ <div style="font-size: 12px; color: var(--text-muted); margin-top: 8px;">
2505
+ Median: ${data.median_response_time?.toFixed(1) || 0}s &bull; Average: ${data.avg_response_time?.toFixed(1) || 0}s
2506
+ </div>
2507
+ </div>
2508
+
2509
+ <!-- Multi-clauding Section -->
2510
+ <div class="chart-card" style="margin: 24px 0;">
2511
+ <div class="chart-title">Multi-Clauding (Parallel Sessions)</div>
2512
+ ${data.multi_clauding?.overlap_events === 0 || !data.multi_clauding ? `
2513
+ <p style="font-size: 14px; color: var(--text-muted); padding: 8px 0;">
2514
+ No parallel session usage detected. You typically work with one OpenCode session at a time.
2515
+ </p>
2516
+ ` : `
2517
+ <div style="display: flex; gap: 24px; margin: 12px 0;">
2518
+ <div style="text-align: center;">
2519
+ <div style="font-size: 24px; font-weight: 700; color: #7c3aed;">${data.multi_clauding.overlap_events}</div>
2520
+ <div style="font-size: 11px; color: var(--text-muted); text-transform: uppercase;">Overlap Events</div>
2521
+ </div>
2522
+ <div style="text-align: center;">
2523
+ <div style="font-size: 24px; font-weight: 700; color: #7c3aed;">${data.multi_clauding.sessions_involved}</div>
2524
+ <div style="font-size: 11px; color: var(--text-muted); text-transform: uppercase;">Sessions Involved</div>
2525
+ </div>
2526
+ <div style="text-align: center;">
2527
+ <div style="font-size: 24px; font-weight: 700; color: #7c3aed;">${data.total_messages > 0 ? Math.round(100 * data.multi_clauding.user_messages_during / data.total_messages) : 0}%</div>
2528
+ <div style="font-size: 11px; color: var(--text-muted); text-transform: uppercase;">Of Messages</div>
2529
+ </div>
2530
+ </div>
2531
+ <p style="font-size: 13px; color: var(--text-main); margin-top: 12px;">
2532
+ You run multiple OpenCode sessions simultaneously. Multi-clauding is detected when sessions
2533
+ overlap in time, suggesting parallel workflows.
2534
+ </p>
2535
+ `}
2536
+ </div>
2537
+
2538
+ <!-- Time of Day & Tool Errors -->
2539
+ <div class="charts-row">
2540
+ <div class="chart-card">
2541
+ <div class="chart-title" style="display: flex; align-items: center; gap: 12px;">
2542
+ User Messages by Time of Day
2543
+ <select id="timezone-select" style="background: var(--card-bg); color: var(--text-main); font-size: 12px; padding: 4px 8px; border-radius: 4px; border: 1px solid var(--border-color);">
2544
+ <option value="0">PT (UTC-8)</option>
2545
+ <option value="3">ET (UTC-5)</option>
2546
+ <option value="8">London (UTC)</option>
2547
+ <option value="9">CET (UTC+1)</option>
2548
+ <option value="17">Tokyo (UTC+9)</option>
2549
+ <option value="custom">Custom offset...</option>
2550
+ </select>
2551
+ <input type="number" id="custom-offset" placeholder="UTC offset" style="display: none; background: var(--card-bg); color: var(--text-main); width: 80px; font-size: 12px; padding: 4px; border-radius: 4px; border: 1px solid var(--border-color);">
2552
+ </div>
2553
+ ${generateTimeOfDayChart(data.message_hours)}
2554
+ </div>
2555
+ <div class="chart-card">
2556
+ <div class="chart-title">Tool Errors Encountered</div>
2557
+ ${data.tool_error_categories && Object.keys(data.tool_error_categories).length > 0 ? generateBarChart(data.tool_error_categories, "#dc2626") : '<p class="empty">No tool errors</p>'}
2558
+ </div>
2559
+ </div>
2560
+
2561
+ ${whatWorksHtml}
2562
+
2563
+ <div class="charts-row">
2564
+ <div class="chart-card">
2565
+ <div class="chart-title">What Helped Most</div>
2566
+ ${generateBarChart(data.success, "#16a34a")}
2567
+ </div>
2568
+ <div class="chart-card">
2569
+ <div class="chart-title">Outcomes</div>
2570
+ ${generateBarChart(data.outcomes, "#8b5cf6", 6, OUTCOME_ORDER)}
2571
+ </div>
2572
+ </div>
2573
+
2574
+ ${frictionHtml}
2575
+
2576
+ <div class="charts-row">
2577
+ <div class="chart-card">
2578
+ <div class="chart-title">Primary Friction Types</div>
2579
+ ${generateBarChart(data.friction, "#dc2626")}
2580
+ </div>
2581
+ <div class="chart-card">
2582
+ <div class="chart-title">Inferred Satisfaction (model-estimated)</div>
2583
+ ${generateBarChart(data.satisfaction, "#eab308", 6, SATISFACTION_ORDER)}
2584
+ </div>
2585
+ </div>
2586
+
2587
+ ${suggestionsHtml}
2588
+
2589
+ ${horizonHtml}
2590
+
2591
+ ${funEndingHtml}
2592
+
2593
+ </div>
2594
+ <script>${js}</script>
2595
+ </body>
2596
+ </html>`;
2597
+ }
2598
+
2599
+ // src/insights/handler.ts
2600
+ var MAX_SESSION_SUMMARIES2 = 50;
2601
+ function getDefaultDbPath3() {
2602
+ return `${Bun.env?.HOME ?? ""}/.local/share/opencode/opencode.db`;
2603
+ }
2604
+ function dedupSessionsById(sessions) {
2605
+ const bestById = new Map;
2606
+ for (const session of sessions) {
2607
+ const existing = bestById.get(session.id);
2608
+ if (!existing || session.user_message_count > existing.user_message_count) {
2609
+ bestById.set(session.id, session);
2610
+ }
2611
+ }
2612
+ return Array.from(bestById.values()).sort((a, b) => b.time_updated - a.time_updated);
2613
+ }
2614
+ function getReportSnapshotPath() {
2615
+ return `${getReportDir()}/report-cache.json`;
2616
+ }
2617
+ function incrementCounter3(record, key, amount = 1) {
2618
+ record[key] = (record[key] || 0) + amount;
2619
+ }
2620
+ function incrementEntries2(target, source) {
2621
+ for (const [key, count] of Object.entries(source)) {
2622
+ if (count > 0) {
2623
+ incrementCounter3(target, key, count);
2624
+ }
2625
+ }
2626
+ }
2627
+ function parseNumber(text) {
2628
+ if (!text)
2629
+ return null;
2630
+ const parsed = Number(text.replace(/,/g, ""));
2631
+ return Number.isFinite(parsed) ? parsed : null;
2632
+ }
2633
+ function stripHtml(value) {
2634
+ return value.replace(/<br\s*\/?>/gi, `
2635
+ `).replace(/<[^>]+>/g, "").replace(/&nbsp;/g, " ").replace(/&amp;/g, "&").replace(/&quot;/g, '"').replace(/&#039;/g, "'").trim();
2636
+ }
2637
+ function parseSummaryFromReportHtml(html) {
2638
+ const match = html.match(/([\d,]+)\s+messages\s+across\s+([\d,]+)\s+sessions(?:\s+\(([\d,]+)\s+total\))?/i);
2639
+ return {
2640
+ totalMessages: parseNumber(match?.[1]),
2641
+ totalSessions: parseNumber(match?.[2]),
2642
+ totalSessionsScanned: parseNumber(match?.[3])
2643
+ };
2644
+ }
2645
+ function parseDateRangeFromReportHtml(html) {
2646
+ const match = html.match(/(\d{4}-\d{2}-\d{2})\s+to\s+(\d{4}-\d{2}-\d{2})/);
2647
+ if (!match)
2648
+ return null;
2649
+ const start = match[1];
2650
+ const end = match[2];
2651
+ if (!start || !end)
2652
+ return null;
2653
+ return { start, end };
2654
+ }
2655
+ function recoverAtAGlanceFromReportHtml(html) {
2656
+ const extractLine = (label, sectionId) => {
2657
+ const pattern = new RegExp(`<strong>${label}:<\\/strong>\\s*([\\s\\S]*?)\\s*<a href="#${sectionId}"`, "i");
2658
+ const match = html.match(pattern);
2659
+ return stripHtml(match?.[1] ?? "");
2660
+ };
2661
+ const whatsWorking = extractLine("What's working", "section-wins");
2662
+ const whatsHindering = extractLine("What's hindering you", "section-friction");
2663
+ const quickWins = extractLine("Quick wins to try", "section-features");
2664
+ const ambitiousWorkflows = extractLine("Ambitious workflows", "section-horizon");
2665
+ if (!whatsWorking && !whatsHindering && !quickWins && !ambitiousWorkflows) {
2666
+ return {};
2667
+ }
2668
+ return {
2669
+ at_a_glance: {
2670
+ ...whatsWorking ? { whats_working: whatsWorking } : {},
2671
+ ...whatsHindering ? { whats_hindering: whatsHindering } : {},
2672
+ ...quickWins ? { quick_wins: quickWins } : {},
2673
+ ...ambitiousWorkflows ? { ambitious_workflows: ambitiousWorkflows } : {}
2674
+ }
2675
+ };
2676
+ }
2677
+ function rebuildAggregatedDataFromCachedArtifacts(facets, reportHtml) {
2678
+ const data = aggregateData([], new Map);
2679
+ data.total_sessions = facets.size;
2680
+ data.sessions_with_facets = facets.size;
2681
+ data.total_sessions_scanned = facets.size;
2682
+ for (const facet of facets.values()) {
2683
+ incrementEntries2(data.goal_categories, facet.goal_categories);
2684
+ incrementCounter3(data.outcomes, facet.outcome);
2685
+ incrementEntries2(data.satisfaction, facet.user_satisfaction_counts);
2686
+ incrementCounter3(data.helpfulness, facet.claude_helpfulness);
2687
+ incrementCounter3(data.session_types, facet.session_type);
2688
+ incrementEntries2(data.friction, facet.friction_counts);
2689
+ if (facet.primary_success !== "none") {
2690
+ incrementCounter3(data.success, facet.primary_success);
2691
+ }
2692
+ if (data.session_summaries.length < MAX_SESSION_SUMMARIES2) {
2693
+ data.session_summaries.push({
2694
+ id: facet.session_id,
2695
+ date: "",
2696
+ summary: facet.brief_summary,
2697
+ goal: facet.underlying_goal
2698
+ });
2699
+ }
2700
+ }
2701
+ const parsedSummary = parseSummaryFromReportHtml(reportHtml);
2702
+ if (parsedSummary.totalMessages !== null) {
2703
+ data.total_messages = parsedSummary.totalMessages;
2704
+ }
2705
+ if (parsedSummary.totalSessions !== null) {
2706
+ data.total_sessions = parsedSummary.totalSessions;
2707
+ }
2708
+ if (parsedSummary.totalSessionsScanned !== null) {
2709
+ data.total_sessions_scanned = parsedSummary.totalSessionsScanned;
2710
+ }
2711
+ const parsedRange = parseDateRangeFromReportHtml(reportHtml);
2712
+ if (parsedRange) {
2713
+ data.date_range = parsedRange;
2714
+ }
2715
+ return data;
2716
+ }
2717
+ async function loadFacetsFromDisk() {
2718
+ const facets = new Map;
2719
+ const facetsDir = getFacetsDir();
2720
+ try {
2721
+ const entries = await readdir2(facetsDir, { withFileTypes: true });
2722
+ for (const entry of entries) {
2723
+ if (!entry.isFile() || !entry.name.endsWith(".json"))
2724
+ continue;
2725
+ const facetPath = `${facetsDir}/${entry.name}`;
2726
+ try {
2727
+ const parsed = await Bun.file(facetPath).json();
2728
+ if (isValidSessionFacets(parsed)) {
2729
+ facets.set(parsed.session_id, parsed);
2730
+ }
2731
+ } catch {
2732
+ continue;
2733
+ }
2734
+ }
2735
+ } catch {
2736
+ return facets;
2737
+ }
2738
+ return facets;
2739
+ }
2740
+ function isRecord(value) {
2741
+ return typeof value === "object" && value !== null && !Array.isArray(value);
2742
+ }
2743
+ function isCachedReportPayload(value) {
2744
+ if (!isRecord(value))
2745
+ return false;
2746
+ if (!Array.isArray(value.facets))
2747
+ return false;
2748
+ if (!value.facets.every((facet) => isValidSessionFacets(facet)))
2749
+ return false;
2750
+ if (!isRecord(value.insights))
2751
+ return false;
2752
+ if (!isRecord(value.data))
2753
+ return false;
2754
+ return true;
2755
+ }
2756
+ async function loadCachedReportPayload() {
2757
+ const snapshotPath = getReportSnapshotPath();
2758
+ try {
2759
+ const file = Bun.file(snapshotPath);
2760
+ if (!await file.exists())
2761
+ return null;
2762
+ const parsed = await file.json();
2763
+ if (!isCachedReportPayload(parsed))
2764
+ return null;
2765
+ const facets = new Map;
2766
+ for (const facet of parsed.facets) {
2767
+ facets.set(facet.session_id, facet);
2768
+ }
2769
+ return {
2770
+ insights: parsed.insights,
2771
+ data: parsed.data,
2772
+ facets
2773
+ };
2774
+ } catch {
2775
+ return null;
2776
+ }
2777
+ }
2778
+ async function saveCachedReportPayload(insights, data, facets) {
2779
+ const snapshotPath = getReportSnapshotPath();
2780
+ try {
2781
+ await mkdir3(dirname2(snapshotPath), { recursive: true });
2782
+ const payload = {
2783
+ insights,
2784
+ data,
2785
+ facets: Array.from(facets.values())
2786
+ };
2787
+ await Bun.write(snapshotPath, JSON.stringify(payload, null, 2));
2788
+ } catch {
2789
+ return;
2790
+ }
2791
+ }
2792
+ async function restoreCachedResult(htmlPath) {
2793
+ const snapshot = await loadCachedReportPayload();
2794
+ if (snapshot) {
2795
+ return snapshot;
2796
+ }
2797
+ let reportHtml = "";
2798
+ try {
2799
+ reportHtml = await Bun.file(htmlPath).text();
2800
+ } catch {
2801
+ reportHtml = "";
2802
+ }
2803
+ const facets = await loadFacetsFromDisk();
2804
+ const insights = recoverAtAGlanceFromReportHtml(reportHtml);
2805
+ const data = rebuildAggregatedDataFromCachedArtifacts(facets, reportHtml);
2806
+ return { insights, data, facets };
2807
+ }
2808
+ function filterMinimalSessions(sessions, facets) {
2809
+ const filteredFacets = new Map;
2810
+ for (const [sessionId, facet] of facets.entries()) {
2811
+ if (!isMinimalSession(facet)) {
2812
+ filteredFacets.set(sessionId, facet);
2813
+ }
2814
+ }
2815
+ const filteredSessions = sessions.filter((session) => {
2816
+ const facet = facets.get(session.id);
2817
+ if (!facet)
2818
+ return true;
2819
+ return !isMinimalSession(facet);
2820
+ });
2821
+ return { sessions: filteredSessions, facets: filteredFacets };
2822
+ }
2823
+ async function collectSessionArtifacts(sessions, dbPath) {
2824
+ const messages = new Map;
2825
+ const parts = new Map;
2826
+ const path = dbPath ?? getDefaultDbPath3();
2827
+ await Promise.all(sessions.map(async (session) => {
2828
+ const [sessionMessages, sessionParts] = await Promise.all([
2829
+ collectMessages(path, session.id).catch(() => []),
2830
+ collectParts(path, session.id).catch(() => [])
2831
+ ]);
2832
+ messages.set(session.id, sessionMessages);
2833
+ parts.set(session.id, sessionParts);
2834
+ }));
2835
+ return { messages, parts };
2836
+ }
2837
+ async function generateUsageReport(client, options = {}) {
2838
+ const htmlPath = getReportPath();
2839
+ if (await isReportFresh(options?.dbPath)) {
2840
+ options.onProgress?.({ stage: "cache-hit" });
2841
+ const cached = await restoreCachedResult(htmlPath);
2842
+ buildExportData(cached.data, cached.insights, cached.facets);
2843
+ buildPromptForCommand(cached.insights, htmlPath, cached.data, getFacetsDir());
2844
+ return {
2845
+ insights: cached.insights,
2846
+ htmlPath,
2847
+ data: cached.data,
2848
+ facets: cached.facets
2849
+ };
2850
+ }
2851
+ const sessions = await collectSessions({
2852
+ dbPath: options?.dbPath,
2853
+ days: options?.days,
2854
+ project: options?.project
2855
+ });
2856
+ const dedupedSessions = dedupSessionsById(sessions);
2857
+ const substantiveSessions = dedupedSessions.filter(isSubstantiveSession);
2858
+ const analyzedSessions = substantiveSessions.slice(0, MAX_SESSIONS);
2859
+ options.onProgress?.({
2860
+ stage: "sessions-collected",
2861
+ completed: analyzedSessions.length,
2862
+ total: dedupedSessions.length
2863
+ });
2864
+ const { messages, parts } = await collectSessionArtifacts(analyzedSessions, options?.dbPath);
2865
+ const extractedFacets = await extractAllFacets(client, analyzedSessions, messages, parts, new Map);
2866
+ const filtered = filterMinimalSessions(analyzedSessions, extractedFacets);
2867
+ const data = aggregateData(filtered.sessions, filtered.facets);
2868
+ data.total_sessions_scanned = dedupedSessions.length;
2869
+ const insights = await generateParallelInsights(client, data, filtered.facets, options.onProgress);
2870
+ const html = generateHtmlReport(data, insights);
2871
+ await mkdir3(dirname2(htmlPath), { recursive: true });
2872
+ await Bun.write(htmlPath, html);
2873
+ await saveCachedReportPayload(insights, data, filtered.facets);
2874
+ buildExportData(data, insights, filtered.facets);
2875
+ buildPromptForCommand(insights, htmlPath, data, getFacetsDir());
2876
+ return {
2877
+ insights,
2878
+ htmlPath,
2879
+ data,
2880
+ facets: filtered.facets
2881
+ };
2882
+ }
2883
+
2884
+ // src/plugins/insights.ts
2885
+ var insightsPlugin = async ({ client }) => {
2886
+ return {
2887
+ tool: {
2888
+ insights_generate: tool({
2889
+ description: "Generate an LLM-driven usage insights report analyzing your OpenCode sessions",
2890
+ args: {
2891
+ days: tool.schema.number().optional(),
2892
+ project: tool.schema.string().optional()
2893
+ },
2894
+ execute: async ({ days, project }, ctx) => {
2895
+ ctx.metadata({
2896
+ title: "Generating OpenCode insights",
2897
+ metadata: { stage: "pipeline:start" }
2898
+ });
2899
+ const result = await generateUsageReport(client, {
2900
+ days,
2901
+ project,
2902
+ onProgress: (metadata) => {
2903
+ ctx.metadata({
2904
+ title: "Generating OpenCode insights",
2905
+ metadata
2906
+ });
2907
+ }
2908
+ });
2909
+ return buildPromptForCommand(result.insights, result.htmlPath ?? getReportPath(), result.data, getFacetsDir());
2910
+ }
2911
+ })
2912
+ }
2913
+ };
2914
+ };
422
2915
  // src/plugins/leaderboard.ts
423
2916
  function getHome3() {
424
2917
  return Bun.env?.HOME ?? (globalThis?.process?.env?.HOME ?? "") ?? "";
@@ -1171,6 +3664,7 @@ export {
1171
3664
  toolCallNotifyPlugin,
1172
3665
  notifyPlugin,
1173
3666
  leaderboardPlugin,
3667
+ insightsPlugin,
1174
3668
  compactionPlugin,
1175
3669
  backgroundSubagentPlugin,
1176
3670
  autoMemoryPlugin