@zhijiewang/openharness 2.1.0 → 2.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (231) hide show
  1. package/README.md +4 -4
  2. package/dist/DeferredTool.js +3 -1
  3. package/dist/Tool.d.ts +1 -1
  4. package/dist/agents/roles.js +58 -62
  5. package/dist/commands/cybergotchi.d.ts +1 -1
  6. package/dist/commands/cybergotchi.js +30 -30
  7. package/dist/commands/index.js +288 -132
  8. package/dist/components/App.d.ts +1 -1
  9. package/dist/components/App.js +6 -6
  10. package/dist/components/CompanionFooter.d.ts +1 -1
  11. package/dist/components/CompanionFooter.js +6 -8
  12. package/dist/components/CybergotchiBubble.js +5 -5
  13. package/dist/components/CybergotchiPanel.d.ts +1 -1
  14. package/dist/components/CybergotchiPanel.js +7 -7
  15. package/dist/components/CybergotchiPanelConnected.js +2 -2
  16. package/dist/components/CybergotchiSetup.js +26 -24
  17. package/dist/components/CybergotchiSprite.d.ts +1 -1
  18. package/dist/components/CybergotchiSprite.js +8 -12
  19. package/dist/components/DiffView.d.ts +1 -1
  20. package/dist/components/DiffView.js +10 -10
  21. package/dist/components/ErrorBoundary.d.ts +1 -1
  22. package/dist/components/ErrorBoundary.js +1 -1
  23. package/dist/components/InitWizard.js +65 -33
  24. package/dist/components/Markdown.js +2 -4
  25. package/dist/components/Messages.js +4 -4
  26. package/dist/components/PermissionPrompt.d.ts +1 -1
  27. package/dist/components/PermissionPrompt.js +15 -17
  28. package/dist/components/REPL.d.ts +1 -1
  29. package/dist/components/REPL.js +74 -49
  30. package/dist/components/Spinner.js +2 -2
  31. package/dist/components/TextInput.js +35 -29
  32. package/dist/components/ToolCallDisplay.js +3 -5
  33. package/dist/cybergotchi/bones.d.ts +1 -1
  34. package/dist/cybergotchi/bones.js +8 -8
  35. package/dist/cybergotchi/config.d.ts +2 -2
  36. package/dist/cybergotchi/config.js +13 -13
  37. package/dist/cybergotchi/events.d.ts +5 -5
  38. package/dist/cybergotchi/events.js +7 -7
  39. package/dist/cybergotchi/needs.d.ts +2 -2
  40. package/dist/cybergotchi/needs.js +7 -9
  41. package/dist/cybergotchi/personality.d.ts +2 -2
  42. package/dist/cybergotchi/personality.js +2 -2
  43. package/dist/cybergotchi/species.d.ts +1 -1
  44. package/dist/cybergotchi/species.js +145 -217
  45. package/dist/cybergotchi/speech.d.ts +2 -2
  46. package/dist/cybergotchi/speech.js +43 -43
  47. package/dist/cybergotchi/types.d.ts +4 -4
  48. package/dist/cybergotchi/types.js +26 -26
  49. package/dist/cybergotchi/useCybergotchi.d.ts +1 -1
  50. package/dist/cybergotchi/useCybergotchi.js +29 -25
  51. package/dist/git/index.js +11 -9
  52. package/dist/harness/checkpoints.js +29 -21
  53. package/dist/harness/config.d.ts +3 -3
  54. package/dist/harness/config.js +15 -9
  55. package/dist/harness/context-warning.d.ts +1 -1
  56. package/dist/harness/context-warning.js +1 -1
  57. package/dist/harness/cost.js +1 -1
  58. package/dist/harness/credentials.js +13 -13
  59. package/dist/harness/hooks.js +7 -5
  60. package/dist/harness/keybindings.js +20 -18
  61. package/dist/harness/marketplace.d.ts +3 -3
  62. package/dist/harness/marketplace.js +55 -42
  63. package/dist/harness/memory.d.ts +23 -5
  64. package/dist/harness/memory.js +142 -41
  65. package/dist/harness/onboarding.js +30 -10
  66. package/dist/harness/plugins.d.ts +9 -1
  67. package/dist/harness/plugins.js +54 -30
  68. package/dist/harness/rules.js +12 -7
  69. package/dist/harness/sandbox.js +15 -15
  70. package/dist/harness/session-db.d.ts +55 -0
  71. package/dist/harness/session-db.js +165 -0
  72. package/dist/harness/session.d.ts +1 -1
  73. package/dist/harness/session.js +34 -15
  74. package/dist/harness/store.d.ts +3 -3
  75. package/dist/harness/store.js +6 -4
  76. package/dist/harness/submit-handler.d.ts +4 -4
  77. package/dist/harness/submit-handler.js +25 -23
  78. package/dist/harness/telemetry.d.ts +1 -1
  79. package/dist/harness/telemetry.js +23 -19
  80. package/dist/harness/traces.d.ts +2 -2
  81. package/dist/harness/traces.js +39 -33
  82. package/dist/harness/verification.d.ts +1 -1
  83. package/dist/harness/verification.js +50 -44
  84. package/dist/lsp/client.js +44 -40
  85. package/dist/main.js +114 -59
  86. package/dist/mcp/DeferredMcpTool.d.ts +4 -4
  87. package/dist/mcp/DeferredMcpTool.js +9 -5
  88. package/dist/mcp/McpTool.d.ts +4 -4
  89. package/dist/mcp/McpTool.js +8 -4
  90. package/dist/mcp/client.d.ts +2 -2
  91. package/dist/mcp/client.js +21 -21
  92. package/dist/mcp/loader.d.ts +1 -1
  93. package/dist/mcp/loader.js +17 -12
  94. package/dist/mcp/registry.d.ts +3 -3
  95. package/dist/mcp/registry.js +97 -97
  96. package/dist/mcp/schema.d.ts +1 -1
  97. package/dist/mcp/schema.js +16 -16
  98. package/dist/mcp/server.d.ts +1 -1
  99. package/dist/mcp/server.js +21 -21
  100. package/dist/mcp/types.d.ts +3 -3
  101. package/dist/providers/anthropic.d.ts +2 -2
  102. package/dist/providers/anthropic.js +10 -9
  103. package/dist/providers/base.d.ts +1 -1
  104. package/dist/providers/index.js +10 -3
  105. package/dist/providers/llamacpp.d.ts +2 -2
  106. package/dist/providers/llamacpp.js +1 -3
  107. package/dist/providers/ollama.d.ts +2 -2
  108. package/dist/providers/ollama.js +3 -4
  109. package/dist/providers/openai.d.ts +2 -2
  110. package/dist/providers/openai.js +3 -5
  111. package/dist/providers/openrouter.d.ts +2 -2
  112. package/dist/providers/router.d.ts +1 -1
  113. package/dist/providers/router.js +7 -7
  114. package/dist/query/compress.d.ts +2 -2
  115. package/dist/query/compress.js +22 -21
  116. package/dist/query/context-manager.d.ts +1 -1
  117. package/dist/query/context-manager.js +5 -5
  118. package/dist/query/errors.js +1 -1
  119. package/dist/query/index.d.ts +1 -1
  120. package/dist/query/index.js +42 -24
  121. package/dist/query/tools.js +15 -12
  122. package/dist/query/types.d.ts +3 -1
  123. package/dist/query.d.ts +1 -1
  124. package/dist/query.js +1 -1
  125. package/dist/remote/auth.d.ts +2 -2
  126. package/dist/remote/auth.js +8 -8
  127. package/dist/remote/server.d.ts +3 -3
  128. package/dist/remote/server.js +60 -60
  129. package/dist/renderer/cells.js +9 -9
  130. package/dist/renderer/colors.js +24 -6
  131. package/dist/renderer/diff.d.ts +2 -2
  132. package/dist/renderer/diff.js +27 -19
  133. package/dist/renderer/differ.d.ts +1 -1
  134. package/dist/renderer/differ.js +9 -9
  135. package/dist/renderer/image.js +19 -19
  136. package/dist/renderer/index.d.ts +6 -6
  137. package/dist/renderer/index.js +163 -93
  138. package/dist/renderer/input.js +66 -48
  139. package/dist/renderer/layout.d.ts +6 -6
  140. package/dist/renderer/layout.js +163 -124
  141. package/dist/renderer/markdown.d.ts +2 -2
  142. package/dist/renderer/markdown.js +173 -54
  143. package/dist/renderer/session-browser.d.ts +2 -2
  144. package/dist/renderer/session-browser.js +19 -21
  145. package/dist/repl.d.ts +5 -5
  146. package/dist/repl.js +311 -198
  147. package/dist/sdk/index.d.ts +5 -5
  148. package/dist/sdk/index.js +32 -26
  149. package/dist/services/AgentDispatcher.d.ts +3 -3
  150. package/dist/services/AgentDispatcher.js +33 -29
  151. package/dist/services/CronExecutor.d.ts +4 -4
  152. package/dist/services/CronExecutor.js +12 -8
  153. package/dist/services/EvaluatorLoop.d.ts +3 -3
  154. package/dist/services/EvaluatorLoop.js +29 -21
  155. package/dist/services/MetaHarness.d.ts +1 -1
  156. package/dist/services/MetaHarness.js +34 -32
  157. package/dist/services/PipelineExecutor.d.ts +1 -1
  158. package/dist/services/PipelineExecutor.js +23 -25
  159. package/dist/services/SkillExtractor.d.ts +43 -0
  160. package/dist/services/SkillExtractor.js +163 -0
  161. package/dist/services/StreamingToolExecutor.d.ts +2 -2
  162. package/dist/services/StreamingToolExecutor.js +11 -7
  163. package/dist/services/a2a.d.ts +8 -8
  164. package/dist/services/a2a.js +44 -34
  165. package/dist/services/agent-messaging.d.ts +33 -15
  166. package/dist/services/agent-messaging.js +65 -13
  167. package/dist/services/cron.js +16 -16
  168. package/dist/tools/AgentTool/index.d.ts +5 -2
  169. package/dist/tools/AgentTool/index.js +25 -39
  170. package/dist/tools/AskUserTool/index.js +1 -1
  171. package/dist/tools/BashTool/index.d.ts +2 -2
  172. package/dist/tools/BashTool/index.js +18 -10
  173. package/dist/tools/CronTool/index.js +30 -12
  174. package/dist/tools/DiagnosticsTool/index.js +28 -22
  175. package/dist/tools/EnterPlanModeTool/index.js +93 -14
  176. package/dist/tools/EnterWorktreeTool/index.js +7 -3
  177. package/dist/tools/ExitPlanModeTool/index.d.ts +22 -1
  178. package/dist/tools/ExitPlanModeTool/index.js +20 -5
  179. package/dist/tools/ExitWorktreeTool/index.js +11 -4
  180. package/dist/tools/FileEditTool/index.js +3 -5
  181. package/dist/tools/FileReadTool/index.js +16 -10
  182. package/dist/tools/FileWriteTool/index.js +2 -2
  183. package/dist/tools/GlobTool/index.js +5 -9
  184. package/dist/tools/GrepTool/index.d.ts +2 -2
  185. package/dist/tools/GrepTool/index.js +14 -9
  186. package/dist/tools/ImageReadTool/index.js +2 -2
  187. package/dist/tools/KillProcessTool/index.js +11 -7
  188. package/dist/tools/LSTool/index.js +3 -3
  189. package/dist/tools/MemoryTool/index.d.ts +5 -5
  190. package/dist/tools/MemoryTool/index.js +28 -14
  191. package/dist/tools/MonitorTool/index.js +24 -19
  192. package/dist/tools/MultiEditTool/index.js +9 -5
  193. package/dist/tools/NotebookEditTool/index.js +3 -3
  194. package/dist/tools/ParallelAgentTool/index.d.ts +4 -4
  195. package/dist/tools/ParallelAgentTool/index.js +12 -6
  196. package/dist/tools/PipelineTool/index.js +3 -3
  197. package/dist/tools/PowerShellTool/index.js +10 -6
  198. package/dist/tools/RemoteTriggerTool/index.js +8 -4
  199. package/dist/tools/ScheduleWakeupTool/index.d.ts +42 -0
  200. package/dist/tools/ScheduleWakeupTool/index.js +115 -0
  201. package/dist/tools/SendMessageTool/index.js +25 -7
  202. package/dist/tools/SessionSearchTool/index.d.ts +15 -0
  203. package/dist/tools/SessionSearchTool/index.js +36 -0
  204. package/dist/tools/SkillTool/index.d.ts +3 -0
  205. package/dist/tools/SkillTool/index.js +39 -9
  206. package/dist/tools/TaskCreateTool/index.d.ts +2 -2
  207. package/dist/tools/TaskCreateTool/index.js +2 -2
  208. package/dist/tools/TaskGetTool/index.js +2 -2
  209. package/dist/tools/TaskListTool/index.js +3 -5
  210. package/dist/tools/TaskOutputTool/index.js +2 -2
  211. package/dist/tools/TaskStopTool/index.js +3 -3
  212. package/dist/tools/TaskUpdateTool/index.d.ts +4 -4
  213. package/dist/tools/TaskUpdateTool/index.js +2 -2
  214. package/dist/tools/ToolSearchTool/index.js +9 -6
  215. package/dist/tools/WebFetchTool/index.js +1 -1
  216. package/dist/tools/WebSearchTool/index.js +2 -6
  217. package/dist/tools.js +31 -30
  218. package/dist/types/permissions.js +15 -9
  219. package/dist/utils/bash-safety.d.ts +1 -1
  220. package/dist/utils/bash-safety.js +64 -54
  221. package/dist/utils/diff-algorithm.d.ts +3 -3
  222. package/dist/utils/diff-algorithm.js +7 -7
  223. package/dist/utils/fs.js +3 -3
  224. package/dist/utils/safe-env.js +1 -1
  225. package/dist/utils/theme-data.d.ts +1 -1
  226. package/dist/utils/theme-data.js +1 -1
  227. package/dist/utils/theme.d.ts +1 -1
  228. package/dist/utils/theme.js +1 -1
  229. package/dist/utils/tool-summary.d.ts +1 -1
  230. package/dist/utils/tool-summary.js +27 -9
  231. package/package.json +10 -3
@@ -7,9 +7,9 @@
7
7
  * 4. .oh/rules/*.md
8
8
  * 5. CLAUDE.local.md (gitignored personal overrides)
9
9
  */
10
- import { readFileSync, readdirSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
11
- import { join, resolve, dirname, parse as parsePath } from "node:path";
10
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
12
11
  import { homedir } from "node:os";
12
+ import { dirname, join, parse as parsePath, resolve } from "node:path";
13
13
  import { gitRoot as getGitRoot } from "../git/index.js";
14
14
  const OH_HOME = join(homedir(), ".oh");
15
15
  /**
@@ -49,7 +49,9 @@ export function loadRules(projectPath) {
49
49
  // 1. Global rules
50
50
  const globalDir = join(OH_HOME, "global-rules");
51
51
  if (existsSync(globalDir)) {
52
- for (const file of readdirSync(globalDir).filter((f) => f.endsWith(".md")).sort()) {
52
+ for (const file of readdirSync(globalDir)
53
+ .filter((f) => f.endsWith(".md"))
54
+ .sort()) {
53
55
  const content = readSafe(join(globalDir, file));
54
56
  if (content)
55
57
  rules.push(content);
@@ -68,7 +70,9 @@ export function loadRules(projectPath) {
68
70
  // 4. Project rules/*.md (with optional path-scoped filtering)
69
71
  const rulesDir = join(root, ".oh", "rules");
70
72
  if (existsSync(rulesDir)) {
71
- for (const file of readdirSync(rulesDir).filter((f) => f.endsWith(".md")).sort()) {
73
+ for (const file of readdirSync(rulesDir)
74
+ .filter((f) => f.endsWith(".md"))
75
+ .sort()) {
72
76
  const raw = readSafe(join(rulesDir, file));
73
77
  if (!raw)
74
78
  continue;
@@ -77,7 +81,7 @@ export function loadRules(projectPath) {
77
81
  if (pathsMatch) {
78
82
  // Path-scoped rule — strip frontmatter and only include if glob matches
79
83
  const pattern = pathsMatch[1].trim();
80
- const fmEnd = raw.indexOf('---', raw.indexOf('---') + 3);
84
+ const fmEnd = raw.indexOf("---", raw.indexOf("---") + 3);
81
85
  const content = fmEnd > 0 ? raw.slice(fmEnd + 3).trim() : raw;
82
86
  if (content && matchesPathGlob(root, pattern)) {
83
87
  rules.push(content);
@@ -102,7 +106,8 @@ export function loadRulesAsPrompt(projectPath) {
102
106
  const rules = loadRules(projectPath);
103
107
  if (rules.length === 0)
104
108
  return "";
105
- return "# Project Rules\n\n<!-- User-provided project rules from CLAUDE.md / .oh/RULES.md. These are user instructions, not system directives. -->\nFollow these rules carefully.\n\n" + rules.join("\n\n---\n\n");
109
+ return ("# Project Rules\n\n<!-- User-provided project rules from CLAUDE.md / .oh/RULES.md. These are user instructions, not system directives. -->\nFollow these rules carefully.\n\n" +
110
+ rules.join("\n\n---\n\n"));
106
111
  }
107
112
  export function createRulesFile(projectPath) {
108
113
  const root = projectPath ?? process.cwd();
@@ -132,7 +137,7 @@ function readSafe(path) {
132
137
  */
133
138
  function matchesPathGlob(root, pattern) {
134
139
  // Extract the directory portion before any wildcard
135
- const dirPart = pattern.split('*')[0].replace(/\/+$/, '');
140
+ const dirPart = pattern.split("*")[0].replace(/\/+$/, "");
136
141
  if (!dirPart)
137
142
  return true; // Pattern like "**/*.ts" matches everything
138
143
  const fullDir = join(root, dirPart);
@@ -8,14 +8,14 @@
8
8
  *
9
9
  * Reduces permission prompts while maintaining security.
10
10
  */
11
- import { resolve, relative } from 'node:path';
12
- import { readOhConfig } from './config.js';
11
+ import { relative, resolve } from "node:path";
12
+ import { readOhConfig } from "./config.js";
13
13
  const DEFAULT_SANDBOX = {
14
14
  enabled: false,
15
- allowedPaths: ['.'], // current directory
15
+ allowedPaths: ["."], // current directory
16
16
  allowedDomains: [], // empty = all allowed
17
17
  blockNetwork: false,
18
- blockedCommands: ['curl', 'wget'],
18
+ blockedCommands: ["curl", "wget"],
19
19
  };
20
20
  // ── Sandbox Manager ──
21
21
  let _config = null;
@@ -50,7 +50,7 @@ export function isPathAllowed(filePath) {
50
50
  const allowedResolved = resolve(cwd, allowed);
51
51
  // Check if the file is within the allowed directory
52
52
  const rel = relative(allowedResolved, resolved);
53
- if (!rel.startsWith('..') && !rel.startsWith('/'))
53
+ if (!rel.startsWith("..") && !rel.startsWith("/"))
54
54
  return true;
55
55
  }
56
56
  return false;
@@ -66,7 +66,7 @@ export function isDomainAllowed(url) {
66
66
  return true;
67
67
  try {
68
68
  const hostname = new URL(url).hostname.toLowerCase();
69
- return config.allowedDomains.some(d => hostname === d.toLowerCase() || hostname.endsWith('.' + d.toLowerCase()));
69
+ return config.allowedDomains.some((d) => hostname === d.toLowerCase() || hostname.endsWith(`.${d.toLowerCase()}`));
70
70
  }
71
71
  catch {
72
72
  return false;
@@ -77,28 +77,28 @@ export function isCommandAllowed(command) {
77
77
  const config = getSandboxConfig();
78
78
  if (!config.enabled)
79
79
  return true;
80
- const firstWord = command.trim().split(/\s+/)[0]?.toLowerCase() ?? '';
80
+ const firstWord = command.trim().split(/\s+/)[0]?.toLowerCase() ?? "";
81
81
  return !config.blockedCommands.includes(firstWord);
82
82
  }
83
83
  /** Get a human-readable sandbox status */
84
84
  export function sandboxStatus() {
85
85
  const config = getSandboxConfig();
86
86
  if (!config.enabled)
87
- return 'Sandbox: disabled';
88
- const lines = ['Sandbox: enabled'];
89
- lines.push(` Allowed paths: ${config.allowedPaths.join(', ') || 'none'}`);
87
+ return "Sandbox: disabled";
88
+ const lines = ["Sandbox: enabled"];
89
+ lines.push(` Allowed paths: ${config.allowedPaths.join(", ") || "none"}`);
90
90
  if (config.blockNetwork) {
91
- lines.push(' Network: blocked');
91
+ lines.push(" Network: blocked");
92
92
  }
93
93
  else if (config.allowedDomains.length > 0) {
94
- lines.push(` Allowed domains: ${config.allowedDomains.join(', ')}`);
94
+ lines.push(` Allowed domains: ${config.allowedDomains.join(", ")}`);
95
95
  }
96
96
  else {
97
- lines.push(' Network: unrestricted');
97
+ lines.push(" Network: unrestricted");
98
98
  }
99
99
  if (config.blockedCommands.length > 0) {
100
- lines.push(` Blocked commands: ${config.blockedCommands.join(', ')}`);
100
+ lines.push(` Blocked commands: ${config.blockedCommands.join(", ")}`);
101
101
  }
102
- return lines.join('\n');
102
+ return lines.join("\n");
103
103
  }
104
104
  //# sourceMappingURL=sandbox.js.map
@@ -0,0 +1,55 @@
1
+ /**
2
+ * SQLite FTS5-based session search index.
3
+ * Provides fast full-text search over session content using BM25 ranking.
4
+ */
5
+ import Database from "better-sqlite3";
6
+ import type { Session } from "./session.js";
7
+ export type SessionIndexEntry = {
8
+ sessionId: string;
9
+ content: string;
10
+ toolsUsed: string[];
11
+ model: string;
12
+ messageCount: number;
13
+ cost: number;
14
+ createdAt: number;
15
+ updatedAt: number;
16
+ };
17
+ export type SessionSearchResult = {
18
+ sessionId: string;
19
+ snippet: string;
20
+ model: string;
21
+ messageCount: number;
22
+ cost: number;
23
+ updatedAt: number;
24
+ rank: number;
25
+ };
26
+ /**
27
+ * Opens or creates a SQLite DB with FTS5 virtual table for session search.
28
+ */
29
+ export declare function openSessionDb(dbPath?: string): Database.Database;
30
+ /**
31
+ * Closes the SQLite database connection.
32
+ */
33
+ export declare function closeSessionDb(db: Database.Database): void;
34
+ /**
35
+ * Upserts a session index entry using delete+insert pattern.
36
+ */
37
+ export declare function indexSession(db: Database.Database, entry: SessionIndexEntry): void;
38
+ /**
39
+ * Searches sessions using FTS5 with BM25 ranking.
40
+ * Returns results with snippets showing matching context.
41
+ */
42
+ export declare function searchSessions(db: Database.Database, query: string, limit?: number): SessionSearchResult[];
43
+ /**
44
+ * Converts a Session object to a SessionIndexEntry for indexing.
45
+ */
46
+ export declare function sessionToIndexEntry(session: Session): SessionIndexEntry;
47
+ /**
48
+ * Rebuilds the FTS5 index from session JSON files on disk.
49
+ */
50
+ export declare function rebuildIndex(db: Database.Database, sessionsDir?: string): number;
51
+ /** Get a shared DB connection (opens once, reuses thereafter) */
52
+ export declare function getSessionDb(): Database.Database;
53
+ /** Close the singleton connection (call on process exit) */
54
+ export declare function closeGlobalSessionDb(): void;
55
+ //# sourceMappingURL=session-db.d.ts.map
@@ -0,0 +1,165 @@
1
+ /**
2
+ * SQLite FTS5-based session search index.
3
+ * Provides fast full-text search over session content using BM25 ranking.
4
+ */
5
+ import { existsSync, mkdirSync, readdirSync, readFileSync } from "node:fs";
6
+ import { homedir } from "node:os";
7
+ import { dirname, join } from "node:path";
8
+ import Database from "better-sqlite3";
9
+ const DEFAULT_DB_PATH = join(homedir(), ".oh", "sessions.db");
10
+ const DEFAULT_SESSION_DIR = join(homedir(), ".oh", "sessions");
11
+ /**
12
+ * Opens or creates a SQLite DB with FTS5 virtual table for session search.
13
+ */
14
+ export function openSessionDb(dbPath) {
15
+ const path = dbPath ?? DEFAULT_DB_PATH;
16
+ const dir = dirname(path);
17
+ mkdirSync(dir, { recursive: true });
18
+ const db = new Database(path);
19
+ // Enable WAL mode for better concurrent read performance
20
+ db.pragma("journal_mode = WAL");
21
+ db.exec(`
22
+ CREATE VIRTUAL TABLE IF NOT EXISTS sessions_fts USING fts5(
23
+ session_id, content, tools_used, model,
24
+ message_count UNINDEXED, cost UNINDEXED,
25
+ created_at UNINDEXED, updated_at UNINDEXED
26
+ );
27
+ `);
28
+ return db;
29
+ }
30
+ /**
31
+ * Closes the SQLite database connection.
32
+ */
33
+ export function closeSessionDb(db) {
34
+ try {
35
+ db.close();
36
+ }
37
+ catch {
38
+ /* skip */
39
+ }
40
+ }
41
+ /**
42
+ * Upserts a session index entry using delete+insert pattern.
43
+ */
44
+ export function indexSession(db, entry) {
45
+ const del = db.prepare("DELETE FROM sessions_fts WHERE session_id = ?");
46
+ const ins = db.prepare("INSERT INTO sessions_fts (session_id, content, tools_used, model, message_count, cost, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)");
47
+ const upsert = db.transaction(() => {
48
+ del.run(entry.sessionId);
49
+ ins.run(entry.sessionId, entry.content, entry.toolsUsed.join(" "), entry.model, entry.messageCount, entry.cost, entry.createdAt, entry.updatedAt);
50
+ });
51
+ upsert();
52
+ }
53
+ /**
54
+ * Searches sessions using FTS5 with BM25 ranking.
55
+ * Returns results with snippets showing matching context.
56
+ */
57
+ export function searchSessions(db, query, limit = 20) {
58
+ const stmt = db.prepare(`
59
+ SELECT
60
+ session_id,
61
+ snippet(sessions_fts, 1, '>>>', '<<<', '...', 64) AS snippet,
62
+ model,
63
+ CAST(message_count AS INTEGER) AS message_count,
64
+ CAST(cost AS REAL) AS cost,
65
+ CAST(updated_at AS INTEGER) AS updated_at,
66
+ rank
67
+ FROM sessions_fts
68
+ WHERE sessions_fts MATCH ?
69
+ ORDER BY rank
70
+ LIMIT ?
71
+ `);
72
+ try {
73
+ const rows = stmt.all(query, limit);
74
+ return rows.map((row) => ({
75
+ sessionId: row.session_id,
76
+ snippet: row.snippet,
77
+ model: row.model,
78
+ messageCount: row.message_count,
79
+ cost: row.cost,
80
+ updatedAt: row.updated_at,
81
+ rank: row.rank,
82
+ }));
83
+ }
84
+ catch (err) {
85
+ // Only swallow FTS5 syntax errors; rethrow DB corruption or other issues
86
+ if (err instanceof Error && (err.message.includes("fts5") || err.message.includes("syntax"))) {
87
+ return [];
88
+ }
89
+ throw err;
90
+ }
91
+ }
92
+ /**
93
+ * Converts a Session object to a SessionIndexEntry for indexing.
94
+ */
95
+ export function sessionToIndexEntry(session) {
96
+ // Concatenate user + assistant message text
97
+ const contentParts = [];
98
+ const toolsSet = new Set();
99
+ for (const msg of session.messages) {
100
+ if (msg.role === "user" || msg.role === "assistant") {
101
+ if (msg.content) {
102
+ contentParts.push(msg.content);
103
+ }
104
+ }
105
+ // Dedupe tool names from toolCalls
106
+ if (msg.toolCalls) {
107
+ for (const tc of msg.toolCalls) {
108
+ toolsSet.add(tc.toolName);
109
+ }
110
+ }
111
+ }
112
+ return {
113
+ sessionId: session.id,
114
+ content: contentParts.join(" "),
115
+ toolsUsed: Array.from(toolsSet),
116
+ model: session.model,
117
+ messageCount: session.messages.length,
118
+ cost: session.totalCost,
119
+ createdAt: session.createdAt,
120
+ updatedAt: session.updatedAt,
121
+ };
122
+ }
123
+ /**
124
+ * Rebuilds the FTS5 index from session JSON files on disk.
125
+ */
126
+ export function rebuildIndex(db, sessionsDir) {
127
+ const dir = sessionsDir ?? DEFAULT_SESSION_DIR;
128
+ if (!existsSync(dir))
129
+ return 0;
130
+ const files = readdirSync(dir).filter((f) => f.endsWith(".json"));
131
+ let count = 0;
132
+ for (const file of files) {
133
+ try {
134
+ const raw = readFileSync(join(dir, file), "utf-8");
135
+ const session = JSON.parse(raw);
136
+ const entry = sessionToIndexEntry(session);
137
+ indexSession(db, entry);
138
+ count++;
139
+ }
140
+ catch {
141
+ /* skip invalid/corrupt files */
142
+ }
143
+ }
144
+ return count;
145
+ }
146
+ // ── Singleton Connection ──
147
+ let _singletonDb = null;
148
+ /** Get a shared DB connection (opens once, reuses thereafter) */
149
+ export function getSessionDb() {
150
+ if (!_singletonDb) {
151
+ _singletonDb = openSessionDb();
152
+ }
153
+ return _singletonDb;
154
+ }
155
+ /** Close the singleton connection (call on process exit) */
156
+ export function closeGlobalSessionDb() {
157
+ if (_singletonDb) {
158
+ try {
159
+ _singletonDb.close();
160
+ }
161
+ catch { /* ignore */ }
162
+ _singletonDb = null;
163
+ }
164
+ }
165
+ //# sourceMappingURL=session-db.js.map
@@ -43,7 +43,7 @@ export declare function getLastSessionId(dir?: string): string | null;
43
43
  * Captures the last user message, recent assistant activity,
44
44
  * and a brief summary for context reconstruction on wake.
45
45
  */
46
- export declare function buildHibernateState(messages: Message[]): Session['hibernate'];
46
+ export declare function buildHibernateState(messages: Message[]): Session["hibernate"];
47
47
  /**
48
48
  * Generate a wake-up context message for a resumed session.
49
49
  * Tells the LLM what happened in the previous session.
@@ -1,10 +1,10 @@
1
1
  /**
2
2
  * Session persistence — save and resume conversations.
3
3
  */
4
- import { readFileSync, writeFileSync, mkdirSync, readdirSync, existsSync, unlinkSync } from "node:fs";
5
- import { join } from "node:path";
6
- import { homedir } from "node:os";
7
4
  import { randomUUID } from "node:crypto";
5
+ import { existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
6
+ import { homedir } from "node:os";
7
+ import { join } from "node:path";
8
8
  const DEFAULT_SESSION_DIR = join(homedir(), ".oh", "sessions");
9
9
  export function createSession(provider, model, extras) {
10
10
  return {
@@ -27,13 +27,28 @@ export function saveSession(session, dir) {
27
27
  const path = join(sessionDir, `${session.id}.json`);
28
28
  session.updatedAt = Date.now();
29
29
  writeFileSync(path, JSON.stringify(session, null, 2));
30
+ // Index session for FTS5 search (fire-and-forget, singleton connection)
31
+ import("./session-db.js")
32
+ .then(({ getSessionDb, indexSession: idx, sessionToIndexEntry }) => {
33
+ try {
34
+ idx(getSessionDb(), sessionToIndexEntry(session));
35
+ }
36
+ catch {
37
+ /* session search is optional */
38
+ }
39
+ })
40
+ .catch(() => {
41
+ /* ignore if session-db unavailable */
42
+ });
30
43
  // Evict old sessions (with lock to prevent concurrent eviction)
31
44
  if (!_evicting) {
32
45
  _evicting = true;
33
46
  try {
34
47
  evictOldSessions(sessionDir);
35
48
  }
36
- catch { /* ignore */ }
49
+ catch {
50
+ /* ignore */
51
+ }
37
52
  _evicting = false;
38
53
  }
39
54
  return path;
@@ -81,23 +96,23 @@ export function buildHibernateState(messages) {
81
96
  if (messages.length === 0)
82
97
  return undefined;
83
98
  // Find last user message
84
- const lastUser = [...messages].reverse().find(m => m.role === 'user');
85
- const lastAssistant = [...messages].reverse().find(m => m.role === 'assistant');
99
+ const lastUser = [...messages].reverse().find((m) => m.role === "user");
100
+ const lastAssistant = [...messages].reverse().find((m) => m.role === "assistant");
86
101
  // Build a brief summary from the last few exchanges
87
102
  const recentMsgs = messages.slice(-6);
88
103
  const summaryParts = [];
89
104
  for (const m of recentMsgs) {
90
- if (m.role === 'user') {
105
+ if (m.role === "user") {
91
106
  summaryParts.push(`User: ${m.content.slice(0, 100)}`);
92
107
  }
93
- else if (m.role === 'assistant' && m.content) {
108
+ else if (m.role === "assistant" && m.content) {
94
109
  summaryParts.push(`Assistant: ${m.content.slice(0, 100)}`);
95
110
  }
96
111
  }
97
112
  return {
98
113
  lastUserMessage: lastUser?.content.slice(0, 200),
99
114
  pendingTask: lastAssistant?.content.slice(0, 200),
100
- summary: summaryParts.join('\n'),
115
+ summary: summaryParts.join("\n"),
101
116
  };
102
117
  }
103
118
  /**
@@ -105,7 +120,7 @@ export function buildHibernateState(messages) {
105
120
  * Tells the LLM what happened in the previous session.
106
121
  */
107
122
  export function buildWakeContext(session) {
108
- const parts = ['[Session Resumed]'];
123
+ const parts = ["[Session Resumed]"];
109
124
  if (session.workingDir) {
110
125
  parts.push(`Previous working directory: ${session.workingDir}`);
111
126
  if (session.workingDir !== process.cwd()) {
@@ -122,8 +137,8 @@ export function buildWakeContext(session) {
122
137
  parts.push(`\nLast user request: ${session.hibernate.lastUserMessage}`);
123
138
  }
124
139
  parts.push(`\nSession has ${session.messages.length} messages and cost $${session.totalCost.toFixed(4)} so far.`);
125
- parts.push('Continue where you left off. If the user\'s last request was incomplete, acknowledge that and ask how to proceed.');
126
- return parts.join('\n');
140
+ parts.push("Continue where you left off. If the user's last request was incomplete, acknowledge that and ask how to proceed.");
141
+ return parts.join("\n");
127
142
  }
128
143
  /** Maximum number of sessions to keep on disk. */
129
144
  const MAX_SESSIONS = 100;
@@ -139,7 +154,8 @@ export function evictOldSessions(dir, maxSessions = MAX_SESSIONS) {
139
154
  if (files.length <= maxSessions)
140
155
  return 0;
141
156
  // Sort by modification time (oldest first)
142
- const withStats = files.map((f) => {
157
+ const withStats = files
158
+ .map((f) => {
143
159
  const path = join(sessionDir, f);
144
160
  try {
145
161
  const data = JSON.parse(readFileSync(path, "utf-8"));
@@ -148,13 +164,16 @@ export function evictOldSessions(dir, maxSessions = MAX_SESSIONS) {
148
164
  catch {
149
165
  return { path, updatedAt: 0 };
150
166
  }
151
- }).sort((a, b) => a.updatedAt - b.updatedAt);
167
+ })
168
+ .sort((a, b) => a.updatedAt - b.updatedAt);
152
169
  const toRemove = withStats.slice(0, files.length - maxSessions);
153
170
  for (const { path } of toRemove) {
154
171
  try {
155
172
  unlinkSync(path);
156
173
  }
157
- catch { /* ignore */ }
174
+ catch {
175
+ /* ignore */
176
+ }
158
177
  }
159
178
  return toRemove.length;
160
179
  }
@@ -4,8 +4,8 @@
4
4
  * Simple reactive store inspired by Zustand but without React dependency.
5
5
  * State is modified via setState() which notifies subscribers.
6
6
  */
7
- import type { Message } from '../types/message.js';
8
- import type { Session } from './session.js';
7
+ import type { Message } from "../types/message.js";
8
+ import type { Session } from "./session.js";
9
9
  export type REPLState = {
10
10
  messages: Message[];
11
11
  loading: boolean;
@@ -14,7 +14,7 @@ export type REPLState = {
14
14
  inputCursor: number;
15
15
  inputHistory: string[];
16
16
  historyIndex: number;
17
- vimMode: 'normal' | 'insert' | null;
17
+ vimMode: "normal" | "insert" | null;
18
18
  fastMode: boolean;
19
19
  companionVisible: boolean;
20
20
  acSuggestions: string[];
@@ -8,8 +8,8 @@ export function createInitialState(overrides) {
8
8
  return {
9
9
  messages: [],
10
10
  loading: false,
11
- currentModel: '',
12
- inputText: '',
11
+ currentModel: "",
12
+ inputText: "",
13
13
  inputCursor: 0,
14
14
  inputHistory: [],
15
15
  historyIndex: -1,
@@ -42,14 +42,16 @@ export function createStore(initial) {
42
42
  return {
43
43
  getState: () => state,
44
44
  setState(partial) {
45
- const updates = typeof partial === 'function' ? partial(state) : partial;
45
+ const updates = typeof partial === "function" ? partial(state) : partial;
46
46
  state = { ...state, ...updates };
47
47
  for (const fn of subscribers)
48
48
  fn(state);
49
49
  },
50
50
  subscribe(fn) {
51
51
  subscribers.add(fn);
52
- return () => { subscribers.delete(fn); };
52
+ return () => {
53
+ subscribers.delete(fn);
54
+ };
53
55
  },
54
56
  };
55
57
  }
@@ -2,10 +2,10 @@
2
2
  * Shared submit/input handler — processes user input before sending to LLM.
3
3
  * Used by both cell renderer REPL and Ink REPL.
4
4
  */
5
- import type { Message } from '../types/message.js';
6
- import type { PermissionMode } from '../types/permissions.js';
7
- import type { CompanionConfig } from '../cybergotchi/types.js';
8
- import type { CostTracker } from './cost.js';
5
+ import type { CompanionConfig } from "../cybergotchi/types.js";
6
+ import type { Message } from "../types/message.js";
7
+ import type { PermissionMode } from "../types/permissions.js";
8
+ import type { CostTracker } from "./cost.js";
9
9
  export type SubmitContext = {
10
10
  messages: Message[];
11
11
  currentModel: string;
@@ -2,10 +2,10 @@
2
2
  * Shared submit/input handler — processes user input before sending to LLM.
3
3
  * Used by both cell renderer REPL and Ink REPL.
4
4
  */
5
- import { createUserMessage, createInfoMessage } from '../types/message.js';
6
- import { processSlashCommand } from '../commands/index.js';
7
- import { cybergotchiEvents } from '../cybergotchi/events.js';
8
- import { resolveMcpMention } from '../mcp/loader.js';
5
+ import { processSlashCommand } from "../commands/index.js";
6
+ import { cybergotchiEvents } from "../cybergotchi/events.js";
7
+ import { resolveMcpMention } from "../mcp/loader.js";
8
+ import { createInfoMessage, createUserMessage } from "../types/message.js";
9
9
  /**
10
10
  * Process user input: handle exit, companion mentions, slash commands,
11
11
  * @mentions, and prepare the prompt for the LLM.
@@ -18,17 +18,17 @@ export async function handleUserInput(input, ctx) {
18
18
  const name = ctx.companionConfig.soul.name.toLowerCase();
19
19
  const lower = trimmed.toLowerCase();
20
20
  if (lower.startsWith(`@${name}`) || lower.startsWith(`${name},`) || lower.startsWith(`${name} `)) {
21
- cybergotchiEvents.emit('cybergotchi', { type: 'userAddressed', text: trimmed });
21
+ cybergotchiEvents.emit("cybergotchi", { type: "userAddressed", text: trimmed });
22
22
  return { handled: true, messages };
23
23
  }
24
24
  }
25
25
  // ! Bash mode — direct shell execution, output added to context
26
- if (trimmed.startsWith('!') && trimmed.length > 1) {
26
+ if (trimmed.startsWith("!") && trimmed.length > 1) {
27
27
  const command = trimmed.slice(1).trim();
28
28
  try {
29
- const { execSync } = await import('node:child_process');
29
+ const { execSync } = await import("node:child_process");
30
30
  const output = execSync(command, {
31
- encoding: 'utf-8',
31
+ encoding: "utf-8",
32
32
  cwd: process.cwd(),
33
33
  timeout: 30_000,
34
34
  maxBuffer: 1024 * 1024,
@@ -37,17 +37,17 @@ export async function handleUserInput(input, ctx) {
37
37
  messages = [...messages, createInfoMessage(`$ ${command}\n${output.trimEnd()}`)];
38
38
  }
39
39
  catch (err) {
40
- const output = String(err.stdout ?? err.stderr ?? err.message ?? 'Command failed');
40
+ const output = String(err.stdout ?? err.stderr ?? err.message ?? "Command failed");
41
41
  messages = [...messages, createInfoMessage(`$ ${command}\n${output.trimEnd()}`)];
42
42
  }
43
43
  return { handled: true, messages };
44
44
  }
45
45
  // Vim toggle
46
- if (trimmed === '/vim') {
46
+ if (trimmed === "/vim") {
47
47
  return { handled: true, messages, vimToggled: true };
48
48
  }
49
49
  // Slash commands
50
- if (trimmed.startsWith('/')) {
50
+ if (trimmed.startsWith("/")) {
51
51
  const cmdCtx = {
52
52
  messages,
53
53
  model: ctx.currentModel,
@@ -96,43 +96,45 @@ export async function handleUserInput(input, ctx) {
96
96
  const companionName = ctx.companionConfig?.soul?.name?.toLowerCase();
97
97
  for (const match of mentions) {
98
98
  const mention = match[1];
99
- const startLine = match[2] ? parseInt(match[2]) : undefined;
100
- const endLine = match[3] ? parseInt(match[3]) : startLine;
99
+ const startLine = match[2] ? parseInt(match[2], 10) : undefined;
100
+ const endLine = match[3] ? parseInt(match[3], 10) : startLine;
101
101
  const fullRef = match[0];
102
102
  if (companionName && mention.toLowerCase() === companionName)
103
103
  continue;
104
104
  // Try local file first (supports paths like @src/main.ts, @README.md#L5-10)
105
105
  try {
106
- const { existsSync, readFileSync } = await import('node:fs');
107
- const { resolve } = await import('node:path');
106
+ const { existsSync, readFileSync } = await import("node:fs");
107
+ const { resolve } = await import("node:path");
108
108
  const filePath = resolve(process.cwd(), mention);
109
109
  if (existsSync(filePath)) {
110
- let content = readFileSync(filePath, 'utf-8');
110
+ let content = readFileSync(filePath, "utf-8");
111
111
  // Apply line range if specified
112
112
  if (startLine !== undefined) {
113
- const lines = content.split('\n');
113
+ const lines = content.split("\n");
114
114
  const start = Math.max(0, startLine - 1); // 1-indexed to 0-indexed
115
115
  const end = endLine !== undefined ? endLine : start + 1;
116
- content = lines.slice(start, end).join('\n');
116
+ content = lines.slice(start, end).join("\n");
117
117
  resolvedInput += `\n\n[File ${fullRef} (lines ${startLine}-${endLine ?? startLine})]:\n${content}`;
118
118
  }
119
119
  else {
120
- const truncated = content.length > 10_000
121
- ? content.slice(0, 10_000) + '\n[...truncated]'
122
- : content;
120
+ const truncated = content.length > 10_000 ? `${content.slice(0, 10_000)}\n[...truncated]` : content;
123
121
  resolvedInput += `\n\n[File @${mention}]:\n${truncated}`;
124
122
  }
125
123
  continue;
126
124
  }
127
125
  }
128
- catch { /* ignore */ }
126
+ catch {
127
+ /* ignore */
128
+ }
129
129
  // Fall back to MCP resource
130
130
  try {
131
131
  const content = await resolveMcpMention(mention);
132
132
  if (content)
133
133
  resolvedInput += `\n\n[Resource @${mention}]:\n${content.slice(0, 5000)}`;
134
134
  }
135
- catch { /* ignore */ }
135
+ catch {
136
+ /* ignore */
137
+ }
136
138
  }
137
139
  return { handled: false, messages, prompt: resolvedInput };
138
140
  }