@mulmoclaude/core 0.24.0 → 0.25.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 (103) hide show
  1. package/assets/helps/collection-skills.md +51 -0
  2. package/assets/helps/custom-view.md +37 -3
  3. package/assets/helps/error-recovery.md +30 -0
  4. package/assets/helps/google-calendar-collection.md +79 -0
  5. package/assets/helps/index.md +1 -0
  6. package/dist/collection/core/project.d.ts +3 -0
  7. package/dist/collection/core/schema.d.ts +13 -1
  8. package/dist/collection/core/schemaZ.d.ts +45 -0
  9. package/dist/collection/index.cjs +18 -14
  10. package/dist/collection/index.cjs.map +1 -1
  11. package/dist/collection/index.d.ts +1 -0
  12. package/dist/collection/index.js +4 -2
  13. package/dist/collection/index.js.map +1 -1
  14. package/dist/collection/paths.cjs +5 -55
  15. package/dist/collection/paths.js +1 -51
  16. package/dist/collection/registry/server/index.cjs +20 -19
  17. package/dist/collection/registry/server/index.cjs.map +1 -1
  18. package/dist/collection/registry/server/index.js +3 -2
  19. package/dist/collection/registry/server/index.js.map +1 -1
  20. package/dist/collection/server/csvStore.d.ts +8 -1
  21. package/dist/collection/server/discoveredCollection.d.ts +4 -0
  22. package/dist/collection/server/discovery.d.ts +1 -0
  23. package/dist/collection/server/index.cjs +58 -54
  24. package/dist/collection/server/index.d.ts +1 -0
  25. package/dist/collection/server/index.js +5 -4
  26. package/dist/collection/server/queryRunner.d.ts +4 -0
  27. package/dist/collection/server/spawn.d.ts +2 -1
  28. package/dist/collection/server/sqliteStore.d.ts +14 -0
  29. package/dist/collection/server/store.d.ts +43 -8
  30. package/dist/collection/server/storePage.d.ts +32 -0
  31. package/dist/collection-watchers/index.cjs +131 -44
  32. package/dist/collection-watchers/index.cjs.map +1 -1
  33. package/dist/collection-watchers/index.d.ts +1 -1
  34. package/dist/collection-watchers/index.js +125 -39
  35. package/dist/collection-watchers/index.js.map +1 -1
  36. package/dist/collection-watchers/reconciler.d.ts +13 -11
  37. package/dist/collection-watchers/watcher.d.ts +4 -3
  38. package/dist/{server-BpEo0FOd.js → discovery-QUi_7aNG.js} +414 -1495
  39. package/dist/discovery-QUi_7aNG.js.map +1 -0
  40. package/dist/{server-CkysfLh4.cjs → discovery-cwtfyDxl.cjs} +463 -1658
  41. package/dist/discovery-cwtfyDxl.cjs.map +1 -0
  42. package/dist/feeds/index.cjs +4 -4
  43. package/dist/feeds/index.js +2 -2
  44. package/dist/feeds/server/index.cjs +35 -22
  45. package/dist/feeds/server/index.cjs.map +1 -1
  46. package/dist/feeds/server/index.js +26 -13
  47. package/dist/feeds/server/index.js.map +1 -1
  48. package/dist/google/apiClient.d.ts +8 -1
  49. package/dist/google/calendar.d.ts +35 -0
  50. package/dist/google/calendarSyncStore.d.ts +7 -0
  51. package/dist/google/collectionSync.d.ts +70 -0
  52. package/dist/google/index.cjs +294 -4
  53. package/dist/google/index.cjs.map +1 -1
  54. package/dist/google/index.d.ts +4 -2
  55. package/dist/google/index.js +279 -5
  56. package/dist/google/index.js.map +1 -1
  57. package/dist/ids-Dl9Ij63K.cjs +163 -0
  58. package/dist/ids-Dl9Ij63K.cjs.map +1 -0
  59. package/dist/ids-P2T89Gou.js +92 -0
  60. package/dist/ids-P2T89Gou.js.map +1 -0
  61. package/dist/{ingestTypes-CFEgTItj.js → ingestTypes-C-IQB4nw.js} +2 -2
  62. package/dist/{ingestTypes-CFEgTItj.js.map → ingestTypes-C-IQB4nw.js.map} +1 -1
  63. package/dist/{ingestTypes-Stc7iQyA.cjs → ingestTypes-DZpAC3N_.cjs} +3 -3
  64. package/dist/{ingestTypes-Stc7iQyA.cjs.map → ingestTypes-DZpAC3N_.cjs.map} +1 -1
  65. package/dist/project-BWI5w_BT.cjs +17 -0
  66. package/dist/project-BWI5w_BT.cjs.map +1 -0
  67. package/dist/project-bU98ycsy.js +12 -0
  68. package/dist/project-bU98ycsy.js.map +1 -0
  69. package/dist/{promptSafety-BLUcImzd.js → promptSafety-Boz5qOs-.js} +2 -84
  70. package/dist/promptSafety-Boz5qOs-.js.map +1 -0
  71. package/dist/{promptSafety-DI_1Rvhk.cjs → promptSafety-Co4cYQ_Z.cjs} +1 -149
  72. package/dist/promptSafety-Co4cYQ_Z.cjs.map +1 -0
  73. package/dist/remote-host/server/index.cjs +6 -4
  74. package/dist/remote-host/server/index.cjs.map +1 -1
  75. package/dist/remote-host/server/index.js +6 -4
  76. package/dist/remote-host/server/index.js.map +1 -1
  77. package/dist/remote-view/index.cjs +5 -3
  78. package/dist/remote-view/index.cjs.map +1 -1
  79. package/dist/remote-view/index.d.ts +3 -1
  80. package/dist/remote-view/index.js +5 -3
  81. package/dist/remote-view/index.js.map +1 -1
  82. package/dist/scheduler/index.cjs +3 -1
  83. package/dist/scheduler/index.cjs.map +1 -1
  84. package/dist/scheduler/index.js +3 -1
  85. package/dist/scheduler/index.js.map +1 -1
  86. package/dist/server-BI3wnTwl.js +1529 -0
  87. package/dist/server-BI3wnTwl.js.map +1 -0
  88. package/dist/server-CKdWENZ-.cjs +1704 -0
  89. package/dist/server-CKdWENZ-.cjs.map +1 -0
  90. package/dist/skill-bridge/index.cjs +2 -2
  91. package/dist/skill-bridge/index.cjs.map +1 -1
  92. package/dist/skill-bridge/index.js +1 -1
  93. package/dist/templatePath-27vUZowm.cjs +75 -0
  94. package/dist/templatePath-27vUZowm.cjs.map +1 -0
  95. package/dist/templatePath-k_WNbL_Q.js +52 -0
  96. package/dist/templatePath-k_WNbL_Q.js.map +1 -0
  97. package/package.json +1 -1
  98. package/dist/collection/paths.cjs.map +0 -1
  99. package/dist/collection/paths.js.map +0 -1
  100. package/dist/promptSafety-BLUcImzd.js.map +0 -1
  101. package/dist/promptSafety-DI_1Rvhk.cjs.map +0 -1
  102. package/dist/server-BpEo0FOd.js.map +0 -1
  103. package/dist/server-CkysfLh4.cjs.map +0 -1
@@ -1,7 +1,7 @@
1
1
  const require_rolldown_runtime = require("./rolldown-runtime-D6vf50IK.cjs");
2
- const require_promptSafety = require("./promptSafety-DI_1Rvhk.cjs");
3
- const require_collection_paths = require("./collection/paths.cjs");
4
- const require_skill_bridge_index = require("./skill-bridge/index.cjs");
2
+ const require_ids = require("./ids-Dl9Ij63K.cjs");
3
+ const require_project = require("./project-BWI5w_BT.cjs");
4
+ const require_templatePath = require("./templatePath-27vUZowm.cjs");
5
5
  let node_path = require("node:path");
6
6
  node_path = require_rolldown_runtime.__toESM(node_path, 1);
7
7
  let node_fs = require("node:fs");
@@ -66,7 +66,7 @@ function collectionsRegistriesConfigPath() {
66
66
  const host = requireHost();
67
67
  return host.paths.collectionsRegistriesConfig(host.workspaceRoot);
68
68
  }
69
- function isPresetSlug$1(slug) {
69
+ function isPresetSlug(slug) {
70
70
  return requireHost().isPresetSlug(slug);
71
71
  }
72
72
  /** Logger proxy so engine modules can `import { log }` and use it exactly like
@@ -83,7 +83,7 @@ var log = {
83
83
  };
84
84
  //#endregion
85
85
  //#region src/collection/server/paths.ts
86
- var SCHEMA_FILE$1 = "schema.json";
86
+ var SCHEMA_FILE = "schema.json";
87
87
  /** Sanitise a user-supplied slug into a safe directory-name leaf.
88
88
  * Returns null for anything that fails the slug whitelist OR isn't a
89
89
  * basename (i.e. survives `path.basename` round-trip unchanged).
@@ -91,7 +91,7 @@ var SCHEMA_FILE$1 = "schema.json";
91
91
  * `js/path-injection` sanitiser. */
92
92
  function safeSlugName(slug) {
93
93
  if (typeof slug !== "string") return null;
94
- if (!require_promptSafety.SAFE_SLUG_PATTERN.test(slug)) return null;
94
+ if (!require_ids.SAFE_SLUG_PATTERN.test(slug)) return null;
95
95
  const basename = node_path.default.basename(slug);
96
96
  if (basename !== slug) return null;
97
97
  return basename;
@@ -103,7 +103,7 @@ function safeSlugName(slug) {
103
103
  * `js/path-injection` sanitiser CodeQL recognises on `safeSlugName`. */
104
104
  function safeRecordId(recordId) {
105
105
  if (typeof recordId !== "string") return null;
106
- if (!require_promptSafety.SAFE_RECORD_ID_PATTERN.test(recordId)) return null;
106
+ if (!require_ids.SAFE_RECORD_ID_PATTERN.test(recordId)) return null;
107
107
  if (recordId.includes("..")) return null;
108
108
  const basename = node_path.default.basename(recordId);
109
109
  if (basename !== recordId) return null;
@@ -940,7 +940,7 @@ async function readHead(absPath, bytes) {
940
940
  /** Decode the whole file into a UTF-8 cache copy and return its path.
941
941
  * Cache key = (path, mtime, size), so a replaced CSV re-decodes and an
942
942
  * unchanged one never does. */
943
- async function pathExists$1(target) {
943
+ async function pathExists(target) {
944
944
  try {
945
945
  await (0, node_fs_promises.stat)(target);
946
946
  return true;
@@ -967,7 +967,7 @@ async function evictSupersededCache(key, keepBasename) {
967
967
  async function decodeToCache(absPath, info) {
968
968
  const key = (0, node_crypto.createHash)("sha256").update(absPath).digest("hex").slice(0, 16);
969
969
  const cached = node_path.default.join(cacheDir(), `${key}-${Math.trunc(info.mtimeMs)}-${info.size}.csv`);
970
- if (!await pathExists$1(cached)) {
970
+ if (!await pathExists(cached)) {
971
971
  const whole = await (0, node_fs_promises.readFile)(absPath);
972
972
  const encoding = fallbackEncoding(whole);
973
973
  const text = iconv_lite.default.decode(whole, encoding);
@@ -1050,13 +1050,12 @@ async function queryCsv(sql, params) {
1050
1050
  connection.disconnectSync();
1051
1051
  }
1052
1052
  }
1053
- /** Every row of the CSV as records — capped, deduped, id-encoded. The
1054
- * key column is pinned to VARCHAR (see `readCsvArgs`). `workspaceRoot`
1055
- * drives the per-read containment check; omitted, the configured host
1056
- * root is used. */
1057
1053
  async function csvList(absPath, primaryKey, workspaceRoot) {
1058
1054
  const utf8Path = await ensureUtf8CsvPath(absPath, workspaceRoot ?? getWorkspaceRoot());
1059
- if (utf8Path === null) return [];
1055
+ if (utf8Path === null) return {
1056
+ items: [],
1057
+ truncated: false
1058
+ };
1060
1059
  let rows;
1061
1060
  try {
1062
1061
  rows = await queryCsv(`SELECT * FROM read_csv(${readCsvArgs(primaryKey)}) LIMIT 5001`, [utf8Path]);
@@ -1066,9 +1065,13 @@ async function csvList(absPath, primaryKey, workspaceRoot) {
1066
1065
  path: absPath,
1067
1066
  primaryKey
1068
1067
  });
1069
- return [];
1068
+ return {
1069
+ items: [],
1070
+ truncated: false
1071
+ };
1070
1072
  }
1071
- if (rows.length > 5e3) {
1073
+ const truncated = rows.length > MAX_CSV_ROWS;
1074
+ if (truncated) {
1072
1075
  log.warn("collections", "dataSource CSV truncated to row cap", {
1073
1076
  path: absPath,
1074
1077
  cap: MAX_CSV_ROWS
@@ -1086,7 +1089,10 @@ async function csvList(absPath, primaryKey, workspaceRoot) {
1086
1089
  path: absPath,
1087
1090
  duplicates: deduped.duplicates
1088
1091
  });
1089
- return deduped.items;
1092
+ return {
1093
+ items: deduped.items,
1094
+ truncated
1095
+ };
1090
1096
  }
1091
1097
  /** The scan-order ordinal column the last-match read adds. Underscore
1092
1098
  * prefix keeps it out of any plausible CSV header namespace; it is
@@ -1118,287 +1124,357 @@ async function csvRunQuery(absPath, primaryKey, query, workspaceRoot) {
1118
1124
  return (await queryCsv(sql, [utf8Path, ...params])).map((row) => Object.fromEntries(Object.entries(row).map(([key, value]) => [key, normalizeCsvValue(value)])));
1119
1125
  }
1120
1126
  //#endregion
1121
- //#region src/collection/server/store.ts
1122
- /** True when the collection accepts UI/tool writes. A `dataSource`
1123
- * collection is read-only: updates happen by editing/replacing the
1124
- * data file itself. Every write entry point checks this BEFORE calling
1125
- * `writeItem`/`deleteItem` server-enforced, not just UI-hidden. */
1126
- function collectionWritable(collection) {
1127
- return !require_promptSafety.isReadOnlySchema(collection.schema);
1128
- }
1129
- /** The one-line refusal write paths surface (HTTP 405 / MCP error text). */
1130
- function readOnlyRefusal(slug) {
1131
- return `collection '${slug}' is read-only (backed by an external dataSource) — update the data file itself instead`;
1132
- }
1133
- /** Pick the store implementation for a discovered collection. A
1134
- * `dataSource` schema whose `dataSourceFile` failed to resolve yields a
1135
- * read-only EMPTY store rather than falling back to the (writable) file
1136
- * store — a half-loaded read-only collection must never become writable. */
1137
- function storeFor(collection, opts = {}) {
1138
- if (require_promptSafety.isReadOnlySchema(collection.schema)) {
1139
- const file = collection.dataSourceFile;
1140
- const key = collection.schema.primaryKey;
1141
- return {
1142
- capabilities: { writable: false },
1143
- list: () => file === void 0 ? Promise.resolve([]) : csvList(file, key, opts.workspaceRoot),
1144
- read: (itemId) => file === void 0 ? Promise.resolve(null) : csvRead(file, key, itemId, opts.workspaceRoot),
1145
- query: (query) => file === void 0 ? Promise.resolve([]) : csvRunQuery(file, key, query, opts.workspaceRoot)
1146
- };
1147
- }
1127
+ //#region src/collection/server/storePage.ts
1128
+ /** Project `fields` (+ the primary key, always) out of each record. Thin
1129
+ * server-typed alias over the shared isomorphic `projectRecordFields`
1130
+ * (../core/project.ts) kept as the store layer's exported name. */
1131
+ function projectItemFields(items, fields, primaryKey) {
1132
+ return require_project.projectRecordFields(items, fields, primaryKey);
1133
+ }
1134
+ /** Slice + project an already-ordered full read into a `ListPage` — the
1135
+ * shared emulation for stores without native paging. Pure, exported for
1136
+ * tests. `limit: 0` is a valid "count only" page. */
1137
+ function pageFromFullRead(items, opts, primaryKey, truncated) {
1138
+ const offset = Math.max(0, opts.offset ?? 0);
1139
+ const end = opts.limit === void 0 ? void 0 : offset + Math.max(0, opts.limit);
1148
1140
  return {
1149
- capabilities: { writable: true },
1150
- list: () => listItems(collection.dataDir, opts),
1151
- read: (itemId) => readItem(collection.dataDir, itemId, opts)
1141
+ items: projectItemFields(items.slice(offset, end), opts.fields, primaryKey),
1142
+ total: items.length,
1143
+ truncated
1152
1144
  };
1153
1145
  }
1154
1146
  //#endregion
1155
- //#region src/collection/server/jsonlQuery.ts
1156
- /** SQL semantics for an aggregate-only query over ZERO rows: one scalar
1157
- * row (`count` = 0, everything else NULL) the same shape the CSV path
1158
- * produces for a header-only file, so callers reading `rows[0]` never
1159
- * break on storage kind or emptiness. A grouped query over zero rows is
1160
- * zero groups (`[]`), also matching SQL. Synthesized here because DuckDB
1161
- * has no empty file to infer a schema from. */
1162
- function emptyCollectionResult(query) {
1163
- if ((query.groupBy?.length ?? 0) > 0) return [];
1164
- const aggregates = Object.entries(query.aggregates ?? {});
1165
- return [Object.fromEntries(aggregates.map(([alias, aggregate]) => [alias, aggregate.op === "count" ? 0 : null]))];
1166
- }
1167
- /** The SOURCE columns a query reads: groupBy columns, aggregate input
1168
- * columns, and where fields. (orderBy resolves to groupBy columns or
1169
- * aggregate ALIASES — never to a new source column — so it adds none.) */
1170
- function referencedSourceColumns(query) {
1171
- const columns = new Set(query.groupBy ?? []);
1172
- for (const aggregate of Object.values(query.aggregates ?? {})) if (aggregate.column !== void 0) columns.add(aggregate.column);
1173
- for (const cond of query.where ?? []) columns.add(cond.field);
1174
- return [...columns];
1175
- }
1176
- /** Referenced columns that appear in NO row — a freshly-added optional
1177
- * field, or a derived field whose inputs no record has yet. DuckDB only
1178
- * infers columns from keys that occur in the JSONL, so without help a
1179
- * valid query on such a field binder-errors here while the CSV path
1180
- * (whose header always declares the column) returns NULLs. Padding the
1181
- * FIRST line with `col: null` makes the column exist (full-scan
1182
- * inference unions keys across lines) with matching NULL semantics. */
1183
- function absentReferencedColumns(rows, query) {
1184
- const present = /* @__PURE__ */ new Set();
1185
- for (const row of rows) for (const key of Object.keys(row)) present.add(key);
1186
- return referencedSourceColumns(query).filter((column) => !present.has(column));
1187
- }
1188
- /** Run a validated query over enriched collection rows. */
1189
- async function runQueryOverRows(rows, query) {
1190
- if (rows.length === 0) return emptyCollectionResult(query);
1191
- await (0, node_fs_promises.mkdir)(cacheDir(), {
1192
- recursive: true,
1193
- mode: 448
1147
+ //#region src/collection/server/sqliteStore.ts
1148
+ var sqliteModule = null;
1149
+ /** Lazy-load node:sqlite once. A runtime without it (Node < 22.5) throws a
1150
+ * clearly-worded error the caller surfaces never a bare MODULE_NOT_FOUND. */
1151
+ function loadSqlite() {
1152
+ sqliteModule ??= import("node:sqlite").then((mod) => mod, (err) => {
1153
+ sqliteModule = null;
1154
+ throw new Error(`sqlite storage needs the node:sqlite module (Node.js >= 22.5) — this runtime cannot load it: ${String(err)}`);
1194
1155
  });
1195
- const jsonlPath = node_path.default.join(cacheDir(), `q-${(0, node_crypto.randomBytes)(8).toString("hex")}.jsonl`);
1156
+ return sqliteModule;
1157
+ }
1158
+ /** The db file's on-disk state. A symlink or non-regular file is refused
1159
+ * (file-disclosure defense, same rule as io.ts record files); ENOENT is
1160
+ * just "no records yet". Any OTHER lstat failure (EACCES, EIO, …) is
1161
+ * rethrown so reads surface a real filesystem problem instead of
1162
+ * silently reporting an empty collection. */
1163
+ async function dbFileState(absPath) {
1196
1164
  try {
1197
- const nullPads = Object.fromEntries(absentReferencedColumns(rows, query).map((column) => [column, null]));
1198
- const handle = await (0, node_fs_promises.open)(jsonlPath, "wx", 384);
1199
- try {
1200
- let first = true;
1201
- for (const row of rows) {
1202
- await handle.write(`${JSON.stringify(first ? {
1203
- ...nullPads,
1204
- ...row
1205
- } : row)}\n`);
1206
- first = false;
1207
- }
1208
- } finally {
1209
- await handle.close();
1210
- }
1211
- const { sql, params } = compileJsonlQuery(query);
1212
- return (await queryCsv(sql, [jsonlPath, ...params])).map((row) => Object.fromEntries(Object.entries(row).map(([key, value]) => [key, normalizeCsvValue(value)])));
1213
- } finally {
1214
- await (0, node_fs_promises.unlink)(jsonlPath).catch((err) => {
1215
- if (err.code !== "ENOENT") log.warn("collections", "temp JSONL cleanup failed", {
1216
- path: jsonlPath,
1217
- error: String(err)
1218
- });
1219
- });
1165
+ return (await (0, node_fs_promises.lstat)(absPath)).isFile() ? "file" : "refused";
1166
+ } catch (err) {
1167
+ if (err.code === "ENOENT") return "missing";
1168
+ throw err;
1220
1169
  }
1221
1170
  }
1222
- //#endregion
1223
- //#region src/collection/core/recordZ.ts
1224
- /** The emptiness rule shared by `required` and the "only check present
1225
- * values" gate. NOT a truthiness check `0` and `false` are filled. */
1226
- var isEmptyValue = (value) => value === void 0 || value === null || value === "";
1227
- /** The historical write-gate checks, verbatim: required non-empty, enum
1228
- * membership (compared as strings, so a numeric `5` satisfies `"5"`). */
1229
- function enforcedProblem(key, spec, value) {
1230
- const empty = isEmptyValue(value);
1231
- if (spec.required && empty) return `missing required field '${key}'`;
1232
- if (!empty && spec.type === "enum" && !spec.values.includes(String(value))) return `'${key}' = '${String(value)}' is not one of [${spec.values.join(", ")}]`;
1233
- return null;
1234
- }
1235
- /** Report-only per-type checks on a PRESENT value. Date / datetime reuse the
1236
- * calendar's STRICT civil parsers (`parseIsoDate` / `parseIsoDateTime`), so
1237
- * the lint flags exactly the values the calendar / trigger / spawn code
1238
- * would silently drop impossible days like `2026-02-30`, and datetimes
1239
- * outside the canonical `YYYY-MM-DDTHH:MM[:SS]` shape (e.g. a `Z` suffix,
1240
- * which the day view can't place). `string`-backed types accept anything
1241
- * stringifiable; `ref` existence is out of scope. */
1242
- function strictTypeProblem(key, spec, value) {
1243
- switch (spec.type) {
1244
- case "number":
1245
- case "money": return Number.isFinite(require_promptSafety.coerceNumeric(value)) ? null : `'${key}' = '${String(value)}' is not numeric (a '${spec.type}' field stores a plain number)`;
1246
- case "boolean": return value === true || value === false ? null : `'${key}' = '${String(value)}' is not a boolean (store true or false, unquoted)`;
1247
- case "date": return require_promptSafety.parseIsoDate(value) !== null ? null : `'${key}' = '${String(value)}' is not a real YYYY-MM-DD date`;
1248
- case "datetime": return require_promptSafety.parseIsoDateTime(value) !== null ? null : `'${key}' = '${String(value)}' is not a YYYY-MM-DDTHH:MM datetime (seconds optional, no timezone suffix — the shape the calendar parses)`;
1249
- default: return null;
1250
- }
1251
- }
1252
- /** Strict check for a PRESENT `table` value: an array of row objects, each
1253
- * row conforming to the sub-schema (required / enum / typed sub-values).
1254
- * First row problem wins, prefixed with the row number so the fix is
1255
- * locatable. */
1256
- function strictTableProblem(key, spec, value) {
1257
- if (!Array.isArray(value)) return `'${key}' = '${String(value)}' is not an array of rows (a 'table' field stores an array of row objects)`;
1258
- for (let index = 0; index < value.length; index++) {
1259
- const row = value[index];
1260
- if (!row || typeof row !== "object" || Array.isArray(row)) return `'${key}' row ${index + 1} is not an object`;
1261
- for (const [subKey, subSpec] of Object.entries(spec.of)) {
1262
- const subValue = row[subKey];
1263
- const problem = enforcedProblem(subKey, subSpec, subValue) ?? (isEmptyValue(subValue) ? null : strictTypeProblem(subKey, subSpec, subValue));
1264
- if (problem) return `'${key}' row ${index + 1}: ${problem}`;
1171
+ var CREATE_TABLE = "CREATE TABLE IF NOT EXISTS records (id TEXT PRIMARY KEY, record TEXT NOT NULL)";
1172
+ /** Open the database for one operation, classifying the two unavailable
1173
+ * states so callers can map them honestly (`refused` path-escape,
1174
+ * `missing` empty / not-foundconflating them would misreport a
1175
+ * containment escape as "item not found"). The containment pre-check runs
1176
+ * BEFORE mkdir even when the file is missing — `isContainedInRoot`
1177
+ * resolves through the closest existing ancestor, so a symlinked-away
1178
+ * parent can never make the recursive mkdir create directories outside
1179
+ * the workspace (same pre/post belt-and-suspenders as io.ts writes). */
1180
+ async function openDb(absPath, workspaceRoot, mode) {
1181
+ const state = await dbFileState(absPath);
1182
+ if (state === "refused") {
1183
+ log.warn("collections", "sqlite database refused: not a regular file", { path: absPath });
1184
+ return { kind: "refused" };
1185
+ }
1186
+ if (!isContainedInRoot(node_path.default.dirname(absPath), workspaceRoot)) {
1187
+ log.warn("collections", "sqlite refused: database dir escapes workspace via symlink", { path: absPath });
1188
+ return { kind: "refused" };
1189
+ }
1190
+ if (mode === "read" && state === "missing") return { kind: "missing" };
1191
+ if (mode === "write") {
1192
+ await (0, node_fs_promises.mkdir)(node_path.default.dirname(absPath), { recursive: true });
1193
+ if (!isContainedInRoot(node_path.default.dirname(absPath), workspaceRoot)) {
1194
+ log.warn("collections", "sqlite write refused: database dir escapes workspace via symlink (post-mkdir)", { path: absPath });
1195
+ return { kind: "refused" };
1265
1196
  }
1266
1197
  }
1267
- return null;
1268
- }
1269
- /** First problem for one field's stored value under `tier`, or null.
1270
- * Enforced checks always run (and their messages never vary by tier — the
1271
- * scan and the write gate must agree on them); strict adds the per-type
1272
- * layer on present values only. */
1273
- function recordFieldProblem(key, spec, value, tier) {
1274
- const enforced = enforcedProblem(key, spec, value);
1275
- if (enforced || tier === "enforced") return enforced;
1276
- if (isEmptyValue(value)) return null;
1277
- if (spec.type === "table") return strictTableProblem(key, spec, value);
1278
- return strictTypeProblem(key, spec, value);
1279
- }
1280
- var compiled = /* @__PURE__ */ new WeakMap();
1281
- /** Compile `schema.fields` into a zod validator for a stored record.
1282
- * Loose object: unknown keys are allowed and any declared key may be
1283
- * absent (records are user files, not parse-and-rewrite targets —
1284
- * callers validate, they never persist the parse output). The checks run
1285
- * as ONE object-level refine iterating fields in declaration order —
1286
- * per-key shape schemas can't express "key may be absent BUT its absence
1287
- * must still reach the required check", and the single loop keeps the
1288
- * first reported issue identical to the historical first-problem-wins
1289
- * contract. */
1290
- function compileRecordZ(schema, tier) {
1291
- const cached = compiled.get(schema)?.[tier];
1292
- if (cached) return cached;
1293
- const stored = Object.entries(schema.fields).filter(([, spec]) => !require_promptSafety.COMPUTED_TYPES.has(spec.type));
1294
- const validator = zod.z.looseObject({}).superRefine((record, ctx) => {
1295
- for (const [key, spec] of stored) {
1296
- const problem = recordFieldProblem(key, spec, record[key], tier);
1297
- if (problem) ctx.addIssue({
1298
- code: "custom",
1299
- message: problem,
1300
- path: [key]
1301
- });
1302
- }
1303
- });
1304
- const entry = compiled.get(schema) ?? {};
1305
- entry[tier] = validator;
1306
- compiled.set(schema, entry);
1307
- return validator;
1308
- }
1309
- /** First schema problem on an in-memory record under `tier`, or null. One
1310
- * issue per record keeps the report short and the fix obvious (the
1311
- * historical contract of `validateRecordObject`). */
1312
- function firstRecordProblem(record, schema, tier) {
1313
- const result = compileRecordZ(schema, tier).safeParse(record);
1314
- if (result.success) return null;
1315
- return result.error.issues[0]?.message ?? "record failed schema validation";
1198
+ const { DatabaseSync } = await loadSqlite();
1199
+ const database = new DatabaseSync(absPath);
1200
+ database.exec("PRAGMA busy_timeout = 5000");
1201
+ database.exec(CREATE_TABLE);
1202
+ return {
1203
+ kind: "ok",
1204
+ database
1205
+ };
1316
1206
  }
1317
- //#endregion
1318
- //#region src/collection/server/validate.ts
1319
- var MAX_ISSUES = 25;
1320
- /** Read every `<id>.json` under the collection's dataDir and report the
1321
- * ones that won't load or violate the schema. An empty list means every
1322
- * record is fine. */
1323
- /** List entries under the data dir, guarding realpath containment (against a
1324
- * symlinked dir swapped in after discovery, like `listItems`) and treating a
1325
- * missing dir as empty while surfacing real I/O faults. */
1326
- async function listRecordFilenames(dataDir, workspaceRoot) {
1327
- if (!isContainedInRoot(dataDir, workspaceRoot)) {
1328
- log.warn("collections", "validate refused: dataDir escapes workspace via symlink", { dataDir });
1329
- return [];
1330
- }
1207
+ /** Run `operation` against the database and always close it; unavailable
1208
+ * states resolve through `onUnavailable` so each caller maps `missing`
1209
+ * vs `refused` to its own result kind. */
1210
+ async function withDb(absPath, workspaceRoot, mode, onUnavailable, operation) {
1211
+ const handle = await openDb(absPath, workspaceRoot, mode);
1212
+ if (handle.kind !== "ok") return onUnavailable(handle.kind);
1331
1213
  try {
1332
- return await (0, node_fs_promises.readdir)(dataDir);
1333
- } catch (err) {
1334
- if (err.code === "ENOENT") return [];
1335
- throw err;
1214
+ return await operation(handle.database);
1215
+ } finally {
1216
+ handle.database.close();
1336
1217
  }
1337
1218
  }
1338
- async function validateCollectionRecords(collection, opts = {}) {
1339
- if (collection.schema.dataSource !== void 0) return [];
1340
- const workspaceRoot = opts.workspaceRoot ?? getWorkspaceRoot();
1341
- const entries = await listRecordFilenames(collection.dataDir, workspaceRoot);
1342
- const issues = [];
1343
- for (const name of entries.sort()) {
1344
- if (!name.endsWith(".json") || name.startsWith(".")) continue;
1345
- if (issues.length >= MAX_ISSUES) break;
1346
- const issue = await inspectRecord(node_path.default.join(collection.dataDir, name), name, collection.schema);
1347
- if (issue) issues.push(issue);
1348
- }
1349
- return issues;
1219
+ /** node:sqlite throws ERR_SQLITE_ERROR with the SQLite extended result
1220
+ * code on `errcode`. Our INSERT's duplicate-id failure is
1221
+ * SQLITE_CONSTRAINT_PRIMARYKEY (1555); SQLITE_CONSTRAINT_UNIQUE (2067)
1222
+ * covers a plain UNIQUE index. Checked structurally (message text kept
1223
+ * only as a fallback for runtimes that don't expose `errcode`). */
1224
+ function isUniqueConstraintError(err) {
1225
+ const { errcode } = err;
1226
+ if (errcode !== void 0) return errcode === 1555 || errcode === 2067;
1227
+ return String(err).includes("UNIQUE constraint");
1350
1228
  }
1351
- async function readRecordText(fullPath, name) {
1229
+ function parseRow(raw) {
1230
+ if (typeof raw !== "string") return null;
1352
1231
  try {
1353
- if (!(await (0, node_fs_promises.lstat)(fullPath)).isFile()) return {
1354
- file: name,
1355
- problem: "not a regular file (symlink?) — skipped, won't appear"
1356
- };
1357
- return { raw: await (0, node_fs_promises.readFile)(fullPath, "utf-8") };
1232
+ const parsed = JSON.parse(raw);
1233
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
1358
1234
  } catch {
1235
+ return null;
1236
+ }
1237
+ }
1238
+ function rowsToItems(rows) {
1239
+ return rows.map((row) => parseRow(row.record)).filter((item) => item !== null);
1240
+ }
1241
+ async function sqliteList(absPath, workspaceRoot) {
1242
+ return withDb(absPath, workspaceRoot, "read", () => [], (database) => rowsToItems(database.prepare("SELECT record FROM records ORDER BY id").all()));
1243
+ }
1244
+ async function sqlitePage(absPath, primaryKey, opts, workspaceRoot) {
1245
+ const emptyPage = {
1246
+ items: [],
1247
+ total: 0,
1248
+ truncated: false
1249
+ };
1250
+ return withDb(absPath, workspaceRoot, "read", () => emptyPage, (database) => {
1251
+ const total = Number(database.prepare("SELECT COUNT(*) AS n FROM records").get().n);
1252
+ const offset = Math.max(0, opts.offset ?? 0);
1253
+ const limit = opts.limit === void 0 ? -1 : Math.max(0, opts.limit);
1359
1254
  return {
1360
- file: name,
1361
- problem: "could not be read — skipped, won't appear"
1255
+ items: projectItemFields(rowsToItems(database.prepare("SELECT record FROM records ORDER BY id LIMIT ? OFFSET ?").all(limit, offset)), opts.fields, primaryKey),
1256
+ total,
1257
+ truncated: false
1362
1258
  };
1363
- }
1259
+ });
1364
1260
  }
1365
- /** Classify a single record file: unreadable / unparseable / non-object /
1366
- * schema violation, or null when it's fine. */
1367
- async function inspectRecord(fullPath, name, schema) {
1368
- const read = await readRecordText(fullPath, name);
1369
- if ("problem" in read) return read;
1370
- let parsed;
1371
- try {
1372
- parsed = JSON.parse(read.raw);
1373
- } catch (err) {
1261
+ async function sqliteRead(absPath, itemId, workspaceRoot) {
1262
+ const safeId = safeRecordId(itemId);
1263
+ if (safeId === null) return null;
1264
+ return withDb(absPath, workspaceRoot, "read", () => null, (database) => {
1265
+ const row = database.prepare("SELECT record FROM records WHERE id = ?").get(safeId);
1266
+ return row === void 0 ? null : parseRow(row.record);
1267
+ });
1268
+ }
1269
+ async function sqliteWrite(absPath, itemId, item, opts) {
1270
+ const safeId = safeRecordId(itemId);
1271
+ if (safeId === null) return {
1272
+ kind: "invalid-id",
1273
+ itemId
1274
+ };
1275
+ const outcome = await withDb(absPath, opts.workspaceRoot, "write", () => ({
1276
+ kind: "path-escape",
1277
+ itemId: safeId
1278
+ }), (database) => {
1279
+ const payload = JSON.stringify(item);
1280
+ if (opts.refuseOverwrite) try {
1281
+ database.prepare("INSERT INTO records (id, record) VALUES (?, ?)").run(safeId, payload);
1282
+ } catch (err) {
1283
+ if (isUniqueConstraintError(err)) return {
1284
+ kind: "conflict",
1285
+ itemId: safeId
1286
+ };
1287
+ throw err;
1288
+ }
1289
+ else database.prepare("INSERT INTO records (id, record) VALUES (?, ?) ON CONFLICT(id) DO UPDATE SET record = excluded.record").run(safeId, payload);
1374
1290
  return {
1375
- file: name,
1376
- problem: `invalid JSON (${err instanceof Error ? err.message : String(err)}) — SKIPPED, won't appear. Usual cause: an unescaped " inside a string value; use 「」/『』 or write \\" instead.`
1291
+ kind: "ok",
1292
+ itemId: safeId,
1293
+ item
1294
+ };
1295
+ });
1296
+ if (outcome.kind === "ok" && opts.slug) publishCollectionChange({
1297
+ slug: opts.slug,
1298
+ ids: [safeId],
1299
+ op: "upsert"
1300
+ });
1301
+ return outcome;
1302
+ }
1303
+ async function sqliteDelete(absPath, itemId, opts) {
1304
+ const safeId = safeRecordId(itemId);
1305
+ if (safeId === null) return {
1306
+ kind: "invalid-id",
1307
+ itemId
1308
+ };
1309
+ const outcome = await withDb(absPath, opts.workspaceRoot, "read", (reason) => reason === "refused" ? {
1310
+ kind: "path-escape",
1311
+ itemId: safeId
1312
+ } : {
1313
+ kind: "not-found",
1314
+ itemId: safeId
1315
+ }, (database) => {
1316
+ const { changes } = database.prepare("DELETE FROM records WHERE id = ?").run(safeId);
1317
+ return Number(changes) === 0 ? {
1318
+ kind: "not-found",
1319
+ itemId: safeId
1320
+ } : {
1321
+ kind: "ok",
1322
+ itemId: safeId
1377
1323
  };
1324
+ });
1325
+ if (outcome.kind === "ok" && opts.slug) publishCollectionChange({
1326
+ slug: opts.slug,
1327
+ ids: [safeId],
1328
+ op: "delete"
1329
+ });
1330
+ return outcome;
1331
+ }
1332
+ /** Best-effort full WAL checkpoint so the MAIN db file alone is a
1333
+ * complete snapshot (committed pages in `<db>-wal` are folded in and the
1334
+ * WAL truncated). Used by `deleteCollection` before archiving. Returns
1335
+ * false on any failure (runtime without node:sqlite, locked db, missing
1336
+ * file) — the caller then archives the sidecar files alongside the db so
1337
+ * no committed data is lost either way. */
1338
+ async function checkpointSqliteDatabase(absPath) {
1339
+ try {
1340
+ const { DatabaseSync } = await loadSqlite();
1341
+ const database = new DatabaseSync(absPath);
1342
+ try {
1343
+ database.exec("PRAGMA wal_checkpoint(TRUNCATE)");
1344
+ } finally {
1345
+ database.close();
1346
+ }
1347
+ return true;
1348
+ } catch {
1349
+ return false;
1378
1350
  }
1379
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {
1380
- file: name,
1381
- problem: "not a JSON object skipped, won't appear"
1351
+ }
1352
+ /** A `storage: sqlite` store over `collection.storageFile`. A schema whose
1353
+ * `storageFile` failed to resolve yields a read-only EMPTY store rather
1354
+ * than a writable one — same fail-closed rule as the CSV store. */
1355
+ function sqliteStoreFor(collection, opts) {
1356
+ const file = collection.storageFile;
1357
+ const key = collection.schema.primaryKey;
1358
+ const slug = opts.slug ?? collection.slug;
1359
+ const root = () => opts.workspaceRoot ?? getWorkspaceRoot();
1360
+ if (file === void 0) return {
1361
+ capabilities: {
1362
+ writable: false,
1363
+ nativeQuery: false,
1364
+ nativePaging: false
1365
+ },
1366
+ list: () => Promise.resolve([]),
1367
+ page: () => Promise.resolve({
1368
+ items: [],
1369
+ total: 0,
1370
+ truncated: false
1371
+ }),
1372
+ read: () => Promise.resolve(null)
1382
1373
  };
1383
- const problem = validateRecordObject(parsed, name.replace(/\.json$/, ""), schema, "strict");
1384
- return problem ? {
1385
- file: name,
1386
- problem
1387
- } : null;
1388
- }
1389
- /** First schema problem on an in-memory record (primaryKey↔id mismatch,
1390
- * then the compiled per-field checks see `../core/recordZ` for the two
1391
- * tiers), or null when it's fine. One issue per record keeps the report
1392
- * short and the fix obvious. Pure + exported so write paths
1393
- * (manageCollection putItems) can gate on the SAME enforced rules the
1394
- * post-hoc file scan reports — `itemId` is the id the record is (or
1395
- * would be) stored under. The default `"enforced"` tier keeps every
1396
- * write gate on the historical three checks; only pass `"strict"` from
1397
- * report-only surfaces. */
1398
- function validateRecordObject(record, itemId, schema, tier = "enforced") {
1399
- const idValue = record[schema.primaryKey];
1400
- if (typeof idValue !== "string" || idValue !== itemId) return `'${schema.primaryKey}' is '${String(idValue ?? "")}' but must equal the filename ('${itemId}'), or the record can't be opened`;
1401
- return firstRecordProblem(record, schema, tier);
1374
+ return {
1375
+ capabilities: {
1376
+ writable: true,
1377
+ nativeQuery: false,
1378
+ nativePaging: true
1379
+ },
1380
+ list: () => sqliteList(file, root()),
1381
+ page: (pageOpts = {}) => sqlitePage(file, key, pageOpts, root()),
1382
+ read: (itemId) => sqliteRead(file, itemId, root()),
1383
+ write: (itemId, item, writeOpts = {}) => sqliteWrite(file, itemId, item, {
1384
+ workspaceRoot: root(),
1385
+ slug,
1386
+ refuseOverwrite: writeOpts.refuseOverwrite
1387
+ }),
1388
+ delete: (itemId) => sqliteDelete(file, itemId, {
1389
+ workspaceRoot: root(),
1390
+ slug
1391
+ })
1392
+ };
1393
+ }
1394
+ //#endregion
1395
+ //#region src/collection/server/store.ts
1396
+ /** The file store's stable order: lexicographic by record id (codepoint
1397
+ * compare — locale-independent). `listItems` returns readdir order, which
1398
+ * is filesystem-dependent; paging needs determinism. */
1399
+ function sortByRecordId(items, primaryKey) {
1400
+ return [...items].sort((left, right) => {
1401
+ const leftId = String(left[primaryKey] ?? "");
1402
+ const rightId = String(right[primaryKey] ?? "");
1403
+ if (leftId < rightId) return -1;
1404
+ return leftId > rightId ? 1 : 0;
1405
+ });
1406
+ }
1407
+ /** True when the collection accepts UI/tool writes. A `dataSource`
1408
+ * collection is read-only: updates happen by editing/replacing the
1409
+ * data file itself. Every write entry point checks this BEFORE calling
1410
+ * `writeItem`/`deleteItem` — server-enforced, not just UI-hidden. */
1411
+ function collectionWritable(collection) {
1412
+ return !require_ids.isReadOnlySchema(collection.schema);
1413
+ }
1414
+ /** The one-line refusal write paths surface (HTTP 405 / MCP error text). */
1415
+ function readOnlyRefusal(slug) {
1416
+ return `collection '${slug}' is read-only (backed by an external dataSource) — update the data file itself instead`;
1417
+ }
1418
+ /** A `dataSource` store over `file` (CSV row order; DuckDB-native query).
1419
+ * A schema whose `dataSourceFile` failed to resolve yields a read-only
1420
+ * EMPTY store rather than falling back to the (writable) file store — a
1421
+ * half-loaded read-only collection must never become writable. */
1422
+ function csvStoreFor(collection, opts) {
1423
+ const file = collection.dataSourceFile;
1424
+ const key = collection.schema.primaryKey;
1425
+ const listAll = () => file === void 0 ? Promise.resolve({
1426
+ items: [],
1427
+ truncated: false
1428
+ }) : csvList(file, key, opts.workspaceRoot);
1429
+ return {
1430
+ capabilities: {
1431
+ writable: false,
1432
+ nativeQuery: true,
1433
+ nativePaging: false
1434
+ },
1435
+ list: () => listAll().then((result) => result.items),
1436
+ page: (pageOpts = {}) => listAll().then((result) => pageFromFullRead(result.items, pageOpts, key, result.truncated)),
1437
+ read: (itemId) => file === void 0 ? Promise.resolve(null) : csvRead(file, key, itemId, opts.workspaceRoot),
1438
+ query: (query) => file === void 0 ? Promise.resolve([]) : csvRunQuery(file, key, query, opts.workspaceRoot)
1439
+ };
1440
+ }
1441
+ /** The classic file store over `<dataDir>/<itemId>.json` records. */
1442
+ function fileStoreFor(collection, opts) {
1443
+ const key = collection.schema.primaryKey;
1444
+ const ioOpts = {
1445
+ ...opts,
1446
+ slug: opts.slug ?? collection.slug
1447
+ };
1448
+ return {
1449
+ capabilities: {
1450
+ writable: true,
1451
+ nativeQuery: false,
1452
+ nativePaging: false
1453
+ },
1454
+ list: () => listItems(collection.dataDir, opts),
1455
+ page: async (pageOpts = {}) => pageFromFullRead(sortByRecordId(await listItems(collection.dataDir, opts), key), pageOpts, key, false),
1456
+ read: (itemId) => readItem(collection.dataDir, itemId, opts),
1457
+ write: (itemId, item, writeOpts = {}) => writeItem(collection.dataDir, itemId, item, {
1458
+ ...ioOpts,
1459
+ refuseOverwrite: writeOpts.refuseOverwrite
1460
+ }),
1461
+ delete: (itemId) => deleteItem(collection.dataDir, itemId, ioOpts)
1462
+ };
1463
+ }
1464
+ var storeFactories = /* @__PURE__ */ new Map([
1465
+ ["file", fileStoreFor],
1466
+ ["csv", csvStoreFor],
1467
+ ["sqlite", sqliteStoreFor]
1468
+ ]);
1469
+ /** Pick the store implementation for a discovered collection via the
1470
+ * factory registry. An unknown kind cannot normally reach here (the
1471
+ * schema's `StorageZ` union gates it), so the throw is a loud invariant
1472
+ * breach, not a user-facing path. */
1473
+ function storeFor(collection, opts = {}) {
1474
+ const kind = require_ids.storageKindFor(collection.schema);
1475
+ const factory = storeFactories.get(kind);
1476
+ if (!factory) throw new Error(`no store factory registered for storage kind '${kind}'`);
1477
+ return factory(collection, opts);
1402
1478
  }
1403
1479
  /** The param name a `set` value references, or null when the value is a
1404
1480
  * literal (non-strings can never be references). A bare/empty prefix
@@ -1427,70 +1503,6 @@ function resolveMutateSet(set, params) {
1427
1503
  return resolved;
1428
1504
  }
1429
1505
  //#endregion
1430
- //#region src/collection/server/mutate.ts
1431
- /** First problem with the submitted params, or null. Every declared param
1432
- * is checked by the shared record-field validator; keys the action never
1433
- * declared are rejected outright — a stray key would otherwise ride the
1434
- * resolved `set` semantics silently. */
1435
- function firstMutateParamProblem(action, params) {
1436
- const declared = action.params ?? {};
1437
- for (const key of Object.keys(params)) if (declared[key] === void 0) return `unknown param '${key}' — not declared by action '${action.id}'`;
1438
- for (const [key, spec] of Object.entries(declared)) {
1439
- const problem = recordFieldProblem(key, spec, params[key], "strict");
1440
- if (problem) return problem;
1441
- }
1442
- return null;
1443
- }
1444
- /** Apply one mutate action to one record. Never throws for data-shaped
1445
- * failures — the outcome's `problem` doubles as agent feedback when an
1446
- * LLM (not the user) pressed the button. */
1447
- async function applyMutateAction(collection, action, itemId, params, opts = {}) {
1448
- const paramProblem = firstMutateParamProblem(action, params);
1449
- if (paramProblem) return {
1450
- ok: false,
1451
- status: "invalid-params",
1452
- problem: paramProblem
1453
- };
1454
- const existing = await readItem(collection.dataDir, itemId, opts);
1455
- if (!existing) return {
1456
- ok: false,
1457
- status: "not-found",
1458
- problem: `item '${itemId}' not found`
1459
- };
1460
- if (!require_promptSafety.actionVisible(action, existing)) return {
1461
- ok: false,
1462
- status: "require-unmet",
1463
- problem: `action '${action.id}' is not available for item '${itemId}' in its current state`
1464
- };
1465
- const stored = Object.entries(existing).filter(([key]) => {
1466
- const spec = collection.schema.fields[key];
1467
- return !spec || !require_promptSafety.COMPUTED_TYPES.has(spec.type);
1468
- });
1469
- const merged = {
1470
- ...Object.fromEntries(stored),
1471
- ...resolveMutateSet(action.set, params)
1472
- };
1473
- const invalid = validateRecordObject(merged, itemId, collection.schema);
1474
- if (invalid) return {
1475
- ok: false,
1476
- status: "invalid-record",
1477
- problem: invalid
1478
- };
1479
- const result = await writeItem(collection.dataDir, itemId, merged, {
1480
- workspaceRoot: opts.workspaceRoot,
1481
- slug: collection.slug
1482
- });
1483
- if (result.kind !== "ok") return {
1484
- ok: false,
1485
- status: "write-refused",
1486
- problem: `write refused (${result.kind})`
1487
- };
1488
- return {
1489
- ok: true,
1490
- item: merged
1491
- };
1492
- }
1493
- //#endregion
1494
1506
  //#region src/collection/core/schemaZ.ts
1495
1507
  /** Optional visibility predicate shared by actions and fields: the target
1496
1508
  * shows only when the open record's `field` (stringified) is one of `in`.
@@ -1583,7 +1595,7 @@ var RefFieldZ = zod.z.object({
1583
1595
  type: zod.z.literal("ref"),
1584
1596
  ...fieldBase,
1585
1597
  to: zod.z.string().min(1)
1586
- }).refine((spec) => require_promptSafety.isSafeSlug(spec.to), slugMessage("to"));
1598
+ }).refine((spec) => require_ids.isSafeSlug(spec.to), slugMessage("to"));
1587
1599
  /** A money amount. See `currencyKeys` for the currency-source contract. */
1588
1600
  var MoneyFieldZ = zod.z.object({
1589
1601
  type: zod.z.literal("money"),
@@ -1618,7 +1630,7 @@ var SubRefFieldZ = zod.z.object({
1618
1630
  type: zod.z.literal("ref"),
1619
1631
  ...subFieldBase,
1620
1632
  to: zod.z.string().min(1)
1621
- }).refine((spec) => require_promptSafety.isSafeSlug(spec.to), slugMessage("to"));
1633
+ }).refine((spec) => require_ids.isSafeSlug(spec.to), slugMessage("to"));
1622
1634
  var SubMoneyFieldZ = zod.z.object({
1623
1635
  type: zod.z.literal("money"),
1624
1636
  ...subFieldBase,
@@ -1681,7 +1693,7 @@ var EmbedFieldZ = zod.z.object({
1681
1693
  to: zod.z.string().min(1),
1682
1694
  id: zod.z.string().trim().min(1).optional(),
1683
1695
  idField: zod.z.string().trim().min(1).optional()
1684
- }).refine((spec) => require_promptSafety.isSafeSlug(spec.to) && spec.id !== void 0 !== (spec.idField !== void 0), {
1696
+ }).refine((spec) => require_ids.isSafeSlug(spec.to) && spec.id !== void 0 !== (spec.idField !== void 0), {
1685
1697
  message: "fields with type 'embed' must declare a `to` (valid collection slug) and exactly one of `id` (a fixed record's primary key) or `idField` (a sibling field naming the per-record target)",
1686
1698
  path: ["id"]
1687
1699
  });
@@ -1702,7 +1714,7 @@ var BacklinksFieldZ = zod.z.object({
1702
1714
  via: zod.z.string().trim().min(1),
1703
1715
  display: zod.z.array(zod.z.string().trim().min(1)).min(1),
1704
1716
  filter: WhenZ.optional()
1705
- }).refine((spec) => require_promptSafety.isSafeSlug(spec.from), slugMessage("from"));
1717
+ }).refine((spec) => require_ids.isSafeSlug(spec.from), slugMessage("from"));
1706
1718
  /** A cross-collection AGGREGATE over a backlink relation (plan step ⑤ of
1707
1719
  * plans/done/collection-ontology.md): a computed number — never stored — that
1708
1720
  * sums a source column (or counts rows) over the records in `from` whose
@@ -1723,7 +1735,7 @@ var RollupFieldZ = zod.z.object({
1723
1735
  op: zod.z.enum(["sum", "count"]),
1724
1736
  column: zod.z.string().trim().min(1).optional(),
1725
1737
  filter: WhenZ.optional()
1726
- }).refine((spec) => require_promptSafety.isSafeSlug(spec.from), slugMessage("from")).refine((spec) => spec.op === "sum" === (spec.column !== void 0), {
1738
+ }).refine((spec) => require_ids.isSafeSlug(spec.from), slugMessage("from")).refine((spec) => spec.op === "sum" === (spec.column !== void 0), {
1727
1739
  message: "a rollup's `column` names the source column to aggregate: required for op \"sum\", meaningless for op \"count\"",
1728
1740
  path: ["column"]
1729
1741
  });
@@ -1795,7 +1807,7 @@ var SeededActionZ = zod.z.object({
1795
1807
  kind: zod.z.enum(["chat", "agent"]),
1796
1808
  ...actionBase,
1797
1809
  role: zod.z.string().trim().min(1),
1798
- template: zod.z.string().trim().min(1).refine(require_collection_paths.isSafeActionTemplatePath, "must be a safe path under `templates/` (e.g. `templates/invoice.md`; no `..`, no leading `/`, no backslash)"),
1810
+ template: zod.z.string().trim().min(1).refine(require_templatePath.isSafeActionTemplatePath, "must be a safe path under `templates/` (e.g. `templates/invoice.md`; no `..`, no leading `/`, no backslash)"),
1799
1811
  when: WhenZ.optional()
1800
1812
  });
1801
1813
  /** `kind: "mutate"` — a declarative, HOST-executed write; no LLM, no
@@ -1839,8 +1851,8 @@ var CustomViewZ = zod.z.object({
1839
1851
  label: zod.z.string().trim().min(1),
1840
1852
  icon: zod.z.string().trim().min(1).optional(),
1841
1853
  target: zod.z.enum(["desktop", "mobile"]).optional(),
1842
- file: zod.z.string().trim().min(1).refine(require_collection_paths.isSafeCustomViewPath, "must be a safe path under `views/` ending in `.html` (e.g. `views/year.html`; no `..`, no leading `/`, no backslash)"),
1843
- i18n: zod.z.string().trim().min(1).refine(require_collection_paths.isSafeCustomViewI18nPath, "must be a safe path under `views/` ending in `.i18n.json` (e.g. `views/year.i18n.json`; no `..`, no leading `/`, no backslash)").optional(),
1854
+ file: zod.z.string().trim().min(1).refine(require_templatePath.isSafeCustomViewPath, "must be a safe path under `views/` ending in `.html` (e.g. `views/year.html`; no `..`, no leading `/`, no backslash)"),
1855
+ i18n: zod.z.string().trim().min(1).refine(require_templatePath.isSafeCustomViewI18nPath, "must be a safe path under `views/` ending in `.i18n.json` (e.g. `views/year.i18n.json`; no `..`, no leading `/`, no backslash)").optional(),
1844
1856
  capabilities: zod.z.array(zod.z.enum(["read", "write"])).optional(),
1845
1857
  editableFields: zod.z.array(zod.z.string().trim().min(1)).optional(),
1846
1858
  allowDelete: zod.z.boolean().optional(),
@@ -1897,9 +1909,9 @@ var SpawnZ = zod.z.object({
1897
1909
  * array) only when the response body isn't itself the array; rss/atom yield
1898
1910
  * items natively and ignore it — so no kind-specific requirement here. */
1899
1911
  var DeclarativeIngestZ = zod.z.object({
1900
- kind: zod.z.enum(require_promptSafety.INGEST_KINDS),
1912
+ kind: zod.z.enum(require_ids.INGEST_KINDS),
1901
1913
  url: zod.z.string().url(),
1902
- schedule: zod.z.enum(require_promptSafety.FEED_SCHEDULES),
1914
+ schedule: zod.z.enum(require_ids.FEED_SCHEDULES),
1903
1915
  atHour: zod.z.number().int().min(0).max(23).optional(),
1904
1916
  itemsAt: zod.z.string().trim().min(1).optional(),
1905
1917
  map: zod.z.record(zod.z.string().trim().min(1), zod.z.string().trim().min(1)),
@@ -1913,11 +1925,26 @@ var DeclarativeIngestZ = zod.z.object({
1913
1925
  * validated the SAME way an action's template is (safe path under
1914
1926
  * `templates/`), so the skill-bridge mirrors it identically. */
1915
1927
  var AgentIngestZ = zod.z.object({
1916
- kind: zod.z.literal(require_promptSafety.AGENT_INGEST_KIND),
1917
- schedule: zod.z.enum(require_promptSafety.FEED_SCHEDULES),
1928
+ kind: zod.z.literal(require_ids.AGENT_INGEST_KIND),
1929
+ schedule: zod.z.enum(require_ids.FEED_SCHEDULES),
1918
1930
  atHour: zod.z.number().int().min(0).max(23).optional(),
1919
1931
  role: zod.z.string().trim().min(1),
1920
- template: zod.z.string().trim().min(1).refine(require_collection_paths.isSafeActionTemplatePath, "must be a safe path under `templates/` (e.g. `templates/refresh.md`; no `..`, no leading `/`, no backslash)")
1932
+ template: zod.z.string().trim().min(1).refine(require_templatePath.isSafeActionTemplatePath, "must be a safe path under `templates/` (e.g. `templates/refresh.md`; no `..`, no leading `/`, no backslash)")
1933
+ });
1934
+ /** Marks a collection as the destination of the LLM-free Google Calendar
1935
+ * sync (#2095). `map` is collectionField → Google event field, so the user's
1936
+ * collection keeps whatever field names it already uses. */
1937
+ var GoogleCalendarSyncZ = zod.z.object({
1938
+ /** Calendar to pull from; defaults to the user's primary. */
1939
+ calendarId: zod.z.string().trim().min(1).optional(),
1940
+ map: zod.z.record(zod.z.string().trim().min(1), zod.z.enum([
1941
+ "summary",
1942
+ "start",
1943
+ "end",
1944
+ "htmlLink",
1945
+ "colorId",
1946
+ "status"
1947
+ ])).refine((map) => Object.keys(map).length > 0, { message: "map at least one field — a `googleCalendar` sync with an empty map writes records that carry only the event id" })
1921
1948
  });
1922
1949
  /** `ingest` is a discriminated union on `kind`: the three declarative
1923
1950
  * retrievers fetch-and-map; `agent` dispatches a hidden worker. Optional on
@@ -2024,11 +2051,24 @@ var DataSourceZ = zod.z.object({
2024
2051
  type: zod.z.literal("csv"),
2025
2052
  path: zod.z.string().min(1)
2026
2053
  });
2054
+ /** Alternative WRITABLE storage backend for a collection's records —
2055
+ * unlike `dataSource` (external read-only file), a `storage` collection
2056
+ * behaves like a normal writable collection; only where the rows live
2057
+ * changes. v1: `sqlite` — records in a single SQLite database file
2058
+ * (`node:sqlite`, one JSON record per row keyed by the primaryKey).
2059
+ * `path` is workspace-relative and containment-checked exactly like
2060
+ * `dataPath`. The store factory registry (`server/store.ts`) picks the
2061
+ * implementation by `type` (plans/refactor-storage-virtualization.md). */
2062
+ var StorageZ = zod.z.object({
2063
+ type: zod.z.literal("sqlite"),
2064
+ path: zod.z.string().min(1)
2065
+ });
2027
2066
  var BareCollectionSchemaZ = zod.z.object({
2028
2067
  title: zod.z.string().min(1),
2029
2068
  icon: zod.z.string().min(1),
2030
2069
  dataPath: zod.z.string().min(1).optional(),
2031
2070
  dataSource: DataSourceZ.optional(),
2071
+ storage: StorageZ.optional(),
2032
2072
  primaryKey: zod.z.string().min(1),
2033
2073
  singleton: zod.z.string().trim().min(1).optional(),
2034
2074
  fields: zod.z.record(zod.z.string(), FieldSpecZ),
@@ -2047,17 +2087,28 @@ var BareCollectionSchemaZ = zod.z.object({
2047
2087
  views: zod.z.array(CustomViewZ).optional(),
2048
2088
  notifyWhen: WhenZ.optional(),
2049
2089
  ingest: IngestZ.optional(),
2090
+ googleCalendar: GoogleCalendarSyncZ.optional(),
2050
2091
  dynamicIcon: DynamicIconSpecZ.optional()
2051
- }).refine((schema) => schema.dataPath !== void 0 !== (schema.dataSource !== void 0), {
2052
- message: "declare exactly one of `dataPath` (native JSON records) or `dataSource` (external read-only data file), never both or neither",
2092
+ }).refine((schema) => [
2093
+ schema.dataPath,
2094
+ schema.dataSource,
2095
+ schema.storage
2096
+ ].filter((declared) => declared !== void 0).length === 1, {
2097
+ message: "declare exactly one of `dataPath` (native JSON records), `dataSource` (external read-only data file), or `storage` (alternative writable backend)",
2053
2098
  path: ["dataPath"]
2054
- }).refine((schema) => schema.dataSource === void 0 || schema.singleton === void 0 && schema.ingest === void 0 && schema.spawn === void 0, {
2055
- message: "a `dataSource` collection is read-only — it cannot declare `singleton`, `ingest`, or `spawn` (all of them write records)",
2099
+ }).refine((schema) => schema.dataSource === void 0 || schema.singleton === void 0 && schema.ingest === void 0 && schema.spawn === void 0 && schema.googleCalendar === void 0, {
2100
+ message: "a `dataSource` collection is read-only — it cannot declare `singleton`, `ingest`, `spawn`, or `googleCalendar` (all of them write records)",
2056
2101
  path: ["dataSource"]
2102
+ }).refine((schema) => schema.googleCalendar === void 0 || Object.keys(schema.googleCalendar.map).every((key) => {
2103
+ const target = schema.fields[key];
2104
+ return target !== void 0 && !require_ids.COMPUTED_TYPES.has(target.type) && key !== schema.primaryKey;
2105
+ }), {
2106
+ message: "a `googleCalendar` map key must name a declared, non-computed field, and never the primaryKey (that always holds the Google event id)",
2107
+ path: ["googleCalendar"]
2057
2108
  }).refine((schema) => schema.dataSource === void 0 || [...schema.actions ?? [], ...schema.collectionActions ?? []].every((action) => action.kind !== "mutate"), {
2058
2109
  message: "a `dataSource` collection is read-only — its actions cannot use `kind: \"mutate\"` (a host write); use `chat`/`agent` actions instead",
2059
2110
  path: ["dataSource"]
2060
- }).refine((schema) => schema.singleton === void 0 || require_promptSafety.isSafeRecordId(schema.singleton), {
2111
+ }).refine((schema) => schema.singleton === void 0 || require_ids.isSafeRecordId(schema.singleton), {
2061
2112
  message: "schema `singleton` must be a valid item id (alphanumeric / hyphen / underscore / interior dot, no `..` or path separators)",
2062
2113
  path: ["singleton"]
2063
2114
  }).refine((schema) => schema.actions === void 0 || new Set(schema.actions.map((action) => action.id)).size === schema.actions.length, {
@@ -2068,7 +2119,7 @@ var BareCollectionSchemaZ = zod.z.object({
2068
2119
  path: ["collectionActions"]
2069
2120
  }).refine((schema) => (schema.actions ?? []).every((action) => action.kind !== "mutate" || Object.keys(action.set).every((key) => {
2070
2121
  const target = schema.fields[key];
2071
- return target !== void 0 && !require_promptSafety.COMPUTED_TYPES.has(target.type) && key !== schema.primaryKey;
2122
+ return target !== void 0 && !require_ids.COMPUTED_TYPES.has(target.type) && key !== schema.primaryKey;
2072
2123
  })), {
2073
2124
  message: "a mutate action's `set` keys must name declared, non-computed fields (and never the primaryKey)",
2074
2125
  path: ["actions"]
@@ -2107,7 +2158,7 @@ var BareCollectionSchemaZ = zod.z.object({
2107
2158
  if (spec?.type !== "flag") return true;
2108
2159
  return spec.where.every((cond) => [cond.field, ...cond.valueFrom ? [cond.valueFrom.field] : []].every((name) => {
2109
2160
  const target = schema.fields[name];
2110
- return target !== void 0 && !require_promptSafety.COMPUTED_TYPES.has(target.type);
2161
+ return target !== void 0 && !require_ids.COMPUTED_TYPES.has(target.type);
2111
2162
  }));
2112
2163
  }, {
2113
2164
  message: "a `flag` named by `completionField` may only reference STORED fields in its `where` — completion is evaluated against the raw record (before deriveAll), where computed values (derived/rollup/toggle/flag/embed/backlinks) are absent",
@@ -2182,7 +2233,7 @@ var BareCollectionSchemaZ = zod.z.object({
2182
2233
  }).refine((schema) => schema.notifyWhen === void 0 || schema.fields[schema.notifyWhen.field] !== void 0, {
2183
2234
  message: "schema `notifyWhen.field` must name a top-level field declared in `fields`",
2184
2235
  path: ["notifyWhen"]
2185
- }).refine((schema) => schema.views === void 0 || schema.views.every((view) => require_promptSafety.isSafeSlug(view.id)), {
2236
+ }).refine((schema) => schema.views === void 0 || schema.views.every((view) => require_ids.isSafeSlug(view.id)), {
2186
2237
  message: "every `views[].id` must be a valid slug (alphanumeric / hyphen / underscore, no path separators)",
2187
2238
  path: ["views"]
2188
2239
  }).refine((schema) => schema.views === void 0 || new Set(schema.views.map((view) => view.id)).size === schema.views.length, {
@@ -2294,6 +2345,23 @@ function acceptParsedSchema(schema, opts) {
2294
2345
  dataSourceFile
2295
2346
  };
2296
2347
  }
2348
+ if (schema.storage !== void 0) {
2349
+ const storageFile = resolveDataDir(schema.storage.path, opts.workspaceRoot);
2350
+ if (storageFile === null) return {
2351
+ ok: false,
2352
+ reason: `storage.path '${schema.storage.path}' escapes the workspace`
2353
+ };
2354
+ const dataDir = resolveDataDir(conventionalDataPath(opts.slug), opts.workspaceRoot);
2355
+ if (dataDir === null) return {
2356
+ ok: false,
2357
+ reason: `slug '${opts.slug}' yields no workspace-contained data dir`
2358
+ };
2359
+ return {
2360
+ ok: true,
2361
+ dataDir,
2362
+ storageFile
2363
+ };
2364
+ }
2297
2365
  const dataDir = resolveDataDir(schema.dataPath ?? "", opts.workspaceRoot);
2298
2366
  if (dataDir === null) return {
2299
2367
  ok: false,
@@ -2307,7 +2375,7 @@ function acceptParsedSchema(schema, opts) {
2307
2375
  async function loadOneCollection(skillsRoot, slug, source, workspaceRoot) {
2308
2376
  const safeName = safeSlugName(slug);
2309
2377
  if (safeName === null) return null;
2310
- const schemaPath = node_path.default.join(skillsRoot, safeName, SCHEMA_FILE$1);
2378
+ const schemaPath = node_path.default.join(skillsRoot, safeName, SCHEMA_FILE);
2311
2379
  let raw;
2312
2380
  try {
2313
2381
  if (!(await (0, node_fs_promises.stat)(schemaPath)).isFile()) return null;
@@ -2358,6 +2426,7 @@ async function loadOneCollection(skillsRoot, slug, source, workspaceRoot) {
2358
2426
  schema,
2359
2427
  dataDir: acceptance.dataDir,
2360
2428
  ...acceptance.dataSourceFile !== void 0 ? { dataSourceFile: acceptance.dataSourceFile } : {},
2429
+ ...acceptance.storageFile !== void 0 ? { storageFile: acceptance.storageFile } : {},
2361
2430
  skillDir: node_path.default.join(skillsRoot, safeName)
2362
2431
  };
2363
2432
  }
@@ -2440,1156 +2509,6 @@ function toDetail(collection) {
2440
2509
  };
2441
2510
  }
2442
2511
  //#endregion
2443
- //#region src/collection/server/ontology.ts
2444
- /** Extract the relations a schema declares, in field declaration order:
2445
- * top-level `ref` / `embed` / `backlinks` fields plus `ref` sub-fields
2446
- * inside `table` columns. Pure — exported so the phase-2 graph panel
2447
- * can reuse it on already-loaded schemas. */
2448
- function schemaRelations(schema) {
2449
- const relations = [];
2450
- for (const [key, spec] of Object.entries(schema.fields)) {
2451
- if (spec.type === "ref" || spec.type === "embed") relations.push({
2452
- field: key,
2453
- kind: spec.type,
2454
- to: spec.to
2455
- });
2456
- if (spec.type === "backlinks" || spec.type === "rollup") relations.push({
2457
- field: key,
2458
- kind: spec.type,
2459
- to: spec.from
2460
- });
2461
- if (spec.type !== "table") continue;
2462
- for (const [subKey, subSpec] of Object.entries(spec.of)) if (subSpec.type === "ref") relations.push({
2463
- field: `${key}.${subKey}`,
2464
- kind: "ref",
2465
- to: subSpec.to
2466
- });
2467
- }
2468
- return relations;
2469
- }
2470
- /** Count the record files in a collection's data dir — the same
2471
- * `<id>.json` entries `listItems` considers, WITHOUT parsing them (the
2472
- * ontology is a summary; a malformed record is still a record). Each
2473
- * candidate is classified by the SHARED `isRegularFile` lstat helper —
2474
- * the exact file-disclosure defense `listItems` applies — so a
2475
- * symlinked record never counts and the count can't diverge from the
2476
- * readable set on any filesystem (Codex review on PR #2099, twice).
2477
- * Fail-soft: a missing dir or a dataDir escaping the workspace via
2478
- * symlink counts 0. */
2479
- async function countRecordFiles(dataDir, workspaceRoot) {
2480
- if (!isContainedInRoot(dataDir, workspaceRoot)) return 0;
2481
- try {
2482
- const names = (await (0, node_fs_promises.readdir)(dataDir)).filter((name) => name.endsWith(".json") && !name.startsWith("."));
2483
- return (await Promise.all(names.map((name) => isRegularFile(node_path.default.join(dataDir, name))))).filter(Boolean).length;
2484
- } catch {
2485
- return 0;
2486
- }
2487
- }
2488
- /** A collection's record count for the ontology. File-backed: the cheap
2489
- * readdir count. `dataSource`-backed: the row count via the CSV store
2490
- * (subject to its row cap) — the dataDir is a phantom there, so the
2491
- * readdir count would always misreport 0. Fail-soft to 0 like
2492
- * `countRecordFiles` (an unreadable file / missing DuckDB must not
2493
- * break the whole ontology). */
2494
- async function countRecords(collection, workspaceRoot) {
2495
- if (!collectionWritable(collection)) try {
2496
- return (await storeFor(collection, { workspaceRoot }).list()).length;
2497
- } catch {
2498
- return 0;
2499
- }
2500
- return countRecordFiles(collection.dataDir, workspaceRoot);
2501
- }
2502
- async function toOntologyEntry(collection, workspaceRoot) {
2503
- const { schema } = collection;
2504
- return {
2505
- slug: collection.slug,
2506
- title: schema.title,
2507
- icon: schema.icon,
2508
- primaryKey: schema.primaryKey,
2509
- displayField: schema.displayField ?? schema.primaryKey,
2510
- recordCount: await countRecords(collection, workspaceRoot),
2511
- relations: schemaRelations(schema)
2512
- };
2513
- }
2514
- /** Build the workspace ontology: every discovered collection (slug-sorted,
2515
- * discovery's order), each with its outbound relations and a
2516
- * readdir-cheap record count — fine to call on demand. */
2517
- async function buildWorkspaceOntology(opts = {}) {
2518
- const workspaceRoot = opts.workspaceRoot ?? getWorkspaceRoot();
2519
- const collections = await discoverCollections(opts);
2520
- return Promise.all(collections.map((collection) => toOntologyEntry(collection, workspaceRoot)));
2521
- }
2522
- //#endregion
2523
- //#region src/collection/server/derive.ts
2524
- /** Slugs of every collection referenced by a `ref` field — top-level
2525
- * and one level into `table` sub-fields (nested tables are
2526
- * schema-rejected). Mirrors the client's `uniqueRefTargets`. */
2527
- function uniqueRefTargets(schema) {
2528
- const targets = /* @__PURE__ */ new Set();
2529
- const walk = (fields) => {
2530
- for (const field of Object.values(fields)) {
2531
- if (field.type === "ref" && typeof field.to === "string" && field.to.length > 0) targets.add(field.to);
2532
- if (field.type === "table" && field.of) walk(field.of);
2533
- }
2534
- };
2535
- walk(schema.fields);
2536
- return [...targets];
2537
- }
2538
- /** Slugs of every collection referenced by an `embed` field (top-level
2539
- * only, like the client). */
2540
- function uniqueEmbedTargets(schema) {
2541
- const targets = /* @__PURE__ */ new Set();
2542
- for (const field of Object.values(schema.fields)) if (field.type === "embed" && typeof field.to === "string" && field.to.length > 0) targets.add(field.to);
2543
- return [...targets];
2544
- }
2545
- /** Slugs of every SOURCE collection a `backlinks` or `rollup` field
2546
- * reverses over — loaded exactly like ref/embed targets (whole
2547
- * collection, once; the two field kinds share one load). */
2548
- function uniqueBacklinkSources(schema) {
2549
- const sources = /* @__PURE__ */ new Set();
2550
- for (const field of Object.values(schema.fields)) if ((field.type === "backlinks" || field.type === "rollup") && field.from.length > 0) sources.add(field.from);
2551
- return [...sources];
2552
- }
2553
- async function loadTarget(slug, opts) {
2554
- const target = await loadCollection(slug, opts);
2555
- if (!target) return null;
2556
- const items = await storeFor(target, { workspaceRoot: opts.workspaceRoot }).list();
2557
- const byId = {};
2558
- for (const item of items) {
2559
- const itemId = item[target.schema.primaryKey];
2560
- if (typeof itemId === "string" && itemId.length > 0) byId[itemId] = require_promptSafety.deriveAll(target.schema, item, {});
2561
- }
2562
- return {
2563
- schema: target.schema,
2564
- byId
2565
- };
2566
- }
2567
- /** Load every ref/embed target and backlink source collection once.
2568
- * Unknown / unloadable targets are simply absent — downstream derefs
2569
- * resolve to null (em-dash) and backlinks to an empty row set, the
2570
- * same fail-soft the UI renders. */
2571
- async function loadLinkedTargets(schema, opts) {
2572
- const slugs = [.../* @__PURE__ */ new Set([
2573
- ...uniqueRefTargets(schema),
2574
- ...uniqueEmbedTargets(schema),
2575
- ...uniqueBacklinkSources(schema)
2576
- ])];
2577
- const loaded = {};
2578
- for (const slug of slugs) {
2579
- const target = await loadTarget(slug, opts);
2580
- if (target) loaded[slug] = target;
2581
- }
2582
- return loaded;
2583
- }
2584
- function toRefRecords(linked) {
2585
- return Object.fromEntries(Object.entries(linked).map(([slug, target]) => [slug, target.byId]));
2586
- }
2587
- /** The matching source rows for one `backlinks` field, projected to the
2588
- * source primaryKey + `display` columns — so getItems on a
2589
- * heavily-referenced record stays a summary, not a dump of the source
2590
- * collection. Missing source ⇒ [] (fail-soft). The rows come from the
2591
- * DERIVED source records (`byId`), so `display`/`filter` on a derived
2592
- * source column (an invoice `total`) works. */
2593
- function projectBacklinks(field, schema, enriched, linked) {
2594
- const source = linked[field.from];
2595
- if (!source) return [];
2596
- return require_promptSafety.backlinkRows(field, String(enriched[schema.primaryKey] ?? ""), Object.values(source.byId)).map((row) => require_promptSafety.projectBacklinkRow(row, field.display, source.schema.primaryKey));
2597
- }
2598
- /** Project the computed (never-stored) field kinds onto one derived
2599
- * record: `toggle` → boolean off its enum, `embed` → the target record
2600
- * (fixed `id` or per-record `idField`), or null when missing,
2601
- * `backlinks` → the matching source rows (see `projectBacklinks`). */
2602
- function projectComputed(schema, enriched, linked) {
2603
- for (const [key, field] of Object.entries(schema.fields)) {
2604
- if (field.type === "toggle" && field.field) enriched[key] = String(enriched[field.field] ?? "") === field.onValue;
2605
- if (field.type === "embed" && field.to) {
2606
- const targetId = require_promptSafety.embedTargetId(field, enriched);
2607
- enriched[key] = targetId && linked[field.to]?.byId[targetId] || null;
2608
- }
2609
- if (field.type === "backlinks") enriched[key] = projectBacklinks(field, schema, enriched, linked);
2610
- }
2611
- return enriched;
2612
- }
2613
- /** Resolve every rollup field onto a COPY of the record, BEFORE the
2614
- * formula pass — a `derived` formula may reference rollup values as
2615
- * plain identifiers (`played = homePlayed + awayPlayed`). Same reverse
2616
- * machinery as backlinks, collapsed to a number: an unresolvable source
2617
- * is null (a formula reading it fails soft to em-dash); an empty match
2618
- * set is a real 0. Returns `record` unchanged when the schema declares
2619
- * no rollups. */
2620
- function projectRollups(schema, record, linked) {
2621
- let out = record;
2622
- for (const [key, field] of Object.entries(schema.fields)) {
2623
- if (field.type !== "rollup") continue;
2624
- if (out === record) out = { ...record };
2625
- const source = linked[field.from];
2626
- const selfId = String(record[schema.primaryKey] ?? "");
2627
- out[key] = source ? require_promptSafety.rollupValue(field, selfId, Object.values(source.byId)) : null;
2628
- }
2629
- return out;
2630
- }
2631
- /** Enrich records with every host-computed field: derived formulas
2632
- * evaluated (cross-collection derefs included), toggles projected,
2633
- * embeds resolved. Loads each linked collection ONCE per call. Input
2634
- * records are not mutated. */
2635
- async function enrichItems(collection, items, opts = {}) {
2636
- const { schema } = collection;
2637
- const linked = await loadLinkedTargets(schema, opts);
2638
- const refRecords = toRefRecords(linked);
2639
- return items.map((item) => projectComputed(schema, require_promptSafety.deriveAll(schema, projectRollups(schema, item, linked), refRecords), linked));
2640
- }
2641
- //#endregion
2642
- //#region src/collection/server/dynamicIcon.ts
2643
- /** Index `items` by their `primaryKey` value — the `recordsById` map a
2644
- * `valueFrom` reference (e.g. `_config.defaultCity`) resolves against.
2645
- * Items whose primary key isn't a string (shouldn't happen for a valid
2646
- * schema, but records are untyped storage) are skipped rather than
2647
- * coerced, so a broken key never silently shadows a real one. */
2648
- function buildRecordsById(items, primaryKey) {
2649
- const entries = items.filter((item) => typeof item[primaryKey] === "string").map((item) => [String(item[primaryKey]), item]);
2650
- return Object.fromEntries(entries);
2651
- }
2652
- /** Order records by their `primaryKey` so record selection is deterministic:
2653
- * `listItems` returns filesystem `readdir` order (arbitrary across machines),
2654
- * which would let `from: "first"`, the no-`orderBy` `latest`, and `orderBy`
2655
- * ties pick a different record — and thus a different icon — between
2656
- * reconciles. A stable id sort pins one answer. */
2657
- function sortByPrimaryKey(items, primaryKey) {
2658
- return [...items].sort((left, right) => String(left[primaryKey] ?? "").localeCompare(String(right[primaryKey] ?? "")));
2659
- }
2660
- /** Compute the effective launcher icon for `collection`: its static
2661
- * `schema.icon` when it declares no `dynamicIcon`, else the icon
2662
- * resolved from `dynamicIcon.source`'s RAW stored records (no
2663
- * derive/enrich — the icon rules match against stored values) via the
2664
- * pure resolver. Fails soft on any read/discovery error (missing source
2665
- * collection, filesystem error): falls back to `dynamicIcon.fallback ??
2666
- * schema.icon` rather than surfacing to the collections list. */
2667
- async function computeCollectionIcon(collection, opts = {}) {
2668
- const { schema } = collection;
2669
- const spec = schema.dynamicIcon;
2670
- if (!spec) return schema.icon;
2671
- try {
2672
- const source = await loadCollection(spec.source.collection, opts);
2673
- if (!source) return spec.fallback ?? schema.icon;
2674
- const ordered = sortByPrimaryKey(await storeFor(source, { workspaceRoot: opts.workspaceRoot }).list(), source.schema.primaryKey);
2675
- const orderBy = spec.source.orderBy ?? require_promptSafety.firstDateField(source.schema);
2676
- const recordsById = buildRecordsById(ordered, source.schema.primaryKey);
2677
- return require_promptSafety.resolveIcon(require_promptSafety.selectDynamicRecord(ordered, spec.source, orderBy, recordsById), spec, schema.icon, recordsById);
2678
- } catch (err) {
2679
- log.warn("collections", "dynamic icon compute failed, falling back", {
2680
- slug: collection.slug,
2681
- source: spec.source.collection,
2682
- error: String(err)
2683
- });
2684
- return spec.fallback ?? schema.icon;
2685
- }
2686
- }
2687
- //#endregion
2688
- //#region src/collection/server/util.ts
2689
- /** Human-readable message from an unknown thrown value. */
2690
- function errorMessage(err) {
2691
- return err instanceof Error ? err.message : String(err);
2692
- }
2693
- var ONE_SECOND_MS = 1e3;
2694
- var ONE_DAY_MS = 1440 * 60 * 1e3;
2695
- //#endregion
2696
- //#region src/collection/server/spawn.ts
2697
- var YMD_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/;
2698
- function pad2(value) {
2699
- return String(value).padStart(2, "0");
2700
- }
2701
- function pad4(value) {
2702
- return String(value).padStart(4, "0");
2703
- }
2704
- /** Days in `month` (1-12) of `year`, leap-year-aware. `new Date(y, m, 0)`
2705
- * is day 0 of the *next* month = the last day of `month`; `.getDate()`
2706
- * reads the civil day regardless of timezone. */
2707
- function daysInMonth(year, month) {
2708
- return new Date(year, month, 0).getDate();
2709
- }
2710
- /** Parse a `YYYY-MM-DD` string into a CivilDate, or null when the value
2711
- * isn't a well-formed in-range calendar date. */
2712
- function parseCivil(raw) {
2713
- if (typeof raw !== "string") return null;
2714
- const match = YMD_PATTERN.exec(raw.trim());
2715
- if (!match) return null;
2716
- const year = Number(match[1]);
2717
- const month = Number(match[2]);
2718
- const day = Number(match[3]);
2719
- if (month < 1 || month > 12) return null;
2720
- if (day < 1 || day > daysInMonth(year, month)) return null;
2721
- return {
2722
- y: year,
2723
- m: month,
2724
- d: day
2725
- };
2726
- }
2727
- /** `YYYY-MM-DD` for storage in a `date` field. */
2728
- function formatCivil(date) {
2729
- return `${pad4(date.y)}-${pad2(date.m)}-${pad2(date.d)}`;
2730
- }
2731
- /** A monotonic integer key for a civil date (YYYYMMDD), for ordering /
2732
- * equality without timezone concerns. */
2733
- function ordinal(date) {
2734
- return date.y * 1e4 + date.m * 100 + date.d;
2735
- }
2736
- /** Add `n` whole days to a civil date. Uses UTC epoch arithmetic so it
2737
- * is DST-immune (we only ever read back the civil Y/M/D). */
2738
- function addDays(date, days) {
2739
- const shifted = new Date(Date.UTC(date.y, date.m - 1, date.d) + days * ONE_DAY_MS);
2740
- return {
2741
- y: shifted.getUTCFullYear(),
2742
- m: shifted.getUTCMonth() + 1,
2743
- d: shifted.getUTCDate()
2744
- };
2745
- }
2746
- /** Advance a civil date by one `every` step. Month/year units preserve
2747
- * the rule's day-of-month anchor, clamped to the target month's length
2748
- * (no drift); day/week units do civil day arithmetic. */
2749
- function advanceTriggerDate(source, every) {
2750
- const { unit, interval } = every;
2751
- if (unit === "day") return addDays(source, interval);
2752
- if (unit === "week") return addDays(source, interval * 7);
2753
- const monthsToAdd = interval * (unit === "year" ? 12 : 1);
2754
- const total = source.y * 12 + (source.m - 1) + monthsToAdd;
2755
- const nextYear = Math.floor(total / 12);
2756
- const nextMonth = total % 12 + 1;
2757
- const dim = daysInMonth(nextYear, nextMonth);
2758
- const anchor = every.dayOfMonth === "last" ? dim : every.dayOfMonth ?? source.d;
2759
- return {
2760
- y: nextYear,
2761
- m: nextMonth,
2762
- d: Math.min(anchor, dim)
2763
- };
2764
- }
2765
- /** True iff `now`'s civil date (local timezone) has reached the fire date
2766
- * for `triggerRaw` — i.e. the trigger date minus `leadDays` (so a 10-day
2767
- * lead fires 10 days early). Returns null when `triggerRaw` isn't a
2768
- * parseable date — callers treat that as "don't fire" and warn. */
2769
- function isTriggerDue(triggerRaw, now, leadDays = 0) {
2770
- const due = parseCivil(triggerRaw);
2771
- if (due === null) return null;
2772
- const fireDate = leadDays > 0 ? addDays(due, -leadDays) : due;
2773
- return ordinal({
2774
- y: now.getFullYear(),
2775
- m: now.getMonth() + 1,
2776
- d: now.getDate()
2777
- }) >= ordinal(fireDate);
2778
- }
2779
- var DATE_SUFFIX_PATTERN = /-\d{8}$/;
2780
- /** Deterministic successor id: `<stem>-<YYYYMMDD>`, where `<stem>` is the
2781
- * source id with a trailing `-YYYYMMDD` stripped if present. So a chain
2782
- * shares one stem and each instance is dated:
2783
- * `rent` → `rent-20260610`
2784
- * `rent-20260610` → `rent-20260710`
2785
- * Slug-safe (alphanumeric + hyphen) and a pure function of the inputs,
2786
- * which is what makes create-if-absent idempotent. */
2787
- function successorId(sourceId, next) {
2788
- return `${sourceId.replace(DATE_SUFFIX_PATTERN, "")}-${pad4(next.y)}${pad2(next.m)}${pad2(next.d)}`;
2789
- }
2790
- /** True iff `item` satisfies the spawn predicate. With an explicit
2791
- * `when`, matches `String(item[when.field]) ∈ when.in`. Without one,
2792
- * defaults to the completion-done condition — the shared, flag-aware
2793
- * `itemIsDone` (core/completion; downhill import, so the module graph
2794
- * stays acyclic). */
2795
- function matchesWhen(when, schema, item) {
2796
- if (when) {
2797
- const raw = item[when.field];
2798
- return raw !== void 0 && raw !== null && when.in.includes(String(raw));
2799
- }
2800
- return require_promptSafety.itemIsDone(schema, item);
2801
- }
2802
- /** Resolve the literal `every` that applies to `sourceItem`. Literal-arm
2803
- * `spawn.every` passes through unchanged. Field-driven `spawn.every` reads
2804
- * `sourceItem[fromField]` and looks it up in `map`; an empty field or a
2805
- * value with no map entry yields null (caller skips + logs — see plan §5).
2806
- * Discovery rejects a map that doesn't cover the enum's values, so null
2807
- * here means a record that predates a map/enum edit. */
2808
- function resolveEvery(every, sourceItem) {
2809
- if (!require_promptSafety.isFieldDrivenEvery(every)) return every;
2810
- const raw = sourceItem[every.fromField];
2811
- if (raw === void 0 || raw === null || raw === "") return null;
2812
- return every.map[String(raw)] ?? null;
2813
- }
2814
- /** Build the successor record purely from (schema, source record, source
2815
- * id). Returns null when the schema has no spawn/triggerField or the
2816
- * source's trigger date is unparseable. */
2817
- function computeSuccessor(schema, sourceItem, sourceId) {
2818
- const { spawn, triggerField } = schema;
2819
- if (!spawn || !triggerField) return null;
2820
- const srcDate = parseCivil(sourceItem[triggerField]);
2821
- if (srcDate === null) return null;
2822
- const every = resolveEvery(spawn.every, sourceItem);
2823
- if (every === null) return null;
2824
- const next = advanceTriggerDate(srcDate, every);
2825
- const nextId = successorId(sourceId, next);
2826
- const record = {};
2827
- for (const field of spawn.carry ?? []) if (Object.prototype.hasOwnProperty.call(sourceItem, field)) record[field] = sourceItem[field];
2828
- Object.assign(record, spawn.set ?? {});
2829
- record[triggerField] = formatCivil(next);
2830
- record[schema.primaryKey] = nextId;
2831
- return {
2832
- id: nextId,
2833
- record
2834
- };
2835
- }
2836
- /** Warn precisely about which of `computeSuccessor`'s two null causes fired
2837
- * (plan §5): an unparseable source trigger date (the original cause), or a
2838
- * field-driven `every` whose record value has no `map` entry (a record that
2839
- * predates a map/enum edit — discovery rejects this statically otherwise). */
2840
- function logSpawnSkip(slug, triggerField, every, sourceItem, sourceId) {
2841
- if (parseCivil(sourceItem[triggerField]) === null) {
2842
- log.warn("collections", "spawn skipped: source trigger date unparseable", {
2843
- slug,
2844
- sourceId,
2845
- triggerField
2846
- });
2847
- return;
2848
- }
2849
- const fromField = require_promptSafety.isFieldDrivenEvery(every) ? every.fromField : void 0;
2850
- log.warn("collections", "spawn skipped: no `every` mapping for frequency value", {
2851
- slug,
2852
- sourceId,
2853
- fromField,
2854
- value: fromField === void 0 ? void 0 : sourceItem[fromField]
2855
- });
2856
- }
2857
- /** Idempotently create the successor for `sourceItem` when it matches the
2858
- * spawn predicate. No-op when the schema declares no spawn, the
2859
- * predicate doesn't match, the trigger date is unparseable, or the
2860
- * successor already exists (create-if-absent). Never overwrites an
2861
- * existing successor — protects any edits the user made to it. */
2862
- async function maybeSpawnSuccessor(slug, schema, dataDir, sourceItem, sourceId, ioOpts = {}) {
2863
- const { spawn } = schema;
2864
- if (!spawn || !schema.triggerField) return;
2865
- if (!matchesWhen(spawn.when, schema, sourceItem)) return;
2866
- const computed = computeSuccessor(schema, sourceItem, sourceId);
2867
- if (computed === null) {
2868
- logSpawnSkip(slug, schema.triggerField, spawn.every, sourceItem, sourceId);
2869
- return;
2870
- }
2871
- if (matchesWhen(spawn.when, schema, computed.record)) {
2872
- log.warn("collections", "spawn skipped: successor would be born matching its own predicate (unbounded respawn)", {
2873
- slug,
2874
- sourceId,
2875
- successorId: computed.id
2876
- });
2877
- return;
2878
- }
2879
- try {
2880
- const result = await writeItem(dataDir, computed.id, computed.record, {
2881
- ...ioOpts,
2882
- refuseOverwrite: true,
2883
- slug
2884
- });
2885
- if (result.kind === "ok") log.info("collections", "spawned successor", {
2886
- slug,
2887
- sourceId,
2888
- successorId: computed.id
2889
- });
2890
- else if (result.kind !== "conflict") log.warn("collections", "spawn write failed", {
2891
- slug,
2892
- sourceId,
2893
- successorId: computed.id,
2894
- kind: result.kind
2895
- });
2896
- } catch (err) {
2897
- log.warn("collections", "spawn write threw", {
2898
- slug,
2899
- sourceId,
2900
- successorId: computed.id,
2901
- error: errorMessage(err)
2902
- });
2903
- }
2904
- }
2905
- //#endregion
2906
- //#region src/collection/server/delete.ts
2907
- /** Human-readable reason for a non-`ok` delete result. Exported so the
2908
- * route maps `kind` → message without inlining the switch (keeps the
2909
- * handler short and the mapping unit-testable). The `Record` is
2910
- * exhaustive — a new refusal kind won't compile until it's added. */
2911
- function deleteCollectionRefusalMessage(result) {
2912
- const { slug } = result;
2913
- return {
2914
- "user-scope": `collection '${slug}' is user-scope (~/.claude/skills/) and is read-only from MulmoClaude`,
2915
- preset: `collection '${slug}' is a preset (mc-*) and re-seeds on restart; unstar it from the catalog instead`,
2916
- "unsafe-data-path": `collection '${slug}' declares a dataPath outside its own data/${slug}/ subtree; refusing to delete`,
2917
- "path-escape": `a directory for collection '${slug}' escapes the workspace`
2918
- }[result.kind];
2919
- }
2920
- /** The canonical staging dir for a slug: `data/skills/<slug>`. */
2921
- function stagingSkillDir(workspaceRoot, slug) {
2922
- return node_path.default.join(skillsStagingDir(workspaceRoot), slug);
2923
- }
2924
- async function pathExists(target) {
2925
- try {
2926
- await (0, node_fs_promises.stat)(target);
2927
- return true;
2928
- } catch {
2929
- return false;
2930
- }
2931
- }
2932
- /** UTC `YYYY-MM-DD` — keeps the archive folder human-sortable. */
2933
- function todayStamp() {
2934
- return (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
2935
- }
2936
- /** Every directory the delete will touch must resolve under the
2937
- * workspace root — guards against a symlinked ancestor escaping it. */
2938
- function deleteTargets(collection, workspaceRoot) {
2939
- return [
2940
- stagingSkillDir(workspaceRoot, collection.slug),
2941
- collection.skillDir,
2942
- collection.dataDir
2943
- ];
2944
- }
2945
- /** The records directory the delete recursively archives + removes
2946
- * (`collection.dataDir`) must live in this collection's OWN per-slug
2947
- * subtree. `dataDir` is normally derived from `schema.dataPath`, but
2948
- * `deleteCollection` accepts a `LoadedCollection` whose fields could be
2949
- * inconsistent — so we validate the RESOLVED target the destructive ops
2950
- * actually touch, not the schema string. A shared root like `data` or
2951
- * `data/skills` would otherwise turn the recursive removal into a
2952
- * workspace-wide wipe whose archive captures only this collection.
2953
- * `path.resolve` collapses any `..` before the prefix test (symlink
2954
- * escapes are caught separately by the realpath containment check in
2955
- * `deleteTargets`).
2956
- *
2957
- * TWO acceptable per-slug subtrees:
2958
- * - `data/<slug>/...` — the convention authored collections default to.
2959
- * - `data/collections/<slug>/...` — what `normalizedDataPath` in the
2960
- * registry-import path stamps onto every imported schema (so imported
2961
- * collections never collide with `data/<slug>/`-shaped authored ones).
2962
- * Both are equally per-collection and equally safe; a delete that targets
2963
- * anything else (e.g. raw `data/`, `data/wiki/`, a sibling slug) is
2964
- * refused. */
2965
- function isDataDirSafe(dataDir, slug, workspaceRoot) {
2966
- const acceptableRoots = [node_path.default.resolve(workspaceRoot, "data", slug), node_path.default.resolve(workspaceRoot, "data", "collections", slug)];
2967
- const resolved = node_path.default.resolve(dataDir);
2968
- return acceptableRoots.some((root) => resolved === root || resolved.startsWith(root + node_path.default.sep));
2969
- }
2970
- /** Step 2 of the restore doc — how to bring the records back. A
2971
- * `dataSource` collection has no record files to copy (its rows live in
2972
- * the external data file, which the delete never touches). */
2973
- function restoreRecordsStep(schema) {
2974
- if (schema.dataPath === void 0) return `2. Records: nothing to copy. This is a \`dataSource\` collection —
2975
- its records are the rows of \`${schema.dataSource?.path}\`, which the
2976
- delete never touched.`;
2977
- return `2. Copy the item data: \`cp\` every file under \`records/\` into
2978
- \`${schema.dataPath}/\`. The records are part of the collection and
2979
- must be restored. They are plain data files (NOT bridged), so use
2980
- \`cp\` — the Write-tool rule in step 1 applies ONLY to the skill
2981
- files, not to these records (there may be many; copy them, do not
2982
- Write them one by one).`;
2983
- }
2984
- function buildRestoreDoc(collection) {
2985
- const { slug, schema } = collection;
2986
- return `# Restore "${schema.title}" (collection \`${slug}\`)
2987
-
2988
- This folder is an automatic backup made when the collection was deleted.
2989
- Follow these steps to restore it.
2990
-
2991
- 1. Recreate the skill files in \`data/skills/${slug}/\` using the **Write
2992
- tool**: read each file under \`skill/\` and Write it to the matching
2993
- path — \`SKILL.md\`, \`schema.json\`, and any \`templates/*\`.
2994
-
2995
- IMPORTANT — use the Write tool, NOT \`cp\` / \`mv\` / a shell redirect.
2996
- The skill-bridge hook mirrors \`data/skills/${slug}/\` into
2997
- \`.claude/skills/${slug}/\`, and that mirror is what actually registers
2998
- the collection. The hook only fires on Write/Edit tool calls, so a
2999
- \`cp\` would leave the files in staging with no \`.claude/skills/\`
3000
- mirror — the collection would stay invisible. (Writing
3001
- \`.claude/skills/\` directly is not an option either: that path is
3002
- permission-gated.)
3003
-
3004
- ${restoreRecordsStep(schema)}
3005
-
3006
- 3. Confirm the collection reappears at \`/collections/${slug}\`.
3007
-
3008
- - slug: \`${slug}\`
3009
- - title: ${schema.title}
3010
- - dataPath: \`${schema.dataPath ?? `(dataSource) ${schema.dataSource?.path}`}\`
3011
- `;
3012
- }
3013
- /** Copy one skill copy + the records + RESTORE.md into `archiveDir`. */
3014
- async function writeArchive(collection, archiveDir, workspaceRoot) {
3015
- const staging = stagingSkillDir(workspaceRoot, collection.slug);
3016
- await (0, node_fs_promises.cp)(await pathExists(staging) ? staging : collection.skillDir, node_path.default.join(archiveDir, "skill"), { recursive: true });
3017
- if (await pathExists(collection.dataDir)) await (0, node_fs_promises.cp)(collection.dataDir, node_path.default.join(archiveDir, "records"), { recursive: true });
3018
- await (0, node_fs_promises.writeFile)(node_path.default.join(archiveDir, "RESTORE.md"), buildRestoreDoc(collection), "utf-8");
3019
- }
3020
- /** Remove all three locations. `rm -rf`-style (force) so a missing dir
3021
- * is a no-op; the now-empty data parent (`data/<slug>/` after its
3022
- * `items/` is gone) is swept too, but only when empty. */
3023
- async function removeLocations(collection, workspaceRoot) {
3024
- await (0, node_fs_promises.rm)(stagingSkillDir(workspaceRoot, collection.slug), {
3025
- recursive: true,
3026
- force: true
3027
- });
3028
- await (0, node_fs_promises.rm)(collection.skillDir, {
3029
- recursive: true,
3030
- force: true
3031
- });
3032
- await (0, node_fs_promises.rm)(collection.dataDir, {
3033
- recursive: true,
3034
- force: true
3035
- });
3036
- await (0, node_fs_promises.rmdir)(node_path.default.dirname(collection.dataDir)).catch(() => void 0);
3037
- }
3038
- async function deleteCollection(collection, opts = {}) {
3039
- const { slug } = collection;
3040
- const workspaceRoot = opts.workspaceRoot ?? getWorkspaceRoot();
3041
- if (collection.source === "user") return {
3042
- kind: "user-scope",
3043
- slug
3044
- };
3045
- if (isPresetSlug$1(slug)) return {
3046
- kind: "preset",
3047
- slug
3048
- };
3049
- if (!isDataDirSafe(collection.dataDir, slug, workspaceRoot)) {
3050
- log.warn("collections", "deleteCollection refused: dataDir is not under the per-collection root", {
3051
- slug,
3052
- dataDir: collection.dataDir
3053
- });
3054
- return {
3055
- kind: "unsafe-data-path",
3056
- slug
3057
- };
3058
- }
3059
- if (deleteTargets(collection, workspaceRoot).some((target) => !isContainedInRoot(target, workspaceRoot))) {
3060
- log.warn("collections", "deleteCollection refused: a target escapes the workspace", { slug });
3061
- return {
3062
- kind: "path-escape",
3063
- slug
3064
- };
3065
- }
3066
- const archiveRel = node_path.default.join(archiveDir(), `${opts.dateStamp ?? todayStamp()}-${(0, node_crypto.randomUUID)()}`);
3067
- const archiveDir$1 = node_path.default.join(workspaceRoot, archiveRel);
3068
- await (0, node_fs_promises.mkdir)(archiveDir$1, { recursive: true });
3069
- await writeArchive(collection, archiveDir$1, workspaceRoot);
3070
- await removeLocations(collection, workspaceRoot);
3071
- log.info("collections", "collection deleted + archived", {
3072
- slug,
3073
- archive: archiveRel
3074
- });
3075
- return {
3076
- kind: "ok",
3077
- slug,
3078
- archivePath: archiveRel
3079
- };
3080
- }
3081
- //#endregion
3082
- //#region src/collection/server/views.ts
3083
- async function fileExists(target) {
3084
- try {
3085
- await (0, node_fs_promises.stat)(target);
3086
- return true;
3087
- } catch (err) {
3088
- const { code } = err;
3089
- if (code === "ENOENT" || code === "ENOTDIR") return false;
3090
- throw err;
3091
- }
3092
- }
3093
- /** The authoritative base dir for a collection's schema.json + view HTML.
3094
- * For a project collection, prefer the staging tree when its schema.json is
3095
- * present (authoring layout); otherwise fall back to the active skill dir
3096
- * (imported layout — staging never materialised). For feed / user, it's
3097
- * always the discovered skillDir. Matches `readCustomViewHtml` so reads and
3098
- * deletes agree on both layouts. */
3099
- async function canonicalBase(collection, workspaceRoot, safeSlug) {
3100
- if (collection.source !== "project") return collection.skillDir;
3101
- const staging = node_path.default.join(skillsStagingDir(workspaceRoot), safeSlug);
3102
- if (await fileExists(node_path.default.join(staging, "schema.json"))) return staging;
3103
- return collection.skillDir;
3104
- }
3105
- /** Every on-disk schema.json that must reflect the removal. The active
3106
- * `<skillDir>/schema.json` is the discovery anchor and is always present.
3107
- * The staging copy is included only when it actually exists, so an imported
3108
- * project collection (no staging mirror) doesn't have an empty staging tree
3109
- * materialised by a side effect of the delete. */
3110
- async function schemaWriteTargets(collection, workspaceRoot, safeSlug) {
3111
- const active = node_path.default.join(collection.skillDir, SCHEMA_FILE$1);
3112
- if (collection.source !== "project") return [active];
3113
- const stagingSchema = node_path.default.join(skillsStagingDir(workspaceRoot), safeSlug, SCHEMA_FILE$1);
3114
- const targets = [];
3115
- if (await fileExists(stagingSchema)) targets.push(stagingSchema);
3116
- targets.push(active);
3117
- return targets;
3118
- }
3119
- /** Idempotent unlink — a missing file is fine (the schema entry still gets
3120
- * cleaned), but a real error (permissions, etc.) propagates. */
3121
- async function unlinkIfPresent(target) {
3122
- try {
3123
- await (0, node_fs_promises.unlink)(target);
3124
- } catch (err) {
3125
- if (err.code !== "ENOENT") throw err;
3126
- }
3127
- }
3128
- /** Re-read the canonical schema.json, drop the `views[]` entry, and write the
3129
- * result back to every on-disk copy so staging + active stay identical. Reads
3130
- * raw (not `collection.schema`) so fields the typed schema doesn't model are
3131
- * preserved verbatim. */
3132
- async function removeViewFromSchemas(collection, viewId, workspaceRoot, safeSlug) {
3133
- const base = await canonicalBase(collection, workspaceRoot, safeSlug);
3134
- const canonical = node_path.default.join(base, SCHEMA_FILE$1);
3135
- const parsed = JSON.parse(await (0, node_fs_promises.readFile)(canonical, "utf-8"));
3136
- if (Array.isArray(parsed.views)) parsed.views = parsed.views.filter((entry) => entry?.id !== viewId);
3137
- const serialized = `${JSON.stringify(parsed, null, 2)}\n`;
3138
- for (const target of await schemaWriteTargets(collection, workspaceRoot, safeSlug)) await writeFileAtomic(target, serialized);
3139
- }
3140
- /** Delete one custom view from `collection`: unlink its HTML file and drop it
3141
- * from every schema.json copy. User-scope and preset (mc-*) collections are
3142
- * refused (read-only / re-seeded on boot), consistent with `deleteCollection`. */
3143
- async function deleteCustomView(collection, viewId, opts = {}) {
3144
- if (collection.source === "user") return { kind: "user-scope" };
3145
- if (isPresetSlug$1(collection.slug)) return { kind: "preset" };
3146
- const safeSlug = safeSlugName(collection.slug);
3147
- if (safeSlug === null) return {
3148
- kind: "unsafe-path",
3149
- viewId
3150
- };
3151
- const views = collection.schema.views ?? [];
3152
- const view = views.find((entry) => entry.id === viewId);
3153
- if (!view) return {
3154
- kind: "not-found",
3155
- viewId
3156
- };
3157
- const workspaceRoot = opts.workspaceRoot ?? getWorkspaceRoot();
3158
- const htmlPath = resolveTemplatePath(await canonicalBase(collection, workspaceRoot, safeSlug), view.file);
3159
- if (htmlPath === null) return {
3160
- kind: "unsafe-path",
3161
- viewId
3162
- };
3163
- await removeViewFromSchemas(collection, viewId, workspaceRoot, safeSlug);
3164
- if (!views.some((entry) => entry.id !== viewId && entry.file === view.file)) await unlinkIfPresent(htmlPath);
3165
- return {
3166
- kind: "ok",
3167
- viewId
3168
- };
3169
- }
3170
- /** True for a launcher-managed preset slug (`mc-<something>`). The sync logic uses
3171
- * this to bound which active skills it may refresh/prune, so user-authored skills are
3172
- * never touched. */
3173
- function isPresetSlug(slug) {
3174
- return slug.startsWith("mc-") && slug.length > 3;
3175
- }
3176
- //#endregion
3177
- //#region src/collection/server/manageTool.ts
3178
- /** Refuse an unselective getItems beyond this many records — a silent
3179
- * truncation would read as "covered everything", and an unbounded dump
3180
- * of a large collection is a token bomb. `ids` or `fields` lifts it. */
3181
- var MAX_UNSELECTIVE_ITEMS = 200;
3182
- /** schema.json basename under a skill dir (canonical staging + active mirror). */
3183
- var SCHEMA_FILE = "schema.json";
3184
- /** The collection-authoring reference, served by the `schemaDocs` action. */
3185
- var SCHEMA_DOCS_FILE = "collection-skills.md";
3186
- /** Cap the rejected-schema issue list so a deeply-broken schema can't flood the result. */
3187
- var MAX_SCHEMA_ISSUES = 20;
3188
- /** The workspace help-docs dir both hosts seed (`@mulmoclaude/core/workspace-setup`
3189
- * syncs the bundled assets here) — the user-editable copy schemaDocs prefers. */
3190
- var HELPS_DIR = "config/helps";
3191
- /** Resolve the workspace root the same way every collections call does:
3192
- * the injected override (tests) or the configured collection host. */
3193
- function resolveBase(deps) {
3194
- return deps.workspaceRoot ?? getWorkspaceRoot();
3195
- }
3196
- /** Shared "unknown collection" message — its schema.json is missing or
3197
- * failed validation, so discovery skipped it. */
3198
- function unknownCollection(slug) {
3199
- return `manageCollection: unknown collection '${require_promptSafety.defangForPrompt(slug)}' — its schema.json is missing or failed validation.`;
3200
- }
3201
- function optionalStringArray(value, name) {
3202
- if (value === void 0) return { ok: true };
3203
- if (!Array.isArray(value) || !value.every((entry) => typeof entry === "string" && entry.length > 0)) return {
3204
- ok: false,
3205
- error: `manageCollection: \`${name}\` must be an array of non-empty strings when present.`
3206
- };
3207
- return {
3208
- ok: true,
3209
- value
3210
- };
3211
- }
3212
- /** Project a record down to the requested fields. The primary key is
3213
- * always kept so every returned record stays addressable for a
3214
- * follow-up getItems/putItems. */
3215
- function projectFields(record, fields, primaryKey) {
3216
- const keys = fields.includes(primaryKey) ? fields : [primaryKey, ...fields];
3217
- return Object.fromEntries(keys.filter((key) => key in record).map((key) => [key, record[key]]));
3218
- }
3219
- /** The validation warning appended to a getItems result when stored
3220
- * record files are malformed (they're silently skipped at read time,
3221
- * so without this they'd just look missing). Issue strings quote
3222
- * record-controlled text → defanged, mirroring the presentCollection
3223
- * dispatch. The record VALUES in `items` ride verbatim, like a raw
3224
- * file Read — only host-composed report strings are defanged. */
3225
- async function recordIssuesWarning(collection, deps) {
3226
- if (deps.ablateValidation) return void 0;
3227
- const issues = await validateCollectionRecords(collection, { workspaceRoot: deps.workspaceRoot });
3228
- if (issues.length === 0) return void 0;
3229
- const lines = issues.map((issue) => `- ${require_promptSafety.defangForPrompt(issue.file)}: ${require_promptSafety.defangForPrompt(issue.problem)}`).join("\n");
3230
- return `${issues.length} record file(s) have data problems and are missing from this result. Fix each (Read → correct → Write):\n${lines}`;
3231
- }
3232
- async function loadRequestedItems(collection, ids, deps) {
3233
- const store = storeFor(collection, { workspaceRoot: deps.workspaceRoot });
3234
- if (!ids) return {
3235
- items: await store.list(),
3236
- missing: []
3237
- };
3238
- const items = [];
3239
- const missing = [];
3240
- for (const recordId of ids) {
3241
- const item = await store.read(recordId).catch(() => null);
3242
- if (item) items.push(item);
3243
- else missing.push(recordId);
3244
- }
3245
- return {
3246
- items,
3247
- missing
3248
- };
3249
- }
3250
- async function handleGetItems(collection, args, deps) {
3251
- const { items, missing } = await loadRequestedItems(collection, args.ids, deps);
3252
- if (!args.ids && !args.fields && items.length > 200) return `manageCollection: refused — '${collection.slug}' has ${items.length} records, over the unselective limit of 200. Pass \`ids\` for specific records or \`fields\` to project only the columns you need.`;
3253
- const enriched = await enrichItems(collection, items, deps);
3254
- const projected = args.fields ? enriched.map((item) => projectFields(item, args.fields, collection.schema.primaryKey)) : enriched;
3255
- const warning = !args.ids || missing.length > 0 ? await recordIssuesWarning(collection, deps) : void 0;
3256
- return JSON.stringify({
3257
- collection: collection.slug,
3258
- count: projected.length,
3259
- items: projected,
3260
- ...missing.length > 0 ? { missing: missing.map((recordId) => require_promptSafety.defangForPrompt(recordId)) } : {},
3261
- ...warning ? { warning } : {}
3262
- });
3263
- }
3264
- /** Reject writes that set host-computed keys, with a pointer at the
3265
- * writable source of truth (the toggle's enum) where one exists. */
3266
- function computedKeyProblem(record, schema) {
3267
- for (const key of Object.keys(record)) {
3268
- const spec = schema.fields[key];
3269
- if (!spec || !require_promptSafety.COMPUTED_TYPES.has(spec.type)) continue;
3270
- if (spec.type === "toggle" && spec.field) return `'${key}' is a toggle projection — write the enum field '${spec.field}' instead`;
3271
- return `'${key}' is ${{
3272
- derived: "derived",
3273
- embed: "an embed",
3274
- backlinks: "a backlinks view",
3275
- rollup: "a rollup"
3276
- }[spec.type] ?? "computed"} — computed by the host, remove it from the record`;
3277
- }
3278
- return null;
3279
- }
3280
- /** `mode: "merge"` resolves the row against the EXISTING record —
3281
- * a partial row updates just the fields it carries, instead of a
3282
- * whole-record upsert silently erasing the optional fields it omits
3283
- * (an upsert of `{id, status}` would pass validation yet drop
3284
- * `notes`/`lesson`/…). Merge is a partial UPDATE by definition, so a
3285
- * missing record is a reject, not an implicit create — a merged-over-
3286
- * nothing partial record is exactly the data shape this mode exists
3287
- * to prevent.
3288
- *
3289
- * Computed keys found in the STORED record are stripped before the
3290
- * merge: the caller's own row was already computed-key-rejected, but a
3291
- * raw-written / legacy record can carry a stale `derived`/`embed`/
3292
- * `toggle` value, and re-writing it would perpetuate a forged
3293
- * host-computed value. A merge heals the record instead.
3294
- *
3295
- * readItem THROWS on a malformed stored file (only ENOENT is null) —
3296
- * downgraded to a per-row rejection here, like loadRequestedItems'
3297
- * `missing`, so one broken file can't abort the whole putItems batch. */
3298
- async function mergeWithExisting(collection, record, itemId, deps) {
3299
- let existing;
3300
- try {
3301
- existing = await readItem(collection.dataDir, itemId, { workspaceRoot: deps.workspaceRoot });
3302
- } catch {
3303
- return `'${itemId}' has a malformed stored file — mode "merge" needs to read it; fix the file (Read → correct → Write) or replace it whole with "upsert"`;
3304
- }
3305
- if (!existing) return `'${itemId}' not found — mode "merge" updates an existing record; use "upsert" or "create" to add it`;
3306
- const stored = Object.entries(existing).filter(([key]) => {
3307
- const spec = collection.schema.fields[key];
3308
- return !spec || !require_promptSafety.COMPUTED_TYPES.has(spec.type);
3309
- });
3310
- return {
3311
- ...Object.fromEntries(stored),
3312
- ...record
3313
- };
3314
- }
3315
- async function putOneItem(collection, record, mode, deps) {
3316
- const { schema } = collection;
3317
- const itemId = resolveCreateItemId(schema, record);
3318
- const reject = (about, problem) => ({ rejected: {
3319
- id: require_promptSafety.defangForPrompt(about),
3320
- problem: require_promptSafety.defangForPrompt(problem)
3321
- } });
3322
- if (itemId === null) return reject("(no id)", `record has no '${schema.primaryKey}' value — set it (it doubles as the filename)`);
3323
- const computed = computedKeyProblem(record, schema);
3324
- if (computed) return reject(itemId, computed);
3325
- let toWrite = record;
3326
- if (mode === "merge") {
3327
- const merged = await mergeWithExisting(collection, record, itemId, deps);
3328
- if (typeof merged === "string") return reject(itemId, merged);
3329
- toWrite = merged;
3330
- }
3331
- if (!deps.ablateValidation) {
3332
- const invalid = validateRecordObject(toWrite, itemId, schema);
3333
- if (invalid) return reject(itemId, invalid);
3334
- }
3335
- const result = await writeItem(collection.dataDir, itemId, toWrite, {
3336
- refuseOverwrite: mode === "create",
3337
- workspaceRoot: deps.workspaceRoot,
3338
- slug: collection.slug
3339
- });
3340
- if (result.kind === "ok") return { written: result.itemId };
3341
- if (result.kind === "invalid-id") return reject(itemId, `'${itemId}' is not a valid record id (letters/digits at the ends; -, _, or . inside; no '..' or path characters)`);
3342
- if (result.kind === "conflict") return reject(itemId, `'${itemId}' already exists — mode "create" refuses overwrite; use "upsert" to update it`);
3343
- return reject(itemId, "write refused: the collection's data dir escapes the workspace");
3344
- }
3345
- /** Aggregation over a collection via the structured query DSL
3346
- * (`core/queryZ.ts`) — the paved road for counts / sums / group-bys
3347
- * that a row listing can't answer honestly. Two engines behind one
3348
- * shape: a dataSource collection queries its CSV natively through the
3349
- * store (`store.query`, uncapped whole-file scan); a file-backed
3350
- * collection aggregates its ENRICHED records (computed fields —
3351
- * `derived` / `rollup` / `toggle` — are real columns) through the same
3352
- * compiled SQL over a temp JSONL (`runQueryOverRows`). */
3353
- async function handleQueryItems(collection, queryArg, deps) {
3354
- const parsed = CollectionQueryZ.safeParse(queryArg);
3355
- if (!parsed.success) return `manageCollection: \`query\` rejected — fix and retry:\n${parsed.error.issues.slice(0, 20).map((issue) => `- ${issue.path.map(String).join(".") || "(root)"}: ${require_promptSafety.defangForPrompt(issue.message)}`).join("\n")}`;
3356
- const store = storeFor(collection, { workspaceRoot: deps.workspaceRoot });
3357
- if (store.query) {
3358
- const rows = await store.query(parsed.data);
3359
- return JSON.stringify({
3360
- collection: collection.slug,
3361
- count: rows.length,
3362
- rows
3363
- });
3364
- }
3365
- const rows = await runQueryOverRows(await enrichItems(collection, await store.list(), deps), parsed.data);
3366
- return JSON.stringify({
3367
- collection: collection.slug,
3368
- count: rows.length,
3369
- rows
3370
- });
3371
- }
3372
- async function handlePutItems(collection, args, deps) {
3373
- if (!collectionWritable(collection)) return `manageCollection: ${readOnlyRefusal(collection.slug)} (its records are the rows of '${collection.schema.dataSource?.path}'; edit that file to change the data).`;
3374
- const written = [];
3375
- const rejected = [];
3376
- for (const record of args.items) {
3377
- const outcome = await putOneItem(collection, record, args.mode, deps);
3378
- if (outcome.written) written.push(outcome.written);
3379
- if (outcome.rejected) rejected.push(outcome.rejected);
3380
- }
3381
- return JSON.stringify({
3382
- collection: collection.slug,
3383
- written,
3384
- rejected
3385
- });
3386
- }
3387
- function parsePutItems(args, slug) {
3388
- const { items, mode } = args;
3389
- if (!(Array.isArray(items) && items.length > 0 && items.every((entry) => Boolean(entry) && typeof entry === "object" && !Array.isArray(entry)))) return "manageCollection: `items` is required for putItems — a non-empty array of record objects.";
3390
- if (mode !== void 0 && mode !== "upsert" && mode !== "create" && mode !== "merge") return "manageCollection: `mode` must be \"upsert\" (default), \"create\", or \"merge\".";
3391
- return {
3392
- slug,
3393
- items,
3394
- mode: mode ?? "upsert"
3395
- };
3396
- }
3397
- /** The machine-readable workspace ontology: every collection with its
3398
- * identity, record count, and outbound `ref`/`embed` relations. Slugs
3399
- * are discovery-sanitized; titles/labels are workspace-authored schema
3400
- * text and ride verbatim — the same trust class as the record values
3401
- * getItems returns. */
3402
- async function handleGetOntology(deps) {
3403
- const collections = await buildWorkspaceOntology(deps);
3404
- return JSON.stringify({
3405
- count: collections.length,
3406
- collections
3407
- });
3408
- }
3409
- /** Return the collection-authoring reference (`collection-skills.md`).
3410
- * Workspace copy first (reflects user edits), bundled asset as the
3411
- * always-present fallback. Both reads guarded; if neither resolves the
3412
- * agent still gets an actionable message instead of a thrown call. */
3413
- async function handleSchemaDocs(deps) {
3414
- const candidates = [node_path.default.join(resolveBase(deps), HELPS_DIR, SCHEMA_DOCS_FILE), ...deps.bundledHelpsDir ? [node_path.default.join(deps.bundledHelpsDir(), SCHEMA_DOCS_FILE)] : []];
3415
- for (const candidate of candidates) try {
3416
- return await (0, node_fs_promises.readFile)(candidate, "utf-8");
3417
- } catch {}
3418
- return `manageCollection: could not read the collection-authoring reference (${SCHEMA_DOCS_FILE}).`;
3419
- }
3420
- /** Return the raw schema.json of an existing collection, for editing.
3421
- * Staging (the canonical writable copy) first, the active mirror as a
3422
- * fallback for user-scope skills that have no staging copy. Raw text —
3423
- * not the parsed schema — so the agent edits the true on-disk source. */
3424
- async function handleGetSchema(slug, deps) {
3425
- const collection = await loadCollection(slug, deps);
3426
- if (!collection) return unknownCollection(slug);
3427
- const candidates = [node_path.default.join(require_skill_bridge_index.dataSkillDir(resolveBase(deps), collection.slug), SCHEMA_FILE), node_path.default.join(collection.skillDir, SCHEMA_FILE)];
3428
- for (const candidate of candidates) try {
3429
- return await (0, node_fs_promises.readFile)(candidate, "utf-8");
3430
- } catch {}
3431
- return `manageCollection: '${require_promptSafety.defangForPrompt(slug)}' has no readable ${SCHEMA_FILE}.`;
3432
- }
3433
- /** Refuse a schema edit the host can't honour: user-scope/feed collections
3434
- * are read-only, and presets (mc-*) re-seed on restart so an edit is lost. */
3435
- function schemaEditRefusal(collection, slug) {
3436
- if (collection.source !== "project") return `manageCollection: '${require_promptSafety.defangForPrompt(slug)}' is ${collection.source}-scope and read-only here — only project collections (data/skills/) can be edited.`;
3437
- if (isPresetSlug(slug)) return `manageCollection: '${require_promptSafety.defangForPrompt(slug)}' is a preset (mc-*) and re-seeds on restart — copy it under a different slug to customise.`;
3438
- return null;
3439
- }
3440
- /** Turn a CollectionSchemaZ failure into a short, actionable list the
3441
- * agent can fix, pointing back at the field reference. */
3442
- function formatSchemaIssues(issues) {
3443
- const shown = issues.slice(0, 20);
3444
- const lines = shown.map((issue) => `- ${issue.path.map(String).join(".") || "(root)"}: ${require_promptSafety.defangForPrompt(issue.message)}`).join("\n");
3445
- const omitted = issues.length - shown.length;
3446
- return `manageCollection: schema rejected — fix and retry (call schemaDocs for the field reference):\n${lines}${omitted > 0 ? `\n- …and ${omitted} more issue(s); fix these first and retry.` : ""}`;
3447
- }
3448
- /** Write the validated schema to the canonical staging copy, then mirror
3449
- * it into the active `.claude/skills/` tree discovery reads — an internal
3450
- * write doesn't fire the skill-bridge hook, so we mirror explicitly. */
3451
- async function writeAndMirrorSchema(slug, schema, deps) {
3452
- const base = resolveBase(deps);
3453
- await writeFileAtomic(node_path.default.join(require_skill_bridge_index.dataSkillDir(base, slug), SCHEMA_FILE), `${JSON.stringify(schema, null, 2)}\n`);
3454
- require_skill_bridge_index.mirrorSkillWrite(base, {
3455
- slug,
3456
- relSegments: [SCHEMA_FILE]
3457
- });
3458
- try {
3459
- await deps.refreshAfterWrite?.();
3460
- } catch {}
3461
- }
3462
- /** The post-Zod acceptance gates discovery applies before a parsed schema
3463
- * becomes a live collection. Mirrors discovery's checks (`loadOneCollection`)
3464
- * so a write can't pass here yet be silently skipped on the next load.
3465
- * putSchema only runs for project-scope collections, so the feed-`ingest`
3466
- * gate doesn't apply. Returns a one-line reason, or null when the schema
3467
- * would be accepted. */
3468
- function schemaDiscoveryGate(schema, base) {
3469
- const primaryField = schema.fields[schema.primaryKey];
3470
- if (!primaryField) return `primaryKey '${schema.primaryKey}' is not one of the declared fields`;
3471
- if (primaryField.primary !== true) return `the primaryKey field '${schema.primaryKey}' must be flagged \`primary: true\``;
3472
- if (schema.dataPath !== void 0 && resolveDataDir(schema.dataPath, base) === null) return `dataPath '${schema.dataPath}' escapes the workspace`;
3473
- if (schema.dataSource !== void 0 && resolveDataDir(schema.dataSource.path, base) === null) return `dataSource.path '${schema.dataSource.path}' escapes the workspace`;
3474
- return null;
3475
- }
3476
- /** Validate a schema against CollectionSchemaZ and, on success, persist it.
3477
- * Edit-only: a new collection is created by writing SKILL.md + schema.json
3478
- * under data/skills/<slug>/ (the normal create flow), not through here. */
3479
- async function handlePutSchema(slug, schemaArg, deps) {
3480
- if (!schemaArg || typeof schemaArg !== "object" || Array.isArray(schemaArg)) return "manageCollection: `schema` is required for putSchema — the full collection schema object.";
3481
- const collection = await loadCollection(slug, deps);
3482
- if (!collection) return `manageCollection: unknown collection '${require_promptSafety.defangForPrompt(slug)}' — create it by writing SKILL.md + ${SCHEMA_FILE} under data/skills/${require_promptSafety.defangForPrompt(slug)}/, then edit it here.`;
3483
- const refusal = schemaEditRefusal(collection, slug);
3484
- if (refusal) return refusal;
3485
- const parsed = CollectionSchemaZ.safeParse(schemaArg);
3486
- if (!parsed.success) return formatSchemaIssues(parsed.error.issues);
3487
- const gate = schemaDiscoveryGate(parsed.data, resolveBase(deps));
3488
- if (gate) return `manageCollection: schema rejected — ${gate} (call schemaDocs for the field reference). It passes basic validation but discovery would skip it, hiding the collection.`;
3489
- await writeAndMirrorSchema(collection.slug, parsed.data, deps);
3490
- return JSON.stringify({
3491
- collection: collection.slug,
3492
- written: true
3493
- });
3494
- }
3495
- var MANAGE_COLLECTION_PROMPT = "Use `manageCollection` instead of raw Read/Write/Edit when working with a collection's records OR its schema (raw file I/O stays available as the escape hatch). Before authoring or changing a collection's `schema.json`, call `schemaDocs` to load the field/DSL reference, then read with `getSchema` and write with `putSchema` — `putSchema` validates the whole schema before writing and returns actionable errors instead of silently failing discovery's validation. `getItems` is the only way to see computed values — `derived` fields (e.g. a portfolio's value), `toggle` projections, and `embed` records are host-computed and never present in the stored JSON files. On large collections pass `ids` and/or `fields` to keep the result small. For a question that spans collections (\"which clients have unpaid invoices?\"), start with `getOntology`: it lists every collection with its primaryKey, record count, and outbound `ref`/`embed` relations, so you know which collections to join before reading any records. `putItems` validates every row against the schema before writing (required fields, enum values, primaryKey = record id) and returns `{ written, rejected }`; fix each rejected row using its `problem` text and retry just those rows. Never include computed fields in a row you write. To update a few fields of an existing record, use `mode: \"merge\"` with a partial row ({ id, <changed fields> }) — the default upsert replaces the WHOLE record, so a partial upsert would silently erase every optional field it omits. Answer aggregation questions (counts, sums, averages, group-bys) with `queryItems` on ANY collection — on a dataSource (CSV) collection it scans the whole file (getItems is row-capped, so aggregates computed from its output can be silently wrong on large files); on a file-backed collection it aggregates the enriched records, so computed fields (derived/rollup/toggle) are queryable columns.";
3496
- /** Validate getItems' optional `ids`/`fields` args, then delegate. */
3497
- async function dispatchGetItems(collection, args, deps) {
3498
- const ids = optionalStringArray(args.ids, "ids");
3499
- if (!ids.ok) return ids.error;
3500
- const fields = optionalStringArray(args.fields, "fields");
3501
- if (!fields.ok) return fields.error;
3502
- return handleGetItems(collection, {
3503
- slug: collection.slug,
3504
- ids: ids.value,
3505
- fields: fields.value
3506
- }, deps);
3507
- }
3508
- async function manageCollectionHandler(deps, args) {
3509
- const action = typeof args.action === "string" ? args.action : "";
3510
- if (action === "schemaDocs") return handleSchemaDocs(deps);
3511
- if (action === "getOntology") return handleGetOntology(deps);
3512
- const slug = typeof args.slug === "string" ? args.slug.trim() : "";
3513
- if (!slug) return "manageCollection: `slug` is required (the collection's slug).";
3514
- if (action === "getSchema") return handleGetSchema(slug, deps);
3515
- if (action === "putSchema") return handlePutSchema(slug, args.schema, deps);
3516
- if (action !== "getItems" && action !== "putItems" && action !== "queryItems") return "manageCollection: `action` must be \"getItems\", \"putItems\", \"queryItems\", \"getOntology\", \"schemaDocs\", \"getSchema\", or \"putSchema\".";
3517
- const collection = await loadCollection(slug, deps);
3518
- if (!collection) return unknownCollection(slug);
3519
- if (action === "getItems") return dispatchGetItems(collection, args, deps);
3520
- if (action === "queryItems") return handleQueryItems(collection, args.query, deps);
3521
- const parsed = parsePutItems(args, slug);
3522
- if (typeof parsed === "string") return parsed;
3523
- return handlePutItems(collection, parsed, deps);
3524
- }
3525
- var MANAGE_COLLECTION_DEFINITION = {
3526
- name: "manageCollection",
3527
- description: "Read and write a schema-driven collection through the host — both its records and its structure. getItems returns records WITH computed values (derived formulas, toggles, embeds) the stored JSON files don't contain; putItems validates each row against the schema before writing. getOntology maps the whole workspace: every collection with its record count and outbound ref/embed relations — call it first for cross-collection questions. schemaDocs returns the collection-authoring reference; getSchema/putSchema read and validate-then-write the collection's schema.json. Prefer it over raw file I/O on collections.",
3528
- inputSchema: {
3529
- type: "object",
3530
- properties: {
3531
- action: {
3532
- type: "string",
3533
- enum: [
3534
- "getItems",
3535
- "putItems",
3536
- "queryItems",
3537
- "getOntology",
3538
- "schemaDocs",
3539
- "getSchema",
3540
- "putSchema"
3541
- ],
3542
- description: "What to do."
3543
- },
3544
- slug: {
3545
- type: "string",
3546
- description: "The collection's slug (its directory name, e.g. `stock-quotes`). Required for everything except schemaDocs and getOntology."
3547
- },
3548
- ids: {
3549
- type: "array",
3550
- items: { type: "string" },
3551
- description: "getItems: only these record ids (primary-key values). Omit for all records."
3552
- },
3553
- fields: {
3554
- type: "array",
3555
- items: { type: "string" },
3556
- description: "getItems: only these fields per record (the primary key is always included). Omit for all fields. Use on large collections."
3557
- },
3558
- items: {
3559
- type: "array",
3560
- items: { type: "object" },
3561
- description: "putItems: the record objects to store. Each must carry the schema's primaryKey value (it doubles as the filename)."
3562
- },
3563
- mode: {
3564
- type: "string",
3565
- enum: [
3566
- "upsert",
3567
- "create",
3568
- "merge"
3569
- ],
3570
- description: "putItems: \"upsert\" (default) replaces existing records WHOLE; \"create\" rejects rows whose id already exists; \"merge\" updates only the fields a row carries, keeping the rest of the existing record (rejects unknown ids). Use \"merge\" when changing a few fields."
3571
- },
3572
- schema: {
3573
- type: "object",
3574
- description: "putSchema: the full collection schema object (same shape as schema.json — title, icon, dataPath, primaryKey, fields, …). Call getSchema first for the current one, and schemaDocs for the field DSL."
3575
- },
3576
- query: {
3577
- type: "object",
3578
- description: "queryItems: a structured aggregation query — `{ groupBy?: [\"col\"], aggregates?: { alias: { op: \"count\"|\"sum\"|\"avg\"|\"min\"|\"max\", column? } }, where?: [{ field, op, value }], orderBy?: [{ field, dir? }], limit? }`. At least one of groupBy/aggregates. On a dataSource (CSV) collection it scans the WHOLE file uncapped (unlike getItems); on a file-backed collection it aggregates the ENRICHED records, so computed fields (derived/rollup/toggle) are queryable columns. Use it for counts / sums / averages / group-bys instead of arithmetic over getItems output."
3579
- }
3580
- },
3581
- required: ["action"]
3582
- }
3583
- };
3584
- function makeManageCollectionTool(deps = {}) {
3585
- return {
3586
- definition: MANAGE_COLLECTION_DEFINITION,
3587
- alwaysActive: true,
3588
- prompt: MANAGE_COLLECTION_PROMPT,
3589
- handler: (args) => manageCollectionHandler(deps, args)
3590
- };
3591
- }
3592
- //#endregion
3593
2512
  Object.defineProperty(exports, "CollectionQueryZ", {
3594
2513
  enumerable: true,
3595
2514
  get: function() {
@@ -3620,28 +2539,10 @@ Object.defineProperty(exports, "MAX_QUERY_ROWS", {
3620
2539
  return MAX_QUERY_ROWS;
3621
2540
  }
3622
2541
  });
3623
- Object.defineProperty(exports, "MAX_SCHEMA_ISSUES", {
3624
- enumerable: true,
3625
- get: function() {
3626
- return MAX_SCHEMA_ISSUES;
3627
- }
3628
- });
3629
- Object.defineProperty(exports, "MAX_UNSELECTIVE_ITEMS", {
3630
- enumerable: true,
3631
- get: function() {
3632
- return MAX_UNSELECTIVE_ITEMS;
3633
- }
3634
- });
3635
- Object.defineProperty(exports, "ONE_SECOND_MS", {
3636
- enumerable: true,
3637
- get: function() {
3638
- return ONE_SECOND_MS;
3639
- }
3640
- });
3641
2542
  Object.defineProperty(exports, "SCHEMA_FILE", {
3642
2543
  enumerable: true,
3643
2544
  get: function() {
3644
- return SCHEMA_FILE$1;
2545
+ return SCHEMA_FILE;
3645
2546
  }
3646
2547
  });
3647
2548
  Object.defineProperty(exports, "acceptParsedSchema", {
@@ -3650,16 +2551,10 @@ Object.defineProperty(exports, "acceptParsedSchema", {
3650
2551
  return acceptParsedSchema;
3651
2552
  }
3652
2553
  });
3653
- Object.defineProperty(exports, "advanceTriggerDate", {
2554
+ Object.defineProperty(exports, "archiveDir", {
3654
2555
  enumerable: true,
3655
2556
  get: function() {
3656
- return advanceTriggerDate;
3657
- }
3658
- });
3659
- Object.defineProperty(exports, "applyMutateAction", {
3660
- enumerable: true,
3661
- get: function() {
3662
- return applyMutateAction;
2557
+ return archiveDir;
3663
2558
  }
3664
2559
  });
3665
2560
  Object.defineProperty(exports, "buildActionSeedPrompt", {
@@ -3674,10 +2569,16 @@ Object.defineProperty(exports, "buildCollectionActionSeedPrompt", {
3674
2569
  return buildCollectionActionSeedPrompt;
3675
2570
  }
3676
2571
  });
3677
- Object.defineProperty(exports, "buildWorkspaceOntology", {
2572
+ Object.defineProperty(exports, "cacheDir", {
2573
+ enumerable: true,
2574
+ get: function() {
2575
+ return cacheDir;
2576
+ }
2577
+ });
2578
+ Object.defineProperty(exports, "checkpointSqliteDatabase", {
3678
2579
  enumerable: true,
3679
2580
  get: function() {
3680
- return buildWorkspaceOntology;
2581
+ return checkpointSqliteDatabase;
3681
2582
  }
3682
2583
  });
3683
2584
  Object.defineProperty(exports, "collectionWritable", {
@@ -3704,24 +2605,6 @@ Object.defineProperty(exports, "compileJsonlQuery", {
3704
2605
  return compileJsonlQuery;
3705
2606
  }
3706
2607
  });
3707
- Object.defineProperty(exports, "compileRecordZ", {
3708
- enumerable: true,
3709
- get: function() {
3710
- return compileRecordZ;
3711
- }
3712
- });
3713
- Object.defineProperty(exports, "computeCollectionIcon", {
3714
- enumerable: true,
3715
- get: function() {
3716
- return computeCollectionIcon;
3717
- }
3718
- });
3719
- Object.defineProperty(exports, "computeSuccessor", {
3720
- enumerable: true,
3721
- get: function() {
3722
- return computeSuccessor;
3723
- }
3724
- });
3725
2608
  Object.defineProperty(exports, "configureCollectionHost", {
3726
2609
  enumerable: true,
3727
2610
  get: function() {
@@ -3734,12 +2617,6 @@ Object.defineProperty(exports, "csvRowToItem", {
3734
2617
  return csvRowToItem;
3735
2618
  }
3736
2619
  });
3737
- Object.defineProperty(exports, "daysInMonth", {
3738
- enumerable: true,
3739
- get: function() {
3740
- return daysInMonth;
3741
- }
3742
- });
3743
2620
  Object.defineProperty(exports, "decodeCsvRecordId", {
3744
2621
  enumerable: true,
3745
2622
  get: function() {
@@ -3752,24 +2629,6 @@ Object.defineProperty(exports, "dedupeByRecordId", {
3752
2629
  return dedupeByRecordId;
3753
2630
  }
3754
2631
  });
3755
- Object.defineProperty(exports, "deleteCollection", {
3756
- enumerable: true,
3757
- get: function() {
3758
- return deleteCollection;
3759
- }
3760
- });
3761
- Object.defineProperty(exports, "deleteCollectionRefusalMessage", {
3762
- enumerable: true,
3763
- get: function() {
3764
- return deleteCollectionRefusalMessage;
3765
- }
3766
- });
3767
- Object.defineProperty(exports, "deleteCustomView", {
3768
- enumerable: true,
3769
- get: function() {
3770
- return deleteCustomView;
3771
- }
3772
- });
3773
2632
  Object.defineProperty(exports, "deleteItem", {
3774
2633
  enumerable: true,
3775
2634
  get: function() {
@@ -3788,30 +2647,6 @@ Object.defineProperty(exports, "encodeCsvRecordId", {
3788
2647
  return encodeCsvRecordId;
3789
2648
  }
3790
2649
  });
3791
- Object.defineProperty(exports, "enrichItems", {
3792
- enumerable: true,
3793
- get: function() {
3794
- return enrichItems;
3795
- }
3796
- });
3797
- Object.defineProperty(exports, "errorMessage", {
3798
- enumerable: true,
3799
- get: function() {
3800
- return errorMessage;
3801
- }
3802
- });
3803
- Object.defineProperty(exports, "firstMutateParamProblem", {
3804
- enumerable: true,
3805
- get: function() {
3806
- return firstMutateParamProblem;
3807
- }
3808
- });
3809
- Object.defineProperty(exports, "formatCivil", {
3810
- enumerable: true,
3811
- get: function() {
3812
- return formatCivil;
3813
- }
3814
- });
3815
2650
  Object.defineProperty(exports, "generateItemId", {
3816
2651
  enumerable: true,
3817
2652
  get: function() {
@@ -3836,16 +2671,16 @@ Object.defineProperty(exports, "isContainedInWorkspace", {
3836
2671
  return isContainedInWorkspace;
3837
2672
  }
3838
2673
  });
3839
- Object.defineProperty(exports, "isRegularFile", {
2674
+ Object.defineProperty(exports, "isPresetSlug", {
3840
2675
  enumerable: true,
3841
2676
  get: function() {
3842
- return isRegularFile;
2677
+ return isPresetSlug;
3843
2678
  }
3844
2679
  });
3845
- Object.defineProperty(exports, "isTriggerDue", {
2680
+ Object.defineProperty(exports, "isRegularFile", {
3846
2681
  enumerable: true,
3847
2682
  get: function() {
3848
- return isTriggerDue;
2683
+ return isRegularFile;
3849
2684
  }
3850
2685
  });
3851
2686
  Object.defineProperty(exports, "itemFilePath", {
@@ -3872,28 +2707,22 @@ Object.defineProperty(exports, "log", {
3872
2707
  return log;
3873
2708
  }
3874
2709
  });
3875
- Object.defineProperty(exports, "makeManageCollectionTool", {
2710
+ Object.defineProperty(exports, "normalizeCsvValue", {
3876
2711
  enumerable: true,
3877
2712
  get: function() {
3878
- return makeManageCollectionTool;
2713
+ return normalizeCsvValue;
3879
2714
  }
3880
2715
  });
3881
- Object.defineProperty(exports, "maybeSpawnSuccessor", {
2716
+ Object.defineProperty(exports, "pageFromFullRead", {
3882
2717
  enumerable: true,
3883
2718
  get: function() {
3884
- return maybeSpawnSuccessor;
2719
+ return pageFromFullRead;
3885
2720
  }
3886
2721
  });
3887
- Object.defineProperty(exports, "normalizeCsvValue", {
2722
+ Object.defineProperty(exports, "projectItemFields", {
3888
2723
  enumerable: true,
3889
2724
  get: function() {
3890
- return normalizeCsvValue;
3891
- }
3892
- });
3893
- Object.defineProperty(exports, "parseCivil", {
3894
- enumerable: true,
3895
- get: function() {
3896
- return parseCivil;
2725
+ return projectItemFields;
3897
2726
  }
3898
2727
  });
3899
2728
  Object.defineProperty(exports, "promptPathsFor", {
@@ -3908,6 +2737,12 @@ Object.defineProperty(exports, "publishCollectionChange", {
3908
2737
  return publishCollectionChange;
3909
2738
  }
3910
2739
  });
2740
+ Object.defineProperty(exports, "queryCsv", {
2741
+ enumerable: true,
2742
+ get: function() {
2743
+ return queryCsv;
2744
+ }
2745
+ });
3911
2746
  Object.defineProperty(exports, "readCustomViewHtml", {
3912
2747
  enumerable: true,
3913
2748
  get: function() {
@@ -3938,12 +2773,6 @@ Object.defineProperty(exports, "readSkillTemplate", {
3938
2773
  return readSkillTemplate;
3939
2774
  }
3940
2775
  });
3941
- Object.defineProperty(exports, "recordFieldProblem", {
3942
- enumerable: true,
3943
- get: function() {
3944
- return recordFieldProblem;
3945
- }
3946
- });
3947
2776
  Object.defineProperty(exports, "resolveCreateItemId", {
3948
2777
  enumerable: true,
3949
2778
  get: function() {
@@ -3956,10 +2785,10 @@ Object.defineProperty(exports, "resolveDataDir", {
3956
2785
  return resolveDataDir;
3957
2786
  }
3958
2787
  });
3959
- Object.defineProperty(exports, "resolveEvery", {
2788
+ Object.defineProperty(exports, "resolveMutateSet", {
3960
2789
  enumerable: true,
3961
2790
  get: function() {
3962
- return resolveEvery;
2791
+ return resolveMutateSet;
3963
2792
  }
3964
2793
  });
3965
2794
  Object.defineProperty(exports, "resolveTemplatePath", {
@@ -3968,12 +2797,6 @@ Object.defineProperty(exports, "resolveTemplatePath", {
3968
2797
  return resolveTemplatePath;
3969
2798
  }
3970
2799
  });
3971
- Object.defineProperty(exports, "runQueryOverRows", {
3972
- enumerable: true,
3973
- get: function() {
3974
- return runQueryOverRows;
3975
- }
3976
- });
3977
2800
  Object.defineProperty(exports, "safeRecordId", {
3978
2801
  enumerable: true,
3979
2802
  get: function() {
@@ -3986,28 +2809,22 @@ Object.defineProperty(exports, "safeSlugName", {
3986
2809
  return safeSlugName;
3987
2810
  }
3988
2811
  });
3989
- Object.defineProperty(exports, "schemaRelations", {
3990
- enumerable: true,
3991
- get: function() {
3992
- return schemaRelations;
3993
- }
3994
- });
3995
2812
  Object.defineProperty(exports, "setCollectionChangePublisher", {
3996
2813
  enumerable: true,
3997
2814
  get: function() {
3998
2815
  return setCollectionChangePublisher;
3999
2816
  }
4000
2817
  });
4001
- Object.defineProperty(exports, "storeFor", {
2818
+ Object.defineProperty(exports, "skillsStagingDir", {
4002
2819
  enumerable: true,
4003
2820
  get: function() {
4004
- return storeFor;
2821
+ return skillsStagingDir;
4005
2822
  }
4006
2823
  });
4007
- Object.defineProperty(exports, "successorId", {
2824
+ Object.defineProperty(exports, "storeFor", {
4008
2825
  enumerable: true,
4009
2826
  get: function() {
4010
- return successorId;
2827
+ return storeFor;
4011
2828
  }
4012
2829
  });
4013
2830
  Object.defineProperty(exports, "toDetail", {
@@ -4022,18 +2839,6 @@ Object.defineProperty(exports, "toSummary", {
4022
2839
  return toSummary;
4023
2840
  }
4024
2841
  });
4025
- Object.defineProperty(exports, "validateCollectionRecords", {
4026
- enumerable: true,
4027
- get: function() {
4028
- return validateCollectionRecords;
4029
- }
4030
- });
4031
- Object.defineProperty(exports, "validateRecordObject", {
4032
- enumerable: true,
4033
- get: function() {
4034
- return validateRecordObject;
4035
- }
4036
- });
4037
2842
  Object.defineProperty(exports, "writeFileAtomic", {
4038
2843
  enumerable: true,
4039
2844
  get: function() {
@@ -4047,4 +2852,4 @@ Object.defineProperty(exports, "writeItem", {
4047
2852
  }
4048
2853
  });
4049
2854
 
4050
- //# sourceMappingURL=server-CkysfLh4.cjs.map
2855
+ //# sourceMappingURL=discovery-cwtfyDxl.cjs.map