@desplega.ai/agent-swarm 1.92.0 → 1.92.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/README.md +1 -1
  2. package/openapi.json +276 -3
  3. package/package.json +6 -6
  4. package/plugin/skills/pages/SKILL.md +5 -2
  5. package/src/be/db.ts +416 -20
  6. package/src/be/memory/boot-reembed.ts +85 -0
  7. package/src/be/memory/constants.ts +44 -2
  8. package/src/be/memory/providers/openai-embedding.ts +15 -5
  9. package/src/be/memory/providers/sqlite-store.ts +325 -76
  10. package/src/be/memory/reranker.ts +35 -17
  11. package/src/be/memory/types.ts +43 -0
  12. package/src/be/migrations/084_script_run_journal_duration.sql +5 -0
  13. package/src/be/migrations/085_script_runs_kind.sql +9 -0
  14. package/src/be/migrations/086_pages_default_authed.sql +64 -0
  15. package/src/be/migrations/087_skill_files.sql +19 -0
  16. package/src/be/modelsdev-cache.json +5622 -2543
  17. package/src/be/seed-scripts/catalog/boot-triage.ts +221 -0
  18. package/src/be/seed-scripts/catalog/catalog-report.ts +457 -0
  19. package/src/be/seed-scripts/catalog/compound-insights.ts +465 -0
  20. package/src/be/seed-scripts/catalog/gh-pr-snapshot.ts +1 -1
  21. package/src/be/seed-scripts/catalog/memory-eval.ts +1059 -0
  22. package/src/be/seed-scripts/catalog/ops-catalog-audit.ts +34 -439
  23. package/src/be/seed-scripts/catalog/schedule-health.ts +78 -2
  24. package/src/be/seed-scripts/catalog/task-failure-audit.ts +48 -1
  25. package/src/be/seed-scripts/index.ts +32 -4
  26. package/src/be/seed-skills/index.ts +0 -7
  27. package/src/be/skill-sync.ts +91 -7
  28. package/src/commands/runner.ts +6 -2
  29. package/src/heartbeat/templates.ts +20 -16
  30. package/src/http/index.ts +50 -7
  31. package/src/http/mcp-user.ts +23 -0
  32. package/src/http/mcp.ts +58 -0
  33. package/src/http/memory.ts +62 -0
  34. package/src/http/pages.ts +1 -1
  35. package/src/http/script-runs.ts +2 -0
  36. package/src/http/scripts.ts +39 -2
  37. package/src/http/skills.ts +225 -0
  38. package/src/providers/claude-adapter.ts +56 -24
  39. package/src/script-workflows/workflow-ctx.ts +7 -3
  40. package/src/scripts-runtime/sdk-allowlist.ts +1 -0
  41. package/src/scripts-runtime/swarm-sdk.ts +13 -0
  42. package/src/scripts-runtime/types/stdlib.d.ts +1 -0
  43. package/src/scripts-runtime/types/swarm-sdk.d.ts +1 -0
  44. package/src/server.ts +2 -0
  45. package/src/tasks/worker-follow-up.ts +12 -0
  46. package/src/tests/claude-adapter-binary.test.ts +135 -81
  47. package/src/tests/create-page-tool.test.ts +19 -2
  48. package/src/tests/heartbeat-checklist.test.ts +36 -0
  49. package/src/tests/mcp-transport-gc.test.ts +58 -0
  50. package/src/tests/memory-e2e.test.ts +6 -6
  51. package/src/tests/memory-health-endpoint.test.ts +78 -0
  52. package/src/tests/memory-rater-e2e.test.ts +4 -5
  53. package/src/tests/memory-reranker.test.ts +135 -124
  54. package/src/tests/memory-store.test.ts +221 -1
  55. package/src/tests/memory.test.ts +13 -12
  56. package/src/tests/pages-http.test.ts +20 -2
  57. package/src/tests/pages-storage.test.ts +26 -0
  58. package/src/tests/scripts-mcp-e2e.test.ts +53 -0
  59. package/src/tests/seed-scripts.test.ts +328 -3
  60. package/src/tests/skill-files-http.test.ts +171 -0
  61. package/src/tests/skill-files.test.ts +162 -0
  62. package/src/tests/skill-get-file-tool.test.ts +110 -0
  63. package/src/tests/skill-sync.test.ts +125 -6
  64. package/src/tests/task-cascade-fail.test.ts +304 -0
  65. package/src/tools/create-page.ts +2 -2
  66. package/src/tools/skills/index.ts +1 -0
  67. package/src/tools/skills/skill-get-file.ts +80 -0
  68. package/src/tools/tool-config.ts +2 -1
  69. package/src/types.ts +20 -0
  70. package/src/utils/internal-ai/complete-structured.ts +2 -2
  71. package/templates/schedules/daily-blocker-digest/content.md +68 -54
  72. package/templates/schedules/daily-compounding-reflection/content.md +4 -4
  73. package/templates/schedules/daily-hn-briefing/content.md +5 -5
  74. package/templates/schedules/daily-workflow-health-audit/content.md +6 -6
  75. package/templates/schedules/gtm-weekly-review/content.md +9 -9
  76. package/templates/schedules/weekly-dependabot-triage/content.md +24 -20
  77. package/templates/skills/agentmail-sending/content.md +6 -7
  78. package/templates/skills/desloppify/content.md +8 -9
  79. package/templates/skills/jira-interaction/content.md +25 -33
  80. package/templates/skills/kapso-whatsapp/content.md +29 -30
  81. package/templates/skills/linear-interaction/content.md +8 -9
  82. package/templates/skills/profile-corruption-escalation/content.md +44 -85
  83. package/templates/skills/sprite-cli/content.md +4 -5
  84. package/templates/skills/turso-interaction/content.md +14 -17
  85. package/templates/skills/workflow-iterate/content.md +38 -391
  86. package/templates/skills/x-api-interactions/content.md +4 -6
  87. package/templates/workflows/llm-safe-release-context/config.json +13 -0
  88. package/templates/workflows/llm-safe-release-context/content.md +69 -0
  89. package/templates/skills/scheduled-task-resilience/config.json +0 -14
  90. package/templates/skills/scheduled-task-resilience/content.md +0 -95
package/src/be/db.ts CHANGED
@@ -64,6 +64,7 @@ import type {
64
64
  ScheduledTaskSummary,
65
65
  ScriptRun,
66
66
  ScriptRunJournalEntry,
67
+ ScriptRunKind,
67
68
  ScriptRunStatus,
68
69
  Service,
69
70
  ServiceStatus,
@@ -71,6 +72,7 @@ import type {
71
72
  SessionCostSource,
72
73
  SessionLog,
73
74
  Skill,
75
+ SkillFile,
74
76
  SkillScope,
75
77
  SkillType,
76
78
  SkillWithInstallInfo,
@@ -113,6 +115,26 @@ export function isSqliteVecAvailable(): boolean {
113
115
  return sqliteVecAvailable;
114
116
  }
115
117
 
118
+ function loadSqliteVec(database: Database): void {
119
+ sqliteVecAvailable = false;
120
+ try {
121
+ const extensionPath = process.env.SQLITE_VEC_EXTENSION_PATH;
122
+ if (extensionPath) {
123
+ database.loadExtension(extensionPath);
124
+ } else {
125
+ const sqliteVec = require("sqlite-vec");
126
+ sqliteVec.load(database);
127
+ }
128
+ sqliteVecAvailable = true;
129
+ console.log(`[db] sqlite-vec loaded${extensionPath ? ` from ${extensionPath}` : ""}`);
130
+ } catch (err) {
131
+ console.warn(
132
+ "[db] sqlite-vec not available, falling back to in-memory cosine:",
133
+ (err as Error).message,
134
+ );
135
+ }
136
+ }
137
+
116
138
  export function initDb(dbPath = "./agent-swarm-db.sqlite"): Database {
117
139
  if (db) {
118
140
  return db;
@@ -129,6 +151,7 @@ export function initDb(dbPath = "./agent-swarm-db.sqlite"): Database {
129
151
  db = Database.deserialize(templateBytes);
130
152
  db.run("PRAGMA busy_timeout = 5000;");
131
153
  db.run("PRAGMA foreign_keys = ON;");
154
+ loadSqliteVec(db);
132
155
  configureDbResolver(resolvePromptTemplate);
133
156
  // Ensure the encryption key is resolved even when restoring from the test
134
157
  // template. The cache may have been cleared via __resetEncryptionKeyForTests
@@ -154,22 +177,7 @@ export function initDb(dbPath = "./agent-swarm-db.sqlite"): Database {
154
177
  // `require.resolve("sqlite-vec-<platform>/vec0.so")` can't find the native
155
178
  // asset — so we prefer an explicit filesystem path when set, and only fall
156
179
  // back to the npm resolver for normal dev runs.
157
- try {
158
- const extensionPath = process.env.SQLITE_VEC_EXTENSION_PATH;
159
- if (extensionPath) {
160
- database.loadExtension(extensionPath);
161
- } else {
162
- const sqliteVec = require("sqlite-vec");
163
- sqliteVec.load(database);
164
- }
165
- sqliteVecAvailable = true;
166
- console.log(`[db] sqlite-vec loaded${extensionPath ? ` from ${extensionPath}` : ""}`);
167
- } catch (err) {
168
- console.warn(
169
- "[db] sqlite-vec not available, falling back to in-memory cosine:",
170
- (err as Error).message,
171
- );
172
- }
180
+ loadSqliteVec(database);
173
181
 
174
182
  // Run database migrations (schema creation + incremental changes)
175
183
  runMigrations(database);
@@ -347,6 +355,7 @@ export function closeDb(): void {
347
355
  db.close();
348
356
  db = null;
349
357
  }
358
+ sqliteVecAvailable = false;
350
359
  }
351
360
 
352
361
  // ============================================================================
@@ -2108,6 +2117,14 @@ export function failTask(id: string, reason: string): AgentTask | null {
2108
2117
  });
2109
2118
  });
2110
2119
  } catch {}
2120
+
2121
+ // Cascade-fail any non-terminal tasks that depend on this one.
2122
+ // The cascade is recursive (transitive closure) and cycle-safe.
2123
+ try {
2124
+ cascadeFailDependents(id, "failed");
2125
+ } catch (err) {
2126
+ console.error("[failTask] cascade-fail dependents error:", err);
2127
+ }
2111
2128
  }
2112
2129
  return row ? rowToAgentTask(row) : null;
2113
2130
  }
@@ -2146,6 +2163,12 @@ export function cancelTask(id: string, reason?: string): AgentTask | null {
2146
2163
  });
2147
2164
  });
2148
2165
  } catch {}
2166
+
2167
+ try {
2168
+ cascadeFailDependents(id, "cancelled");
2169
+ } catch (err) {
2170
+ console.error("[cancelTask] cascade-fail dependents error:", err);
2171
+ }
2149
2172
  }
2150
2173
 
2151
2174
  return row ? rowToAgentTask(row) : null;
@@ -2209,6 +2232,12 @@ export function supersedeTask(
2209
2232
  });
2210
2233
  });
2211
2234
  } catch {}
2235
+
2236
+ try {
2237
+ cascadeFailDependents(id, "superseded");
2238
+ } catch (err) {
2239
+ console.error("[supersedeTask] cascade-fail dependents error:", err);
2240
+ }
2212
2241
  }
2213
2242
 
2214
2243
  return row ? rowToAgentTask(row) : null;
@@ -3381,6 +3410,75 @@ export function checkDependencies(taskId: string): {
3381
3410
  return { ready: blockedBy.length === 0, blockedBy };
3382
3411
  }
3383
3412
 
3413
+ /**
3414
+ * Reverse-lookup: find all tasks whose `dependsOn` JSON array contains `parentId`.
3415
+ * Uses SQLite `json_each` to scan the dependsOn column efficiently.
3416
+ * Returns only non-terminal tasks by default (the callers want to cascade-fail
3417
+ * live dependents, not re-process already-finished ones).
3418
+ */
3419
+ export function getDependentTasks(
3420
+ parentId: string,
3421
+ opts?: { includeTerminal?: boolean },
3422
+ ): AgentTask[] {
3423
+ const database = getDb();
3424
+ const rows = database
3425
+ .prepare<AgentTaskRow, [string]>(
3426
+ `SELECT t.*
3427
+ FROM agent_tasks t, json_each(t.dependsOn) AS dep
3428
+ WHERE dep.value = ?`,
3429
+ )
3430
+ .all(parentId);
3431
+
3432
+ const tasks = rows.map(rowToAgentTask);
3433
+ if (opts?.includeTerminal) return tasks;
3434
+ return tasks.filter((t) => !isTerminalTaskStatus(t.status));
3435
+ }
3436
+
3437
+ export interface CascadeFailResult {
3438
+ taskId: string;
3439
+ taskSubject: string;
3440
+ }
3441
+
3442
+ /**
3443
+ * Recursively cascade-fail all transitive dependents of a parent task.
3444
+ * Walks the full dependency graph: if A fails, and B depends on A, and C
3445
+ * depends on B, then both B and C are failed.
3446
+ *
3447
+ * Guards against cycles with a visited set. Skips already-terminal tasks.
3448
+ * Returns the list of tasks that were actually cascade-failed (for follow-up
3449
+ * enrichment).
3450
+ */
3451
+ export function cascadeFailDependents(
3452
+ parentId: string,
3453
+ parentStatus: string,
3454
+ visited?: Set<string>,
3455
+ ): CascadeFailResult[] {
3456
+ const seen = visited ?? new Set<string>();
3457
+ if (seen.has(parentId)) return [];
3458
+ seen.add(parentId);
3459
+
3460
+ const dependents = getDependentTasks(parentId);
3461
+ const results: CascadeFailResult[] = [];
3462
+
3463
+ for (const dep of dependents) {
3464
+ if (seen.has(dep.id)) continue;
3465
+
3466
+ const reason = `Blocked dependency ${parentId.slice(0, 8)} was ${parentStatus}`;
3467
+ const failed = failTask(dep.id, reason);
3468
+ if (failed) {
3469
+ results.push({
3470
+ taskId: failed.id,
3471
+ taskSubject: failed.task.slice(0, 120),
3472
+ });
3473
+ // Recurse: this dependent may itself have dependents
3474
+ const transitive = cascadeFailDependents(dep.id, "failed (cascade)", seen);
3475
+ results.push(...transitive);
3476
+ }
3477
+ }
3478
+
3479
+ return results;
3480
+ }
3481
+
3384
3482
  // ============================================================================
3385
3483
  // Agent Profile Operations
3386
3484
  // ============================================================================
@@ -7091,7 +7189,7 @@ export function createPage(data: {
7091
7189
  title: string;
7092
7190
  description?: string;
7093
7191
  contentType: PageContentType;
7094
- authMode: PageAuthMode;
7192
+ authMode?: PageAuthMode;
7095
7193
  passwordHash?: string;
7096
7194
  body: string;
7097
7195
  needsCredentials?: string[];
@@ -7110,7 +7208,7 @@ export function createPage(data: {
7110
7208
  data.title,
7111
7209
  data.description ?? null,
7112
7210
  data.contentType,
7113
- data.authMode,
7211
+ data.authMode ?? "authed",
7114
7212
  data.passwordHash ?? null,
7115
7213
  data.body,
7116
7214
  data.needsCredentials ? JSON.stringify(data.needsCredentials) : null,
@@ -8692,6 +8790,119 @@ function rowToSkillWithInstall(row: SkillWithInstallRow): SkillWithInstallInfo {
8692
8790
  };
8693
8791
  }
8694
8792
 
8793
+ type SkillFileRow = {
8794
+ id: string;
8795
+ skillId: string;
8796
+ path: string;
8797
+ content: string;
8798
+ mimeType: string;
8799
+ isBinary: number;
8800
+ size: number | null;
8801
+ createdAt: string;
8802
+ lastUpdatedAt: string;
8803
+ };
8804
+
8805
+ function rowToSkillFile(row: SkillFileRow): SkillFile {
8806
+ return {
8807
+ id: row.id,
8808
+ skillId: row.skillId,
8809
+ path: row.path,
8810
+ content: row.content,
8811
+ mimeType: row.mimeType,
8812
+ isBinary: row.isBinary === 1,
8813
+ size: row.size,
8814
+ createdAt: row.createdAt,
8815
+ lastUpdatedAt: row.lastUpdatedAt,
8816
+ };
8817
+ }
8818
+
8819
+ export type SkillFileInput = {
8820
+ path: string;
8821
+ content: string;
8822
+ mimeType?: string;
8823
+ isBinary?: boolean;
8824
+ size?: number | null;
8825
+ };
8826
+
8827
+ export type SkillFileManifestEntry = Omit<SkillFile, "content">;
8828
+ type NormalizedSkillFileInput = {
8829
+ path: string;
8830
+ content: string;
8831
+ mimeType: string;
8832
+ isBinary: boolean;
8833
+ size: number;
8834
+ };
8835
+
8836
+ export const SKILL_FILE_LIMITS = {
8837
+ maxCount: Number(process.env.SKILL_FILES_MAX_COUNT ?? 100),
8838
+ maxTotalBytes: Number(process.env.SKILL_FILES_MAX_TOTAL_BYTES ?? 10 * 1024 * 1024),
8839
+ maxFileBytes: Number(process.env.SKILL_FILES_MAX_FILE_BYTES ?? 500 * 1024),
8840
+ };
8841
+
8842
+ const BINARY_SKILL_FILE_PLACEHOLDER = "[binary file - not synced]";
8843
+
8844
+ export function normalizeSkillFilePath(path: string): string {
8845
+ const raw = path.trim().replace(/\\/g, "/");
8846
+ if (!raw) throw new Error("File path is required");
8847
+ if (raw.startsWith("/")) throw new Error("File path must be relative");
8848
+
8849
+ const parts = raw.split("/").filter(Boolean);
8850
+ if (parts.length === 0) throw new Error("File path is required");
8851
+ if (parts.some((part) => part === "." || part === "..")) {
8852
+ throw new Error("File path cannot contain traversal segments");
8853
+ }
8854
+
8855
+ const normalized = parts.join("/");
8856
+ if (normalized === "SKILL.md") {
8857
+ throw new Error("SKILL.md is stored on the skill record, not in skill_files");
8858
+ }
8859
+ return normalized;
8860
+ }
8861
+
8862
+ function byteSize(content: string): number {
8863
+ return Buffer.byteLength(content, "utf8");
8864
+ }
8865
+
8866
+ function normalizeSkillFileInput(input: SkillFileInput): NormalizedSkillFileInput {
8867
+ const path = normalizeSkillFilePath(input.path);
8868
+ const isBinary = input.isBinary === true;
8869
+ const content = isBinary ? input.content || BINARY_SKILL_FILE_PLACEHOLDER : input.content;
8870
+ const size = input.size ?? byteSize(content);
8871
+ if (!Number.isFinite(size) || size < 0) {
8872
+ throw new Error("File size must be a non-negative number");
8873
+ }
8874
+ if (size > SKILL_FILE_LIMITS.maxFileBytes) {
8875
+ throw new Error(`File ${path} exceeds max size ${SKILL_FILE_LIMITS.maxFileBytes}`);
8876
+ }
8877
+
8878
+ return {
8879
+ path,
8880
+ content,
8881
+ mimeType: input.mimeType ?? "text/plain",
8882
+ isBinary,
8883
+ size,
8884
+ };
8885
+ }
8886
+
8887
+ function assertSkillFileLimits(skillId: string, incoming: SkillFileInput[], replaceAll: boolean) {
8888
+ const existing = replaceAll ? [] : listSkillFileManifest(skillId);
8889
+ const byPath = new Map(existing.map((file) => [file.path, file.size ?? 0]));
8890
+
8891
+ for (const input of incoming) {
8892
+ const normalized = normalizeSkillFileInput(input);
8893
+ byPath.set(normalized.path, normalized.size);
8894
+ }
8895
+
8896
+ if (byPath.size > SKILL_FILE_LIMITS.maxCount) {
8897
+ throw new Error(`Skill file count exceeds max ${SKILL_FILE_LIMITS.maxCount}`);
8898
+ }
8899
+
8900
+ const total = [...byPath.values()].reduce((sum, size) => sum + size, 0);
8901
+ if (total > SKILL_FILE_LIMITS.maxTotalBytes) {
8902
+ throw new Error(`Skill files exceed max total size ${SKILL_FILE_LIMITS.maxTotalBytes}`);
8903
+ }
8904
+ }
8905
+
8695
8906
  export interface SkillInsert {
8696
8907
  name: string;
8697
8908
  description: string;
@@ -8865,6 +9076,124 @@ export function updateSkill(
8865
9076
  return row ? rowToSkill(row) : null;
8866
9077
  }
8867
9078
 
9079
+ function bumpSkillVersion(skillId: string, now = new Date().toISOString()) {
9080
+ getDb()
9081
+ .prepare("UPDATE skills SET version = version + 1, lastUpdatedAt = ? WHERE id = ?")
9082
+ .run(now, skillId);
9083
+ }
9084
+
9085
+ export function listSkillFileManifest(skillId: string): SkillFileManifestEntry[] {
9086
+ return getDb()
9087
+ .prepare<SkillFileRow, [string]>(
9088
+ `SELECT id, skillId, path, content, mimeType, isBinary, size, createdAt, lastUpdatedAt
9089
+ FROM skill_files
9090
+ WHERE skillId = ?
9091
+ ORDER BY path ASC`,
9092
+ )
9093
+ .all(skillId)
9094
+ .map((row) => {
9095
+ const { content: _content, ...manifest } = rowToSkillFile(row);
9096
+ return manifest;
9097
+ });
9098
+ }
9099
+
9100
+ export function getSkillFiles(skillId: string): SkillFile[] {
9101
+ return getDb()
9102
+ .prepare<SkillFileRow, [string]>(
9103
+ `SELECT id, skillId, path, content, mimeType, isBinary, size, createdAt, lastUpdatedAt
9104
+ FROM skill_files
9105
+ WHERE skillId = ?
9106
+ ORDER BY path ASC`,
9107
+ )
9108
+ .all(skillId)
9109
+ .map(rowToSkillFile);
9110
+ }
9111
+
9112
+ export function getSkillFile(skillId: string, path: string): SkillFile | null {
9113
+ const normalizedPath = normalizeSkillFilePath(path);
9114
+ const row = getDb()
9115
+ .prepare<SkillFileRow, [string, string]>(
9116
+ `SELECT id, skillId, path, content, mimeType, isBinary, size, createdAt, lastUpdatedAt
9117
+ FROM skill_files
9118
+ WHERE skillId = ? AND path = ?`,
9119
+ )
9120
+ .get(skillId, normalizedPath);
9121
+ return row ? rowToSkillFile(row) : null;
9122
+ }
9123
+
9124
+ export function upsertSkillFile(skillId: string, input: SkillFileInput): SkillFile {
9125
+ const payload = normalizeSkillFileInput(input);
9126
+ assertSkillFileLimits(skillId, [payload], false);
9127
+
9128
+ const id = crypto.randomUUID();
9129
+ const now = new Date().toISOString();
9130
+ return upsertSkillFileUnchecked(skillId, payload, id, now, true);
9131
+ }
9132
+
9133
+ function upsertSkillFileUnchecked(
9134
+ skillId: string,
9135
+ payload: NormalizedSkillFileInput,
9136
+ id: string,
9137
+ now: string,
9138
+ bumpVersion: boolean,
9139
+ ): SkillFile {
9140
+ const row = getDb()
9141
+ .prepare<SkillFileRow, (string | number | null)[]>(
9142
+ `INSERT INTO skill_files (
9143
+ id, skillId, path, content, mimeType, isBinary, size, createdAt, lastUpdatedAt
9144
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
9145
+ ON CONFLICT(skillId, path) DO UPDATE SET
9146
+ content = excluded.content,
9147
+ mimeType = excluded.mimeType,
9148
+ isBinary = excluded.isBinary,
9149
+ size = excluded.size,
9150
+ lastUpdatedAt = excluded.lastUpdatedAt
9151
+ RETURNING *`,
9152
+ )
9153
+ .get(
9154
+ id,
9155
+ skillId,
9156
+ payload.path,
9157
+ payload.content,
9158
+ payload.mimeType,
9159
+ payload.isBinary ? 1 : 0,
9160
+ payload.size,
9161
+ now,
9162
+ now,
9163
+ );
9164
+
9165
+ if (!row) throw new Error("Failed to upsert skill file");
9166
+ if (bumpVersion) bumpSkillVersion(skillId, now);
9167
+ return rowToSkillFile(row);
9168
+ }
9169
+
9170
+ export function upsertSkillFiles(skillId: string, files: SkillFileInput[]): SkillFile[] {
9171
+ if (files.length === 0) return [];
9172
+ const normalized = files.map(normalizeSkillFileInput);
9173
+ assertSkillFileLimits(skillId, normalized, false);
9174
+
9175
+ const now = new Date().toISOString();
9176
+ return getDb().transaction(() => {
9177
+ const rows = normalized.map((file) =>
9178
+ upsertSkillFileUnchecked(skillId, file, crypto.randomUUID(), now, false),
9179
+ );
9180
+ bumpSkillVersion(skillId, now);
9181
+ return rows;
9182
+ })();
9183
+ }
9184
+
9185
+ export function deleteSkillFile(skillId: string, path: string): boolean {
9186
+ const normalizedPath = normalizeSkillFilePath(path);
9187
+ const result = getDb()
9188
+ .prepare("DELETE FROM skill_files WHERE skillId = ? AND path = ?")
9189
+ .run(skillId, normalizedPath);
9190
+ if (result.changes > 0) {
9191
+ bumpSkillVersion(skillId);
9192
+ return true;
9193
+ }
9194
+ return false;
9195
+ }
9196
+
8868
9197
  export function deleteSkill(id: string): boolean {
8869
9198
  const result = getDb().prepare("DELETE FROM skills WHERE id = ?").run(id);
8870
9199
  return result.changes > 0;
@@ -11231,6 +11560,7 @@ type ScriptRunRow = {
11231
11560
  scriptName: string | null;
11232
11561
  source: string;
11233
11562
  args: string;
11563
+ kind: string;
11234
11564
  status: string;
11235
11565
  pid: number | null;
11236
11566
  startedAt: string;
@@ -11256,6 +11586,7 @@ function rowToScriptRun(row: ScriptRunRow): ScriptRun {
11256
11586
  scriptName: row.scriptName ?? undefined,
11257
11587
  source: row.source,
11258
11588
  args: JSON.parse(row.args),
11589
+ kind: row.kind as ScriptRunKind,
11259
11590
  status: row.status as ScriptRunStatus,
11260
11591
  pid: row.pid ?? undefined,
11261
11592
  startedAt: row.startedAt,
@@ -11322,6 +11653,67 @@ export function createScriptRun(data: {
11322
11653
  return { run: rowToScriptRun(row), existing: false };
11323
11654
  }
11324
11655
 
11656
+ // Persist a synchronous inline run (POST /api/scripts/run) as an already-terminal
11657
+ // row. Unlike createScriptRun these never get a journal and never use the
11658
+ // idempotencyKey column (inline idempotency lives in the kv table).
11659
+ export function recordInlineScriptRun(data: {
11660
+ id: string;
11661
+ agentId: string;
11662
+ source: string;
11663
+ args: unknown;
11664
+ scriptName?: string;
11665
+ status: "completed" | "failed";
11666
+ output?: unknown;
11667
+ error?: string;
11668
+ startedAt: string;
11669
+ finishedAt: string;
11670
+ requestedByUserId?: string;
11671
+ createdBy?: string;
11672
+ }): ScriptRun {
11673
+ const row = getDb()
11674
+ .prepare<
11675
+ ScriptRunRow,
11676
+ [
11677
+ string,
11678
+ string,
11679
+ string | null,
11680
+ string,
11681
+ string,
11682
+ string,
11683
+ string | null,
11684
+ string | null,
11685
+ string,
11686
+ string,
11687
+ string | null,
11688
+ string | null,
11689
+ string | null,
11690
+ ]
11691
+ >(
11692
+ `INSERT INTO script_runs
11693
+ (id, agentId, scriptName, source, args, kind, status, output, error,
11694
+ startedAt, finishedAt, requestedByUserId, created_by, updated_by)
11695
+ VALUES (?, ?, ?, ?, ?, 'inline', ?, ?, ?, ?, ?, ?, ?, ?)
11696
+ RETURNING *`,
11697
+ )
11698
+ .get(
11699
+ data.id,
11700
+ data.agentId,
11701
+ data.scriptName ?? null,
11702
+ data.source,
11703
+ JSON.stringify(data.args ?? null),
11704
+ data.status,
11705
+ data.output === undefined ? null : JSON.stringify(data.output),
11706
+ data.error ?? null,
11707
+ data.startedAt,
11708
+ data.finishedAt,
11709
+ data.requestedByUserId ?? null,
11710
+ data.createdBy ?? null,
11711
+ data.createdBy ?? null,
11712
+ );
11713
+ if (!row) throw new Error("Failed to record inline script run");
11714
+ return rowToScriptRun(row);
11715
+ }
11716
+
11325
11717
  export function getScriptRun(id: string): ScriptRun | null {
11326
11718
  const row = getDb()
11327
11719
  .prepare<ScriptRunRow, [string]>("SELECT * FROM script_runs WHERE id = ?")
@@ -11459,6 +11851,7 @@ type ScriptRunJournalRow = {
11459
11851
  error: string | null;
11460
11852
  startedAt: string;
11461
11853
  completedAt: string | null;
11854
+ durationMs: number | null;
11462
11855
  created_by: string | null;
11463
11856
  updated_by: string | null;
11464
11857
  };
@@ -11475,6 +11868,7 @@ function rowToScriptRunJournalEntry(row: ScriptRunJournalRow): ScriptRunJournalE
11475
11868
  error: row.error ?? undefined,
11476
11869
  startedAt: row.startedAt,
11477
11870
  completedAt: row.completedAt ?? undefined,
11871
+ durationMs: row.durationMs ?? undefined,
11478
11872
  };
11479
11873
  }
11480
11874
 
@@ -11498,11 +11892,12 @@ export function upsertScriptRunJournalStep(data: {
11498
11892
  status: "completed" | "failed";
11499
11893
  result?: unknown;
11500
11894
  error?: string;
11895
+ durationMs?: number;
11501
11896
  }): void {
11502
11897
  getDb().run(
11503
11898
  `INSERT OR IGNORE INTO script_run_journal
11504
- (id, runId, stepKey, stepType, config, status, result, error, completedAt)
11505
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))`,
11899
+ (id, runId, stepKey, stepType, config, status, result, error, durationMs, completedAt)
11900
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))`,
11506
11901
  [
11507
11902
  crypto.randomUUID(),
11508
11903
  data.runId,
@@ -11512,6 +11907,7 @@ export function upsertScriptRunJournalStep(data: {
11512
11907
  data.status,
11513
11908
  data.result !== undefined ? JSON.stringify(data.result) : null,
11514
11909
  data.error ?? null,
11910
+ data.durationMs ?? null,
11515
11911
  ],
11516
11912
  );
11517
11913
  }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Startup backfill: detect agent_memory rows with wrong-dimension embeddings
3
+ * (not 512d) and re-embed them in the background. Runs once per boot,
4
+ * async/non-blocking, idempotent, no-op when the DB is clean.
5
+ *
6
+ * This is the app-level equivalent of a forward-only migration — SQL can't
7
+ * call OpenAI, so the backfill runs at startup instead.
8
+ */
9
+
10
+ import { getDb } from "@/be/db";
11
+ import { EMBEDDING_DIMENSIONS } from "./constants";
12
+ import { getEmbeddingProvider, getMemoryStore } from "./index";
13
+
14
+ const VECTOR_BYTES = EMBEDDING_DIMENSIONS * Float32Array.BYTES_PER_ELEMENT;
15
+ const BATCH_SIZE = 20;
16
+ const BACKFILL_KV_KEY = "memory:reembed:backfill_complete";
17
+
18
+ export async function runBootReembed(): Promise<void> {
19
+ const db = getDb();
20
+
21
+ const invalidCount =
22
+ db
23
+ .prepare<{ count: number }, []>(
24
+ `SELECT COUNT(*) as count FROM agent_memory
25
+ WHERE embedding IS NOT NULL AND length(embedding) != ${VECTOR_BYTES}`,
26
+ )
27
+ .get()?.count ?? 0;
28
+
29
+ if (invalidCount === 0) {
30
+ return;
31
+ }
32
+
33
+ const provider = getEmbeddingProvider();
34
+ const testEmbed = await provider.embed("test");
35
+ if (!testEmbed) {
36
+ console.warn(
37
+ `[boot-reembed] skipped: ${invalidCount} wrong-dimension rows found but no OpenAI key configured`,
38
+ );
39
+ return;
40
+ }
41
+
42
+ console.log(`[boot-reembed] starting: ${invalidCount} rows with wrong embedding dimensions`);
43
+
44
+ const store = getMemoryStore();
45
+ const rows = db
46
+ .prepare<{ id: string; content: string }, []>(
47
+ `SELECT id, content FROM agent_memory
48
+ WHERE embedding IS NOT NULL AND length(embedding) != ${VECTOR_BYTES}`,
49
+ )
50
+ .all();
51
+
52
+ let reembedded = 0;
53
+ let failed = 0;
54
+
55
+ for (let i = 0; i < rows.length; i += BATCH_SIZE) {
56
+ const batch = rows.slice(i, i + BATCH_SIZE);
57
+ try {
58
+ const embeddings = await provider.embedBatch(batch.map((m) => m.content));
59
+ for (let j = 0; j < embeddings.length; j++) {
60
+ if (embeddings[j]) {
61
+ store.updateEmbedding(batch[j]!.id, embeddings[j]!, provider.name);
62
+ reembedded++;
63
+ }
64
+ }
65
+ } catch (err) {
66
+ failed += batch.length;
67
+ console.error(
68
+ `[boot-reembed] batch ${Math.floor(i / BATCH_SIZE) + 1} failed:`,
69
+ (err as Error).message,
70
+ );
71
+ }
72
+ }
73
+
74
+ const afterInvalid =
75
+ db
76
+ .prepare<{ count: number }, []>(
77
+ `SELECT COUNT(*) as count FROM agent_memory
78
+ WHERE embedding IS NOT NULL AND length(embedding) != ${VECTOR_BYTES}`,
79
+ )
80
+ .get()?.count ?? 0;
81
+
82
+ console.log(
83
+ `[boot-reembed] complete: reembedded=${reembedded} failed=${failed} remaining_invalid=${afterInvalid}`,
84
+ );
85
+ }