@steerable/agent-shell 0.6.15

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 (204) hide show
  1. package/LICENSE +91 -0
  2. package/contracts/tool-contract.json +326 -0
  3. package/dist/attachments.d.ts +41 -0
  4. package/dist/attachments.js +147 -0
  5. package/dist/brand.d.ts +24 -0
  6. package/dist/brand.js +92 -0
  7. package/dist/host/http-routes.d.ts +21 -0
  8. package/dist/host/http-routes.js +55 -0
  9. package/dist/host/ipc.d.ts +11 -0
  10. package/dist/host/ipc.js +20 -0
  11. package/dist/host/pack-assembly.d.ts +86 -0
  12. package/dist/host/pack-assembly.js +32 -0
  13. package/dist/host/runtime.d.ts +69 -0
  14. package/dist/host/runtime.js +207 -0
  15. package/dist/host/visible-terminal-exec.d.ts +21 -0
  16. package/dist/host/visible-terminal-exec.js +151 -0
  17. package/dist/hosted-web-search.d.ts +13 -0
  18. package/dist/hosted-web-search.js +82 -0
  19. package/dist/image-attachment.d.ts +39 -0
  20. package/dist/image-attachment.js +133 -0
  21. package/dist/insights/flush.d.ts +10 -0
  22. package/dist/insights/flush.js +139 -0
  23. package/dist/insights/record.d.ts +17 -0
  24. package/dist/insights/record.js +44 -0
  25. package/dist/json-store.d.ts +9 -0
  26. package/dist/json-store.js +22 -0
  27. package/dist/llm/index.d.ts +23 -0
  28. package/dist/llm/index.js +105 -0
  29. package/dist/llm/ollama.d.ts +23 -0
  30. package/dist/llm/ollama.js +242 -0
  31. package/dist/llm/openai-compat.d.ts +20 -0
  32. package/dist/llm/openai-compat.js +199 -0
  33. package/dist/llm/sidecar-provider.d.ts +37 -0
  34. package/dist/llm/sidecar-provider.js +163 -0
  35. package/dist/llm/tool-choice.d.ts +35 -0
  36. package/dist/llm/tool-choice.js +85 -0
  37. package/dist/llm/types.d.ts +122 -0
  38. package/dist/llm/types.js +1 -0
  39. package/dist/local-backend/agent-capability.d.ts +101 -0
  40. package/dist/local-backend/agent-capability.js +174 -0
  41. package/dist/local-backend/ai-title.d.ts +43 -0
  42. package/dist/local-backend/ai-title.js +173 -0
  43. package/dist/local-backend/auto-continue-helper.d.ts +80 -0
  44. package/dist/local-backend/auto-continue-helper.js +83 -0
  45. package/dist/local-backend/branch-helper.d.ts +24 -0
  46. package/dist/local-backend/branch-helper.js +27 -0
  47. package/dist/local-backend/context-compactor.d.ts +81 -0
  48. package/dist/local-backend/context-compactor.js +213 -0
  49. package/dist/local-backend/coreloop-stream.d.ts +245 -0
  50. package/dist/local-backend/coreloop-stream.js +277 -0
  51. package/dist/local-backend/deferred-detector.d.ts +15 -0
  52. package/dist/local-backend/deferred-detector.js +124 -0
  53. package/dist/local-backend/history-helper.d.ts +30 -0
  54. package/dist/local-backend/history-helper.js +34 -0
  55. package/dist/local-backend/interrupted-helper.d.ts +29 -0
  56. package/dist/local-backend/interrupted-helper.js +25 -0
  57. package/dist/local-backend/live-stream.d.ts +36 -0
  58. package/dist/local-backend/live-stream.js +23 -0
  59. package/dist/local-backend/llm-diagnose.d.ts +37 -0
  60. package/dist/local-backend/llm-diagnose.js +284 -0
  61. package/dist/local-backend/message-triggers.d.ts +27 -0
  62. package/dist/local-backend/message-triggers.js +67 -0
  63. package/dist/local-backend/pack-backend-routes.d.ts +31 -0
  64. package/dist/local-backend/pack-backend-routes.js +62 -0
  65. package/dist/local-backend/pack-turn-hooks.d.ts +37 -0
  66. package/dist/local-backend/pack-turn-hooks.js +67 -0
  67. package/dist/local-backend/prompt-builder.d.ts +101 -0
  68. package/dist/local-backend/prompt-builder.js +246 -0
  69. package/dist/local-backend/regenerate-helper.d.ts +62 -0
  70. package/dist/local-backend/regenerate-helper.js +75 -0
  71. package/dist/local-backend/router.d.ts +175 -0
  72. package/dist/local-backend/router.js +3139 -0
  73. package/dist/local-backend/skill-install.d.ts +19 -0
  74. package/dist/local-backend/skill-install.js +71 -0
  75. package/dist/local-backend/skill-loader.d.ts +92 -0
  76. package/dist/local-backend/skill-loader.js +146 -0
  77. package/dist/local-backend/skills/00-identity/SKILL.md +32 -0
  78. package/dist/local-backend/skills/10-goal/SKILL.md +59 -0
  79. package/dist/local-backend/skills/11-loop/SKILL.md +71 -0
  80. package/dist/local-backend/skills/12-create-skill/SKILL.md +88 -0
  81. package/dist/local-backend/skills/70-plan-mode/SKILL.md +58 -0
  82. package/dist/local-backend/skills/80-tool-usage/SKILL.md +70 -0
  83. package/dist/local-backend/skills/81-anti-deferred/SKILL.md +53 -0
  84. package/dist/local-backend/skills/82-data-grounding/SKILL.md +56 -0
  85. package/dist/local-backend/skills/85-local-exec/SKILL.md +86 -0
  86. package/dist/local-backend/skills/86-proactive-coding/SKILL.md +51 -0
  87. package/dist/local-backend/subagent-profiles.d.ts +30 -0
  88. package/dist/local-backend/subagent-profiles.js +74 -0
  89. package/dist/local-backend/task-process.d.ts +12 -0
  90. package/dist/local-backend/task-process.js +176 -0
  91. package/dist/local-backend/task-service.d.ts +135 -0
  92. package/dist/local-backend/task-service.js +565 -0
  93. package/dist/local-backend/turn-duration.d.ts +2 -0
  94. package/dist/local-backend/turn-duration.js +9 -0
  95. package/dist/local-backend/turn-timeline.d.ts +16 -0
  96. package/dist/local-backend/turn-timeline.js +42 -0
  97. package/dist/local-backend/worktree-service.d.ts +84 -0
  98. package/dist/local-backend/worktree-service.js +243 -0
  99. package/dist/local-edit.d.ts +48 -0
  100. package/dist/local-edit.js +44 -0
  101. package/dist/local-executor.d.ts +255 -0
  102. package/dist/local-executor.js +881 -0
  103. package/dist/local-script-registry.d.ts +28 -0
  104. package/dist/local-script-registry.js +63 -0
  105. package/dist/log.d.ts +13 -0
  106. package/dist/log.js +12 -0
  107. package/dist/main.d.ts +1 -0
  108. package/dist/main.js +855 -0
  109. package/dist/mcp-executor.d.ts +45 -0
  110. package/dist/mcp-executor.js +241 -0
  111. package/dist/mcp-server-registry.d.ts +104 -0
  112. package/dist/mcp-server-registry.js +234 -0
  113. package/dist/preload-default.d.ts +1 -0
  114. package/dist/preload-default.js +9 -0
  115. package/dist/preload.cjs +395 -0
  116. package/dist/preload.d.ts +20 -0
  117. package/dist/preload.js +411 -0
  118. package/dist/product-config.d.ts +43 -0
  119. package/dist/product-config.js +26 -0
  120. package/dist/project-registry.d.ts +55 -0
  121. package/dist/project-registry.js +106 -0
  122. package/dist/project-rules.d.ts +15 -0
  123. package/dist/project-rules.js +102 -0
  124. package/dist/runtime.d.ts +62 -0
  125. package/dist/runtime.js +217 -0
  126. package/dist/scenario/pack.d.ts +8 -0
  127. package/dist/scenario/pack.js +1 -0
  128. package/dist/scenario/registry.d.ts +24 -0
  129. package/dist/scenario/registry.js +31 -0
  130. package/dist/server/http-server.d.ts +39 -0
  131. package/dist/server/http-server.js +361 -0
  132. package/dist/server/index.d.ts +1 -0
  133. package/dist/server/index.js +107 -0
  134. package/dist/server/sse-bus.d.ts +14 -0
  135. package/dist/server/sse-bus.js +31 -0
  136. package/dist/shell-adapt.d.ts +21 -0
  137. package/dist/shell-adapt.js +104 -0
  138. package/dist/sidecar/boot.d.ts +36 -0
  139. package/dist/sidecar/boot.js +343 -0
  140. package/dist/sidecar/egress-hint.d.ts +15 -0
  141. package/dist/sidecar/egress-hint.js +46 -0
  142. package/dist/sidecar/egress-proxy.d.ts +183 -0
  143. package/dist/sidecar/egress-proxy.js +419 -0
  144. package/dist/sidecar/errors.d.ts +22 -0
  145. package/dist/sidecar/errors.js +38 -0
  146. package/dist/sidecar/exec-sandbox.d.ts +48 -0
  147. package/dist/sidecar/exec-sandbox.js +94 -0
  148. package/dist/sidecar/handle.d.ts +32 -0
  149. package/dist/sidecar/handle.js +53 -0
  150. package/dist/sidecar/index.d.ts +14 -0
  151. package/dist/sidecar/index.js +13 -0
  152. package/dist/sidecar/proxy-detect.d.ts +50 -0
  153. package/dist/sidecar/proxy-detect.js +182 -0
  154. package/dist/sidecar/reverse-approval.d.ts +55 -0
  155. package/dist/sidecar/reverse-approval.js +86 -0
  156. package/dist/sidecar/reverse-ask-user.d.ts +34 -0
  157. package/dist/sidecar/reverse-ask-user.js +59 -0
  158. package/dist/sidecar/reverse-spawn.d.ts +19 -0
  159. package/dist/sidecar/reverse-spawn.js +161 -0
  160. package/dist/sidecar/reverse-tools.d.ts +29 -0
  161. package/dist/sidecar/reverse-tools.js +106 -0
  162. package/dist/sidecar/safety-patterns.d.ts +41 -0
  163. package/dist/sidecar/safety-patterns.js +157 -0
  164. package/dist/sidecar/storage-path.d.ts +14 -0
  165. package/dist/sidecar/storage-path.js +35 -0
  166. package/dist/sidecar/supervisor.d.ts +218 -0
  167. package/dist/sidecar/supervisor.js +932 -0
  168. package/dist/sidecar/types.d.ts +601 -0
  169. package/dist/sidecar/types.js +1 -0
  170. package/dist/single-instance.d.ts +11 -0
  171. package/dist/single-instance.js +21 -0
  172. package/dist/storage/empty-chats.d.ts +9 -0
  173. package/dist/storage/empty-chats.js +16 -0
  174. package/dist/storage/index.d.ts +373 -0
  175. package/dist/storage/index.js +1158 -0
  176. package/dist/storage/insights-redact.d.ts +2 -0
  177. package/dist/storage/insights-redact.js +30 -0
  178. package/dist/storage/insights-settings.d.ts +53 -0
  179. package/dist/storage/insights-settings.js +92 -0
  180. package/dist/storage/llm-settings.d.ts +120 -0
  181. package/dist/storage/llm-settings.js +233 -0
  182. package/dist/storage/local-store-singleton.d.ts +28 -0
  183. package/dist/storage/local-store-singleton.js +38 -0
  184. package/dist/storage/message-order.d.ts +25 -0
  185. package/dist/storage/message-order.js +27 -0
  186. package/dist/storage/pack-migrations.d.ts +22 -0
  187. package/dist/storage/pack-migrations.js +24 -0
  188. package/dist/storage/pack-seeds.d.ts +36 -0
  189. package/dist/storage/pack-seeds.js +42 -0
  190. package/dist/storage/telemetry-settings.d.ts +38 -0
  191. package/dist/storage/telemetry-settings.js +59 -0
  192. package/dist/storage/usage-summary.d.ts +55 -0
  193. package/dist/storage/usage-summary.js +38 -0
  194. package/dist/storage/web-search-settings.d.ts +38 -0
  195. package/dist/storage/web-search-settings.js +74 -0
  196. package/dist/storage/write-lease.d.ts +26 -0
  197. package/dist/storage/write-lease.js +74 -0
  198. package/dist/terminal-manager.d.ts +83 -0
  199. package/dist/terminal-manager.js +506 -0
  200. package/dist/tool-router.d.ts +228 -0
  201. package/dist/tool-router.js +930 -0
  202. package/dist/tool-search-rank.d.ts +42 -0
  203. package/dist/tool-search-rank.js +96 -0
  204. package/package.json +67 -0
@@ -0,0 +1,1158 @@
1
+ import { randomUUID } from 'crypto';
2
+ import path from 'path';
3
+ import Database from 'better-sqlite3';
4
+ import { getUserDataDir } from '../runtime.js';
5
+ import { getProductConfig } from '../product-config.js';
6
+ import { ALL_ROUND_ASSISTANT_AGENT_ID, LOCAL_ASSISTANT_AGENT_ID, getBrand, } from '../brand.js';
7
+ import { DEFAULT_LLM_SETTINGS, llmSettingsCarryExpiredBakedKey, llmSettingsMatchOllamaDefaults, mergeLlmSettings, } from './llm-settings.js';
8
+ import { MESSAGE_CUT_FROM_ID, MESSAGE_ORDER_DESC, } from './message-order.js';
9
+ import { LIST_EMPTY_CHAT_IDS_SQL } from './empty-chats.js';
10
+ import { mergeTelemetrySettings, } from './telemetry-settings.js';
11
+ import { mergeWebSearchSettings, } from './web-search-settings.js';
12
+ import { mergeInsightsProfile, mergeInsightsSettings, } from './insights-settings.js';
13
+ import { acquireWriteLease, lockPathForDb } from './write-lease.js';
14
+ import { createLocalStore } from './local-store-singleton.js';
15
+ import { getPackMigrations } from './pack-migrations.js';
16
+ import { getPackAgentSeeds, isUntouchedSeed, matchesPreviousIdentity, } from './pack-seeds.js';
17
+ import { buildUsageSummary, } from './usage-summary.js';
18
+ /** tasks.depends_on 列的 JSON 解析——坏行(非数组/非字符串项)归一为 null 而不是炸掉读路径。 */
19
+ function parseDependsOn(raw) {
20
+ if (raw == null)
21
+ return null;
22
+ try {
23
+ const parsed = JSON.parse(String(raw));
24
+ if (!Array.isArray(parsed))
25
+ return null;
26
+ const ids = parsed.filter((x) => typeof x === 'string' && x.length > 0);
27
+ return ids.length ? ids : null;
28
+ }
29
+ catch {
30
+ return null;
31
+ }
32
+ }
33
+ export { DEFAULT_SYSTEM_PROMPT } from './llm-settings.js';
34
+ export { DEFAULT_TELEMETRY_SETTINGS, telemetryEnabled, normalizeTelemetryEndpoint, } from './telemetry-settings.js';
35
+ export { DEFAULT_WEB_SEARCH_SETTINGS, mergeWebSearchSettings, hostedSearchAvailable, sidecarWebSearchEnv, defaultSearchBaseUrl, } from './web-search-settings.js';
36
+ export { mergeInsightsSettings, insightsNeedsPrompt, canAutoUpload, rowsEligibleForAutoUpload, resolveInsightsApiBase, } from './insights-settings.js';
37
+ const DEFAULT_LOCAL_USER_ID = 'local';
38
+ // 线协议常量:insights 导出包 schema 名由遥测接收端(产品注入的
39
+ // insightsApiBase 对端)定义,改名会破坏对端解析。
40
+ const INSIGHTS_EXPORT_SCHEMA = 'deeppath-agent-insights/v1'; // shell-neutral:allow(线协议常量,见上行)
41
+ export class LocalStore {
42
+ db;
43
+ writeLease;
44
+ constructor() {
45
+ // 主库文件名是产品注入配置(3.1,product.json dbFileName)——产品
46
+ // 借此保住存量数据文件;中性 shell 缺省 agent-shell.db。
47
+ const dbPath = path.join(getUserDataDir(), getProductConfig().dbFileName ?? 'agent-shell.db');
48
+ this.writeLease = acquireWriteLease(lockPathForDb(dbPath));
49
+ this.db = new Database(dbPath);
50
+ this.db.pragma('journal_mode = WAL');
51
+ // The schema declares `ON DELETE CASCADE` foreign keys (chat_messages,
52
+ // harness_traces -> chat_sessions), but SQLite ignores FK constraints
53
+ // unless explicitly enabled per-connection. Without this, deleteChat()
54
+ // only removed the session row and left orphaned messages/traces behind.
55
+ this.db.pragma('foreign_keys = ON');
56
+ this.migrate();
57
+ this.cleanupOrphanedRows();
58
+ this.seedDefaults();
59
+ }
60
+ /**
61
+ * 场景包存储扩展点:把同一个 SQLite 连接借给包内存储类(各包的
62
+ * <Pack>Store)。包只读写自己 migrations 声明的包前缀表;shell 表结构对包不透明。
63
+ */
64
+ getPackDb() {
65
+ return this.db;
66
+ }
67
+ /**
68
+ * One-time cleanup for rows that became orphaned while `foreign_keys` was
69
+ * off in earlier versions of the app (deleteChat() previously left
70
+ * chat_messages/harness_traces behind for deleted chat_sessions).
71
+ */
72
+ cleanupOrphanedRows() {
73
+ this.db.exec(`
74
+ DELETE FROM chat_messages
75
+ WHERE chat_id NOT IN (SELECT id FROM chat_sessions);
76
+ DELETE FROM harness_traces
77
+ WHERE chat_id NOT IN (SELECT id FROM chat_sessions);
78
+ DELETE FROM tasks
79
+ WHERE chat_id NOT IN (SELECT id FROM chat_sessions);
80
+ `);
81
+ }
82
+ migrate() {
83
+ this.db.exec(`
84
+ CREATE TABLE IF NOT EXISTS chat_sessions (
85
+ id TEXT PRIMARY KEY,
86
+ title TEXT NOT NULL,
87
+ user_id TEXT NOT NULL,
88
+ agent_id TEXT,
89
+ is_pinned INTEGER NOT NULL DEFAULT 0,
90
+ system_prompt TEXT,
91
+ pinned_refs TEXT,
92
+ created_at TEXT NOT NULL,
93
+ updated_at TEXT NOT NULL
94
+ );
95
+
96
+ CREATE TABLE IF NOT EXISTS chat_messages (
97
+ id TEXT PRIMARY KEY,
98
+ chat_id TEXT NOT NULL,
99
+ role TEXT NOT NULL,
100
+ content TEXT NOT NULL,
101
+ message_metadata TEXT,
102
+ created_at TEXT NOT NULL,
103
+ FOREIGN KEY(chat_id) REFERENCES chat_sessions(id) ON DELETE CASCADE
104
+ );
105
+
106
+ CREATE TABLE IF NOT EXISTS chat_agents (
107
+ id TEXT PRIMARY KEY,
108
+ slug TEXT,
109
+ name TEXT NOT NULL,
110
+ icon TEXT,
111
+ color TEXT,
112
+ description TEXT,
113
+ role_prompt TEXT,
114
+ forbidden_prompt TEXT,
115
+ skill_ids TEXT NOT NULL,
116
+ tool_policy TEXT NOT NULL,
117
+ allow_external_skills INTEGER NOT NULL DEFAULT 0,
118
+ is_builtin INTEGER NOT NULL DEFAULT 0,
119
+ is_archived INTEGER NOT NULL DEFAULT 0,
120
+ sort_order INTEGER NOT NULL DEFAULT 0,
121
+ created_at TEXT NOT NULL,
122
+ updated_at TEXT NOT NULL
123
+ );
124
+
125
+ CREATE TABLE IF NOT EXISTS settings_kv (
126
+ key TEXT PRIMARY KEY,
127
+ value TEXT NOT NULL
128
+ );
129
+
130
+ CREATE TABLE IF NOT EXISTS harness_traces (
131
+ id TEXT PRIMARY KEY,
132
+ chat_id TEXT NOT NULL,
133
+ message_id TEXT,
134
+ started_at_ms INTEGER NOT NULL,
135
+ duration_ms INTEGER,
136
+ status TEXT NOT NULL,
137
+ payload TEXT NOT NULL,
138
+ created_at TEXT NOT NULL,
139
+ FOREIGN KEY(chat_id) REFERENCES chat_sessions(id) ON DELETE CASCADE
140
+ );
141
+
142
+ CREATE INDEX IF NOT EXISTS idx_harness_traces_chat ON harness_traces(chat_id, started_at_ms DESC);
143
+
144
+ CREATE TABLE IF NOT EXISTS usage_events (
145
+ id TEXT PRIMARY KEY,
146
+ chat_id TEXT,
147
+ kind TEXT NOT NULL,
148
+ provider TEXT,
149
+ model TEXT,
150
+ prompt_tokens INTEGER NOT NULL DEFAULT 0,
151
+ completion_tokens INTEGER NOT NULL DEFAULT 0,
152
+ total_tokens INTEGER NOT NULL DEFAULT 0,
153
+ cached_prompt_tokens INTEGER NOT NULL DEFAULT 0,
154
+ cost_usd REAL,
155
+ created_at TEXT NOT NULL
156
+ );
157
+
158
+ CREATE INDEX IF NOT EXISTS idx_usage_events_created ON usage_events(created_at DESC);
159
+
160
+ CREATE TABLE IF NOT EXISTS insights_outbox (
161
+ id TEXT PRIMARY KEY,
162
+ kind TEXT NOT NULL,
163
+ payload TEXT NOT NULL,
164
+ created_at TEXT NOT NULL,
165
+ uploaded_at TEXT,
166
+ upload_error TEXT
167
+ );
168
+
169
+ CREATE INDEX IF NOT EXISTS idx_insights_outbox_pending ON insights_outbox(kind, uploaded_at, created_at);
170
+
171
+ CREATE TABLE IF NOT EXISTS tasks (
172
+ id TEXT PRIMARY KEY,
173
+ chat_id TEXT NOT NULL,
174
+ task TEXT NOT NULL,
175
+ status TEXT NOT NULL,
176
+ answer TEXT,
177
+ error TEXT,
178
+ worktree_path TEXT,
179
+ worktree_branch TEXT,
180
+ worktree_state TEXT,
181
+ record_id TEXT,
182
+ trace_id TEXT,
183
+ created_at TEXT NOT NULL,
184
+ updated_at TEXT NOT NULL,
185
+ FOREIGN KEY(chat_id) REFERENCES chat_sessions(id) ON DELETE CASCADE
186
+ );
187
+
188
+ CREATE INDEX IF NOT EXISTS idx_tasks_chat ON tasks(chat_id, datetime(updated_at) DESC);
189
+ `);
190
+ // 跨轮压缩已下沉框架 CoreLoop(token 压力触发 CompactionHooks,压缩边界
191
+ // 持久化到 durable record)。桌面不再维护滚动摘要;历史库里的
192
+ // context_summary / context_summary_upto / context_compaction_delegated
193
+ // 列成为无人读写的孤儿列(SQLite 不易 drop,保留无害)。
194
+ // 项目模式(增量迁移):chat 可绑定 ProjectRegistry 里的项目 id。
195
+ // 项目记录本身存在 electron-store(agent-projects.json),这里只存外键;
196
+ // 删项目时由路由层把本列置 NULL(会话降级为无项目对话)。
197
+ this.ensureColumn('chat_sessions', 'project_id', 'TEXT');
198
+ // 任务编排(增量迁移):depends_on 存 JSON string[]——本任务等待的
199
+ // 依赖 taskId 列表;NULL = 无依赖。依赖必须指向建任务时已存在的
200
+ // 任务,所以依赖图天然无环。
201
+ this.ensureColumn('tasks', 'depends_on', 'TEXT');
202
+ // 后台任务推理过程:流事件回写,不bump updated_at(见 saveTaskProcess)。
203
+ this.ensureColumn('tasks', 'process_json', 'TEXT');
204
+ // 智能体技能面(增量迁移):load_all_skills = 无视触发条件全量加载。
205
+ // 此前这个行为是 router 里 `chatAgentId === 'all-round-assistant'` 的
206
+ // 硬编码,自建智能体拿不到;建列时一次性回填给内置「智能助手」,之后
207
+ // 由用户在智能体管理页自由开关(不会每次启动被重置)。
208
+ if (this.ensureColumn('chat_agents', 'load_all_skills', 'INTEGER NOT NULL DEFAULT 0')) {
209
+ this.db
210
+ .prepare(`UPDATE chat_agents SET load_all_skills = 1 WHERE id = ?`)
211
+ .run(ALL_ROUND_ASSISTANT_AGENT_ID);
212
+ }
213
+ // 场景包迁移(0.3b):构造时应用一次已注册的包迁移(兼容注册先于
214
+ // 构造的路径);注册晚于构造的容错路径见 applyRegisteredPackMigrations
215
+ // 模块级函数(host/runtime 在包装配前调用)。
216
+ this.applyPackMigrations();
217
+ }
218
+ appliedPackMigrations = new Set();
219
+ /**
220
+ * 应用尚未应用的已注册包迁移(幂等,按包 id 去重)。
221
+ */
222
+ applyPackMigrations() {
223
+ for (const { packId, migration } of getPackMigrations()) {
224
+ if (this.appliedPackMigrations.has(packId))
225
+ continue;
226
+ if (migration.ddl && migration.ddl.length > 0) {
227
+ this.db.exec(migration.ddl.join(';\n'));
228
+ }
229
+ for (const col of migration.ensureColumns ?? []) {
230
+ this.ensureColumn(col.table, col.column, col.type);
231
+ }
232
+ this.appliedPackMigrations.add(packId);
233
+ }
234
+ }
235
+ /**
236
+ * 应用一条包智能体种子(0.3d 的通用机制):
237
+ * 包激活 —— 不存在则种子;命中历史代文案则静默升级到当前代;被归档但
238
+ * 未定制的行重新露出。包缺席 —— 未定制的内置行归档。用户改过的行不动。
239
+ */
240
+ applyPackAgentSeed(seed, active, insertAgent, now) {
241
+ const row = this.db
242
+ .prepare(`SELECT id, name, description, role_prompt, is_archived FROM chat_agents WHERE id = ?`)
243
+ .get(seed.id);
244
+ if (!active) {
245
+ if (row && row.is_archived === 0 && isUntouchedSeed(seed, row)) {
246
+ this.db
247
+ .prepare(`UPDATE chat_agents SET is_archived = 1, updated_at = ? WHERE id = ?`)
248
+ .run(now, seed.id);
249
+ }
250
+ return;
251
+ }
252
+ if (!row) {
253
+ insertAgent.run({
254
+ id: seed.id,
255
+ slug: seed.slug,
256
+ name: seed.name,
257
+ icon: seed.icon ?? null,
258
+ color: seed.color ?? null,
259
+ description: seed.description ?? null,
260
+ rolePrompt: seed.rolePrompt,
261
+ forbiddenPrompt: seed.forbiddenPrompt ?? null,
262
+ skillIds: JSON.stringify(seed.skillIds ?? []),
263
+ toolPolicy: JSON.stringify({ mode: 'all', tools: [] }),
264
+ allowExternalSkills: 1,
265
+ loadAllSkills: seed.loadAllSkills ? 1 : 0,
266
+ isBuiltin: 1,
267
+ isArchived: 0,
268
+ sortOrder: seed.sortOrder ?? 0,
269
+ createdAt: now,
270
+ updatedAt: now,
271
+ });
272
+ return;
273
+ }
274
+ // 历史代文案 = 用户从未定制:静默升级到当前代(改过的保留用户定制)。
275
+ if (matchesPreviousIdentity(seed, row)) {
276
+ this.db
277
+ .prepare(`UPDATE chat_agents
278
+ SET name = @name,
279
+ description = @description,
280
+ role_prompt = @rolePrompt,
281
+ updated_at = @updatedAt
282
+ WHERE id = @id`)
283
+ .run({
284
+ name: seed.name,
285
+ description: seed.description ?? null,
286
+ rolePrompt: seed.rolePrompt,
287
+ updatedAt: now,
288
+ id: seed.id,
289
+ });
290
+ }
291
+ // 从未定制过、却被归档的行:包装回该产品后重新露出。
292
+ if (row.is_archived === 1 && isUntouchedSeed(seed, row)) {
293
+ this.db
294
+ .prepare(`UPDATE chat_agents SET is_archived = 0, updated_at = ? WHERE id = ?`)
295
+ .run(now, seed.id);
296
+ }
297
+ }
298
+ /**
299
+ * SQLite 没有 ADD COLUMN IF NOT EXISTS,先查 PRAGMA 再加列。
300
+ *
301
+ * @returns 本次是否真的加了列(调用方据此做一次性回填)。
302
+ */
303
+ ensureColumn(table, column, type) {
304
+ const columns = this.db.prepare(`PRAGMA table_info(${table})`).all();
305
+ if (columns.some((c) => c.name === column))
306
+ return false;
307
+ this.db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${type}`);
308
+ return true;
309
+ }
310
+ seedDefaults() {
311
+ const now = new Date().toISOString();
312
+ const hasBuiltin = this.db
313
+ .prepare(`SELECT id FROM chat_agents WHERE id = ?`)
314
+ .get(LOCAL_ASSISTANT_AGENT_ID);
315
+ // 激活包中 sortOrder 0 的主打种子占据列表首位时,shell 默认智能体
316
+ // 让位到 1(0.3d 起由包种子驱动,不再按 flavor 硬编码)。
317
+ const hasHeroSeed = getPackAgentSeeds().some((r) => r.active && r.seeds.some((s) => s.sortOrder === 0));
318
+ const insertAgent = this.db.prepare(`
319
+ INSERT INTO chat_agents (
320
+ id, slug, name, icon, color, description, role_prompt, forbidden_prompt,
321
+ skill_ids, tool_policy, allow_external_skills, load_all_skills, is_builtin, is_archived,
322
+ sort_order, created_at, updated_at
323
+ ) VALUES (
324
+ @id, @slug, @name, @icon, @color, @description, @rolePrompt, @forbiddenPrompt,
325
+ @skillIds, @toolPolicy, @allowExternalSkills, @loadAllSkills, @isBuiltin, @isArchived,
326
+ @sortOrder, @createdAt, @updatedAt
327
+ )
328
+ `);
329
+ if (!hasBuiltin) {
330
+ insertAgent.run({
331
+ id: LOCAL_ASSISTANT_AGENT_ID,
332
+ slug: LOCAL_ASSISTANT_AGENT_ID,
333
+ name: '电脑操作员',
334
+ icon: 'Cpu',
335
+ color: '#4f46e5',
336
+ description: '默认本地助手,可调用 shell、文件与 MCP 工具。',
337
+ rolePrompt: '你是本地离线助手,回答时清晰、可执行。',
338
+ forbiddenPrompt: null,
339
+ skillIds: JSON.stringify([]),
340
+ toolPolicy: JSON.stringify({ mode: 'all', tools: [] }),
341
+ allowExternalSkills: 1,
342
+ loadAllSkills: 0,
343
+ isBuiltin: 1,
344
+ isArchived: 0,
345
+ // 激活包有主打种子(sortOrder 0)时让位到 1。
346
+ sortOrder: hasHeroSeed ? 1 : 0,
347
+ createdAt: now,
348
+ updatedAt: now,
349
+ });
350
+ }
351
+ else {
352
+ this.db.prepare(`UPDATE chat_agents SET name = ? WHERE id = ?`).run('电脑操作员', LOCAL_ASSISTANT_AGENT_ID);
353
+ }
354
+ const hasAllRound = this.db
355
+ .prepare(`SELECT id FROM chat_agents WHERE id = ?`)
356
+ .get(ALL_ROUND_ASSISTANT_AGENT_ID);
357
+ if (!hasAllRound) {
358
+ insertAgent.run({
359
+ id: ALL_ROUND_ASSISTANT_AGENT_ID,
360
+ slug: ALL_ROUND_ASSISTANT_AGENT_ID,
361
+ name: '智能助手',
362
+ icon: '🔮',
363
+ color: '#a855f7',
364
+ description: '全能智能体,自动加载全部已安装本地技能和工具,无需限定条件。',
365
+ rolePrompt: [
366
+ '你是 **智能助手**,一个具备高权限且集成了全部本地技能(Skills)的行动体。',
367
+ '你具备完整的本地命令执行(local_exec_shell)、文件读写能力,以及当前产品安装的全部场景工具。',
368
+ '你的系统提示词中已经无条件加载了所有已安装的本地技能与工具。',
369
+ '请根据用户的需求,直接、高效地调用最合适的本地工具或执行脚本来解决任务。'
370
+ ].join('\n'),
371
+ forbiddenPrompt: null,
372
+ skillIds: JSON.stringify([]),
373
+ toolPolicy: JSON.stringify({ mode: 'all', tools: [] }),
374
+ allowExternalSkills: 1,
375
+ // 「智能助手」的定位就是无条件加载全部技能(此前是 router 硬编码)。
376
+ loadAllSkills: 1,
377
+ isBuiltin: 1,
378
+ isArchived: 0,
379
+ sortOrder: 2,
380
+ createdAt: now,
381
+ updatedAt: now,
382
+ });
383
+ }
384
+ else {
385
+ const agent = this.db.prepare(`SELECT role_prompt FROM chat_agents WHERE id = ?`).get(ALL_ROUND_ASSISTANT_AGENT_ID);
386
+ if (agent) {
387
+ const updatedPrompt = agent.role_prompt.replace(/全能专家助手/g, '智能助手');
388
+ this.db.prepare(`UPDATE chat_agents SET name = ?, role_prompt = ? WHERE id = ?`).run('智能助手', updatedPrompt, ALL_ROUND_ASSISTANT_AGENT_ID);
389
+ }
390
+ else {
391
+ this.db.prepare(`UPDATE chat_agents SET name = ? WHERE id = ?`).run('智能助手', ALL_ROUND_ASSISTANT_AGENT_ID);
392
+ }
393
+ }
394
+ // ─── 场景包智能体种子(0.3d) ───
395
+ // 激活包:不存在则种子;命中历史代文案(用户从未定制)则静默升级;
396
+ // 被归档但未定制的行重新露出。未激活包:未定制的内置行归档(用户改
397
+ // 过的保留)。种子数据在 packs/<id>/seeds.ts,由 packs/active.ts 注册。
398
+ for (const { seeds, active } of getPackAgentSeeds()) {
399
+ for (const seed of seeds) {
400
+ this.applyPackAgentSeed(seed, active, insertAgent, now);
401
+ }
402
+ }
403
+ // 主打种子(激活包 sortOrder 0)把 shell 默认智能体挤到第二位:仅当
404
+ // 双方都还在出厂位置(local=0、种子=0/1)才交换,用户调过序不动。
405
+ for (const { seeds, active } of getPackAgentSeeds()) {
406
+ if (!active)
407
+ continue;
408
+ for (const seed of seeds) {
409
+ if (seed.sortOrder !== 0)
410
+ continue;
411
+ const localSort = this.db
412
+ .prepare(`SELECT sort_order FROM chat_agents WHERE id = ?`)
413
+ .get(LOCAL_ASSISTANT_AGENT_ID);
414
+ const seedSort = this.db
415
+ .prepare(`SELECT sort_order FROM chat_agents WHERE id = ?`)
416
+ .get(seed.id);
417
+ if (localSort &&
418
+ seedSort &&
419
+ localSort.sort_order === 0 &&
420
+ (seedSort.sort_order === 0 || seedSort.sort_order === 1)) {
421
+ this.db
422
+ .prepare(`UPDATE chat_agents SET sort_order = 1, updated_at = ? WHERE id = ?`)
423
+ .run(now, LOCAL_ASSISTANT_AGENT_ID);
424
+ this.db
425
+ .prepare(`UPDATE chat_agents SET sort_order = 0, updated_at = ? WHERE id = ?`)
426
+ .run(now, seed.id);
427
+ }
428
+ }
429
+ }
430
+ const hasSettings = this.getLlmSettings();
431
+ if (!hasSettings) {
432
+ // 全新安装:直接写出厂默认 (DEFAULT_LLM_SETTINGS = OpenAI 兼容 / DeepSeek)。
433
+ this.setLlmSettings(DEFAULT_LLM_SETTINGS);
434
+ }
435
+ else if (llmSettingsMatchOllamaDefaults(hasSettings)) {
436
+ // 老安装但用户从未改过 LLM 设置(依然停留在出厂 Ollama 预设):静默迁移
437
+ // 到 DeepSeek 默认,避免 Windows 用户卡在"本机没装 ollama → 上来就不能聊"。
438
+ this.setLlmSettings(DEFAULT_LLM_SETTINGS);
439
+ }
440
+ else if (llmSettingsCarryExpiredBakedKey(hasSettings)) {
441
+ // 老安装保存过已作废的出厂内置 key(≤0.0.35):静默清掉(保留其它自定义
442
+ // 字段),设置页会引导用户填自己的 key。不清则用户永远拿着死 key 撞 401。
443
+ this.setLlmSettings({ ...hasSettings, apiKey: undefined });
444
+ }
445
+ // 其它情况(用户改过 provider/model/baseUrl/apiKey 任一项):尊重自定义,不动。
446
+ }
447
+ /**
448
+ * `Math.max`/`Math.min` propagate `NaN` instead of clamping it, so a bad
449
+ * caller-supplied value (e.g. `?page=abc` parsed via `Number()`) used to
450
+ * produce a NaN OFFSET/LIMIT and an ill-defined SQL query result. All
451
+ * paginated list methods below route through this to clamp to a sane
452
+ * integer range, falling back to `fallback` for non-finite input.
453
+ */
454
+ static clampInt(value, fallback, min, max) {
455
+ if (!Number.isFinite(value))
456
+ return fallback;
457
+ return Math.max(min, Math.min(Math.trunc(value), max));
458
+ }
459
+ listChats(page = 1, limit = 50) {
460
+ const safePage = LocalStore.clampInt(page, 1, 1, Number.MAX_SAFE_INTEGER);
461
+ const safeLimit = LocalStore.clampInt(limit, 50, 1, 200);
462
+ const offset = (safePage - 1) * safeLimit;
463
+ const totalRow = this.db.prepare(`SELECT COUNT(*) as count FROM chat_sessions`).get();
464
+ const rows = this.db.prepare(`
465
+ SELECT *
466
+ FROM chat_sessions
467
+ ORDER BY is_pinned DESC, datetime(updated_at) DESC
468
+ LIMIT ? OFFSET ?
469
+ `).all(safeLimit, offset);
470
+ return {
471
+ chats: rows.map(row => this.mapChatSession(row)),
472
+ total: totalRow.count,
473
+ };
474
+ }
475
+ createChat(title = '新对话', agentId = getBrand().defaultAgentId, projectId = null) {
476
+ return this.createChatWithId(randomUUID(), title, agentId, projectId);
477
+ }
478
+ /**
479
+ * 用调用方给定的 id 建会话(**幂等**:已存在则原样返回,不覆盖)。
480
+ *
481
+ * 会话 URL(`/api/v2/chats/:id/*`、前端 `#/agent/:chatId`)在产品和外部
482
+ * 平台眼里就是「一次会话任务」的身份。本地会话可能因为用户清理、空会话
483
+ * prune、或换机重装而消失,但平台仍会拿原来的 URL 再次运行——首次发送时
484
+ * 按 URL 里的 id 现场补建,才不会把一次本来能跑的任务打断在
485
+ * `chat not found`。
486
+ */
487
+ createChatWithId(chatId, title = '新对话', agentId = 'local-assistant', projectId = null) {
488
+ const existing = this.getChat(chatId);
489
+ if (existing)
490
+ return existing;
491
+ const now = new Date().toISOString();
492
+ // INSERT OR IGNORE:两个请求同时补建同一个 id(并发首发送)时,后到的
493
+ // 那条不会因主键冲突被打成 500——两边拿到的都是同一条会话。
494
+ this.db.prepare(`
495
+ INSERT OR IGNORE INTO chat_sessions (id, title, user_id, agent_id, project_id, created_at, updated_at)
496
+ VALUES (?, ?, ?, ?, ?, ?, ?)
497
+ `).run(chatId, title, DEFAULT_LOCAL_USER_ID, agentId, projectId, now, now);
498
+ const row = this.db.prepare(`SELECT * FROM chat_sessions WHERE id = ?`).get(chatId);
499
+ return this.mapChatSession(row);
500
+ }
501
+ /** 删除项目时调用:把该项目下所有会话降级为无项目对话(不删会话)。 */
502
+ clearProjectAssignment(projectId) {
503
+ const info = this.db
504
+ .prepare(`UPDATE chat_sessions SET project_id = NULL WHERE project_id = ?`)
505
+ .run(projectId);
506
+ return info.changes;
507
+ }
508
+ getChat(chatId) {
509
+ const row = this.db.prepare(`SELECT * FROM chat_sessions WHERE id = ?`).get(chatId);
510
+ if (!row)
511
+ return null;
512
+ return this.mapChatSession(row);
513
+ }
514
+ updateChat(chatId, updates) {
515
+ const existing = this.getChat(chatId);
516
+ if (!existing)
517
+ return null;
518
+ // 过滤 undefined:调用方对"未提供的字段"传 undefined,直接展开会把
519
+ // 现有值抹成 undefined(JSON body 里没有 undefined,缺字段≠置空)。
520
+ // projectId 显式传 null 才是"移出项目",会保留下来。
521
+ const clean = Object.fromEntries(Object.entries(updates).filter(([, value]) => value !== undefined));
522
+ const next = {
523
+ ...existing,
524
+ ...clean,
525
+ updatedAt: new Date().toISOString(),
526
+ };
527
+ this.db.prepare(`
528
+ UPDATE chat_sessions
529
+ SET title = ?, system_prompt = ?, pinned_refs = ?, is_pinned = ?, project_id = ?, updated_at = ?
530
+ WHERE id = ?
531
+ `).run(next.title, next.systemPrompt, JSON.stringify(next.pinnedRefs ?? null), next.isPinned ? 1 : 0, next.projectId ?? null, next.updatedAt, chatId);
532
+ return this.getChat(chatId);
533
+ }
534
+ deleteChat(chatId) {
535
+ const info = this.db.prepare(`DELETE FROM chat_sessions WHERE id = ?`).run(chatId);
536
+ this.db.prepare(`DELETE FROM settings_kv WHERE key = ?`).run(`chat_record:${chatId}`);
537
+ this.db.prepare(`DELETE FROM settings_kv WHERE key = ?`).run(`turn_active:${chatId}`);
538
+ return info.changes > 0;
539
+ }
540
+ chatHasMessages(chatId) {
541
+ const row = this.db
542
+ .prepare(`SELECT 1 FROM chat_messages WHERE chat_id = ? LIMIT 1`)
543
+ .get(chatId);
544
+ return Boolean(row);
545
+ }
546
+ /**
547
+ * Drop a chat that never received a message. Used when the user leaves a
548
+ * "新对话" composer without sending — empty sessions must not stay in the
549
+ * sidebar. Missing or already-populated chats are a no-op (`false`).
550
+ */
551
+ deleteChatIfEmpty(chatId) {
552
+ if (!this.getChat(chatId) || this.chatHasMessages(chatId))
553
+ return false;
554
+ return this.deleteChat(chatId);
555
+ }
556
+ /**
557
+ * Delete every session with zero messages, optionally keeping `exceptChatId`
558
+ * (the open composer). Returns the removed ids.
559
+ */
560
+ deleteEmptyChats(exceptChatId) {
561
+ const except = exceptChatId ?? null;
562
+ const rows = this.db.prepare(LIST_EMPTY_CHAT_IDS_SQL).all(except, except);
563
+ const ids = rows.map((row) => row.id);
564
+ for (const id of ids)
565
+ this.deleteChat(id);
566
+ return ids;
567
+ }
568
+ /**
569
+ * W5-2: the chat's ACTIVE durable-record id on the sidecar. Regenerate
570
+ * forks the record (non-destructive — the old tail stays discoverable as
571
+ * a branch), so a chat's live record moves from `chatId` to its branch;
572
+ * null means the chatId itself.
573
+ */
574
+ getChatRecordId(chatId) {
575
+ const row = this.db
576
+ .prepare(`SELECT value FROM settings_kv WHERE key = ?`)
577
+ .get(`chat_record:${chatId}`);
578
+ return row?.value ?? null;
579
+ }
580
+ setChatRecordId(chatId, recordId) {
581
+ this.db.prepare(`
582
+ INSERT INTO settings_kv (key, value) VALUES (?, ?)
583
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value
584
+ `).run(`chat_record:${chatId}`, recordId);
585
+ }
586
+ /**
587
+ * W7-1: durable "a turn is in flight" marker, written BEFORE the stream
588
+ * starts and cleared only after the terminal assistant message persists
589
+ * (or the turn fails before any reply could exist). A crash or kill
590
+ * mid-turn never reaches the clear, so a surviving marker is the crash
591
+ * signature — distinct from `completionStatus: 'cancelled' | 'failed'`,
592
+ * which only a live process can write. Cleared AFTER the assistant
593
+ * write, never before: the reverse order would turn a crash in that gap
594
+ * into a lost reply with no recourse, while this order's worst case is a
595
+ * stale marker the detector dismisses once it sees the trailing
596
+ * assistant message.
597
+ */
598
+ setTurnActive(chatId) {
599
+ this.db.prepare(`
600
+ INSERT INTO settings_kv (key, value) VALUES (?, ?)
601
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value
602
+ `).run(`turn_active:${chatId}`, JSON.stringify({ startedAt: new Date().toISOString() }));
603
+ }
604
+ clearTurnActive(chatId) {
605
+ this.db.prepare(`DELETE FROM settings_kv WHERE key = ?`).run(`turn_active:${chatId}`);
606
+ }
607
+ getTurnActive(chatId) {
608
+ const row = this.db
609
+ .prepare(`SELECT value FROM settings_kv WHERE key = ?`)
610
+ .get(`turn_active:${chatId}`);
611
+ if (!row)
612
+ return null;
613
+ try {
614
+ const parsed = JSON.parse(row.value);
615
+ return typeof parsed.startedAt === 'string' ? { startedAt: parsed.startedAt } : null;
616
+ }
617
+ catch {
618
+ return null;
619
+ }
620
+ }
621
+ /**
622
+ * Newest first under {@link MESSAGE_ORDER_DESC}, capped at `limit`; callers
623
+ * that want transcript order reverse it.
624
+ */
625
+ listMessages(chatId, limit = 200) {
626
+ const rows = this.db.prepare(`
627
+ SELECT *
628
+ FROM chat_messages
629
+ WHERE chat_id = ?
630
+ ORDER BY ${MESSAGE_ORDER_DESC}
631
+ LIMIT ?
632
+ `).all(chatId, LocalStore.clampInt(limit, 200, 1, 1000));
633
+ return rows.map(row => this.mapChatMessage(row));
634
+ }
635
+ getMessage(chatId, messageId) {
636
+ const row = this.db
637
+ .prepare(`SELECT * FROM chat_messages WHERE id = ? AND chat_id = ?`)
638
+ .get(messageId, chatId);
639
+ return row ? this.mapChatMessage(row) : null;
640
+ }
641
+ /**
642
+ * Deletes `messageId` and every message after it in the same chat. Used by
643
+ * regenerate: truncate from the assistant reply being regenerated onward, so
644
+ * the next turn re-runs against the history as it stood right before that
645
+ * reply, instead of appending a new "please regenerate" user turn on top of
646
+ * stale history.
647
+ *
648
+ * "After" is {@link MESSAGE_CUT_FROM_ID}, the same key `listMessages` orders
649
+ * by, so the cut matches what the user sees below the regenerated reply.
650
+ */
651
+ deleteMessagesFrom(chatId, messageId) {
652
+ const info = this.db
653
+ .prepare(`
654
+ DELETE FROM chat_messages
655
+ WHERE chat_id = ?
656
+ AND ${MESSAGE_CUT_FROM_ID}
657
+ `)
658
+ .run(chatId, messageId, chatId);
659
+ return info.changes;
660
+ }
661
+ addMessage(chatId, role, content, messageMetadata = null) {
662
+ const id = randomUUID();
663
+ const now = new Date().toISOString();
664
+ this.db.prepare(`
665
+ INSERT INTO chat_messages (id, chat_id, role, content, message_metadata, created_at)
666
+ VALUES (?, ?, ?, ?, ?, ?)
667
+ `).run(id, chatId, role, content, messageMetadata, now);
668
+ this.db.prepare(`UPDATE chat_sessions SET updated_at = ? WHERE id = ?`).run(now, chatId);
669
+ const row = this.db.prepare(`SELECT * FROM chat_messages WHERE id = ?`).get(id);
670
+ return this.mapChatMessage(row);
671
+ }
672
+ /**
673
+ * W1.2.1: replace the chat's whole message list with a branch projection.
674
+ * The desktop store is the UI projection of the ACTIVE framework record —
675
+ * switching branches re-projects it. The previous projection is not lost:
676
+ * its source of truth is the framework history record it came from.
677
+ * Timestamps are synthesized (base + index ms) to preserve projection
678
+ * order under the created_at ordering.
679
+ */
680
+ replaceChatMessages(chatId, messages) {
681
+ const base = Date.now();
682
+ const insert = this.db.prepare(`
683
+ INSERT INTO chat_messages (id, chat_id, role, content, message_metadata, created_at)
684
+ VALUES (?, ?, ?, ?, NULL, ?)
685
+ `);
686
+ this.db.transaction(() => {
687
+ this.db.prepare(`DELETE FROM chat_messages WHERE chat_id = ?`).run(chatId);
688
+ messages.forEach((m, i) => {
689
+ insert.run(randomUUID(), chatId, m.role, m.content, new Date(base + i).toISOString());
690
+ });
691
+ this.db
692
+ .prepare(`UPDATE chat_sessions SET updated_at = ? WHERE id = ?`)
693
+ .run(new Date(base + messages.length).toISOString(), chatId);
694
+ })();
695
+ }
696
+ listChatAgents(includeArchived = false) {
697
+ const rows = this.db.prepare(`
698
+ SELECT *
699
+ FROM chat_agents
700
+ ${includeArchived ? '' : 'WHERE is_archived = 0'}
701
+ ORDER BY sort_order ASC, created_at ASC, rowid ASC
702
+ `).all();
703
+ return rows.map(row => this.mapChatAgent(row));
704
+ }
705
+ getChatAgent(agentId) {
706
+ const row = this.db.prepare(`SELECT * FROM chat_agents WHERE id = ?`).get(agentId);
707
+ if (!row)
708
+ return null;
709
+ return this.mapChatAgent(row);
710
+ }
711
+ createChatAgent(input) {
712
+ const id = input.id || randomUUID();
713
+ const now = new Date().toISOString();
714
+ this.db.prepare(`
715
+ INSERT INTO chat_agents (
716
+ id, slug, name, icon, color, description, role_prompt, forbidden_prompt,
717
+ skill_ids, tool_policy, allow_external_skills, load_all_skills,
718
+ is_builtin, is_archived, sort_order, created_at, updated_at
719
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
720
+ `).run(id, input.slug ?? null, input.name, input.icon ?? null, input.color ?? null, input.description ?? null, input.rolePrompt ?? null, input.forbiddenPrompt ?? null, JSON.stringify(input.skillIds ?? []), JSON.stringify(input.toolPolicy ?? { mode: 'all', tools: [] }), input.allowExternalSkills ? 1 : 0, input.loadAllSkills ? 1 : 0, input.isBuiltin ? 1 : 0, input.isArchived ? 1 : 0, input.sortOrder ?? 0, now, now);
721
+ const created = this.getChatAgent(id);
722
+ if (!created)
723
+ throw new Error('Failed to create chat agent');
724
+ return created;
725
+ }
726
+ updateChatAgent(agentId, updates) {
727
+ const existing = this.getChatAgent(agentId);
728
+ if (!existing)
729
+ return null;
730
+ // 过滤 undefined(同 updateChat):PATCH 路由对"未提供的字段"传 undefined,
731
+ // 直接展开会把现有值抹成 undefined——sort_order 等 NOT NULL 列写入直接抛,
732
+ // icon / forbidden_prompt 等可空列被静默清空。
733
+ const clean = Object.fromEntries(Object.entries(updates).filter(([, value]) => value !== undefined));
734
+ const merged = {
735
+ ...existing,
736
+ ...clean,
737
+ updatedAt: new Date().toISOString(),
738
+ };
739
+ this.db.prepare(`
740
+ UPDATE chat_agents
741
+ SET slug = ?, name = ?, icon = ?, color = ?, description = ?, role_prompt = ?, forbidden_prompt = ?,
742
+ skill_ids = ?, tool_policy = ?, allow_external_skills = ?, load_all_skills = ?,
743
+ is_builtin = ?, is_archived = ?, sort_order = ?, updated_at = ?
744
+ WHERE id = ?
745
+ `).run(merged.slug, merged.name, merged.icon, merged.color, merged.description, merged.rolePrompt, merged.forbiddenPrompt, JSON.stringify(merged.skillIds), JSON.stringify(merged.toolPolicy), merged.allowExternalSkills ? 1 : 0, merged.loadAllSkills ? 1 : 0, merged.isBuiltin ? 1 : 0, merged.isArchived ? 1 : 0, merged.sortOrder, merged.updatedAt, agentId);
746
+ return this.getChatAgent(agentId);
747
+ }
748
+ archiveChatAgent(agentId) {
749
+ const updated = this.updateChatAgent(agentId, { isArchived: true });
750
+ return Boolean(updated);
751
+ }
752
+ saveTrace(input) {
753
+ const now = new Date().toISOString();
754
+ this.db.prepare(`
755
+ INSERT INTO harness_traces (id, chat_id, message_id, started_at_ms, duration_ms, status, payload, created_at)
756
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
757
+ `).run(input.id, input.chatId, input.messageId ?? null, input.startedAtMs, input.durationMs ?? null, input.status, JSON.stringify(input.payload), now);
758
+ const row = this.db.prepare(`SELECT * FROM harness_traces WHERE id = ?`).get(input.id);
759
+ return this.mapHarnessTrace(row);
760
+ }
761
+ listTracesByChat(chatId, limit = 50) {
762
+ const rows = this.db.prepare(`
763
+ SELECT *
764
+ FROM harness_traces
765
+ WHERE chat_id = ?
766
+ ORDER BY started_at_ms DESC
767
+ LIMIT ?
768
+ `).all(chatId, LocalStore.clampInt(limit, 50, 1, 500));
769
+ return rows.map(row => this.mapHarnessTrace(row));
770
+ }
771
+ getTrace(traceId) {
772
+ const row = this.db.prepare(`SELECT * FROM harness_traces WHERE id = ?`).get(traceId);
773
+ if (!row)
774
+ return null;
775
+ return this.mapHarnessTrace(row);
776
+ }
777
+ // ─── 4.6a 跨 turn 后台任务 ───────────────────────────────────────────
778
+ createTask(input) {
779
+ const id = randomUUID();
780
+ const now = new Date().toISOString();
781
+ this.db.prepare(`
782
+ INSERT INTO tasks (
783
+ id, chat_id, task, status, answer, error,
784
+ worktree_path, worktree_branch, worktree_state, record_id, trace_id,
785
+ depends_on, created_at, updated_at
786
+ ) VALUES (?, ?, ?, ?, NULL, NULL, ?, ?, ?, ?, NULL, ?, ?, ?)
787
+ `).run(id, input.chatId, input.task, input.initialStatus ?? 'running', input.worktreePath ?? null, input.worktreeBranch ?? null,
788
+ // worktree 任务一创建就进入 pending(等待终态后合并/丢弃);
789
+ // 非 worktree 任务该列无意义,保持 NULL。
790
+ input.worktreePath ? 'pending' : null, input.recordId ?? null, input.dependsOn?.length ? JSON.stringify(input.dependsOn) : null, now, now);
791
+ const row = this.db.prepare(`SELECT * FROM tasks WHERE id = ?`).get(id);
792
+ return this.mapTask(row);
793
+ }
794
+ getTask(taskId) {
795
+ const row = this.db.prepare(`SELECT * FROM tasks WHERE id = ?`).get(taskId);
796
+ return row ? this.mapTask(row) : null;
797
+ }
798
+ /** 按 chat 列任务(updated_at 新→旧);chatId 缺省时列全部(任务面板的全局视图)。 */
799
+ listTasks(chatId, limit = 100) {
800
+ const safeLimit = LocalStore.clampInt(limit, 100, 1, 500);
801
+ const rows = (chatId
802
+ ? this.db.prepare(`SELECT * FROM tasks WHERE chat_id = ? ORDER BY datetime(updated_at) DESC LIMIT ?`).all(chatId, safeLimit)
803
+ : this.db.prepare(`SELECT * FROM tasks ORDER BY datetime(updated_at) DESC LIMIT ?`).all(safeLimit));
804
+ return rows.map(row => this.mapTask(row));
805
+ }
806
+ /**
807
+ * 终态/进度回写。只写传入的字段(undefined 过滤),updated_at 总是刷新。
808
+ * 与 updateChat 同约定:显式传 null 才是置空。
809
+ */
810
+ updateTask(taskId, updates) {
811
+ const existing = this.getTask(taskId);
812
+ if (!existing)
813
+ return null;
814
+ const clean = Object.fromEntries(Object.entries(updates).filter(([, value]) => value !== undefined));
815
+ const next = {
816
+ ...existing,
817
+ ...clean,
818
+ updatedAt: new Date().toISOString(),
819
+ };
820
+ this.db.prepare(`
821
+ UPDATE tasks
822
+ SET status = ?, answer = ?, error = ?, worktree_state = ?, trace_id = ?, record_id = ?, updated_at = ?
823
+ WHERE id = ?
824
+ `).run(next.status, next.answer, next.error, next.worktreeState, next.traceId, next.recordId, next.updatedAt, taskId);
825
+ return this.getTask(taskId);
826
+ }
827
+ /**
828
+ * 回写推理时间线。故意不碰 updated_at——流过程中的增量不应把任务
829
+ * 面板按「最近活动」反复顶到最上。
830
+ */
831
+ saveTaskProcess(taskId, processJson) {
832
+ this.db.prepare(`UPDATE tasks SET process_json = ? WHERE id = ?`).run(processJson, taskId);
833
+ }
834
+ /**
835
+ * 启动清扫:running 任务只可能属于上一个已死的进程(任务流不跨进程
836
+ * 存活),全部落成 failed/interrupted——与 W7-1 turn_active 检测器同
837
+ * 理,残留 running 是崩溃签名,不是真相。
838
+ */
839
+ failRunningTasks(reason) {
840
+ const info = this.db
841
+ .prepare(`UPDATE tasks SET status = 'failed', error = ?, updated_at = ? WHERE status = 'running'`)
842
+ .run(reason, new Date().toISOString());
843
+ // 级联:blocked 任务的点火能力在 TaskService(进程内),不跨进程存活;
844
+ // 重启后统一标 failed,模型经 task_status 看到原因后可重跑。
845
+ const blocked = this.db
846
+ .prepare(`SELECT id FROM tasks WHERE status = 'blocked'`)
847
+ .all();
848
+ const now = new Date().toISOString();
849
+ for (const row of blocked) {
850
+ this.db
851
+ .prepare(`UPDATE tasks SET status = 'failed', error = ?, updated_at = ? WHERE id = ?`)
852
+ .run(`进程重启中断了编排等待(依赖任务已随进程终止);请重新 task_run。`, now, row.id);
853
+ }
854
+ return info.changes + blocked.length;
855
+ }
856
+ // ─── W6-9 用量与成本归因 ─────────────────────────────────────────────
857
+ /**
858
+ * 记录一条用量事件(一轮 chat / 一次标题生成 / 一次摘要压缩等)。
859
+ * `costUsd` 为 null 表示该模型无单价(本地/未知)——存 NULL,面板渲染为 "—"。
860
+ */
861
+ recordUsageEvent(input) {
862
+ this.db.prepare(`
863
+ INSERT INTO usage_events
864
+ (id, chat_id, kind, provider, model, prompt_tokens, completion_tokens, total_tokens, cached_prompt_tokens, cost_usd, created_at)
865
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
866
+ `).run(randomUUID(), input.chatId ?? null, input.kind, input.provider ?? null, input.model ?? null, Math.max(0, Math.floor(input.promptTokens)), Math.max(0, Math.floor(input.completionTokens)), Math.max(0, Math.floor(input.totalTokens)), Math.max(0, Math.floor(input.cachedPromptTokens ?? 0)), input.costUsd ?? null, new Date().toISOString());
867
+ }
868
+ /**
869
+ * 聚合最近 `sinceDays` 天的用量:按 model 分桶( token 合计 + 成本合计),
870
+ * 外加总计。成本只统计有单价的模型(cost_usd 非 NULL);无单价模型的
871
+ * token 照常计入,但成本列不计。返回按 totalTokens 降序。
872
+ */
873
+ getUsageSummary(sinceDays = 30) {
874
+ const since = new Date(Date.now() - sinceDays * 24 * 60 * 60 * 1000).toISOString();
875
+ const rows = this.db.prepare(`
876
+ SELECT
877
+ COALESCE(model, '(unknown)') AS model,
878
+ COALESCE(provider, '') AS provider,
879
+ COUNT(*) AS turns,
880
+ SUM(prompt_tokens) AS prompt_tokens,
881
+ SUM(completion_tokens) AS completion_tokens,
882
+ SUM(total_tokens) AS total_tokens,
883
+ SUM(cached_prompt_tokens) AS cached_prompt_tokens,
884
+ SUM(cost_usd) AS cost_usd
885
+ FROM usage_events
886
+ WHERE created_at >= ?
887
+ GROUP BY model, provider
888
+ ORDER BY total_tokens DESC
889
+ `).all(since);
890
+ return buildUsageSummary(rows, sinceDays);
891
+ }
892
+ getLlmSettings() {
893
+ const row = this.db.prepare(`SELECT value FROM settings_kv WHERE key = 'llm_settings'`).get();
894
+ if (!row)
895
+ return null;
896
+ try {
897
+ return JSON.parse(row.value);
898
+ }
899
+ catch {
900
+ return null;
901
+ }
902
+ }
903
+ setLlmSettings(settings) {
904
+ const merged = mergeLlmSettings(settings);
905
+ this.db.prepare(`
906
+ INSERT INTO settings_kv (key, value) VALUES ('llm_settings', ?)
907
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value
908
+ `).run(JSON.stringify(merged));
909
+ return merged;
910
+ }
911
+ // ─── W6-6 遥测(OTLP collector + 隐私模式)────────────────────────────
912
+ /** 读取遥测设置;从未配置过时返回 null(调用方按"关"处理)。 */
913
+ getTelemetrySettings() {
914
+ const row = this.db.prepare(`SELECT value FROM settings_kv WHERE key = 'telemetry_settings'`).get();
915
+ if (!row)
916
+ return null;
917
+ try {
918
+ return mergeTelemetrySettings(JSON.parse(row.value));
919
+ }
920
+ catch {
921
+ return null;
922
+ }
923
+ }
924
+ setTelemetrySettings(settings) {
925
+ const merged = mergeTelemetrySettings(settings);
926
+ this.db.prepare(`
927
+ INSERT INTO settings_kv (key, value) VALUES ('telemetry_settings', ?)
928
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value
929
+ `).run(JSON.stringify(merged));
930
+ return merged;
931
+ }
932
+ getWebSearchSettings() {
933
+ const row = this.db.prepare(`SELECT value FROM settings_kv WHERE key = 'web_search_settings'`).get();
934
+ if (!row)
935
+ return null;
936
+ try {
937
+ return mergeWebSearchSettings(JSON.parse(row.value));
938
+ }
939
+ catch {
940
+ return null;
941
+ }
942
+ }
943
+ setWebSearchSettings(settings) {
944
+ const merged = mergeWebSearchSettings(settings);
945
+ this.db.prepare(`
946
+ INSERT INTO settings_kv (key, value) VALUES ('web_search_settings', ?)
947
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value
948
+ `).run(JSON.stringify(merged));
949
+ return merged;
950
+ }
951
+ // ─── 帮助改进产品(行为 / 对话 / 资料 分项同意 + 本地队列)──────────
952
+ getInsightsSettings() {
953
+ const row = this.db.prepare(`SELECT value FROM settings_kv WHERE key = 'insights_settings'`).get();
954
+ if (!row)
955
+ return null;
956
+ try {
957
+ return mergeInsightsSettings(JSON.parse(row.value));
958
+ }
959
+ catch {
960
+ return null;
961
+ }
962
+ }
963
+ ensureInsightsSettings() {
964
+ const existing = this.getInsightsSettings();
965
+ if (existing)
966
+ return existing;
967
+ return this.setInsightsSettings({});
968
+ }
969
+ setInsightsSettings(settings) {
970
+ const current = this.getInsightsSettings();
971
+ const merged = mergeInsightsSettings({
972
+ ...current,
973
+ ...settings,
974
+ installId: current?.installId ?? settings.installId,
975
+ profile: mergeInsightsProfile({ ...current?.profile, ...settings.profile }),
976
+ });
977
+ this.db.prepare(`
978
+ INSERT INTO settings_kv (key, value) VALUES ('insights_settings', ?)
979
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value
980
+ `).run(JSON.stringify(merged));
981
+ return merged;
982
+ }
983
+ enqueueInsight(kind, payload) {
984
+ const row = {
985
+ id: randomUUID(),
986
+ kind,
987
+ payload,
988
+ createdAt: new Date().toISOString(),
989
+ uploadedAt: null,
990
+ uploadError: null,
991
+ };
992
+ this.db.prepare(`
993
+ INSERT INTO insights_outbox (id, kind, payload, created_at, uploaded_at, upload_error)
994
+ VALUES (?, ?, ?, ?, NULL, NULL)
995
+ `).run(row.id, row.kind, JSON.stringify(row.payload), row.createdAt);
996
+ return row;
997
+ }
998
+ listInsightOutbox(opts = {}) {
999
+ const limit = Math.max(1, Math.min(opts.limit ?? 200, 500));
1000
+ let sql = `SELECT * FROM insights_outbox`;
1001
+ const where = [];
1002
+ const params = [];
1003
+ if (opts.kind) {
1004
+ where.push('kind = ?');
1005
+ params.push(opts.kind);
1006
+ }
1007
+ if (opts.uploaded === true) {
1008
+ where.push('uploaded_at IS NOT NULL');
1009
+ }
1010
+ else if (opts.uploaded === false) {
1011
+ where.push('uploaded_at IS NULL');
1012
+ }
1013
+ if (where.length)
1014
+ sql += ` WHERE ${where.join(' AND ')}`;
1015
+ sql += ` ORDER BY created_at DESC LIMIT ?`;
1016
+ params.push(limit);
1017
+ const rows = this.db.prepare(sql).all(...params);
1018
+ return rows.map((row) => this.mapInsightOutbox(row));
1019
+ }
1020
+ markInsightUploaded(id) {
1021
+ this.db.prepare(`UPDATE insights_outbox SET uploaded_at = ?, upload_error = NULL WHERE id = ?`).run(new Date().toISOString(), id);
1022
+ }
1023
+ markInsightUploadError(id, error) {
1024
+ this.db.prepare(`UPDATE insights_outbox SET upload_error = ? WHERE id = ?`).run(error.slice(0, 200), id);
1025
+ }
1026
+ insightStats() {
1027
+ const row = this.db.prepare(`
1028
+ SELECT
1029
+ SUM(CASE WHEN kind = 'event' THEN 1 ELSE 0 END) AS events,
1030
+ SUM(CASE WHEN kind = 'turn' THEN 1 ELSE 0 END) AS turns,
1031
+ SUM(CASE WHEN kind = 'profile' THEN 1 ELSE 0 END) AS profile,
1032
+ SUM(CASE WHEN uploaded_at IS NULL THEN 1 ELSE 0 END) AS pending
1033
+ FROM insights_outbox
1034
+ `).get();
1035
+ return {
1036
+ events: Number(row?.events ?? 0),
1037
+ turns: Number(row?.turns ?? 0),
1038
+ profile: Number(row?.profile ?? 0),
1039
+ pending: Number(row?.pending ?? 0),
1040
+ };
1041
+ }
1042
+ exportInsightsBundle() {
1043
+ const settings = this.ensureInsightsSettings();
1044
+ return {
1045
+ schema: INSIGHTS_EXPORT_SCHEMA,
1046
+ exportedAt: new Date().toISOString(),
1047
+ installId: settings.installId,
1048
+ settings: {
1049
+ shareBehavior: settings.shareBehavior,
1050
+ shareConversation: settings.shareConversation,
1051
+ shareProfile: settings.shareProfile,
1052
+ },
1053
+ profile: settings.profile,
1054
+ stats: this.insightStats(),
1055
+ records: this.listInsightOutbox({ limit: 500 }),
1056
+ };
1057
+ }
1058
+ mapInsightOutbox(row) {
1059
+ return {
1060
+ id: String(row.id),
1061
+ kind: row.kind === 'turn' || row.kind === 'profile' ? row.kind : 'event',
1062
+ payload: this.safeJson(row.payload, {}),
1063
+ createdAt: String(row.created_at),
1064
+ uploadedAt: row.uploaded_at ? String(row.uploaded_at) : null,
1065
+ uploadError: row.upload_error ? String(row.upload_error) : null,
1066
+ };
1067
+ }
1068
+ // ─── 场景包存储 ─────────────────────────────────────────────────
1069
+ // 包数据访问收敛进包内存储(0.4,如 packages/pack-*/src/store.ts)。
1070
+ // 宿主只通过 getPackDb() 把同一个 SQLite 句柄借给包存储。
1071
+ mapChatSession(row) {
1072
+ return {
1073
+ id: String(row.id),
1074
+ title: String(row.title ?? '新对话'),
1075
+ userId: String(row.user_id ?? DEFAULT_LOCAL_USER_ID),
1076
+ agentId: row.agent_id ? String(row.agent_id) : null,
1077
+ projectId: row.project_id ? String(row.project_id) : null,
1078
+ createdAt: String(row.created_at),
1079
+ updatedAt: String(row.updated_at),
1080
+ isPinned: Number(row.is_pinned ?? 0) === 1,
1081
+ systemPrompt: row.system_prompt ? String(row.system_prompt) : null,
1082
+ pinnedRefs: row.pinned_refs ? this.safeJson(row.pinned_refs) : null,
1083
+ };
1084
+ }
1085
+ mapChatMessage(row) {
1086
+ return {
1087
+ id: String(row.id),
1088
+ chatId: String(row.chat_id),
1089
+ role: String(row.role),
1090
+ content: String(row.content ?? ''),
1091
+ messageMetadata: row.message_metadata ? String(row.message_metadata) : null,
1092
+ createdAt: String(row.created_at),
1093
+ };
1094
+ }
1095
+ mapHarnessTrace(row) {
1096
+ return {
1097
+ id: String(row.id),
1098
+ chatId: String(row.chat_id),
1099
+ messageId: row.message_id ? String(row.message_id) : null,
1100
+ startedAtMs: Number(row.started_at_ms ?? 0),
1101
+ durationMs: row.duration_ms === null || row.duration_ms === undefined ? null : Number(row.duration_ms),
1102
+ status: String(row.status ?? 'unknown'),
1103
+ payload: String(row.payload ?? '{}'),
1104
+ createdAt: String(row.created_at ?? new Date().toISOString()),
1105
+ };
1106
+ }
1107
+ mapTask(row) {
1108
+ return {
1109
+ id: String(row.id),
1110
+ chatId: String(row.chat_id),
1111
+ task: String(row.task ?? ''),
1112
+ status: String(row.status ?? 'running'),
1113
+ answer: row.answer != null ? String(row.answer) : null,
1114
+ error: row.error != null ? String(row.error) : null,
1115
+ worktreePath: row.worktree_path != null ? String(row.worktree_path) : null,
1116
+ worktreeBranch: row.worktree_branch != null ? String(row.worktree_branch) : null,
1117
+ worktreeState: (row.worktree_state != null ? String(row.worktree_state) : null),
1118
+ recordId: row.record_id != null ? String(row.record_id) : null,
1119
+ traceId: row.trace_id != null ? String(row.trace_id) : null,
1120
+ dependsOn: parseDependsOn(row.depends_on),
1121
+ processJson: row.process_json != null ? String(row.process_json) : null,
1122
+ createdAt: String(row.created_at),
1123
+ updatedAt: String(row.updated_at),
1124
+ };
1125
+ }
1126
+ mapChatAgent(row) {
1127
+ return {
1128
+ id: String(row.id),
1129
+ slug: row.slug ? String(row.slug) : null,
1130
+ name: String(row.name),
1131
+ icon: row.icon ? String(row.icon) : null,
1132
+ color: row.color ? String(row.color) : null,
1133
+ description: row.description ? String(row.description) : null,
1134
+ rolePrompt: row.role_prompt ? String(row.role_prompt) : null,
1135
+ forbiddenPrompt: row.forbidden_prompt ? String(row.forbidden_prompt) : null,
1136
+ skillIds: this.safeJson(row.skill_ids, []),
1137
+ toolPolicy: this.safeJson(row.tool_policy, { mode: 'all', tools: [] }),
1138
+ allowExternalSkills: Number(row.allow_external_skills ?? 0) === 1,
1139
+ loadAllSkills: Number(row.load_all_skills ?? 0) === 1,
1140
+ isBuiltin: Number(row.is_builtin ?? 0) === 1,
1141
+ isArchived: Number(row.is_archived ?? 0) === 1,
1142
+ sortOrder: Number(row.sort_order ?? 0),
1143
+ createdAt: String(row.created_at),
1144
+ updatedAt: String(row.updated_at),
1145
+ };
1146
+ }
1147
+ safeJson(value, fallback = null) {
1148
+ if (typeof value !== 'string')
1149
+ return fallback;
1150
+ try {
1151
+ return JSON.parse(value);
1152
+ }
1153
+ catch {
1154
+ return fallback;
1155
+ }
1156
+ }
1157
+ }
1158
+ export const localStore = createLocalStore(() => new LocalStore());