@oh-my-pi/pi-coding-agent 4.1.0 → 4.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +40 -0
- package/README.md +2 -1
- package/docs/sdk.md +0 -3
- package/package.json +6 -5
- package/src/config.ts +9 -0
- package/src/core/agent-storage.ts +450 -0
- package/src/core/auth-storage.ts +102 -183
- package/src/core/compaction/branch-summarization.ts +5 -4
- package/src/core/compaction/compaction.ts +7 -6
- package/src/core/compaction/utils.ts +6 -11
- package/src/core/custom-commands/bundled/review/index.ts +22 -94
- package/src/core/custom-share.ts +66 -0
- package/src/core/history-storage.ts +15 -7
- package/src/core/prompt-templates.ts +271 -1
- package/src/core/sdk.ts +14 -3
- package/src/core/settings-manager.ts +100 -34
- package/src/core/slash-commands.ts +4 -1
- package/src/core/storage-migration.ts +215 -0
- package/src/core/system-prompt.ts +87 -289
- package/src/core/title-generator.ts +3 -2
- package/src/core/tools/ask.ts +2 -2
- package/src/core/tools/bash.ts +2 -1
- package/src/core/tools/calculator.ts +2 -1
- package/src/core/tools/edit.ts +2 -1
- package/src/core/tools/find.ts +2 -1
- package/src/core/tools/gemini-image.ts +2 -1
- package/src/core/tools/git.ts +2 -2
- package/src/core/tools/grep.ts +2 -1
- package/src/core/tools/index.test.ts +0 -28
- package/src/core/tools/index.ts +0 -6
- package/src/core/tools/lsp/index.ts +2 -1
- package/src/core/tools/output.ts +2 -1
- package/src/core/tools/read.ts +4 -1
- package/src/core/tools/ssh.ts +4 -2
- package/src/core/tools/task/agents.ts +56 -30
- package/src/core/tools/task/commands.ts +9 -8
- package/src/core/tools/task/index.ts +7 -15
- package/src/core/tools/web-fetch.ts +2 -1
- package/src/core/tools/web-search/auth.ts +106 -16
- package/src/core/tools/web-search/index.ts +3 -2
- package/src/core/tools/web-search/providers/anthropic.ts +44 -6
- package/src/core/tools/write.ts +2 -1
- package/src/core/voice.ts +3 -1
- package/src/main.ts +1 -1
- package/src/migrations.ts +20 -20
- package/src/modes/interactive/controllers/command-controller.ts +527 -0
- package/src/modes/interactive/controllers/event-controller.ts +340 -0
- package/src/modes/interactive/controllers/extension-ui-controller.ts +600 -0
- package/src/modes/interactive/controllers/input-controller.ts +585 -0
- package/src/modes/interactive/controllers/selector-controller.ts +585 -0
- package/src/modes/interactive/interactive-mode.ts +364 -3143
- package/src/modes/interactive/theme/theme.ts +5 -5
- package/src/modes/interactive/types.ts +189 -0
- package/src/modes/interactive/utils/ui-helpers.ts +449 -0
- package/src/modes/interactive/utils/voice-manager.ts +96 -0
- package/src/prompts/{explore.md → agents/explore.md} +7 -5
- package/src/prompts/agents/frontmatter.md +7 -0
- package/src/prompts/{plan.md → agents/plan.md} +3 -3
- package/src/prompts/{task.md → agents/task.md} +1 -1
- package/src/prompts/review-request.md +44 -8
- package/src/prompts/system/custom-system-prompt.md +80 -0
- package/src/prompts/system/file-operations.md +12 -0
- package/src/prompts/system/system-prompt.md +232 -0
- package/src/prompts/system/title-system.md +2 -0
- package/src/prompts/tools/bash.md +1 -1
- package/src/prompts/tools/read.md +1 -1
- package/src/prompts/tools/task.md +9 -3
- package/src/core/tools/rulebook.ts +0 -132
- package/src/prompts/system-prompt.md +0 -43
- package/src/prompts/title-system.md +0 -8
- /package/src/prompts/{architect-plan.md → agents/architect-plan.md} +0 -0
- /package/src/prompts/{implement-with-critic.md → agents/implement-with-critic.md} +0 -0
- /package/src/prompts/{implement.md → agents/implement.md} +0 -0
- /package/src/prompts/{init.md → agents/init.md} +0 -0
- /package/src/prompts/{reviewer.md → agents/reviewer.md} +0 -0
- /package/src/prompts/{branch-summary-preamble.md → compaction/branch-summary-preamble.md} +0 -0
- /package/src/prompts/{branch-summary.md → compaction/branch-summary.md} +0 -0
- /package/src/prompts/{compaction-summary.md → compaction/compaction-summary.md} +0 -0
- /package/src/prompts/{compaction-turn-prefix.md → compaction/compaction-turn-prefix.md} +0 -0
- /package/src/prompts/{compaction-update-summary.md → compaction/compaction-update-summary.md} +0 -0
- /package/src/prompts/{summarization-system.md → system/summarization-system.md} +0 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,46 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [4.2.0] - 2026-01-10
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Added `/dump` slash command to copy the full session transcript to the clipboard
|
|
10
|
+
- Added automatic Nerd Fonts detection for terminals like iTerm, WezTerm, Kitty, Ghostty, and Alacritty to set appropriate symbol preset
|
|
11
|
+
- Added `NERD_FONTS` environment variable override (`1` or `0`) to manually control Nerd Fonts symbol preset
|
|
12
|
+
- Added Handlebars templating engine for prompt template rendering with `{{arg}}` helper for positional arguments
|
|
13
|
+
- Added support for custom share scripts at ~/.omp/agent/share.ts to replace default GitHub Gist sharing
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
|
|
17
|
+
- Changed rules system to use `read` tool for loading rule content instead of dedicated `rulebook` tool
|
|
18
|
+
- Separated `/export` and `/dump` commands—`/export` now only exports to HTML file, while `/dump` copies session transcript to clipboard
|
|
19
|
+
- Updated `/export` command to no longer accept `--copy` flag (use `/dump` instead)
|
|
20
|
+
- Changed prompt template rendering to use Handlebars instead of simple string replacement
|
|
21
|
+
- Updated prompt layout optimization to normalize indentation and collapse excessive blank lines
|
|
22
|
+
- Changed auth migration to merge credentials per-provider instead of skipping when any credentials exist in database
|
|
23
|
+
- Migrated settings and auth credential storage from JSON files to SQLite database (agent.db)
|
|
24
|
+
- Updated credential migration message to reference agent.db instead of auth.json
|
|
25
|
+
- Renamed Glob tool references to Find tool throughout prompts and documentation
|
|
26
|
+
- Updated project context formatting to use XML-style tags for clearer structure
|
|
27
|
+
- Refined bash tool guidance to prefer dedicated tools (read/grep/find/ls) over bash for file operations
|
|
28
|
+
- Updated system prompt with clearer tone guidelines emphasizing directness and conciseness
|
|
29
|
+
- Revised workflow instructions to require explicit planning for non-trivial tasks
|
|
30
|
+
- Enhanced verification guidance to prefer external feedback loops like tests and linters
|
|
31
|
+
- Added explicit alignment and prohibited behavior sections to improve response quality
|
|
32
|
+
|
|
33
|
+
### Removed
|
|
34
|
+
|
|
35
|
+
- Removed `rulebook` tool - rules are now loaded via the `read` tool instead of a dedicated tool
|
|
36
|
+
|
|
37
|
+
### Fixed
|
|
38
|
+
|
|
39
|
+
- Fixed message submission lag caused by synchronous history database writes by deferring DB operations with setImmediate
|
|
40
|
+
|
|
41
|
+
### Security
|
|
42
|
+
|
|
43
|
+
- Hardened file permissions on agent database directory (700) and database file (600) to restrict access
|
|
44
|
+
|
|
5
45
|
## [4.1.0] - 2026-01-10
|
|
6
46
|
### Added
|
|
7
47
|
|
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
|
|
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.
|
|
3
|
+
"version": "4.2.0",
|
|
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.
|
|
43
|
-
"@oh-my-pi/pi-agent-core": "4.
|
|
44
|
-
"@oh-my-pi/pi-git-tool": "4.
|
|
45
|
-
"@oh-my-pi/pi-tui": "4.
|
|
42
|
+
"@oh-my-pi/pi-ai": "4.2.0",
|
|
43
|
+
"@oh-my-pi/pi-agent-core": "4.2.0",
|
|
44
|
+
"@oh-my-pi/pi-git-tool": "4.2.0",
|
|
45
|
+
"@oh-my-pi/pi-tui": "4.2.0",
|
|
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");
|
|
@@ -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
|
+
}
|