@oh-my-pi/pi-coding-agent 4.1.0 → 4.2.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 (90) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/README.md +2 -1
  3. package/docs/sdk.md +0 -3
  4. package/package.json +6 -5
  5. package/src/config.ts +9 -0
  6. package/src/core/agent-session.ts +3 -3
  7. package/src/core/agent-storage.ts +450 -0
  8. package/src/core/auth-storage.ts +102 -183
  9. package/src/core/compaction/branch-summarization.ts +5 -4
  10. package/src/core/compaction/compaction.ts +7 -6
  11. package/src/core/compaction/utils.ts +6 -11
  12. package/src/core/custom-commands/bundled/review/index.ts +22 -94
  13. package/src/core/custom-share.ts +66 -0
  14. package/src/core/export-html/index.ts +1 -33
  15. package/src/core/history-storage.ts +15 -7
  16. package/src/core/prompt-templates.ts +271 -1
  17. package/src/core/sdk.ts +14 -3
  18. package/src/core/settings-manager.ts +100 -34
  19. package/src/core/slash-commands.ts +4 -1
  20. package/src/core/storage-migration.ts +215 -0
  21. package/src/core/system-prompt.ts +130 -290
  22. package/src/core/title-generator.ts +3 -2
  23. package/src/core/tools/ask.ts +2 -2
  24. package/src/core/tools/bash.ts +2 -1
  25. package/src/core/tools/calculator.ts +2 -1
  26. package/src/core/tools/complete.ts +5 -2
  27. package/src/core/tools/edit.ts +2 -1
  28. package/src/core/tools/find.ts +2 -1
  29. package/src/core/tools/gemini-image.ts +2 -1
  30. package/src/core/tools/git.ts +2 -2
  31. package/src/core/tools/grep.ts +2 -1
  32. package/src/core/tools/index.test.ts +0 -28
  33. package/src/core/tools/index.ts +0 -6
  34. package/src/core/tools/lsp/index.ts +2 -1
  35. package/src/core/tools/output.ts +2 -1
  36. package/src/core/tools/read.ts +4 -1
  37. package/src/core/tools/ssh.ts +4 -2
  38. package/src/core/tools/task/agents.ts +56 -30
  39. package/src/core/tools/task/commands.ts +5 -8
  40. package/src/core/tools/task/index.ts +7 -15
  41. package/src/core/tools/web-fetch.ts +2 -1
  42. package/src/core/tools/web-search/auth.ts +106 -16
  43. package/src/core/tools/web-search/index.ts +3 -2
  44. package/src/core/tools/web-search/providers/anthropic.ts +44 -6
  45. package/src/core/tools/write.ts +2 -1
  46. package/src/core/voice.ts +3 -1
  47. package/src/discovery/builtin.ts +9 -54
  48. package/src/discovery/claude.ts +16 -69
  49. package/src/discovery/codex.ts +11 -36
  50. package/src/discovery/helpers.ts +52 -1
  51. package/src/main.ts +1 -1
  52. package/src/migrations.ts +20 -20
  53. package/src/modes/interactive/controllers/command-controller.ts +527 -0
  54. package/src/modes/interactive/controllers/event-controller.ts +340 -0
  55. package/src/modes/interactive/controllers/extension-ui-controller.ts +600 -0
  56. package/src/modes/interactive/controllers/input-controller.ts +585 -0
  57. package/src/modes/interactive/controllers/selector-controller.ts +585 -0
  58. package/src/modes/interactive/interactive-mode.ts +363 -3139
  59. package/src/modes/interactive/theme/theme.ts +5 -5
  60. package/src/modes/interactive/types.ts +189 -0
  61. package/src/modes/interactive/utils/ui-helpers.ts +449 -0
  62. package/src/modes/interactive/utils/voice-manager.ts +96 -0
  63. package/src/prompts/{explore.md → agents/explore.md} +7 -5
  64. package/src/prompts/agents/frontmatter.md +7 -0
  65. package/src/prompts/{plan.md → agents/plan.md} +3 -3
  66. package/src/prompts/agents/planner.md +112 -0
  67. package/src/prompts/agents/task.md +15 -0
  68. package/src/prompts/review-request.md +44 -8
  69. package/src/prompts/system/custom-system-prompt.md +80 -0
  70. package/src/prompts/system/file-operations.md +12 -0
  71. package/src/prompts/system/system-prompt.md +237 -0
  72. package/src/prompts/system/title-system.md +2 -0
  73. package/src/prompts/tools/bash.md +1 -1
  74. package/src/prompts/tools/read.md +1 -1
  75. package/src/prompts/tools/task.md +34 -22
  76. package/src/core/tools/rulebook.ts +0 -132
  77. package/src/prompts/architect-plan.md +0 -10
  78. package/src/prompts/implement-with-critic.md +0 -11
  79. package/src/prompts/implement.md +0 -11
  80. package/src/prompts/system-prompt.md +0 -43
  81. package/src/prompts/task.md +0 -14
  82. package/src/prompts/title-system.md +0 -8
  83. /package/src/prompts/{init.md → agents/init.md} +0 -0
  84. /package/src/prompts/{reviewer.md → agents/reviewer.md} +0 -0
  85. /package/src/prompts/{branch-summary-preamble.md → compaction/branch-summary-preamble.md} +0 -0
  86. /package/src/prompts/{branch-summary.md → compaction/branch-summary.md} +0 -0
  87. /package/src/prompts/{compaction-summary.md → compaction/compaction-summary.md} +0 -0
  88. /package/src/prompts/{compaction-turn-prefix.md → compaction/compaction-turn-prefix.md} +0 -0
  89. /package/src/prompts/{compaction-update-summary.md → compaction/compaction-update-summary.md} +0 -0
  90. /package/src/prompts/{summarization-system.md → system/summarization-system.md} +0 -0
package/CHANGELOG.md CHANGED
@@ -2,6 +2,72 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [4.2.1] - 2026-01-11
6
+ ### Added
7
+
8
+ - Added automatic discovery and listing of AGENTS.md files in the system prompt, providing agents with an authoritative list of project-specific instruction files without runtime searching
9
+ - Added `planner` built-in agent for comprehensive implementation planning with slow model
10
+
11
+ ### Changed
12
+
13
+ - Refactored skill discovery to use unified `loadSkillsFromDir` helper across all providers, reducing code duplication
14
+ - Updated skill discovery to scan only `skills/*/SKILL.md` entries instead of recursive walks in Codex provider
15
+ - Added guidance to Task tool documentation to isolate file scopes when assigning tasks to prevent agent conflicts
16
+ - Updated Task tool documentation to emphasize that subagents have no access to conversation history and require all relevant context to be explicitly passed
17
+ - Revised task agent prompt to clarify that subagents have full tool access and can make file edits, run commands, and create files
18
+ - OpenAI Codex: updated to use bundled system prompt from upstream
19
+ - Changed `complete` tool to make `data` parameter optional when aborting, while still requiring it for successful completions
20
+ - Skills discovery now scans only `skills/*/SKILL.md` entries instead of recursive walks
21
+
22
+ ### Removed
23
+
24
+ - Removed `architect-plan`, `implement`, and `implement-with-critic` built-in agent commands
25
+
26
+ ### Fixed
27
+
28
+ - Fixed editor border rendering glitch after canceling slash command autocomplete
29
+ - Fixed login/logout credential path message to reference agent.db
30
+
31
+ ## [4.2.0] - 2026-01-10
32
+
33
+ ### Added
34
+
35
+ - Added `/dump` slash command to copy the full session transcript to the clipboard
36
+ - Added automatic Nerd Fonts detection for terminals like iTerm, WezTerm, Kitty, Ghostty, and Alacritty to set appropriate symbol preset
37
+ - Added `NERD_FONTS` environment variable override (`1` or `0`) to manually control Nerd Fonts symbol preset
38
+ - Added Handlebars templating engine for prompt template rendering with `{{arg}}` helper for positional arguments
39
+ - Added support for custom share scripts at ~/.omp/agent/share.ts to replace default GitHub Gist sharing
40
+
41
+ ### Changed
42
+
43
+ - Changed rules system to use `read` tool for loading rule content instead of dedicated `rulebook` tool
44
+ - Separated `/export` and `/dump` commands—`/export` now only exports to HTML file, while `/dump` copies session transcript to clipboard
45
+ - Updated `/export` command to no longer accept `--copy` flag (use `/dump` instead)
46
+ - Changed prompt template rendering to use Handlebars instead of simple string replacement
47
+ - Updated prompt layout optimization to normalize indentation and collapse excessive blank lines
48
+ - Changed auth migration to merge credentials per-provider instead of skipping when any credentials exist in database
49
+ - Migrated settings and auth credential storage from JSON files to SQLite database (agent.db)
50
+ - Updated credential migration message to reference agent.db instead of auth.json
51
+ - Renamed Glob tool references to Find tool throughout prompts and documentation
52
+ - Updated project context formatting to use XML-style tags for clearer structure
53
+ - Refined bash tool guidance to prefer dedicated tools (read/grep/find/ls) over bash for file operations
54
+ - Updated system prompt with clearer tone guidelines emphasizing directness and conciseness
55
+ - Revised workflow instructions to require explicit planning for non-trivial tasks
56
+ - Enhanced verification guidance to prefer external feedback loops like tests and linters
57
+ - Added explicit alignment and prohibited behavior sections to improve response quality
58
+
59
+ ### Removed
60
+
61
+ - Removed `rulebook` tool - rules are now loaded via the `read` tool instead of a dedicated tool
62
+
63
+ ### Fixed
64
+
65
+ - Fixed message submission lag caused by synchronous history database writes by deferring DB operations with setImmediate
66
+
67
+ ### Security
68
+
69
+ - Hardened file permissions on agent database directory (700) and database file (600) to restrict access
70
+
5
71
  ## [4.1.0] - 2026-01-10
6
72
  ### Added
7
73
 
package/README.md CHANGED
@@ -223,7 +223,8 @@ The agent reads, writes, and edits files, and executes commands via bash.
223
223
  | ------------------------- | --------------------------------------------------------------------------- |
224
224
  | `/settings` | Open settings menu (thinking, theme, queue mode, toggles) |
225
225
  | `/model` | Switch models mid-session. Use `/model <search>` or `provider/model` to prefilter/disambiguate. |
226
- | `/export [file\|--copy]` | Export session to HTML file or copy to clipboard |
226
+ | `/export [file]` | Export session to HTML file |
227
+ | `/dump` | Copy session transcript to clipboard |
227
228
  | `/share` | Upload session as secret GitHub gist, get shareable URL (requires `gh` CLI) |
228
229
  | `/session` | Show session info: path, message counts, token usage, cost |
229
230
  | `/hotkeys` | Show all keyboard shortcuts |
package/docs/sdk.md CHANGED
@@ -374,7 +374,6 @@ All tools are defined in `BUILTIN_TOOLS`:
374
374
  - `notebook` - Jupyter notebook editing
375
375
  - `output` - Task output retrieval
376
376
  - `read` - File reading (text and images)
377
- - `rulebook` - Rule reference (requires rules)
378
377
  - `task` - Subagent spawning
379
378
  - `web_fetch` - Web page fetching
380
379
  - `web_search` - Web search
@@ -390,10 +389,8 @@ import { BUILTIN_TOOLS, createTools, type ToolSession } from "@oh-my-pi/pi-codin
390
389
  const session: ToolSession = {
391
390
  cwd: "/path/to/project",
392
391
  hasUI: false,
393
- rulebookRules: [],
394
392
  getSessionFile: () => null,
395
393
  getSessionSpawns: () => "*",
396
- getAvailableTools: () => Object.keys(BUILTIN_TOOLS),
397
394
  };
398
395
 
399
396
  const tools = await createTools(session);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-coding-agent",
3
- "version": "4.1.0",
3
+ "version": "4.2.1",
4
4
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
5
5
  "type": "module",
6
6
  "ompConfig": {
@@ -39,10 +39,10 @@
39
39
  "prepublishOnly": "bun run generate-template && bun run clean && bun run build"
40
40
  },
41
41
  "dependencies": {
42
- "@oh-my-pi/pi-ai": "4.1.0",
43
- "@oh-my-pi/pi-agent-core": "4.1.0",
44
- "@oh-my-pi/pi-git-tool": "4.1.0",
45
- "@oh-my-pi/pi-tui": "4.1.0",
42
+ "@oh-my-pi/pi-ai": "4.2.1",
43
+ "@oh-my-pi/pi-agent-core": "4.2.1",
44
+ "@oh-my-pi/pi-git-tool": "4.2.1",
45
+ "@oh-my-pi/pi-tui": "4.2.1",
46
46
  "@openai/agents": "^0.3.7",
47
47
  "@sinclair/typebox": "^0.34.46",
48
48
  "ajv": "^8.17.1",
@@ -51,6 +51,7 @@
51
51
  "diff": "^8.0.2",
52
52
  "file-type": "^21.1.1",
53
53
  "glob": "^11.0.3",
54
+ "handlebars": "^4.7.8",
54
55
  "highlight.js": "^11.11.1",
55
56
  "marked": "^15.0.12",
56
57
  "minimatch": "^10.1.1",
package/src/config.ts CHANGED
@@ -79,6 +79,15 @@ export function getSettingsPath(): string {
79
79
  return join(getAgentDir(), "settings.json");
80
80
  }
81
81
 
82
+ /**
83
+ * Gets the path to agent.db (SQLite database for settings and auth storage).
84
+ * @param agentDir - Base agent directory, defaults to ~/.omp/agent
85
+ * @returns Absolute path to the agent.db file
86
+ */
87
+ export function getAgentDbPath(agentDir: string = getAgentDir()): string {
88
+ return join(agentDir, "agent.db");
89
+ }
90
+
82
91
  /** Get path to tools directory */
83
92
  export function getToolsDir(): string {
84
93
  return join(getAgentDir(), "tools");
@@ -17,7 +17,7 @@ import type { Agent, AgentEvent, AgentMessage, AgentState, AgentTool, ThinkingLe
17
17
  import type { AssistantMessage, ImageContent, Message, Model, TextContent, Usage } from "@oh-my-pi/pi-ai";
18
18
  import { isContextOverflow, modelsAreEqual, supportsXhigh } from "@oh-my-pi/pi-ai";
19
19
  import type { Rule } from "../capability/rule";
20
- import { getAuthPath } from "../config";
20
+ import { getAgentDbPath } from "../config";
21
21
  import { theme } from "../modes/interactive/theme/theme";
22
22
  import { type BashResult, executeBash as executeBashCommand, executeBashWithOperations } from "./bash-executor";
23
23
  import {
@@ -761,7 +761,7 @@ export class AgentSession {
761
761
  if (!this.model) {
762
762
  throw new Error(
763
763
  "No model selected.\n\n" +
764
- `Use /login, set an API key environment variable, or create ${getAuthPath()}\n\n` +
764
+ `Use /login, set an API key environment variable, or create ${getAgentDbPath()}\n\n` +
765
765
  "Then use /model to select a model.",
766
766
  );
767
767
  }
@@ -771,7 +771,7 @@ export class AgentSession {
771
771
  if (!apiKey) {
772
772
  throw new Error(
773
773
  `No API key found for ${this.model.provider}.\n\n` +
774
- `Use /login, set an API key environment variable, or create ${getAuthPath()}`,
774
+ `Use /login, set an API key environment variable, or create ${getAgentDbPath()}`,
775
775
  );
776
776
  }
777
777
 
@@ -0,0 +1,450 @@
1
+ import { Database } from "bun:sqlite";
2
+ import { chmodSync, existsSync, mkdirSync } from "node:fs";
3
+ import { dirname } from "node:path";
4
+ import { getAgentDbPath } from "../config";
5
+ import type { AuthCredential } from "./auth-storage";
6
+ import { logger } from "./logger";
7
+ import type { Settings } from "./settings-manager";
8
+
9
+ /** Prepared SQLite statement type from bun:sqlite */
10
+ type Statement = ReturnType<Database["prepare"]>;
11
+
12
+ /** Row shape for settings table queries */
13
+ type SettingsRow = {
14
+ key: string;
15
+ value: string;
16
+ };
17
+
18
+ /** Row shape for auth_credentials table queries */
19
+ type AuthRow = {
20
+ id: number;
21
+ provider: string;
22
+ credential_type: string;
23
+ data: string;
24
+ };
25
+
26
+ /**
27
+ * Auth credential with database row ID for updates/deletes.
28
+ * Wraps AuthCredential with storage metadata.
29
+ */
30
+ export interface StoredAuthCredential {
31
+ id: number;
32
+ provider: string;
33
+ credential: AuthCredential;
34
+ }
35
+
36
+ /** Bump when schema changes require migration */
37
+ const SCHEMA_VERSION = 2;
38
+
39
+ /**
40
+ * Type guard for plain objects.
41
+ * @param value - Value to check
42
+ * @returns True if value is a non-null, non-array object
43
+ */
44
+ function isRecord(value: unknown): value is Record<string, unknown> {
45
+ return !!value && typeof value === "object" && !Array.isArray(value);
46
+ }
47
+
48
+ /**
49
+ * Converts credential to DB format, stripping the type discriminant from the data blob.
50
+ * @param credential - The credential to serialize
51
+ * @returns Object with credentialType and JSON data string, or null for unknown types
52
+ */
53
+ function serializeCredential(
54
+ credential: AuthCredential,
55
+ ): { credentialType: AuthCredential["type"]; data: string } | null {
56
+ if (credential.type === "api_key") {
57
+ return {
58
+ credentialType: "api_key",
59
+ data: JSON.stringify({ key: credential.key }),
60
+ };
61
+ }
62
+ if (credential.type === "oauth") {
63
+ const { type: _type, ...rest } = credential;
64
+ return {
65
+ credentialType: "oauth",
66
+ data: JSON.stringify(rest),
67
+ };
68
+ }
69
+ return null;
70
+ }
71
+
72
+ /**
73
+ * Reconstructs credential from DB row, re-adding the type discriminant.
74
+ * @param row - Database row containing credential data
75
+ * @returns Reconstructed AuthCredential, or null if parsing fails or type is unknown
76
+ */
77
+ function deserializeCredential(row: AuthRow): AuthCredential | null {
78
+ let parsed: unknown;
79
+ try {
80
+ parsed = JSON.parse(row.data);
81
+ } catch (error) {
82
+ logger.warn("AgentStorage failed to parse auth credential", {
83
+ provider: row.provider,
84
+ id: row.id,
85
+ error: String(error),
86
+ });
87
+ return null;
88
+ }
89
+ if (!isRecord(parsed)) {
90
+ logger.warn("AgentStorage auth credential data invalid", {
91
+ provider: row.provider,
92
+ id: row.id,
93
+ });
94
+ return null;
95
+ }
96
+ if (row.credential_type === "api_key") {
97
+ return { type: "api_key", ...(parsed as Record<string, unknown>) } as AuthCredential;
98
+ }
99
+ if (row.credential_type === "oauth") {
100
+ return { type: "oauth", ...(parsed as Record<string, unknown>) } as AuthCredential;
101
+ }
102
+ logger.warn("AgentStorage unknown credential type", {
103
+ provider: row.provider,
104
+ id: row.id,
105
+ type: row.credential_type,
106
+ });
107
+ return null;
108
+ }
109
+
110
+ /**
111
+ * Unified SQLite storage for agent settings and auth credentials.
112
+ * Uses singleton pattern per database path; access via AgentStorage.open().
113
+ */
114
+ export class AgentStorage {
115
+ private db: Database;
116
+ private static instances = new Map<string, AgentStorage>();
117
+
118
+ private listSettingsStmt: Statement;
119
+ private insertSettingStmt: Statement;
120
+ private deleteSettingsStmt: Statement;
121
+ private listAuthStmt: Statement;
122
+ private listAuthByProviderStmt: Statement;
123
+ private insertAuthStmt: Statement;
124
+ private updateAuthStmt: Statement;
125
+ private deleteAuthStmt: Statement;
126
+ private deleteAuthByProviderStmt: Statement;
127
+ private countAuthStmt: Statement;
128
+
129
+ private constructor(dbPath: string) {
130
+ this.ensureDir(dbPath);
131
+ this.db = new Database(dbPath);
132
+
133
+ this.initializeSchema();
134
+ this.hardenPermissions(dbPath);
135
+
136
+ this.listSettingsStmt = this.db.prepare("SELECT key, value FROM settings");
137
+ this.insertSettingStmt = this.db.prepare(
138
+ "INSERT INTO settings (key, value, updated_at) VALUES (?, ?, unixepoch())",
139
+ );
140
+ this.deleteSettingsStmt = this.db.prepare("DELETE FROM settings");
141
+
142
+ this.listAuthStmt = this.db.prepare(
143
+ "SELECT id, provider, credential_type, data FROM auth_credentials ORDER BY id ASC",
144
+ );
145
+ this.listAuthByProviderStmt = this.db.prepare(
146
+ "SELECT id, provider, credential_type, data FROM auth_credentials WHERE provider = ? ORDER BY id ASC",
147
+ );
148
+ this.insertAuthStmt = this.db.prepare(
149
+ "INSERT INTO auth_credentials (provider, credential_type, data) VALUES (?, ?, ?) RETURNING id",
150
+ );
151
+ this.updateAuthStmt = this.db.prepare(
152
+ "UPDATE auth_credentials SET credential_type = ?, data = ?, updated_at = unixepoch() WHERE id = ?",
153
+ );
154
+ this.deleteAuthStmt = this.db.prepare("DELETE FROM auth_credentials WHERE id = ?");
155
+ this.deleteAuthByProviderStmt = this.db.prepare("DELETE FROM auth_credentials WHERE provider = ?");
156
+ this.countAuthStmt = this.db.prepare("SELECT COUNT(*) as count FROM auth_credentials");
157
+ }
158
+
159
+ /**
160
+ * Creates tables if missing and migrates legacy single-blob settings to key-value format.
161
+ * Handles v1 to v2 schema migration for settings table.
162
+ */
163
+ private initializeSchema(): void {
164
+ this.db.exec(`
165
+ PRAGMA journal_mode=WAL;
166
+ PRAGMA synchronous=NORMAL;
167
+
168
+ CREATE TABLE IF NOT EXISTS auth_credentials (
169
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
170
+ provider TEXT NOT NULL,
171
+ credential_type TEXT NOT NULL,
172
+ data TEXT NOT NULL,
173
+ created_at INTEGER NOT NULL DEFAULT (unixepoch()),
174
+ updated_at INTEGER NOT NULL DEFAULT (unixepoch())
175
+ );
176
+ CREATE INDEX IF NOT EXISTS idx_auth_provider ON auth_credentials(provider);
177
+
178
+ CREATE TABLE IF NOT EXISTS schema_version (version INTEGER PRIMARY KEY);
179
+ `);
180
+
181
+ const settingsInfo = this.db.prepare("PRAGMA table_info(settings)").all() as Array<{ name?: string }>;
182
+ const hasSettingsTable = settingsInfo.length > 0;
183
+ const hasKey = settingsInfo.some((column) => column.name === "key");
184
+ const hasValue = settingsInfo.some((column) => column.name === "value");
185
+
186
+ if (!hasSettingsTable) {
187
+ this.db.exec(`
188
+ CREATE TABLE settings (
189
+ key TEXT PRIMARY KEY,
190
+ value TEXT NOT NULL,
191
+ updated_at INTEGER NOT NULL DEFAULT (unixepoch())
192
+ );
193
+ `);
194
+ } else if (!hasKey || !hasValue) {
195
+ // Migrate v1 schema: single JSON blob in `data` column → per-key rows
196
+ let legacySettings: Record<string, unknown> | null = null;
197
+ const row = this.db.prepare("SELECT data FROM settings WHERE id = 1").get() as { data?: string } | undefined;
198
+ if (row?.data) {
199
+ try {
200
+ const parsed = JSON.parse(row.data);
201
+ if (isRecord(parsed)) {
202
+ legacySettings = parsed;
203
+ } else {
204
+ logger.warn("AgentStorage legacy settings invalid shape");
205
+ }
206
+ } catch (error) {
207
+ logger.warn("AgentStorage failed to parse legacy settings", { error: String(error) });
208
+ }
209
+ }
210
+
211
+ const migrate = this.db.transaction((settings: Record<string, unknown> | null) => {
212
+ this.db.exec("DROP TABLE settings");
213
+ this.db.exec(`
214
+ CREATE TABLE settings (
215
+ key TEXT PRIMARY KEY,
216
+ value TEXT NOT NULL,
217
+ updated_at INTEGER NOT NULL DEFAULT (unixepoch())
218
+ );
219
+ `);
220
+ if (settings) {
221
+ const insert = this.db.prepare(
222
+ "INSERT INTO settings (key, value, updated_at) VALUES (?, ?, unixepoch())",
223
+ );
224
+ for (const [key, value] of Object.entries(settings)) {
225
+ if (value === undefined) continue;
226
+ const serialized = JSON.stringify(value);
227
+ if (serialized === undefined) continue;
228
+ insert.run(key, serialized);
229
+ }
230
+ }
231
+ });
232
+
233
+ migrate(legacySettings);
234
+ }
235
+
236
+ const versionRow = this.db.prepare("SELECT version FROM schema_version ORDER BY version DESC LIMIT 1").get() as
237
+ | { version?: number }
238
+ | undefined;
239
+ if (versionRow?.version !== undefined && versionRow.version !== SCHEMA_VERSION) {
240
+ logger.warn("AgentStorage schema version mismatch", {
241
+ current: versionRow.version,
242
+ expected: SCHEMA_VERSION,
243
+ });
244
+ }
245
+ this.db.prepare("INSERT OR REPLACE INTO schema_version(version) VALUES (?)").run(SCHEMA_VERSION);
246
+ }
247
+
248
+ /**
249
+ * Returns singleton instance for the given database path, creating if needed.
250
+ * @param dbPath - Path to the SQLite database file (defaults to config path)
251
+ * @returns AgentStorage instance for the given path
252
+ */
253
+ static open(dbPath: string = getAgentDbPath()): AgentStorage {
254
+ const existing = AgentStorage.instances.get(dbPath);
255
+ if (existing) return existing;
256
+ const storage = new AgentStorage(dbPath);
257
+ AgentStorage.instances.set(dbPath, storage);
258
+ return storage;
259
+ }
260
+
261
+ /**
262
+ * Retrieves all settings from storage.
263
+ * @returns Settings object, or null if no settings are stored
264
+ */
265
+ getSettings(): Settings | null {
266
+ const rows = (this.listSettingsStmt.all() as SettingsRow[]) ?? [];
267
+ if (rows.length === 0) return null;
268
+ const settings: Record<string, unknown> = {};
269
+ for (const row of rows) {
270
+ try {
271
+ settings[row.key] = JSON.parse(row.value) as unknown;
272
+ } catch (error) {
273
+ logger.warn("AgentStorage failed to parse setting", {
274
+ key: row.key,
275
+ error: String(error),
276
+ });
277
+ }
278
+ }
279
+ return settings as Settings;
280
+ }
281
+
282
+ /**
283
+ * Atomically replaces all settings in storage.
284
+ * Uses delete-then-insert within a transaction for consistency.
285
+ * @param settings - Settings object to persist
286
+ */
287
+ saveSettings(settings: Settings): void {
288
+ const entries = Object.entries(settings).filter(([, value]) => value !== undefined);
289
+ const replace = this.db.transaction((rows: Array<[string, unknown]>) => {
290
+ this.deleteSettingsStmt.run();
291
+ for (const [key, value] of rows) {
292
+ const serialized = JSON.stringify(value);
293
+ if (serialized === undefined) continue;
294
+ this.insertSettingStmt.run(key, serialized);
295
+ }
296
+ });
297
+
298
+ try {
299
+ replace(entries);
300
+ } catch (error) {
301
+ logger.error("AgentStorage failed to save settings", { error: String(error) });
302
+ }
303
+ }
304
+
305
+ /**
306
+ * Checks if any auth credentials exist in storage.
307
+ * @returns True if at least one credential is stored
308
+ */
309
+ hasAuthCredentials(): boolean {
310
+ const row = this.countAuthStmt.get() as { count?: number } | undefined;
311
+ return (row?.count ?? 0) > 0;
312
+ }
313
+
314
+ /**
315
+ * Lists auth credentials, optionally filtered by provider.
316
+ * @param provider - Optional provider name to filter by
317
+ * @returns Array of stored credentials with their database IDs
318
+ */
319
+ listAuthCredentials(provider?: string): StoredAuthCredential[] {
320
+ const rows =
321
+ (provider
322
+ ? (this.listAuthByProviderStmt.all(provider) as AuthRow[])
323
+ : (this.listAuthStmt.all() as AuthRow[])) ?? [];
324
+
325
+ const results: StoredAuthCredential[] = [];
326
+ for (const row of rows) {
327
+ const credential = deserializeCredential(row);
328
+ if (!credential) continue;
329
+ results.push({ id: row.id, provider: row.provider, credential });
330
+ }
331
+ return results;
332
+ }
333
+
334
+ /**
335
+ * Atomically replaces all credentials for a provider.
336
+ * Useful for OAuth token refresh where old tokens should be discarded.
337
+ * @param provider - Provider name (e.g., "anthropic", "openai")
338
+ * @param credentials - New credentials to store
339
+ * @returns Array of newly stored credentials with their database IDs
340
+ */
341
+ replaceAuthCredentialsForProvider(provider: string, credentials: AuthCredential[]): StoredAuthCredential[] {
342
+ const replace = this.db.transaction((providerName: string, items: AuthCredential[]) => {
343
+ this.deleteAuthByProviderStmt.run(providerName);
344
+ const inserted: StoredAuthCredential[] = [];
345
+ for (const credential of items) {
346
+ const record = this.insertAuthCredential(providerName, credential);
347
+ if (record) inserted.push(record);
348
+ }
349
+ return inserted;
350
+ });
351
+
352
+ return replace(provider, credentials);
353
+ }
354
+
355
+ /**
356
+ * Updates an existing auth credential by ID.
357
+ * @param id - Database row ID of the credential to update
358
+ * @param credential - New credential data
359
+ */
360
+ updateAuthCredential(id: number, credential: AuthCredential): void {
361
+ const serialized = serializeCredential(credential);
362
+ if (!serialized) {
363
+ logger.warn("AgentStorage updateAuthCredential invalid type", { id, type: credential.type });
364
+ return;
365
+ }
366
+ try {
367
+ this.updateAuthStmt.run(serialized.credentialType, serialized.data, id);
368
+ } catch (error) {
369
+ logger.warn("AgentStorage updateAuthCredential failed", { id, error: String(error) });
370
+ }
371
+ }
372
+
373
+ /**
374
+ * Deletes an auth credential by ID.
375
+ * @param id - Database row ID of the credential to delete
376
+ */
377
+ deleteAuthCredential(id: number): void {
378
+ try {
379
+ this.deleteAuthStmt.run(id);
380
+ } catch (error) {
381
+ logger.warn("AgentStorage deleteAuthCredential failed", { id, error: String(error) });
382
+ }
383
+ }
384
+
385
+ /**
386
+ * Deletes all auth credentials for a provider.
387
+ * @param provider - Provider name whose credentials should be deleted
388
+ */
389
+ deleteAuthCredentialsForProvider(provider: string): void {
390
+ try {
391
+ this.deleteAuthByProviderStmt.run(provider);
392
+ } catch (error) {
393
+ logger.warn("AgentStorage deleteAuthCredentialsForProvider failed", {
394
+ provider,
395
+ error: String(error),
396
+ });
397
+ }
398
+ }
399
+
400
+ /**
401
+ * Inserts a new auth credential for a provider.
402
+ * @param provider - Provider name (e.g., "anthropic", "openai")
403
+ * @param credential - Credential to insert
404
+ * @returns Stored credential with database ID, or null on failure
405
+ */
406
+ private insertAuthCredential(provider: string, credential: AuthCredential): StoredAuthCredential | null {
407
+ const serialized = serializeCredential(credential);
408
+ if (!serialized) {
409
+ logger.warn("AgentStorage insertAuthCredential invalid type", { provider, type: credential.type });
410
+ return null;
411
+ }
412
+ try {
413
+ const row = this.insertAuthStmt.get(provider, serialized.credentialType, serialized.data) as
414
+ | { id?: number }
415
+ | undefined;
416
+ if (!row?.id) {
417
+ logger.warn("AgentStorage insertAuthCredential missing id", { provider });
418
+ return null;
419
+ }
420
+ return { id: row.id, provider, credential };
421
+ } catch (error) {
422
+ logger.warn("AgentStorage insertAuthCredential failed", { provider, error: String(error) });
423
+ return null;
424
+ }
425
+ }
426
+
427
+ /**
428
+ * Ensures the parent directory for the database file exists.
429
+ * @param dbPath - Path to the database file
430
+ */
431
+ private ensureDir(dbPath: string): void {
432
+ mkdirSync(dirname(dbPath), { recursive: true });
433
+ }
434
+
435
+ private hardenPermissions(dbPath: string): void {
436
+ const dir = dirname(dbPath);
437
+ try {
438
+ chmodSync(dir, 0o700);
439
+ } catch (error) {
440
+ logger.warn("AgentStorage failed to chmod agent dir", { path: dir, error: String(error) });
441
+ }
442
+
443
+ if (!existsSync(dbPath)) return;
444
+ try {
445
+ chmodSync(dbPath, 0o600);
446
+ } catch (error) {
447
+ logger.warn("AgentStorage failed to chmod db file", { path: dbPath, error: String(error) });
448
+ }
449
+ }
450
+ }