om-memory-system 4.29.1 → 4.31.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 (143) hide show
  1. package/README.md +1 -0
  2. package/dist/adapters/opencode/conversation.js +22 -17
  3. package/dist/adapters/opencode/skills-tool.d.ts +3 -0
  4. package/dist/adapters/opencode/skills-tool.js +31 -0
  5. package/dist/adapters/pi/capture.js +39 -23
  6. package/dist/adapters/pi/extension.d.ts +1 -0
  7. package/dist/adapters/pi/extension.js +3 -0
  8. package/dist/adapters/pi/skills-tool.d.ts +3 -0
  9. package/dist/adapters/pi/skills-tool.js +34 -0
  10. package/dist/cli/index.js +5 -0
  11. package/dist/cli/skills-command.d.ts +12 -0
  12. package/dist/cli/skills-command.js +242 -0
  13. package/dist/cli/skills-record-past.d.ts +5 -0
  14. package/dist/cli/skills-record-past.js +85 -0
  15. package/dist/config-template.d.ts +1 -1
  16. package/dist/config-template.js +12 -0
  17. package/dist/config.d.ts +64 -48
  18. package/dist/config.js +28 -5
  19. package/dist/core/host.d.ts +7 -0
  20. package/dist/core/profile-backoff.d.ts +16 -8
  21. package/dist/core/profile-backoff.js +25 -16
  22. package/dist/core/skill-agent-modes.d.ts +4 -0
  23. package/dist/core/skill-agent-modes.js +145 -0
  24. package/dist/core/skill-operation-reply.d.ts +16 -0
  25. package/dist/core/skill-operation-reply.js +35 -0
  26. package/dist/core/skill-operations.d.ts +58 -0
  27. package/dist/core/skill-operations.js +296 -0
  28. package/dist/core/skills-tool.d.ts +90 -0
  29. package/dist/core/skills-tool.js +64 -0
  30. package/dist/core/tool-input.d.ts +7 -0
  31. package/dist/core/tool-input.js +21 -0
  32. package/dist/core/tool-outcome.d.ts +13 -0
  33. package/dist/core/tool-outcome.js +22 -0
  34. package/dist/core/turn-outputs.d.ts +30 -0
  35. package/dist/core/turn-outputs.js +22 -0
  36. package/dist/importer/auto-backfill.js +13 -1
  37. package/dist/importer/claude-conversation.d.ts +1 -0
  38. package/dist/importer/claude-conversation.js +44 -10
  39. package/dist/importer/claude-hook-api.d.ts +0 -8
  40. package/dist/importer/claude-hook-api.js +36 -65
  41. package/dist/importer/claude-import.js +3 -1
  42. package/dist/importer/claude-reader.d.ts +2 -0
  43. package/dist/importer/claude-reader.js +2 -2
  44. package/dist/importer/claude-turn-units.d.ts +26 -0
  45. package/dist/importer/claude-turn-units.js +85 -0
  46. package/dist/importer/import-args.d.ts +3 -0
  47. package/dist/importer/import-args.js +8 -2
  48. package/dist/importer/import-project.d.ts +6 -0
  49. package/dist/importer/import-project.js +9 -0
  50. package/dist/importer/import-runs.d.ts +8 -2
  51. package/dist/importer/import-runs.js +3 -3
  52. package/dist/importer/import-sessions.d.ts +8 -1
  53. package/dist/importer/import-sessions.js +17 -4
  54. package/dist/importer/import-turn-outputs.d.ts +49 -0
  55. package/dist/importer/import-turn-outputs.js +88 -0
  56. package/dist/importer/importer.d.ts +5 -2
  57. package/dist/importer/importer.js +18 -32
  58. package/dist/importer/opencode-import.d.ts +2 -0
  59. package/dist/importer/opencode-import.js +6 -3
  60. package/dist/importer/opencode-reader.js +11 -5
  61. package/dist/importer/pi-conversation.d.ts +5 -0
  62. package/dist/importer/pi-conversation.js +33 -9
  63. package/dist/importer/profile-catch-up.d.ts +1 -1
  64. package/dist/importer/record-past-turns.d.ts +28 -0
  65. package/dist/importer/record-past-turns.js +63 -0
  66. package/dist/importer/run-import.d.ts +4 -0
  67. package/dist/importer/run-import.js +24 -3
  68. package/dist/importer/web-import-api.d.ts +14 -0
  69. package/dist/importer/web-import-api.js +6 -0
  70. package/dist/importer/web-import-group.d.ts +4 -1
  71. package/dist/importer/web-import-group.js +26 -6
  72. package/dist/importer/web-import-jobs.js +6 -2
  73. package/dist/index.js +6 -1
  74. package/dist/services/ai/session/ai-session-manager.js +13 -0
  75. package/dist/services/auto-capture.d.ts +1 -1
  76. package/dist/services/auto-capture.js +71 -19
  77. package/dist/services/capture-attempt-store.d.ts +2 -0
  78. package/dist/services/capture-attempt-store.js +11 -0
  79. package/dist/services/chat/chat-runtime.d.ts +2 -0
  80. package/dist/services/chat/chat-runtime.js +3 -2
  81. package/dist/services/global-config-writer.js +32 -2
  82. package/dist/services/profile-cleanup-routes.d.ts +2 -2
  83. package/dist/services/profile-cleanup-routes.js +19 -2
  84. package/dist/services/settings-snapshot.js +7 -1
  85. package/dist/services/skill-learning/agent-proposals.d.ts +76 -0
  86. package/dist/services/skill-learning/agent-proposals.js +180 -0
  87. package/dist/services/skill-learning/apply.d.ts +50 -0
  88. package/dist/services/skill-learning/apply.js +247 -0
  89. package/dist/services/skill-learning/auto-review.d.ts +40 -0
  90. package/dist/services/skill-learning/auto-review.js +132 -0
  91. package/dist/services/skill-learning/draft-turns.d.ts +60 -0
  92. package/dist/services/skill-learning/draft-turns.js +131 -0
  93. package/dist/services/skill-learning/drafting.d.ts +73 -0
  94. package/dist/services/skill-learning/drafting.js +263 -0
  95. package/dist/services/skill-learning/line-diff.d.ts +6 -0
  96. package/dist/services/skill-learning/line-diff.js +94 -0
  97. package/dist/services/skill-learning/operations-deps.d.ts +7 -0
  98. package/dist/services/skill-learning/operations-deps.js +19 -0
  99. package/dist/services/skill-learning/proposal-store.d.ts +82 -0
  100. package/dist/services/skill-learning/proposal-store.js +164 -0
  101. package/dist/services/skill-learning/ranking.d.ts +36 -0
  102. package/dist/services/skill-learning/ranking.js +140 -0
  103. package/dist/services/skill-learning/settings-for.d.ts +11 -0
  104. package/dist/services/skill-learning/settings-for.js +21 -0
  105. package/dist/services/skill-learning/skill-config.d.ts +45 -0
  106. package/dist/services/skill-learning/skill-config.js +70 -0
  107. package/dist/services/skill-learning/skill-detect.d.ts +29 -0
  108. package/dist/services/skill-learning/skill-detect.js +130 -0
  109. package/dist/services/skill-learning/skill-folders.d.ts +24 -0
  110. package/dist/services/skill-learning/skill-folders.js +42 -0
  111. package/dist/services/skill-learning/skill-index.d.ts +28 -0
  112. package/dist/services/skill-learning/skill-index.js +118 -0
  113. package/dist/services/skill-learning/skill-model.d.ts +10 -0
  114. package/dist/services/skill-learning/skill-model.js +15 -0
  115. package/dist/services/skill-learning/store.d.ts +79 -0
  116. package/dist/services/skill-learning/store.js +245 -0
  117. package/dist/services/skill-learning/turn-output.d.ts +34 -0
  118. package/dist/services/skill-learning/turn-output.js +98 -0
  119. package/dist/services/skill-learning/turn-summaries.d.ts +24 -0
  120. package/dist/services/skill-learning/turn-summaries.js +64 -0
  121. package/dist/services/skill-learning/web-review.d.ts +18 -0
  122. package/dist/services/skill-learning/web-review.js +44 -0
  123. package/dist/services/skill-routes.d.ts +20 -0
  124. package/dist/services/skill-routes.js +249 -0
  125. package/dist/services/turso/connection-manager.js +11 -7
  126. package/dist/services/turso/table-lease.d.ts +40 -0
  127. package/dist/services/turso/table-lease.js +77 -0
  128. package/dist/services/turso/turso-db.d.ts +4 -0
  129. package/dist/services/turso/turso-db.js +13 -0
  130. package/dist/services/user-prompt/profile-catch-up-lease.d.ts +3 -22
  131. package/dist/services/user-prompt/profile-catch-up-lease.js +9 -65
  132. package/dist/services/web-jobs.d.ts +7 -0
  133. package/dist/services/web-jobs.js +14 -0
  134. package/dist/services/web-route-modules.d.ts +2 -2
  135. package/dist/services/web-route-modules.js +6 -1
  136. package/dist/services/web-server.js +13 -21
  137. package/dist/v2/adapter.js +13 -8
  138. package/dist/web/assets/index-9hu4Z7SV.css +2 -0
  139. package/dist/web/assets/index-BMt9gPNK.js +129 -0
  140. package/dist/web/index.html +2 -2
  141. package/package.json +1 -1
  142. package/dist/web/assets/index-CHBKLTiT.js +0 -126
  143. package/dist/web/assets/index-DsQXsY3T.css +0 -2
@@ -0,0 +1,249 @@
1
+ import { existsSync } from "node:fs";
2
+ import { RECORDING_OFF_TEXT, runSkillOperation, } from "../core/skill-operations.js";
3
+ import { log } from "./logger.js";
4
+ import { undoProposal } from "./skill-learning/apply.js";
5
+ import { skillOperationsDeps } from "./skill-learning/operations-deps.js";
6
+ import { getProposal, listProposals, setSkillMode, } from "./skill-learning/proposal-store.js";
7
+ import { SKILL_ORDERS, rankSkills } from "./skill-learning/ranking.js";
8
+ import { skillTurnSettingsFor } from "./skill-learning/settings-for.js";
9
+ import { getSkillIndex } from "./skill-learning/skill-index.js";
10
+ import { SkillStore, SkillStoreError, skillsDbPath } from "./skill-learning/store.js";
11
+ import { recentSkillTurns, skillTurnsByIds } from "./skill-learning/turn-summaries.js";
12
+ const PROPOSAL_STATES = [
13
+ "pending",
14
+ "applied",
15
+ "stale",
16
+ "rejected",
17
+ "undone",
18
+ ];
19
+ // Codes from the skill operations, apply, undo and drafting. A code not listed is a 500.
20
+ const STATUS_BY_CODE = {
21
+ "invalid-args": 400,
22
+ "invalid-name": 400,
23
+ "not-watched": 400,
24
+ "not-skill-file": 400,
25
+ "outside-folder": 400,
26
+ "not-found": 404,
27
+ "recording-off": 409,
28
+ ambiguous: 409,
29
+ "not-pending": 409,
30
+ "not-applied": 409,
31
+ "no-version": 409,
32
+ stale: 409,
33
+ exists: 409,
34
+ changed: 409,
35
+ "read-only": 409,
36
+ "not-configured": 409,
37
+ busy: 409,
38
+ "import-error": 400,
39
+ timeout: 502,
40
+ "no-tool-call": 502,
41
+ "invalid-reply": 502,
42
+ };
43
+ function statusFor(code) {
44
+ if (code.startsWith("http-"))
45
+ return 502;
46
+ return STATUS_BY_CODE[code] ?? 500;
47
+ }
48
+ const ok = (code, data, status = 200) => ({
49
+ status,
50
+ body: { success: true, code, data },
51
+ });
52
+ const fail = (code, error, extra = {}) => ({
53
+ status: statusFor(code),
54
+ body: { success: false, code, error, ...extra },
55
+ });
56
+ function fromOperation(result) {
57
+ if (!result.success)
58
+ return fail(result.code, result.text);
59
+ return {
60
+ status: 200,
61
+ body: { success: true, code: result.code, message: result.text, data: result.data ?? {} },
62
+ };
63
+ }
64
+ function text(value) {
65
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
66
+ }
67
+ function folderCounts(index, exists) {
68
+ return index.folders.map((folder) => ({
69
+ path: folder,
70
+ skills: index.entries.filter((e) => e.folder === folder).length,
71
+ exists: exists(folder),
72
+ }));
73
+ }
74
+ /**
75
+ * The Skills page routes under `/api/skills`. Returns undefined for any other
76
+ * path. The web server applies the origin, token and JSON checks before this.
77
+ * Reads never create `skills.db`; a write opens it with `init` first.
78
+ */
79
+ export async function routeSkills(path, method, url, readBody, ctx) {
80
+ if (path !== "/api/skills" && !path.startsWith("/api/skills/"))
81
+ return undefined;
82
+ try {
83
+ return await dispatch(path, method, url, readBody, ctx);
84
+ }
85
+ catch (error) {
86
+ if (error instanceof SkillStoreError)
87
+ return fail("read-only", error.message);
88
+ log("skills route: error", {
89
+ path: path.replace(/\/[^/]{16,}$/, "/:id"),
90
+ method,
91
+ code: error?.code ?? "error",
92
+ name: error instanceof Error ? error.name : typeof error,
93
+ }, "error");
94
+ return fail("error", "Skills request failed");
95
+ }
96
+ }
97
+ async function dispatch(path, method, url, readBody, ctx) {
98
+ const settings = skillTurnSettingsFor(ctx.cwd);
99
+ const store = new SkillStore(skillsDbPath(settings.storagePath));
100
+ const now = Date.now();
101
+ const index = () => getSkillIndex(settings.folders, now);
102
+ if (method === "GET") {
103
+ if (path === "/api/skills") {
104
+ const order = (url.searchParams.get("order") ?? "uses");
105
+ if (!SKILL_ORDERS.includes(order)) {
106
+ return fail("invalid-args", `order must be one of: ${SKILL_ORDERS.join(", ")}`);
107
+ }
108
+ const current = index();
109
+ return ok("ok", {
110
+ recording: settings.enabled,
111
+ skills: await rankSkills(store, current, now, order),
112
+ folders: folderCounts(current, existsSync),
113
+ });
114
+ }
115
+ if (path === "/api/skills/folders") {
116
+ return ok("ok", { folders: folderCounts(index(), existsSync) });
117
+ }
118
+ if (path === "/api/skills/stats") {
119
+ return ok("ok", {
120
+ turns: await store.countTurns(),
121
+ fileSize: store.fileSize(),
122
+ recording: settings.enabled,
123
+ redact: settings.redact,
124
+ });
125
+ }
126
+ if (path === "/api/skills/skill")
127
+ return showSkill(url, store, index(), now);
128
+ if (path === "/api/skills/proposals")
129
+ return proposals(url, store);
130
+ const one = /^\/api\/skills\/proposals\/([^/]+)$/.exec(path);
131
+ if (one) {
132
+ const proposal = await getProposal(store, decodeURIComponent(one[1]));
133
+ if (!proposal)
134
+ return fail("not-found", "No such proposal");
135
+ return ok("ok", {
136
+ proposal,
137
+ evidence: await skillTurnsByIds(store, proposal.evidenceTurnIds),
138
+ });
139
+ }
140
+ return undefined;
141
+ }
142
+ if (method !== "POST")
143
+ return undefined;
144
+ const operation = (mode, args) => runSkillOperation(mode, args, skillOperationsDeps(ctx.cwd, "person")).then(fromOperation);
145
+ switch (path) {
146
+ case "/api/skills/draft": {
147
+ const body = await readBody();
148
+ const idea = body.idea && typeof body.idea === "object" ? body.idea : undefined;
149
+ return operation("draft", {
150
+ skill: text(body.skill),
151
+ ...(idea ? { idea: idea, folder: text(body.folder) } : {}),
152
+ });
153
+ }
154
+ case "/api/skills/ideas":
155
+ return operation("ideas", {});
156
+ case "/api/skills/apply":
157
+ return operation("apply", { id: text((await readBody()).id) });
158
+ case "/api/skills/reject":
159
+ return operation("reject", { id: text((await readBody()).id) });
160
+ case "/api/skills/undo":
161
+ return undo(text((await readBody()).id), store, settings.folders);
162
+ case "/api/skills/mode":
163
+ return setMode(await readBody(), store, index());
164
+ case "/api/skills/delete-turns":
165
+ return deleteTurns(await readBody(), store);
166
+ case "/api/skills/record-past":
167
+ return recordPast(settings.enabled, ctx);
168
+ }
169
+ return undefined;
170
+ }
171
+ async function showSkill(url, store, index, now) {
172
+ const path = text(url.searchParams.get("path"));
173
+ const name = text(url.searchParams.get("name"));
174
+ if (!path && !name)
175
+ return fail("invalid-args", "Give path or name");
176
+ const skill = (await rankSkills(store, index, now)).find((s) => path ? s.path === path : s.path === null && s.name === name);
177
+ if (!skill)
178
+ return fail("not-found", "No such skill");
179
+ return ok("ok", {
180
+ skill,
181
+ recentTurns: await recentSkillTurns(store, skill, index),
182
+ proposals: skill.path ? await listProposals(store, { skillPath: skill.path }) : [],
183
+ });
184
+ }
185
+ async function proposals(url, store) {
186
+ const state = text(url.searchParams.get("state"));
187
+ const skill = text(url.searchParams.get("skill"));
188
+ if (state && !PROPOSAL_STATES.includes(state)) {
189
+ return fail("invalid-args", `state must be one of: ${PROPOSAL_STATES.join(", ")}`);
190
+ }
191
+ const filter = {};
192
+ if (state)
193
+ filter.state = state;
194
+ // A path names one file; a bare name matches every skill with that name.
195
+ if (skill && /[/\\]/.test(skill))
196
+ filter.skillPath = skill;
197
+ else if (skill)
198
+ filter.skillName = skill;
199
+ return ok("ok", { proposals: await listProposals(store, filter) });
200
+ }
201
+ async function undo(id, store, folders) {
202
+ if (!id)
203
+ return fail("invalid-args", "Give the proposal id");
204
+ if (!(await store.existingDb()))
205
+ return fail("not-found", `No proposal ${id}`);
206
+ await store.init();
207
+ const result = await undoProposal(id, { store, folders });
208
+ if (!result.ok) {
209
+ // Undo refuses a file changed by hand and shows what it would have restored.
210
+ const extra = result.code === "changed" ? { savedContent: result.savedContent ?? null } : {};
211
+ return fail(result.code, result.message, extra);
212
+ }
213
+ return ok("undone", { proposal: result.proposal, path: result.path });
214
+ }
215
+ async function setMode(body, store, index) {
216
+ const skillPath = text(body.skillPath);
217
+ if (!skillPath || (body.mode !== "manual" && body.mode !== "automatic")) {
218
+ return fail("invalid-args", "Give skillPath and mode manual or automatic");
219
+ }
220
+ if (!index.entries.some((e) => e.path === skillPath)) {
221
+ return fail("not-found", "No watched skill has this path");
222
+ }
223
+ await store.init();
224
+ await setSkillMode(store, skillPath, body.mode);
225
+ return ok("ok", { skillPath, mode: body.mode });
226
+ }
227
+ async function deleteTurns(body, store) {
228
+ if (body.confirm !== true)
229
+ return fail("invalid-args", "Send confirm: true to delete every turn");
230
+ // No file means nothing to delete; init would create one.
231
+ if (!(await store.existingDb()))
232
+ return ok("ok", { deleted: 0 });
233
+ await store.init();
234
+ return ok("ok", { deleted: await store.deleteAllTurns() });
235
+ }
236
+ async function recordPast(enabled, ctx) {
237
+ if (!enabled)
238
+ return fail("recording-off", RECORDING_OFF_TEXT);
239
+ try {
240
+ const job = await ctx.startRecordPast(ctx.cwd);
241
+ return ok("started", { id: job.id, job }, 202);
242
+ }
243
+ catch (error) {
244
+ // Import errors carry their own status, like the import start route.
245
+ const status = error.status;
246
+ const message = error instanceof Error ? error.message : "Record past turns failed";
247
+ return fail(status === 409 ? "busy" : "import-error", message);
248
+ }
249
+ }
@@ -8,12 +8,17 @@ import { TursoDb } from "./turso-db.js";
8
8
  function toFileUrl(dbPath) {
9
9
  return dbPath.startsWith("file:") ? dbPath : `file:${dbPath}`;
10
10
  }
11
- function assertPathInsideStorage(dbPath) {
12
- const storageRoot = resolve(CONFIG.storagePath);
13
- const resolvedPath = resolve(dbPath);
14
- const relativePath = relative(storageRoot, resolvedPath);
11
+ function insideRoot(root, dbPath) {
12
+ const relativePath = relative(resolve(root), resolve(dbPath));
15
13
  // Only treat path-segment traversal as escape (not filenames containing "..").
16
- if (relativePath === ".." || relativePath.startsWith(`..${sep}`) || isAbsolute(relativePath)) {
14
+ return !(relativePath === ".." ||
15
+ relativePath.startsWith(`..${sep}`) ||
16
+ isAbsolute(relativePath));
17
+ }
18
+ function assertPathInsideStorage(dbPath) {
19
+ // skills.db stays in the global storage path when a project moves storagePath.
20
+ const roots = [CONFIG.storagePath, CONFIG.skillStoragePath].filter((root) => root !== undefined);
21
+ if (!roots.some((root) => insideRoot(root, dbPath))) {
17
22
  throw new Error(`Refusing to open database outside storagePath: ${dbPath}`);
18
23
  }
19
24
  }
@@ -64,8 +69,7 @@ export class TursoConnectionManager {
64
69
  // lock (see cross-process-write-lock.ts); the default rollback
65
70
  // journal is kept because file-level migration copies rely on the
66
71
  // main database file being current after every commit.
67
- await db.execute("PRAGMA busy_timeout = 5000");
68
- await db.execute("PRAGMA foreign_keys = ON");
72
+ await db.setConnectionPragmas(["PRAGMA busy_timeout = 5000", "PRAGMA foreign_keys = ON"]);
69
73
  this.connections.set(dbPath, db);
70
74
  return db;
71
75
  }
@@ -0,0 +1,40 @@
1
+ import type { TursoDb } from "./turso-db.js";
2
+ /** One batch with its retry at the 120-second limit fits well inside this. */
3
+ export declare const LEASE_EXPIRY_MS: number;
4
+ export type BatchStart = "ok" | "superseded" | "aborted";
5
+ export interface TableLeaseOptions {
6
+ /** A fixed table name from the code. It goes into the SQL as written. */
7
+ table: string;
8
+ /** The database that holds the table. */
9
+ connection: () => Promise<TursoDb>;
10
+ now?: () => number;
11
+ sleep?: (ms: number) => Promise<void>;
12
+ }
13
+ /**
14
+ * A run record in one single-row table, shared across processes. The newest
15
+ * run owns the record. A run sends a batch only while it owns the record and
16
+ * no other run is mid-batch, so no batch is sent twice. A record not
17
+ * refreshed for 10 minutes has expired.
18
+ */
19
+ export declare class TableLease {
20
+ private readonly options;
21
+ private prepared;
22
+ private readonly table;
23
+ private readonly now;
24
+ private readonly sleep;
25
+ constructor(options: TableLeaseOptions);
26
+ private db;
27
+ /** Become the owner. The previous owner's mid-batch mark stays until its batch ends. */
28
+ take(owner: string): Promise<void>;
29
+ /** Wait until this run may send its next batch, or report that a newer run took over. */
30
+ beginBatch(owner: string, options?: {
31
+ signal?: AbortSignal;
32
+ onWait?: () => void;
33
+ }): Promise<BatchStart>;
34
+ /** Clear this run's mid-batch mark, also after a newer run took over. */
35
+ endBatch(owner: string): Promise<void>;
36
+ /** Remove the record only when this run still owns it. */
37
+ release(owner: string): Promise<void>;
38
+ /** True while an unexpired record exists; a live pass skips meanwhile. */
39
+ isActive(): Promise<boolean>;
40
+ }
@@ -0,0 +1,77 @@
1
+ /** One batch with its retry at the 120-second limit fits well inside this. */
2
+ export const LEASE_EXPIRY_MS = 10 * 60 * 1000;
3
+ const WAIT_MS = 1000;
4
+ /**
5
+ * A run record in one single-row table, shared across processes. The newest
6
+ * run owns the record. A run sends a batch only while it owns the record and
7
+ * no other run is mid-batch, so no batch is sent twice. A record not
8
+ * refreshed for 10 minutes has expired.
9
+ */
10
+ export class TableLease {
11
+ options;
12
+ prepared = null;
13
+ table;
14
+ now;
15
+ sleep;
16
+ constructor(options) {
17
+ this.options = options;
18
+ this.table = options.table;
19
+ this.now = options.now ?? Date.now;
20
+ this.sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
21
+ }
22
+ db() {
23
+ this.prepared ??= (async () => {
24
+ const db = await this.options.connection();
25
+ await db.run(`CREATE TABLE IF NOT EXISTS ${this.table} (
26
+ id INTEGER PRIMARY KEY CHECK (id = 1),
27
+ owner TEXT NOT NULL, busy_owner TEXT, refreshed_at INTEGER NOT NULL
28
+ )`);
29
+ return db;
30
+ })();
31
+ return this.prepared;
32
+ }
33
+ /** Become the owner. The previous owner's mid-batch mark stays until its batch ends. */
34
+ async take(owner) {
35
+ const db = await this.db();
36
+ await db.run(`INSERT INTO ${this.table} (id, owner, busy_owner, refreshed_at)
37
+ VALUES (1, ?, NULL, ?)
38
+ ON CONFLICT(id) DO UPDATE SET owner = excluded.owner`, [owner, this.now()]);
39
+ }
40
+ /** Wait until this run may send its next batch, or report that a newer run took over. */
41
+ async beginBatch(owner, options = {}) {
42
+ const db = await this.db();
43
+ for (;;) {
44
+ if (options.signal?.aborted)
45
+ return "aborted";
46
+ const now = this.now();
47
+ const changed = await db.run(`UPDATE ${this.table} SET busy_owner = ?, refreshed_at = ?
48
+ WHERE id = 1 AND owner = ?
49
+ AND (busy_owner IS NULL OR busy_owner = ? OR refreshed_at < ?)`, [owner, now, owner, owner, now - LEASE_EXPIRY_MS]);
50
+ if (changed > 0)
51
+ return "ok";
52
+ const row = await db.get(`SELECT owner FROM ${this.table} WHERE id = 1`);
53
+ if (!row || String(row.owner) !== owner)
54
+ return "superseded";
55
+ options.onWait?.();
56
+ await this.sleep(WAIT_MS);
57
+ }
58
+ }
59
+ /** Clear this run's mid-batch mark, also after a newer run took over. */
60
+ async endBatch(owner) {
61
+ const db = await this.db();
62
+ await db.run(`UPDATE ${this.table} SET busy_owner = NULL, refreshed_at = ? WHERE busy_owner = ?`, [this.now(), owner]);
63
+ }
64
+ /** Remove the record only when this run still owns it. */
65
+ async release(owner) {
66
+ const db = await this.db();
67
+ await db.run(`DELETE FROM ${this.table} WHERE id = 1 AND owner = ?`, [owner]);
68
+ }
69
+ /** True while an unexpired record exists; a live pass skips meanwhile. */
70
+ async isActive() {
71
+ const db = await this.db();
72
+ const row = await db.get(`SELECT 1 FROM ${this.table} WHERE id = 1 AND refreshed_at >= ?`, [
73
+ this.now() - LEASE_EXPIRY_MS,
74
+ ]);
75
+ return Boolean(row);
76
+ }
77
+ }
@@ -2,7 +2,11 @@ import type { Client, InArgs, ResultSet, Transaction } from "@libsql/client";
2
2
  type Row = Record<string, unknown>;
3
3
  export declare class TursoDb {
4
4
  private readonly client;
5
+ /** Per-connection pragmas, set again after each transaction (see `transaction`). */
6
+ private pragmas;
5
7
  constructor(client: Client);
8
+ /** Run each pragma now and again after every transaction. */
9
+ setConnectionPragmas(pragmas: string[]): Promise<void>;
6
10
  getClient(): Client;
7
11
  execute(sql: string, args?: InArgs): Promise<ResultSet>;
8
12
  batch(statements: Array<{
@@ -1,8 +1,16 @@
1
1
  export class TursoDb {
2
2
  client;
3
+ /** Per-connection pragmas, set again after each transaction (see `transaction`). */
4
+ pragmas = [];
3
5
  constructor(client) {
4
6
  this.client = client;
5
7
  }
8
+ /** Run each pragma now and again after every transaction. */
9
+ async setConnectionPragmas(pragmas) {
10
+ this.pragmas = pragmas;
11
+ for (const pragma of pragmas)
12
+ await this.execute(pragma);
13
+ }
6
14
  getClient() {
7
15
  return this.client;
8
16
  }
@@ -45,6 +53,11 @@ export class TursoDb {
45
53
  }
46
54
  finally {
47
55
  tx.close();
56
+ // libSQL hands the transaction's handle away and opens a fresh one, which
57
+ // starts without pragmas. Without this, busy_timeout drops to 0 and a
58
+ // second process holding the write lock gives SQLITE_BUSY at once.
59
+ for (const pragma of this.pragmas)
60
+ await this.execute(pragma).catch(() => { });
48
61
  }
49
62
  }
50
63
  async close() {
@@ -1,34 +1,15 @@
1
- /** One batch with its retry at the 120-second limit fits well inside this. */
2
- export declare const LEASE_EXPIRY_MS: number;
3
- export type BatchStart = "ok" | "superseded" | "aborted";
1
+ import { TableLease } from "../turso/table-lease.js";
2
+ export { LEASE_EXPIRY_MS, type BatchStart } from "../turso/table-lease.js";
4
3
  /**
5
4
  * The catch-up run record, shared by the web app and the terminal command
6
5
  * through `user-prompts.db`. The newest run owns the record. A run sends a
7
6
  * batch only while it owns the record and no other run is mid-batch, so no
8
7
  * batch is sent twice. A record not refreshed for 10 minutes has expired.
9
8
  */
10
- export declare class CatchUpLease {
11
- private readonly options;
12
- private prepared;
13
- private readonly now;
14
- private readonly sleep;
9
+ export declare class CatchUpLease extends TableLease {
15
10
  constructor(options?: {
16
11
  path?: string;
17
12
  now?: () => number;
18
13
  sleep?: (ms: number) => Promise<void>;
19
14
  });
20
- private db;
21
- /** Become the owner. The previous owner's mid-batch mark stays until its batch ends. */
22
- take(owner: string): Promise<void>;
23
- /** Wait until this run may send its next batch, or report that a newer run took over. */
24
- beginBatch(owner: string, options?: {
25
- signal?: AbortSignal;
26
- onWait?: () => void;
27
- }): Promise<BatchStart>;
28
- /** Clear this run's mid-batch mark, also after a newer run took over. */
29
- endBatch(owner: string): Promise<void>;
30
- /** Remove the record only when this run still owns it. */
31
- release(owner: string): Promise<void>;
32
- /** True while an unexpired record exists; a live pass skips meanwhile. */
33
- isActive(): Promise<boolean>;
34
15
  }
@@ -1,77 +1,21 @@
1
1
  import { join } from "node:path";
2
2
  import { CONFIG } from "../../config.js";
3
3
  import { tursoConnectionManager } from "../turso/connection-manager.js";
4
- /** One batch with its retry at the 120-second limit fits well inside this. */
5
- export const LEASE_EXPIRY_MS = 10 * 60 * 1000;
6
- const WAIT_MS = 1000;
4
+ import { TableLease } from "../turso/table-lease.js";
5
+ export { LEASE_EXPIRY_MS } from "../turso/table-lease.js";
7
6
  /**
8
7
  * The catch-up run record, shared by the web app and the terminal command
9
8
  * through `user-prompts.db`. The newest run owns the record. A run sends a
10
9
  * batch only while it owns the record and no other run is mid-batch, so no
11
10
  * batch is sent twice. A record not refreshed for 10 minutes has expired.
12
11
  */
13
- export class CatchUpLease {
14
- options;
15
- prepared = null;
16
- now;
17
- sleep;
12
+ export class CatchUpLease extends TableLease {
18
13
  constructor(options = {}) {
19
- this.options = options;
20
- this.now = options.now ?? Date.now;
21
- this.sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
22
- }
23
- db() {
24
- this.prepared ??= (async () => {
25
- const path = this.options.path ?? join(CONFIG.storagePath, "user-prompts.db");
26
- const db = await tursoConnectionManager.getConnection(path);
27
- await db.run(`CREATE TABLE IF NOT EXISTS profile_catch_up_lease (
28
- id INTEGER PRIMARY KEY CHECK (id = 1),
29
- owner TEXT NOT NULL, busy_owner TEXT, refreshed_at INTEGER NOT NULL
30
- )`);
31
- return db;
32
- })();
33
- return this.prepared;
34
- }
35
- /** Become the owner. The previous owner's mid-batch mark stays until its batch ends. */
36
- async take(owner) {
37
- const db = await this.db();
38
- await db.run(`INSERT INTO profile_catch_up_lease (id, owner, busy_owner, refreshed_at)
39
- VALUES (1, ?, NULL, ?)
40
- ON CONFLICT(id) DO UPDATE SET owner = excluded.owner`, [owner, this.now()]);
41
- }
42
- /** Wait until this run may send its next batch, or report that a newer run took over. */
43
- async beginBatch(owner, options = {}) {
44
- const db = await this.db();
45
- for (;;) {
46
- if (options.signal?.aborted)
47
- return "aborted";
48
- const now = this.now();
49
- const changed = await db.run(`UPDATE profile_catch_up_lease SET busy_owner = ?, refreshed_at = ?
50
- WHERE id = 1 AND owner = ?
51
- AND (busy_owner IS NULL OR busy_owner = ? OR refreshed_at < ?)`, [owner, now, owner, owner, now - LEASE_EXPIRY_MS]);
52
- if (changed > 0)
53
- return "ok";
54
- const row = await db.get("SELECT owner FROM profile_catch_up_lease WHERE id = 1");
55
- if (!row || String(row.owner) !== owner)
56
- return "superseded";
57
- options.onWait?.();
58
- await this.sleep(WAIT_MS);
59
- }
60
- }
61
- /** Clear this run's mid-batch mark, also after a newer run took over. */
62
- async endBatch(owner) {
63
- const db = await this.db();
64
- await db.run("UPDATE profile_catch_up_lease SET busy_owner = NULL, refreshed_at = ? WHERE busy_owner = ?", [this.now(), owner]);
65
- }
66
- /** Remove the record only when this run still owns it. */
67
- async release(owner) {
68
- const db = await this.db();
69
- await db.run("DELETE FROM profile_catch_up_lease WHERE id = 1 AND owner = ?", [owner]);
70
- }
71
- /** True while an unexpired record exists; a live pass skips meanwhile. */
72
- async isActive() {
73
- const db = await this.db();
74
- const row = await db.get("SELECT 1 FROM profile_catch_up_lease WHERE id = 1 AND refreshed_at >= ?", [this.now() - LEASE_EXPIRY_MS]);
75
- return Boolean(row);
14
+ super({
15
+ table: "profile_catch_up_lease",
16
+ connection: () => tursoConnectionManager.getConnection(options.path ?? join(CONFIG.storagePath, "user-prompts.db")),
17
+ now: options.now,
18
+ sleep: options.sleep,
19
+ });
76
20
  }
77
21
  }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Background jobs of the web app that owns the port. That process serves the
3
+ * Claude Code hooks, so web-server.ts also starts their capture retries next to
4
+ * this call (the importer import must stay in web-server.ts). Kept out of
5
+ * web-server.ts, which sits at its size ceiling.
6
+ */
7
+ export declare function startWebJobs(): Promise<void>;
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Background jobs of the web app that owns the port. That process serves the
3
+ * Claude Code hooks, so web-server.ts also starts their capture retries next to
4
+ * this call (the importer import must stay in web-server.ts). Kept out of
5
+ * web-server.ts, which sits at its size ceiling.
6
+ */
7
+ export async function startWebJobs() {
8
+ const [config, { startWebSkillReview }] = await Promise.all([
9
+ import("../config.js"),
10
+ import("./skill-learning/web-review.js"),
11
+ ]);
12
+ // A config reload replaces CONFIG, so read the live module binding each tick.
13
+ startWebSkillReview(() => config.CONFIG);
14
+ }
@@ -1,8 +1,8 @@
1
- import type { ChatRuntimeConfig } from "./chat/chat-runtime.js";
2
1
  import { type RouteResult } from "./chat/chat-routes.js";
2
+ import type { SkillRouteContext } from "./skill-routes.js";
3
3
  /**
4
4
  * Route modules that live outside web-server.ts, which sits at its size
5
5
  * ceiling. Each returns undefined for paths it does not own. The request body
6
6
  * is read once, and only by a route that needs it.
7
7
  */
8
- export declare function routeModules(path: string, method: string, url: URL, req: Request, config: ChatRuntimeConfig): Promise<RouteResult | undefined>;
8
+ export declare function routeModules(path: string, method: string, url: URL, req: Request, ctx: SkillRouteContext): Promise<RouteResult | undefined>;
@@ -6,7 +6,8 @@ import { routeProfileCleanup } from "./profile-cleanup-routes.js";
6
6
  * ceiling. Each returns undefined for paths it does not own. The request body
7
7
  * is read once, and only by a route that needs it.
8
8
  */
9
- export async function routeModules(path, method, url, req, config) {
9
+ export async function routeModules(path, method, url, req, ctx) {
10
+ const { CONFIG: config } = await import("../config.js");
10
11
  let body;
11
12
  const readBody = () => (body ??= req.json().catch(() => ({})));
12
13
  const cleanup = await routeProfileCleanup(path, method, url, readBody);
@@ -15,5 +16,9 @@ export async function routeModules(path, method, url, req, config) {
15
16
  const hosts = await routeMemoryHosts(path, method, url, readBody, config);
16
17
  if (hosts !== undefined)
17
18
  return hosts;
19
+ if (path === "/api/skills" || path.startsWith("/api/skills/")) {
20
+ const { routeSkills } = await import("./skill-routes.js");
21
+ return routeSkills(path, method, url, readBody, ctx);
22
+ }
18
23
  return routeChat(path, method, readBody, config);
19
24
  }