@mulmoclaude/core 0.22.1 → 0.23.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 (59) hide show
  1. package/assets/helps/collection-skills.md +110 -1
  2. package/assets/helps/custom-view.md +38 -0
  3. package/assets/helps/error-recovery.md +36 -0
  4. package/assets/skills-preset/mc-zenn/SKILL.md +31 -0
  5. package/dist/collection/core/queryZ.d.ts +90 -0
  6. package/dist/collection/core/schema.d.ts +14 -1
  7. package/dist/collection/core/schemaZ.d.ts +18 -1
  8. package/dist/collection/index.cjs +3 -2
  9. package/dist/collection/index.cjs.map +1 -1
  10. package/dist/collection/index.d.ts +1 -0
  11. package/dist/collection/index.js +3 -3
  12. package/dist/collection/index.js.map +1 -1
  13. package/dist/collection/registry/server/index.cjs +12 -4
  14. package/dist/collection/registry/server/index.cjs.map +1 -1
  15. package/dist/collection/registry/server/index.js +12 -4
  16. package/dist/collection/registry/server/index.js.map +1 -1
  17. package/dist/collection/server/csvQuery.d.ts +27 -0
  18. package/dist/collection/server/csvStore.d.ts +51 -0
  19. package/dist/collection/server/discoveredCollection.d.ts +9 -1
  20. package/dist/collection/server/discovery.d.ts +7 -3
  21. package/dist/collection/server/index.cjs +17 -2
  22. package/dist/collection/server/index.d.ts +6 -0
  23. package/dist/collection/server/index.js +3 -3
  24. package/dist/collection/server/jsonlQuery.d.ts +4 -0
  25. package/dist/collection/server/manageTool.d.ts +4 -0
  26. package/dist/collection/server/store.d.ts +31 -0
  27. package/dist/collection-watchers/index.cjs +104 -3
  28. package/dist/collection-watchers/index.cjs.map +1 -1
  29. package/dist/collection-watchers/index.js +102 -3
  30. package/dist/collection-watchers/index.js.map +1 -1
  31. package/dist/feeds/index.cjs +2 -2
  32. package/dist/feeds/index.js +2 -2
  33. package/dist/feeds/server/index.cjs +3 -3
  34. package/dist/feeds/server/index.js +3 -3
  35. package/dist/google/auth.d.ts +18 -3
  36. package/dist/google/authFlow.d.ts +1 -0
  37. package/dist/google/calendar.d.ts +44 -0
  38. package/dist/google/index.cjs +165 -47
  39. package/dist/google/index.cjs.map +1 -1
  40. package/dist/google/index.d.ts +2 -2
  41. package/dist/google/index.js +159 -48
  42. package/dist/google/index.js.map +1 -1
  43. package/dist/{ingestTypes-DEjpiZGM.js → ingestTypes-B-dXxUF9.js} +2 -2
  44. package/dist/{ingestTypes-DEjpiZGM.js.map → ingestTypes-B-dXxUF9.js.map} +1 -1
  45. package/dist/{ingestTypes-Dh5lniBL.cjs → ingestTypes-Cev9q77C.cjs} +2 -2
  46. package/dist/{ingestTypes-Dh5lniBL.cjs.map → ingestTypes-Cev9q77C.cjs.map} +1 -1
  47. package/dist/{promptSafety-DN5V__Ku.cjs → promptSafety-DRd15gQ6.cjs} +15 -1
  48. package/dist/promptSafety-DRd15gQ6.cjs.map +1 -0
  49. package/dist/{promptSafety-DyG3EuC7.js → promptSafety-rDWA9_JS.js} +10 -2
  50. package/dist/promptSafety-rDWA9_JS.js.map +1 -0
  51. package/dist/{server-Bd8l1bhv.js → server-D2ibbixF.js} +772 -79
  52. package/dist/server-D2ibbixF.js.map +1 -0
  53. package/dist/{server-Bh-DPK-H.cjs → server-Jaogu3Ky.cjs} +860 -76
  54. package/dist/server-Jaogu3Ky.cjs.map +1 -0
  55. package/package.json +3 -1
  56. package/dist/promptSafety-DN5V__Ku.cjs.map +0 -1
  57. package/dist/promptSafety-DyG3EuC7.js.map +0 -1
  58. package/dist/server-Bd8l1bhv.js.map +0 -1
  59. package/dist/server-Bh-DPK-H.cjs.map +0 -1
@@ -1,10 +1,12 @@
1
- import { B as isFieldDrivenEvery, C as backlinkRows, D as actionVisible, E as rollupValue, F as AGENT_INGEST_KIND, I as COMPUTED_TYPES, L as FEED_SCHEDULES, M as SAFE_SLUG_PATTERN, N as isSafeRecordId, P as isSafeSlug, R as INGEST_KINDS, T as projectBacklinkRow, b as resolveIcon, d as parseIsoDateTime, g as deriveAll, j as SAFE_RECORD_ID_PATTERN, t as defangForPrompt, u as parseIsoDate, w as coerceNumeric, x as selectDynamicRecord, y as firstDateField, z as embedTargetId } from "./promptSafety-DyG3EuC7.js";
1
+ import { B as isFieldDrivenEvery, C as backlinkRows, D as actionVisible, E as rollupValue, F as AGENT_INGEST_KIND, I as COMPUTED_TYPES, L as FEED_SCHEDULES, M as SAFE_SLUG_PATTERN, N as isSafeRecordId, P as isSafeSlug, R as INGEST_KINDS, T as projectBacklinkRow, V as isReadOnlySchema, b as resolveIcon, d as parseIsoDateTime, g as deriveAll, j as SAFE_RECORD_ID_PATTERN, t as defangForPrompt, u as parseIsoDate, w as coerceNumeric, x as selectDynamicRecord, y as firstDateField, z as embedTargetId } from "./promptSafety-rDWA9_JS.js";
2
2
  import { isSafeActionTemplatePath, isSafeCustomViewI18nPath, isSafeCustomViewPath } from "./collection/paths.js";
3
3
  import { dataSkillDir, mirrorSkillWrite } from "./skill-bridge/index.js";
4
4
  import path from "node:path";
5
5
  import { promises, realpathSync } from "node:fs";
6
- import { cp, lstat, mkdir, open, readFile, readdir, rm, rmdir, stat, unlink, writeFile } from "node:fs/promises";
7
- import { randomBytes, randomUUID } from "node:crypto";
6
+ import { cp, lstat, mkdir, open, readFile, readdir, rename, rm, rmdir, stat, unlink, writeFile } from "node:fs/promises";
7
+ import { createHash, randomBytes, randomUUID } from "node:crypto";
8
+ import { tmpdir } from "node:os";
9
+ import iconv from "iconv-lite";
8
10
  import { z } from "zod";
9
11
  //#region src/collection/server/host.ts
10
12
  var current = null;
@@ -572,7 +574,7 @@ function promptPathsFor(collection, workspaceRoot) {
572
574
  const skillDir = (rel === "" || rel.startsWith("..") ? collection.skillDir : rel).split(path.sep).join("/");
573
575
  return {
574
576
  slug: collection.slug,
575
- dataPath: collection.schema.dataPath,
577
+ dataPath: collection.schema.dataPath ?? collection.schema.dataSource?.path ?? "",
576
578
  skillDir
577
579
  };
578
580
  }
@@ -635,6 +637,586 @@ ${dataJson}
635
637
  ${templateText}`;
636
638
  }
637
639
  //#endregion
640
+ //#region src/collection/core/queryZ.ts
641
+ /** Result-column aliases double as SQL identifiers and JSON keys — keep
642
+ * them to a conservative identifier charset so neither side needs
643
+ * escaping gymnastics. */
644
+ var SAFE_ALIAS_PATTERN = /^[A-Za-z_]\w{0,63}$/;
645
+ /** Hard ceiling on returned rows; `limit` clamps below it. A group-by on
646
+ * a near-unique column would otherwise return one row per source row —
647
+ * the exact materialization the aggregate path exists to avoid. */
648
+ var MAX_QUERY_ROWS = 1e4;
649
+ /** Default row cap when the query declares no `limit`. */
650
+ var DEFAULT_QUERY_ROWS = 1e3;
651
+ /** One aggregate column: `count` (rows; `column` optional to count
652
+ * non-null cells) or `sum`/`avg`/`min`/`max` over a named CSV column. */
653
+ var QueryAggregateZ = z.object({
654
+ op: z.enum([
655
+ "count",
656
+ "sum",
657
+ "avg",
658
+ "min",
659
+ "max"
660
+ ]),
661
+ column: z.string().min(1).optional()
662
+ }).refine((aggregate) => aggregate.op === "count" || aggregate.column !== void 0, {
663
+ message: "`column` is required for every aggregate op except `count`",
664
+ path: ["column"]
665
+ });
666
+ /** One filter condition. Same op vocabulary as the schema-level `where`
667
+ * (`core/where.ts`) so authors learn one set; values may be typed
668
+ * (number / boolean) since CSV columns are. `in` requires an array
669
+ * value, every other op a scalar. */
670
+ var QueryWhereZ = z.object({
671
+ field: z.string().min(1),
672
+ op: z.enum([
673
+ "eq",
674
+ "ne",
675
+ "in",
676
+ "gt",
677
+ "gte",
678
+ "lt",
679
+ "lte",
680
+ "contains"
681
+ ]),
682
+ value: z.union([
683
+ z.string(),
684
+ z.number(),
685
+ z.boolean(),
686
+ z.array(z.union([
687
+ z.string(),
688
+ z.number(),
689
+ z.boolean()
690
+ ])).min(1).max(100)
691
+ ])
692
+ }).refine((cond) => cond.op === "in" === Array.isArray(cond.value), {
693
+ message: "`in` requires an array value (the allowed set); every other op requires a scalar value",
694
+ path: ["value"]
695
+ });
696
+ var QueryOrderZ = z.object({
697
+ /** A `groupBy` column or an aggregate alias — membership enforced by
698
+ * the whole-query refine below. */
699
+ field: z.string().min(1),
700
+ dir: z.enum(["asc", "desc"]).optional()
701
+ });
702
+ /** The whole query. At least one of `groupBy` / `aggregates` must be
703
+ * present: bare `groupBy` is a DISTINCT listing, bare `aggregates` a
704
+ * whole-file scalar row, together a grouped aggregation. */
705
+ var CollectionQueryZ = z.object({
706
+ groupBy: z.array(z.string().min(1)).max(8).refine((columns) => new Set(columns.map((column) => column.toLowerCase())).size === columns.length, { message: "`groupBy` columns must be unique (case-insensitively — SQL identifiers ignore case)" }).optional(),
707
+ aggregates: z.record(z.string().regex(SAFE_ALIAS_PATTERN, "aggregate aliases must be simple identifiers (letters/digits/underscore)"), QueryAggregateZ).optional(),
708
+ where: z.array(QueryWhereZ).max(16).optional(),
709
+ orderBy: z.array(QueryOrderZ).max(4).optional(),
710
+ limit: z.number().int().min(1).max(MAX_QUERY_ROWS).optional()
711
+ }).refine((query) => (query.groupBy?.length ?? 0) > 0 || Object.keys(query.aggregates ?? {}).length > 0, {
712
+ message: "declare at least one of `groupBy` (columns to bucket by) or `aggregates` (values to compute)",
713
+ path: ["groupBy"]
714
+ }).refine((query) => Object.keys(query.aggregates ?? {}).length <= 32, {
715
+ message: `\`aggregates\` supports at most 32 entries`,
716
+ path: ["aggregates"]
717
+ }).refine((query) => {
718
+ const groupLower = new Set((query.groupBy ?? []).map((column) => column.toLowerCase()));
719
+ const seen = /* @__PURE__ */ new Set();
720
+ return Object.keys(query.aggregates ?? {}).every((alias) => {
721
+ const lower = alias.toLowerCase();
722
+ if (groupLower.has(lower) || seen.has(lower)) return false;
723
+ seen.add(lower);
724
+ return true;
725
+ });
726
+ }, {
727
+ message: "aggregate aliases must be unique and must not collide with `groupBy` column names (case-insensitively — SQL identifiers ignore case)",
728
+ path: ["aggregates"]
729
+ }).refine((query) => {
730
+ const sortable = /* @__PURE__ */ new Set([...query.groupBy ?? [], ...Object.keys(query.aggregates ?? {})]);
731
+ return (query.orderBy ?? []).every((order) => sortable.has(order.field));
732
+ }, {
733
+ message: "every `orderBy.field` must be a `groupBy` column or an aggregate alias",
734
+ path: ["orderBy"]
735
+ });
736
+ //#endregion
737
+ //#region src/collection/server/csvQuery.ts
738
+ /** Double-quote a SQL identifier (CSV column name / result alias). */
739
+ function quoteIdent(name) {
740
+ return `"${name.replaceAll("\"", "\"\"")}"`;
741
+ }
742
+ /** Single-quote a SQL string literal (a `types={...}` struct key). */
743
+ function quoteLiteral(value) {
744
+ return `'${value.replaceAll("'", "''")}'`;
745
+ }
746
+ /** The `read_csv` argument list shared by every CSV query: the (prepared)
747
+ * path plus a `types` pin forcing the key column to VARCHAR — without it
748
+ * DuckDB's sniffer turns `001` into BIGINT 1, so leading zeros vanish
749
+ * and distinct keys collapse. */
750
+ function readCsvArgs(primaryKey) {
751
+ return `?, types={${quoteLiteral(primaryKey)}: 'VARCHAR'}`;
752
+ }
753
+ /** One aggregate's SQL expression. `sum`/`avg` TRY_CAST to DOUBLE so a
754
+ * column the sniffer kept as VARCHAR (mixed values) aggregates over its
755
+ * numeric cells instead of erroring; non-numeric cells become NULL and
756
+ * are skipped — standard BI tolerance. `min`/`max` stay native (they are
757
+ * meaningful on strings and dates too). */
758
+ function aggregateExpr(aggregate) {
759
+ const { op, column } = aggregate;
760
+ if (op === "count") return column === void 0 ? "count(*)" : `count(${quoteIdent(column)})`;
761
+ if (op === "sum" || op === "avg") return `${op}(TRY_CAST(${quoteIdent(column ?? "")} AS DOUBLE))`;
762
+ return `${op}(${quoteIdent(column ?? "")})`;
763
+ }
764
+ /** One where condition → SQL fragment + its bound parameters. String
765
+ * equality compares against `CAST(col AS VARCHAR)` so a sniffer-typed
766
+ * column still matches its textual value; numeric/boolean values compare
767
+ * natively (DuckDB coerces the column side). */
768
+ function whereFragment(cond) {
769
+ const column = quoteIdent(cond.field);
770
+ const asText = `CAST(${column} AS VARCHAR)`;
771
+ if (cond.op === "in") {
772
+ const values = cond.value;
773
+ return {
774
+ sql: `${values.every((value) => typeof value === "string") ? asText : column} IN (${values.map(() => "?").join(", ")})`,
775
+ params: values
776
+ };
777
+ }
778
+ if (cond.op === "contains") return {
779
+ sql: `contains(${asText}, ?)`,
780
+ params: [String(cond.value)]
781
+ };
782
+ const operator = {
783
+ eq: "=",
784
+ ne: "<>",
785
+ gt: ">",
786
+ gte: ">=",
787
+ lt: "<",
788
+ lte: "<="
789
+ }[cond.op];
790
+ return {
791
+ sql: `${typeof cond.value === "string" && (cond.op === "eq" || cond.op === "ne") ? asText : column} ${operator} ?`,
792
+ params: [cond.value]
793
+ };
794
+ }
795
+ /** Compile a validated query against `fromSql` (a table-function call
796
+ * whose FIRST placeholder is the source path — the executor binds it).
797
+ * Returns the SQL and the where-value parameters that follow the path.
798
+ * Callers MUST have run `CollectionQueryZ` first; this function trusts
799
+ * the shape (aliases already charset-checked, orderBy membership already
800
+ * enforced). */
801
+ function compileQuery(query, fromSql) {
802
+ const groupBy = query.groupBy ?? [];
803
+ const aggregates = Object.entries(query.aggregates ?? {});
804
+ const selectList = [...groupBy.map(quoteIdent), ...aggregates.map(([alias, aggregate]) => `${aggregateExpr(aggregate)} AS ${quoteIdent(alias)}`)];
805
+ const where = (query.where ?? []).map(whereFragment);
806
+ const clauses = [`SELECT ${selectList.join(", ")}`, `FROM ${fromSql}`];
807
+ if (where.length > 0) clauses.push(`WHERE ${where.map((fragment) => fragment.sql).join(" AND ")}`);
808
+ if (groupBy.length > 0) clauses.push(`GROUP BY ${groupBy.map(quoteIdent).join(", ")}`);
809
+ const orderBy = (query.orderBy ?? []).map((order) => quoteIdent(order.field) + (order.dir === "desc" ? " DESC" : " ASC"));
810
+ if (orderBy.length > 0) clauses.push(`ORDER BY ${orderBy.join(", ")}`);
811
+ clauses.push(`LIMIT ${query.limit ?? 1e3}`);
812
+ return {
813
+ sql: clauses.join(" "),
814
+ params: where.flatMap((fragment) => fragment.params)
815
+ };
816
+ }
817
+ /** Compile against a CSV file (the dataSource store's engine). */
818
+ function compileCsvQuery(query, primaryKey) {
819
+ return compileQuery(query, `read_csv(${readCsvArgs(primaryKey)})`);
820
+ }
821
+ /** Compile against a JSONL file of ENRICHED records — the file-backed
822
+ * collections' engine (see `jsonlQuery.ts`). No VARCHAR key pin needed:
823
+ * enriched record ids are already strings. `sample_size=-1` makes the
824
+ * schema inference scan EVERY line — with the default sample, a sparse
825
+ * optional/derived field first appearing past the sample would not be
826
+ * inferred as a column and the query would binder-error on it (Codex P2
827
+ * on #2165). The full scan costs nothing extra here: aggregation reads
828
+ * the whole file anyway. */
829
+ function compileJsonlQuery(query) {
830
+ return compileQuery(query, `read_json(?, format='newline_delimited', sample_size=-1)`);
831
+ }
832
+ //#endregion
833
+ //#region src/collection/server/csvStore.ts
834
+ /** `list()` row cap. Over-cap files are truncated with a warn — the v1
835
+ * contract is "browse + per-record views", not full-table analytics. */
836
+ var MAX_CSV_ROWS = 5e3;
837
+ /** Record ids minted from non-safe key values: `id0x` + utf-8 hex. Raw key
838
+ * values that themselves match this pattern are ALSO encoded, so the
839
+ * encoded namespace never collides with a raw value (injective mapping). */
840
+ var ENCODED_ID_PATTERN = /^id0x([0-9a-f]+)$/;
841
+ /** A CSV key value → the record id it's addressed by. Safe values pass
842
+ * through untouched; everything else (and anything shaped like an encoded
843
+ * id) becomes `id0x<hex>`. Pure + exported for unit tests. */
844
+ function encodeCsvRecordId(rawKey) {
845
+ if (safeRecordId(rawKey) === rawKey && !ENCODED_ID_PATTERN.test(rawKey)) return rawKey;
846
+ return `id0x${Buffer.from(rawKey, "utf-8").toString("hex")}`;
847
+ }
848
+ /** A record id → the CSV key value to look up. Inverse of
849
+ * `encodeCsvRecordId` for encoded ids; anything else is already the raw
850
+ * value. Pure + exported for unit tests. */
851
+ function decodeCsvRecordId(itemId) {
852
+ const match = ENCODED_ID_PATTERN.exec(itemId);
853
+ if (!match) return itemId;
854
+ return Buffer.from(match[1], "hex").toString("utf-8");
855
+ }
856
+ /** Normalize one DuckDB JS value into a JSON-safe record value: BigInt →
857
+ * number (string beyond the safe range), DATE/TIMESTAMP → ISO string
858
+ * (date-only when the clock is exactly UTC midnight, matching the `date`
859
+ * field contract), exotic DuckDB values → their string form. Pure +
860
+ * exported for unit tests. */
861
+ function normalizeCsvValue(value) {
862
+ if (typeof value === "bigint") return value <= BigInt(Number.MAX_SAFE_INTEGER) && value >= BigInt(-Number.MAX_SAFE_INTEGER) ? Number(value) : value.toString();
863
+ if (value instanceof Date) {
864
+ const iso = value.toISOString();
865
+ return iso.endsWith("T00:00:00.000Z") ? iso.slice(0, 10) : iso;
866
+ }
867
+ if (value !== null && typeof value === "object") return String(value);
868
+ return value;
869
+ }
870
+ /** One raw DuckDB row → a CollectionItem, or null when the key cell is
871
+ * missing/empty (the row can't be addressed). The primaryKey field is
872
+ * OVERWRITTEN with the (possibly encoded) record id so `item[primaryKey]`
873
+ * and the record's address never drift — same invariant the file store's
874
+ * write path enforces. Pure + exported for unit tests. */
875
+ function csvRowToItem(row, primaryKey) {
876
+ const normalized = Object.fromEntries(Object.entries(row).map(([key, value]) => [key, normalizeCsvValue(value)]));
877
+ const rawKey = normalized[primaryKey];
878
+ if (rawKey === null || rawKey === void 0 || String(rawKey) === "") return null;
879
+ return {
880
+ ...normalized,
881
+ [primaryKey]: encodeCsvRecordId(String(rawKey))
882
+ };
883
+ }
884
+ /** Dedupe by record id, LAST row wins (matches `csvRead`'s last-match
885
+ * pick). Returns the surviving items in first-seen order. Pure +
886
+ * exported for unit tests. */
887
+ function dedupeByRecordId(items, primaryKey) {
888
+ const byId = /* @__PURE__ */ new Map();
889
+ for (const item of items) byId.set(String(item[primaryKey]), item);
890
+ return {
891
+ items: [...byId.values()],
892
+ duplicates: items.length - byId.size
893
+ };
894
+ }
895
+ /** True when a thrown DuckDB error is the `types` pin naming a column the
896
+ * CSV doesn't have — the schema/file-mismatch case the caller downgrades
897
+ * to "empty collection + warn" instead of a 500. */
898
+ function isMissingKeyColumnError(err) {
899
+ return String(err).includes("do not exist in the CSV");
900
+ }
901
+ /** Bytes sniffed for UTF-8 validity. The trailing 3 bytes of the sample
902
+ * are dropped so a multibyte char split at the boundary can't produce a
903
+ * false negative on a valid file. */
904
+ var SNIFF_BYTES = 1024 * 1024;
905
+ function isValidUtf8(buf) {
906
+ try {
907
+ new TextDecoder("utf-8", { fatal: true }).decode(buf);
908
+ return true;
909
+ } catch {
910
+ return false;
911
+ }
912
+ }
913
+ /** Detect the (best-effort) encoding of a non-UTF-8 buffer. BOMs decide
914
+ * UTF-16; otherwise cp932 (the Shift_JIS superset — Excel-exported
915
+ * Japanese CSVs are the primary non-UTF-8 case this feature serves). */
916
+ function fallbackEncoding(buf) {
917
+ if (buf.length >= 2 && buf[0] === 255 && buf[1] === 254) return "utf-16le";
918
+ if (buf.length >= 2 && buf[0] === 254 && buf[1] === 255) return "utf-16be";
919
+ return "cp932";
920
+ }
921
+ function cacheDir() {
922
+ return path.join(tmpdir(), "mulmoclaude-csv-utf8");
923
+ }
924
+ /** Read only the first `bytes` of a file — the encoding sniff must not
925
+ * pull a multi-hundred-MB CSV into memory on the (common) UTF-8 path. */
926
+ async function readHead(absPath, bytes) {
927
+ const handle = await open(absPath, "r");
928
+ try {
929
+ const { size } = await handle.stat();
930
+ const buf = Buffer.alloc(Math.min(bytes, size));
931
+ await handle.read(buf, 0, buf.length, 0);
932
+ return buf;
933
+ } finally {
934
+ await handle.close();
935
+ }
936
+ }
937
+ /** Decode the whole file into a UTF-8 cache copy and return its path.
938
+ * Cache key = (path, mtime, size), so a replaced CSV re-decodes and an
939
+ * unchanged one never does. */
940
+ async function pathExists$1(target) {
941
+ try {
942
+ await stat(target);
943
+ return true;
944
+ } catch {
945
+ return false;
946
+ }
947
+ }
948
+ /** Best-effort removal of older decode-cache entries for the same source
949
+ * path — a frequently-replaced large CSV would otherwise accumulate one
950
+ * full copy per (mtime, size) forever. Runs AFTER the current copy is
951
+ * published; a concurrent reader holding an old fd is unaffected
952
+ * (unlink-while-open is safe on POSIX). */
953
+ async function evictSupersededCache(key, keepBasename) {
954
+ try {
955
+ const entries = await readdir(cacheDir());
956
+ await Promise.all(entries.filter((name) => name.startsWith(`${key}-`) && name !== keepBasename).map((name) => unlink(path.join(cacheDir(), name)).catch(() => void 0)));
957
+ } catch {}
958
+ }
959
+ /** Decode the whole file into a UTF-8 cache copy and return its path.
960
+ * Cache key = (path, mtime, size), so a replaced CSV re-decodes and an
961
+ * unchanged one never does; superseded copies are evicted. The cache
962
+ * lives in the SHARED OS tmpdir, so the dir is 0700 and files 0600 —
963
+ * decoded rows must not be readable by other local users. */
964
+ async function decodeToCache(absPath, info) {
965
+ const key = createHash("sha256").update(absPath).digest("hex").slice(0, 16);
966
+ const cached = path.join(cacheDir(), `${key}-${Math.trunc(info.mtimeMs)}-${info.size}.csv`);
967
+ if (!await pathExists$1(cached)) {
968
+ const whole = await readFile(absPath);
969
+ const encoding = fallbackEncoding(whole);
970
+ const text = iconv.decode(whole, encoding);
971
+ await mkdir(cacheDir(), {
972
+ recursive: true,
973
+ mode: 448
974
+ });
975
+ const tmp = `${cached}.${randomBytes(4).toString("hex")}.tmp`;
976
+ await writeFile(tmp, text, {
977
+ encoding: "utf-8",
978
+ mode: 384
979
+ });
980
+ await rename(tmp, cached);
981
+ log.info("collections", "decoded non-UTF-8 dataSource file to cache", {
982
+ path: absPath,
983
+ encoding
984
+ });
985
+ await evictSupersededCache(key, path.basename(cached));
986
+ }
987
+ return cached;
988
+ }
989
+ /** Re-validate the dataSource file at READ time, mirroring the JSON
990
+ * store's per-read defenses: realpath containment (a symlink swapped in
991
+ * after discovery must not walk out of the workspace) and an lstat
992
+ * regular-file check (a symlink leaf is refused outright, even one
993
+ * pointing inside the workspace — same rule as `isRegularFile` on
994
+ * record files). Returns the stat info, or null for "no readable file"
995
+ * (ENOENT / refused), which callers render as an empty collection. */
996
+ async function safeCsvStat(absPath, workspaceRoot) {
997
+ if (!isContainedInRoot(absPath, workspaceRoot)) {
998
+ log.warn("collections", "dataSource read refused: path escapes workspace", { path: absPath });
999
+ return null;
1000
+ }
1001
+ let info;
1002
+ try {
1003
+ info = await lstat(absPath);
1004
+ } catch (err) {
1005
+ if (err.code === "ENOENT") return null;
1006
+ throw err;
1007
+ }
1008
+ if (!info.isFile()) {
1009
+ log.warn("collections", "dataSource read refused: not a regular file (symlink?)", { path: absPath });
1010
+ return null;
1011
+ }
1012
+ return info;
1013
+ }
1014
+ /** Return a path DuckDB can read as UTF-8: the original file when it
1015
+ * already is UTF-8 (the cheap, common case — only the head is sniffed),
1016
+ * else a decoded cache copy (see `decodeToCache`). Returns null when
1017
+ * there is no readable file (missing, symlink, or containment-refused —
1018
+ * see `safeCsvStat`), which callers render as an empty collection. */
1019
+ async function ensureUtf8CsvPath(absPath, workspaceRoot) {
1020
+ const info = await safeCsvStat(absPath, workspaceRoot);
1021
+ if (info === null) return null;
1022
+ const head = await readHead(absPath, SNIFF_BYTES);
1023
+ const sample = head.length === SNIFF_BYTES ? head.subarray(0, SNIFF_BYTES - 3) : head;
1024
+ if (!(head.length >= 2 && (head[0] === 255 && head[1] === 254 || head[0] === 254 && head[1] === 255)) && isValidUtf8(sample)) return absPath;
1025
+ return decodeToCache(absPath, info);
1026
+ }
1027
+ var instancePromise = null;
1028
+ /** Lazily create one shared in-memory DuckDB instance. The dynamic import
1029
+ * keeps the native module OUT of core's load path — a platform where the
1030
+ * prebuilt binding is missing degrades to a per-query error on dataSource
1031
+ * collections only, never a broken core. A failed init is retried on the
1032
+ * next call (the promise is reset). */
1033
+ async function duckDbInstance() {
1034
+ if (instancePromise === null) instancePromise = import("@duckdb/node-api").then((mod) => mod.DuckDBInstance.create(":memory:"));
1035
+ try {
1036
+ return await instancePromise;
1037
+ } catch (err) {
1038
+ instancePromise = null;
1039
+ throw new Error(`DuckDB is unavailable on this host (@duckdb/node-api failed to load: ${String(err)}) — dataSource collections cannot be read`);
1040
+ }
1041
+ }
1042
+ async function queryCsv(sql, params) {
1043
+ const connection = await (await duckDbInstance()).connect();
1044
+ try {
1045
+ return (await connection.runAndReadAll(sql, params)).getRowObjectsJS();
1046
+ } finally {
1047
+ connection.disconnectSync();
1048
+ }
1049
+ }
1050
+ /** Every row of the CSV as records — capped, deduped, id-encoded. The
1051
+ * key column is pinned to VARCHAR (see `readCsvArgs`). `workspaceRoot`
1052
+ * drives the per-read containment check; omitted, the configured host
1053
+ * root is used. */
1054
+ async function csvList(absPath, primaryKey, workspaceRoot) {
1055
+ const utf8Path = await ensureUtf8CsvPath(absPath, workspaceRoot ?? getWorkspaceRoot());
1056
+ if (utf8Path === null) return [];
1057
+ let rows;
1058
+ try {
1059
+ rows = await queryCsv(`SELECT * FROM read_csv(${readCsvArgs(primaryKey)}) LIMIT 5001`, [utf8Path]);
1060
+ } catch (err) {
1061
+ if (!isMissingKeyColumnError(err)) throw err;
1062
+ log.warn("collections", "dataSource CSV has no primaryKey column — every row is skipped", {
1063
+ path: absPath,
1064
+ primaryKey
1065
+ });
1066
+ return [];
1067
+ }
1068
+ if (rows.length > 5e3) {
1069
+ log.warn("collections", "dataSource CSV truncated to row cap", {
1070
+ path: absPath,
1071
+ cap: MAX_CSV_ROWS
1072
+ });
1073
+ rows.length = MAX_CSV_ROWS;
1074
+ }
1075
+ const items = rows.map((row) => csvRowToItem(row, primaryKey)).filter((item) => item !== null);
1076
+ const skipped = rows.length - items.length;
1077
+ if (skipped > 0) log.warn("collections", "dataSource CSV rows skipped (empty key cell)", {
1078
+ path: absPath,
1079
+ skipped
1080
+ });
1081
+ const deduped = dedupeByRecordId(items, primaryKey);
1082
+ if (deduped.duplicates > 0) log.warn("collections", "dataSource CSV has duplicate key values (last row wins)", {
1083
+ path: absPath,
1084
+ duplicates: deduped.duplicates
1085
+ });
1086
+ return deduped.items;
1087
+ }
1088
+ /** The scan-order ordinal column the last-match read adds. Underscore
1089
+ * prefix keeps it out of any plausible CSV header namespace; it is
1090
+ * stripped from the returned record either way. */
1091
+ var ROW_ORDINAL = "__mc_row";
1092
+ /** One record by id. The comparison value rides as a prepared-statement
1093
+ * parameter, and the LAST matching row is selected IN DuckDB (scan-order
1094
+ * ordinal + LIMIT 1) — a CSV with thousands of duplicate keys must not
1095
+ * materialize them all for one detail read. Consistent with csvList's
1096
+ * last-wins dedupe. */
1097
+ async function csvRead(absPath, primaryKey, itemId, workspaceRoot) {
1098
+ const utf8Path = await ensureUtf8CsvPath(absPath, workspaceRoot ?? getWorkspaceRoot());
1099
+ if (utf8Path === null) return null;
1100
+ const rawKey = decodeCsvRecordId(itemId);
1101
+ const last = (await queryCsv(`SELECT * FROM (SELECT *, row_number() OVER () AS ${quoteIdent(ROW_ORDINAL)} FROM read_csv(${readCsvArgs(primaryKey)})) WHERE CAST(${quoteIdent(primaryKey)} AS VARCHAR) = ? ORDER BY ${quoteIdent(ROW_ORDINAL)} DESC LIMIT 1`, [utf8Path, rawKey])).at(0);
1102
+ if (last === void 0) return null;
1103
+ const { [ROW_ORDINAL]: __ordinal, ...record } = last;
1104
+ return csvRowToItem(record, primaryKey);
1105
+ }
1106
+ /** Run a validated aggregation query (the structured DSL — see
1107
+ * `core/queryZ.ts`) over the WHOLE file: no row cap on the scan (a
1108
+ * capped aggregate would be a wrong number), only the result-row LIMIT
1109
+ * the compiler emits. Values are normalized like list/read rows so a
1110
+ * chart consumer gets plain JSON scalars. */
1111
+ async function csvRunQuery(absPath, primaryKey, query, workspaceRoot) {
1112
+ const utf8Path = await ensureUtf8CsvPath(absPath, workspaceRoot ?? getWorkspaceRoot());
1113
+ if (utf8Path === null) return [];
1114
+ const { sql, params } = compileCsvQuery(query, primaryKey);
1115
+ return (await queryCsv(sql, [utf8Path, ...params])).map((row) => Object.fromEntries(Object.entries(row).map(([key, value]) => [key, normalizeCsvValue(value)])));
1116
+ }
1117
+ //#endregion
1118
+ //#region src/collection/server/store.ts
1119
+ /** True when the collection accepts UI/tool writes. A `dataSource`
1120
+ * collection is read-only: updates happen by editing/replacing the
1121
+ * data file itself. Every write entry point checks this BEFORE calling
1122
+ * `writeItem`/`deleteItem` — server-enforced, not just UI-hidden. */
1123
+ function collectionWritable(collection) {
1124
+ return !isReadOnlySchema(collection.schema);
1125
+ }
1126
+ /** The one-line refusal write paths surface (HTTP 405 / MCP error text). */
1127
+ function readOnlyRefusal(slug) {
1128
+ return `collection '${slug}' is read-only (backed by an external dataSource) — update the data file itself instead`;
1129
+ }
1130
+ /** Pick the store implementation for a discovered collection. A
1131
+ * `dataSource` schema whose `dataSourceFile` failed to resolve yields a
1132
+ * read-only EMPTY store rather than falling back to the (writable) file
1133
+ * store — a half-loaded read-only collection must never become writable. */
1134
+ function storeFor(collection, opts = {}) {
1135
+ if (isReadOnlySchema(collection.schema)) {
1136
+ const file = collection.dataSourceFile;
1137
+ const key = collection.schema.primaryKey;
1138
+ return {
1139
+ capabilities: { writable: false },
1140
+ list: () => file === void 0 ? Promise.resolve([]) : csvList(file, key, opts.workspaceRoot),
1141
+ read: (itemId) => file === void 0 ? Promise.resolve(null) : csvRead(file, key, itemId, opts.workspaceRoot),
1142
+ query: (query) => file === void 0 ? Promise.resolve([]) : csvRunQuery(file, key, query, opts.workspaceRoot)
1143
+ };
1144
+ }
1145
+ return {
1146
+ capabilities: { writable: true },
1147
+ list: () => listItems(collection.dataDir, opts),
1148
+ read: (itemId) => readItem(collection.dataDir, itemId, opts)
1149
+ };
1150
+ }
1151
+ //#endregion
1152
+ //#region src/collection/server/jsonlQuery.ts
1153
+ /** SQL semantics for an aggregate-only query over ZERO rows: one scalar
1154
+ * row (`count` = 0, everything else NULL) — the same shape the CSV path
1155
+ * produces for a header-only file, so callers reading `rows[0]` never
1156
+ * break on storage kind or emptiness. A grouped query over zero rows is
1157
+ * zero groups (`[]`), also matching SQL. Synthesized here because DuckDB
1158
+ * has no empty file to infer a schema from. */
1159
+ function emptyCollectionResult(query) {
1160
+ if ((query.groupBy?.length ?? 0) > 0) return [];
1161
+ const aggregates = Object.entries(query.aggregates ?? {});
1162
+ return [Object.fromEntries(aggregates.map(([alias, aggregate]) => [alias, aggregate.op === "count" ? 0 : null]))];
1163
+ }
1164
+ /** The SOURCE columns a query reads: groupBy columns, aggregate input
1165
+ * columns, and where fields. (orderBy resolves to groupBy columns or
1166
+ * aggregate ALIASES — never to a new source column — so it adds none.) */
1167
+ function referencedSourceColumns(query) {
1168
+ const columns = new Set(query.groupBy ?? []);
1169
+ for (const aggregate of Object.values(query.aggregates ?? {})) if (aggregate.column !== void 0) columns.add(aggregate.column);
1170
+ for (const cond of query.where ?? []) columns.add(cond.field);
1171
+ return [...columns];
1172
+ }
1173
+ /** Referenced columns that appear in NO row — a freshly-added optional
1174
+ * field, or a derived field whose inputs no record has yet. DuckDB only
1175
+ * infers columns from keys that occur in the JSONL, so without help a
1176
+ * valid query on such a field binder-errors here while the CSV path
1177
+ * (whose header always declares the column) returns NULLs. Padding the
1178
+ * FIRST line with `col: null` makes the column exist (full-scan
1179
+ * inference unions keys across lines) with matching NULL semantics. */
1180
+ function absentReferencedColumns(rows, query) {
1181
+ const present = /* @__PURE__ */ new Set();
1182
+ for (const row of rows) for (const key of Object.keys(row)) present.add(key);
1183
+ return referencedSourceColumns(query).filter((column) => !present.has(column));
1184
+ }
1185
+ /** Run a validated query over enriched collection rows. */
1186
+ async function runQueryOverRows(rows, query) {
1187
+ if (rows.length === 0) return emptyCollectionResult(query);
1188
+ await mkdir(cacheDir(), {
1189
+ recursive: true,
1190
+ mode: 448
1191
+ });
1192
+ const jsonlPath = path.join(cacheDir(), `q-${randomBytes(8).toString("hex")}.jsonl`);
1193
+ try {
1194
+ const nullPads = Object.fromEntries(absentReferencedColumns(rows, query).map((column) => [column, null]));
1195
+ const handle = await open(jsonlPath, "wx", 384);
1196
+ try {
1197
+ let first = true;
1198
+ for (const row of rows) {
1199
+ await handle.write(`${JSON.stringify(first ? {
1200
+ ...nullPads,
1201
+ ...row
1202
+ } : row)}\n`);
1203
+ first = false;
1204
+ }
1205
+ } finally {
1206
+ await handle.close();
1207
+ }
1208
+ const { sql, params } = compileJsonlQuery(query);
1209
+ return (await queryCsv(sql, [jsonlPath, ...params])).map((row) => Object.fromEntries(Object.entries(row).map(([key, value]) => [key, normalizeCsvValue(value)])));
1210
+ } finally {
1211
+ await unlink(jsonlPath).catch((err) => {
1212
+ if (err.code !== "ENOENT") log.warn("collections", "temp JSONL cleanup failed", {
1213
+ path: jsonlPath,
1214
+ error: String(err)
1215
+ });
1216
+ });
1217
+ }
1218
+ }
1219
+ //#endregion
638
1220
  //#region src/collection/core/recordZ.ts
639
1221
  /** The emptiness rule shared by `required` and the "only check present
640
1222
  * values" gate. NOT a truthiness check — `0` and `false` are filled. */
@@ -751,6 +1333,7 @@ async function listRecordFilenames(dataDir, workspaceRoot) {
751
1333
  }
752
1334
  }
753
1335
  async function validateCollectionRecords(collection, opts = {}) {
1336
+ if (collection.schema.dataSource !== void 0) return [];
754
1337
  const workspaceRoot = opts.workspaceRoot ?? getWorkspaceRoot();
755
1338
  const entries = await listRecordFilenames(collection.dataDir, workspaceRoot);
756
1339
  const issues = [];
@@ -1400,10 +1983,24 @@ function fieldDrivenFromFieldCarried(schema) {
1400
1983
  }
1401
1984
  return (carry ?? []).includes(driven.fromField);
1402
1985
  }
1986
+ /** External-data collection: the records ARE the rows of a user-supplied
1987
+ * data file (v1: CSV), queried through DuckDB — never copied into
1988
+ * `<dataDir>/<id>.json` files. Declaring `dataSource` makes the collection
1989
+ * **read-only** in every UI/tool write path; updates happen by replacing /
1990
+ * editing the file itself (file-watch republishes the views). `path` is
1991
+ * workspace-relative and containment-checked exactly like `dataPath`. The
1992
+ * row-id column is the schema's existing `primaryKey` — there is
1993
+ * deliberately no second key concept here.
1994
+ * See plans/feat-collection-csv-duckdb-source.md. */
1995
+ var DataSourceZ = z.object({
1996
+ type: z.literal("csv"),
1997
+ path: z.string().min(1)
1998
+ });
1403
1999
  var CollectionSchemaZ = z.object({
1404
2000
  title: z.string().min(1),
1405
2001
  icon: z.string().min(1),
1406
- dataPath: z.string().min(1),
2002
+ dataPath: z.string().min(1).optional(),
2003
+ dataSource: DataSourceZ.optional(),
1407
2004
  primaryKey: z.string().min(1),
1408
2005
  singleton: z.string().trim().min(1).optional(),
1409
2006
  fields: z.record(z.string(), FieldSpecZ),
@@ -1423,6 +2020,15 @@ var CollectionSchemaZ = z.object({
1423
2020
  notifyWhen: WhenZ.optional(),
1424
2021
  ingest: IngestZ.optional(),
1425
2022
  dynamicIcon: DynamicIconSpecZ.optional()
2023
+ }).refine((schema) => schema.dataPath !== void 0 !== (schema.dataSource !== void 0), {
2024
+ message: "declare exactly one of `dataPath` (native JSON records) or `dataSource` (external read-only data file), never both or neither",
2025
+ path: ["dataPath"]
2026
+ }).refine((schema) => schema.dataSource === void 0 || schema.singleton === void 0 && schema.ingest === void 0 && schema.spawn === void 0, {
2027
+ message: "a `dataSource` collection is read-only — it cannot declare `singleton`, `ingest`, or `spawn` (all of them write records)",
2028
+ path: ["dataSource"]
2029
+ }).refine((schema) => schema.dataSource === void 0 || [...schema.actions ?? [], ...schema.collectionActions ?? []].every((action) => action.kind !== "mutate"), {
2030
+ message: "a `dataSource` collection is read-only — its actions cannot use `kind: \"mutate\"` (a host write); use `chat`/`agent` actions instead",
2031
+ path: ["dataSource"]
1426
2032
  }).refine((schema) => schema.singleton === void 0 || isSafeRecordId(schema.singleton), {
1427
2033
  message: "schema `singleton` must be a valid item id (alphanumeric / hyphen / underscore / interior dot, no `..` or path separators)",
1428
2034
  path: ["singleton"]
@@ -1548,6 +2154,12 @@ function applyFeedSchemaDefaults(parsed, slug) {
1548
2154
  dataPath: `data/feeds/${slug}`
1549
2155
  };
1550
2156
  }
2157
+ /** The conventional per-slug records dir a `dataSource` collection gets as
2158
+ * its `dataDir` (records never live there, but archive/delete paths stay
2159
+ * well-defined — same shape the registry's R3 normalization uses). */
2160
+ function conventionalDataPath(slug) {
2161
+ return `data/collections/${slug}/items`;
2162
+ }
1551
2163
  /** The acceptance gates discovery applies AFTER `CollectionSchemaZ` parses,
1552
2164
  * before a schema becomes a live collection:
1553
2165
  *
@@ -1557,7 +2169,8 @@ function applyFeedSchemaDefaults(parsed, slug) {
1557
2169
  * edit is dropped with no error;
1558
2170
  * - a `feed` schema must declare an `ingest` block (else it's a dead,
1559
2171
  * non-refreshable card);
1560
- * - `dataPath` must resolve INSIDE the workspace.
2172
+ * - `dataPath` — or a `dataSource`'s `path` — must resolve INSIDE the
2173
+ * workspace (same realpath containment for both).
1561
2174
  *
1562
2175
  * Exported so `manageCollection`'s `putSchema` can run the SAME gates before
1563
2176
  * it reports success — a schema that passes `CollectionSchemaZ` but fails one
@@ -1577,7 +2190,24 @@ function acceptParsedSchema(schema, opts) {
1577
2190
  ok: false,
1578
2191
  reason: "a feed schema must declare an `ingest` block"
1579
2192
  };
1580
- const dataDir = resolveDataDir(schema.dataPath, opts.workspaceRoot);
2193
+ if (schema.dataSource !== void 0) {
2194
+ const dataSourceFile = resolveDataDir(schema.dataSource.path, opts.workspaceRoot);
2195
+ if (dataSourceFile === null) return {
2196
+ ok: false,
2197
+ reason: `dataSource.path '${schema.dataSource.path}' escapes the workspace`
2198
+ };
2199
+ const dataDir = resolveDataDir(conventionalDataPath(opts.slug), opts.workspaceRoot);
2200
+ if (dataDir === null) return {
2201
+ ok: false,
2202
+ reason: `slug '${opts.slug}' yields no workspace-contained data dir`
2203
+ };
2204
+ return {
2205
+ ok: true,
2206
+ dataDir,
2207
+ dataSourceFile
2208
+ };
2209
+ }
2210
+ const dataDir = resolveDataDir(schema.dataPath ?? "", opts.workspaceRoot);
1581
2211
  if (dataDir === null) return {
1582
2212
  ok: false,
1583
2213
  reason: `dataPath '${schema.dataPath}' escapes the workspace`
@@ -1625,7 +2255,8 @@ async function loadOneCollection(skillsRoot, slug, source, workspaceRoot) {
1625
2255
  const schema = parsed.data;
1626
2256
  const acceptance = acceptParsedSchema(schema, {
1627
2257
  source,
1628
- workspaceRoot
2258
+ workspaceRoot,
2259
+ slug: safeName
1629
2260
  });
1630
2261
  if (!acceptance.ok) {
1631
2262
  log.warn("collections", "schema.json rejected after validation, skipping", {
@@ -1639,6 +2270,7 @@ async function loadOneCollection(skillsRoot, slug, source, workspaceRoot) {
1639
2270
  source,
1640
2271
  schema,
1641
2272
  dataDir: acceptance.dataDir,
2273
+ ...acceptance.dataSourceFile !== void 0 ? { dataSourceFile: acceptance.dataSourceFile } : {},
1642
2274
  skillDir: path.join(skillsRoot, safeName)
1643
2275
  };
1644
2276
  }
@@ -1710,7 +2342,8 @@ function toSummary(collection) {
1710
2342
  slug: collection.slug,
1711
2343
  title: collection.schema.title,
1712
2344
  icon: collection.schema.icon,
1713
- source: collection.source
2345
+ source: collection.source,
2346
+ ...collection.schema.dataSource !== void 0 ? { readonly: true } : {}
1714
2347
  };
1715
2348
  }
1716
2349
  function toDetail(collection) {
@@ -1765,6 +2398,20 @@ async function countRecordFiles(dataDir, workspaceRoot) {
1765
2398
  return 0;
1766
2399
  }
1767
2400
  }
2401
+ /** A collection's record count for the ontology. File-backed: the cheap
2402
+ * readdir count. `dataSource`-backed: the row count via the CSV store
2403
+ * (subject to its row cap) — the dataDir is a phantom there, so the
2404
+ * readdir count would always misreport 0. Fail-soft to 0 like
2405
+ * `countRecordFiles` (an unreadable file / missing DuckDB must not
2406
+ * break the whole ontology). */
2407
+ async function countRecords(collection, workspaceRoot) {
2408
+ if (!collectionWritable(collection)) try {
2409
+ return (await storeFor(collection, { workspaceRoot }).list()).length;
2410
+ } catch {
2411
+ return 0;
2412
+ }
2413
+ return countRecordFiles(collection.dataDir, workspaceRoot);
2414
+ }
1768
2415
  async function toOntologyEntry(collection, workspaceRoot) {
1769
2416
  const { schema } = collection;
1770
2417
  return {
@@ -1773,7 +2420,7 @@ async function toOntologyEntry(collection, workspaceRoot) {
1773
2420
  icon: schema.icon,
1774
2421
  primaryKey: schema.primaryKey,
1775
2422
  displayField: schema.displayField ?? schema.primaryKey,
1776
- recordCount: await countRecordFiles(collection.dataDir, workspaceRoot),
2423
+ recordCount: await countRecords(collection, workspaceRoot),
1777
2424
  relations: schemaRelations(schema)
1778
2425
  };
1779
2426
  }
@@ -1819,7 +2466,7 @@ function uniqueBacklinkSources(schema) {
1819
2466
  async function loadTarget(slug, opts) {
1820
2467
  const target = await loadCollection(slug, opts);
1821
2468
  if (!target) return null;
1822
- const items = await listItems(target.dataDir, { workspaceRoot: opts.workspaceRoot });
2469
+ const items = await storeFor(target, { workspaceRoot: opts.workspaceRoot }).list();
1823
2470
  const byId = {};
1824
2471
  for (const item of items) {
1825
2472
  const itemId = item[target.schema.primaryKey];
@@ -1937,7 +2584,7 @@ async function computeCollectionIcon(collection, opts = {}) {
1937
2584
  try {
1938
2585
  const source = await loadCollection(spec.source.collection, opts);
1939
2586
  if (!source) return spec.fallback ?? schema.icon;
1940
- const ordered = sortByPrimaryKey(await listItems(source.dataDir, { workspaceRoot: opts.workspaceRoot }), source.schema.primaryKey);
2587
+ const ordered = sortByPrimaryKey(await storeFor(source, { workspaceRoot: opts.workspaceRoot }).list(), source.schema.primaryKey);
1941
2588
  const orderBy = spec.source.orderBy ?? firstDateField(source.schema);
1942
2589
  const recordsById = buildRecordsById(ordered, source.schema.primaryKey);
1943
2590
  return resolveIcon(selectDynamicRecord(ordered, spec.source, orderBy, recordsById), spec, schema.icon, recordsById);
@@ -2235,6 +2882,20 @@ function isDataDirSafe(dataDir, slug, workspaceRoot) {
2235
2882
  const resolved = path.resolve(dataDir);
2236
2883
  return acceptableRoots.some((root) => resolved === root || resolved.startsWith(root + path.sep));
2237
2884
  }
2885
+ /** Step 2 of the restore doc — how to bring the records back. A
2886
+ * `dataSource` collection has no record files to copy (its rows live in
2887
+ * the external data file, which the delete never touches). */
2888
+ function restoreRecordsStep(schema) {
2889
+ if (schema.dataPath === void 0) return `2. Records: nothing to copy. This is a \`dataSource\` collection —
2890
+ its records are the rows of \`${schema.dataSource?.path}\`, which the
2891
+ delete never touched.`;
2892
+ return `2. Copy the item data: \`cp\` every file under \`records/\` into
2893
+ \`${schema.dataPath}/\`. The records are part of the collection and
2894
+ must be restored. They are plain data files (NOT bridged), so use
2895
+ \`cp\` — the Write-tool rule in step 1 applies ONLY to the skill
2896
+ files, not to these records (there may be many; copy them, do not
2897
+ Write them one by one).`;
2898
+ }
2238
2899
  function buildRestoreDoc(collection) {
2239
2900
  const { slug, schema } = collection;
2240
2901
  return `# Restore "${schema.title}" (collection \`${slug}\`)
@@ -2255,18 +2916,13 @@ Follow these steps to restore it.
2255
2916
  \`.claude/skills/\` directly is not an option either: that path is
2256
2917
  permission-gated.)
2257
2918
 
2258
- 2. Copy the item data: \`cp\` every file under \`records/\` into
2259
- \`${schema.dataPath}/\`. The records are part of the collection and
2260
- must be restored. They are plain data files (NOT bridged), so use
2261
- \`cp\` — the Write-tool rule in step 1 applies ONLY to the skill
2262
- files, not to these records (there may be many; copy them, do not
2263
- Write them one by one).
2919
+ ${restoreRecordsStep(schema)}
2264
2920
 
2265
2921
  3. Confirm the collection reappears at \`/collections/${slug}\`.
2266
2922
 
2267
2923
  - slug: \`${slug}\`
2268
2924
  - title: ${schema.title}
2269
- - dataPath: \`${schema.dataPath}\`
2925
+ - dataPath: \`${schema.dataPath ?? `(dataSource) ${schema.dataSource?.path}`}\`
2270
2926
  `;
2271
2927
  }
2272
2928
  /** Copy one skill copy + the records + RESTORE.md into `archiveDir`. */
@@ -2489,14 +3145,15 @@ async function recordIssuesWarning(collection, deps) {
2489
3145
  return `${issues.length} record file(s) have data problems and are missing from this result. Fix each (Read → correct → Write):\n${lines}`;
2490
3146
  }
2491
3147
  async function loadRequestedItems(collection, ids, deps) {
3148
+ const store = storeFor(collection, { workspaceRoot: deps.workspaceRoot });
2492
3149
  if (!ids) return {
2493
- items: await listItems(collection.dataDir, { workspaceRoot: deps.workspaceRoot }),
3150
+ items: await store.list(),
2494
3151
  missing: []
2495
3152
  };
2496
3153
  const items = [];
2497
3154
  const missing = [];
2498
3155
  for (const recordId of ids) {
2499
- const item = await readItem(collection.dataDir, recordId, { workspaceRoot: deps.workspaceRoot }).catch(() => null);
3156
+ const item = await store.read(recordId).catch(() => null);
2500
3157
  if (item) items.push(item);
2501
3158
  else missing.push(recordId);
2502
3159
  }
@@ -2600,7 +3257,35 @@ async function putOneItem(collection, record, mode, deps) {
2600
3257
  if (result.kind === "conflict") return reject(itemId, `'${itemId}' already exists — mode "create" refuses overwrite; use "upsert" to update it`);
2601
3258
  return reject(itemId, "write refused: the collection's data dir escapes the workspace");
2602
3259
  }
3260
+ /** Aggregation over a collection via the structured query DSL
3261
+ * (`core/queryZ.ts`) — the paved road for counts / sums / group-bys
3262
+ * that a row listing can't answer honestly. Two engines behind one
3263
+ * shape: a dataSource collection queries its CSV natively through the
3264
+ * store (`store.query`, uncapped whole-file scan); a file-backed
3265
+ * collection aggregates its ENRICHED records (computed fields —
3266
+ * `derived` / `rollup` / `toggle` — are real columns) through the same
3267
+ * compiled SQL over a temp JSONL (`runQueryOverRows`). */
3268
+ async function handleQueryItems(collection, queryArg, deps) {
3269
+ const parsed = CollectionQueryZ.safeParse(queryArg);
3270
+ 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)"}: ${defangForPrompt(issue.message)}`).join("\n")}`;
3271
+ const store = storeFor(collection, { workspaceRoot: deps.workspaceRoot });
3272
+ if (store.query) {
3273
+ const rows = await store.query(parsed.data);
3274
+ return JSON.stringify({
3275
+ collection: collection.slug,
3276
+ count: rows.length,
3277
+ rows
3278
+ });
3279
+ }
3280
+ const rows = await runQueryOverRows(await enrichItems(collection, await store.list(), deps), parsed.data);
3281
+ return JSON.stringify({
3282
+ collection: collection.slug,
3283
+ count: rows.length,
3284
+ rows
3285
+ });
3286
+ }
2603
3287
  async function handlePutItems(collection, args, deps) {
3288
+ 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).`;
2604
3289
  const written = [];
2605
3290
  const rejected = [];
2606
3291
  for (const record of args.items) {
@@ -2699,7 +3384,8 @@ function schemaDiscoveryGate(schema, base) {
2699
3384
  const primaryField = schema.fields[schema.primaryKey];
2700
3385
  if (!primaryField) return `primaryKey '${schema.primaryKey}' is not one of the declared fields`;
2701
3386
  if (primaryField.primary !== true) return `the primaryKey field '${schema.primaryKey}' must be flagged \`primary: true\``;
2702
- if (resolveDataDir(schema.dataPath, base) === null) return `dataPath '${schema.dataPath}' escapes the workspace`;
3387
+ if (schema.dataPath !== void 0 && resolveDataDir(schema.dataPath, base) === null) return `dataPath '${schema.dataPath}' escapes the workspace`;
3388
+ if (schema.dataSource !== void 0 && resolveDataDir(schema.dataSource.path, base) === null) return `dataSource.path '${schema.dataSource.path}' escapes the workspace`;
2703
3389
  return null;
2704
3390
  }
2705
3391
  /** Validate a schema against CollectionSchemaZ and, on success, persist it.
@@ -2721,7 +3407,7 @@ async function handlePutSchema(slug, schemaArg, deps) {
2721
3407
  written: true
2722
3408
  });
2723
3409
  }
2724
- 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.";
3410
+ 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.";
2725
3411
  /** Validate getItems' optional `ids`/`fields` args, then delegate. */
2726
3412
  async function dispatchGetItems(collection, args, deps) {
2727
3413
  const ids = optionalStringArray(args.ids, "ids");
@@ -2742,76 +3428,83 @@ async function manageCollectionHandler(deps, args) {
2742
3428
  if (!slug) return "manageCollection: `slug` is required (the collection's slug).";
2743
3429
  if (action === "getSchema") return handleGetSchema(slug, deps);
2744
3430
  if (action === "putSchema") return handlePutSchema(slug, args.schema, deps);
2745
- if (action !== "getItems" && action !== "putItems") return "manageCollection: `action` must be \"getItems\", \"putItems\", \"getOntology\", \"schemaDocs\", \"getSchema\", or \"putSchema\".";
3431
+ if (action !== "getItems" && action !== "putItems" && action !== "queryItems") return "manageCollection: `action` must be \"getItems\", \"putItems\", \"queryItems\", \"getOntology\", \"schemaDocs\", \"getSchema\", or \"putSchema\".";
2746
3432
  const collection = await loadCollection(slug, deps);
2747
3433
  if (!collection) return unknownCollection(slug);
2748
3434
  if (action === "getItems") return dispatchGetItems(collection, args, deps);
3435
+ if (action === "queryItems") return handleQueryItems(collection, args.query, deps);
2749
3436
  const parsed = parsePutItems(args, slug);
2750
3437
  if (typeof parsed === "string") return parsed;
2751
3438
  return handlePutItems(collection, parsed, deps);
2752
3439
  }
2753
- function makeManageCollectionTool(deps = {}) {
2754
- return {
2755
- definition: {
2756
- name: "manageCollection",
2757
- 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.",
2758
- inputSchema: {
3440
+ var MANAGE_COLLECTION_DEFINITION = {
3441
+ name: "manageCollection",
3442
+ 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.",
3443
+ inputSchema: {
3444
+ type: "object",
3445
+ properties: {
3446
+ action: {
3447
+ type: "string",
3448
+ enum: [
3449
+ "getItems",
3450
+ "putItems",
3451
+ "queryItems",
3452
+ "getOntology",
3453
+ "schemaDocs",
3454
+ "getSchema",
3455
+ "putSchema"
3456
+ ],
3457
+ description: "What to do."
3458
+ },
3459
+ slug: {
3460
+ type: "string",
3461
+ description: "The collection's slug (its directory name, e.g. `stock-quotes`). Required for everything except schemaDocs and getOntology."
3462
+ },
3463
+ ids: {
3464
+ type: "array",
3465
+ items: { type: "string" },
3466
+ description: "getItems: only these record ids (primary-key values). Omit for all records."
3467
+ },
3468
+ fields: {
3469
+ type: "array",
3470
+ items: { type: "string" },
3471
+ description: "getItems: only these fields per record (the primary key is always included). Omit for all fields. Use on large collections."
3472
+ },
3473
+ items: {
3474
+ type: "array",
3475
+ items: { type: "object" },
3476
+ description: "putItems: the record objects to store. Each must carry the schema's primaryKey value (it doubles as the filename)."
3477
+ },
3478
+ mode: {
3479
+ type: "string",
3480
+ enum: [
3481
+ "upsert",
3482
+ "create",
3483
+ "merge"
3484
+ ],
3485
+ 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."
3486
+ },
3487
+ schema: {
3488
+ type: "object",
3489
+ 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."
3490
+ },
3491
+ query: {
2759
3492
  type: "object",
2760
- properties: {
2761
- action: {
2762
- type: "string",
2763
- enum: [
2764
- "getItems",
2765
- "putItems",
2766
- "getOntology",
2767
- "schemaDocs",
2768
- "getSchema",
2769
- "putSchema"
2770
- ],
2771
- description: "What to do."
2772
- },
2773
- slug: {
2774
- type: "string",
2775
- description: "The collection's slug (its directory name, e.g. `stock-quotes`). Required for everything except schemaDocs and getOntology."
2776
- },
2777
- ids: {
2778
- type: "array",
2779
- items: { type: "string" },
2780
- description: "getItems: only these record ids (primary-key values). Omit for all records."
2781
- },
2782
- fields: {
2783
- type: "array",
2784
- items: { type: "string" },
2785
- description: "getItems: only these fields per record (the primary key is always included). Omit for all fields. Use on large collections."
2786
- },
2787
- items: {
2788
- type: "array",
2789
- items: { type: "object" },
2790
- description: "putItems: the record objects to store. Each must carry the schema's primaryKey value (it doubles as the filename)."
2791
- },
2792
- mode: {
2793
- type: "string",
2794
- enum: [
2795
- "upsert",
2796
- "create",
2797
- "merge"
2798
- ],
2799
- 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."
2800
- },
2801
- schema: {
2802
- type: "object",
2803
- 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."
2804
- }
2805
- },
2806
- required: ["action"]
3493
+ 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."
2807
3494
  }
2808
3495
  },
3496
+ required: ["action"]
3497
+ }
3498
+ };
3499
+ function makeManageCollectionTool(deps = {}) {
3500
+ return {
3501
+ definition: MANAGE_COLLECTION_DEFINITION,
2809
3502
  alwaysActive: true,
2810
3503
  prompt: MANAGE_COLLECTION_PROMPT,
2811
3504
  handler: (args) => manageCollectionHandler(deps, args)
2812
3505
  };
2813
3506
  }
2814
3507
  //#endregion
2815
- export { resolveTemplatePath as $, validateCollectionRecords as A, promptPathsFor as B, discoverCollections as C, CollectionSchemaZ as D, toSummary as E, buildCollectionActionSeedPrompt as F, resolveCreateItemId as G, readCustomViewI18n as H, deleteItem as I, SCHEMA_FILE$1 as J, writeItem as K, generateItemId as L, compileRecordZ as M, recordFieldProblem as N, applyMutateAction as O, buildActionSeedPrompt as P, resolveDataDir as Q, isRegularFile as R, acceptParsedSchema as S, toDetail as T, readItem as U, readCustomViewHtml as V, readSkillTemplate as W, isContainedInWorkspace as X, isContainedInRoot as Y, itemFilePath as Z, errorMessage as _, deleteCollection as a, log as at, buildWorkspaceOntology as b, computeSuccessor as c, isTriggerDue as d, safeRecordId as et, maybeSpawnSuccessor as f, ONE_SECOND_MS as g, successorId as h, deleteCustomView as i, getWorkspaceRoot as it, validateRecordObject as j, firstMutateParamProblem as k, daysInMonth as l, resolveEvery as m, MAX_UNSELECTIVE_ITEMS as n, collectionsRegistriesConfigPath as nt, deleteCollectionRefusalMessage as o, publishCollectionChange as ot, parseCivil as p, writeFileAtomic as q, makeManageCollectionTool as r, configureCollectionHost as rt, advanceTriggerDate as s, setCollectionChangePublisher as st, MAX_SCHEMA_ISSUES as t, safeSlugName as tt, formatCivil as u, computeCollectionIcon as v, loadCollection as w, schemaRelations as x, enrichItems as y, listItems as z };
3508
+ export { isRegularFile as $, validateCollectionRecords as A, decodeCsvRecordId as B, discoverCollections as C, CollectionSchemaZ as D, toSummary as E, collectionWritable as F, compileJsonlQuery as G, encodeCsvRecordId as H, readOnlyRefusal as I, MAX_QUERY_ROWS as J, CollectionQueryZ as K, storeFor as L, compileRecordZ as M, recordFieldProblem as N, applyMutateAction as O, runQueryOverRows as P, generateItemId as Q, MAX_CSV_ROWS as R, acceptParsedSchema as S, setCollectionChangePublisher as St, toDetail as T, normalizeCsvValue as U, dedupeByRecordId as V, compileCsvQuery as W, buildCollectionActionSeedPrompt as X, buildActionSeedPrompt as Y, deleteItem as Z, errorMessage as _, collectionsRegistriesConfigPath as _t, deleteCollection as a, readSkillTemplate as at, buildWorkspaceOntology as b, log as bt, computeSuccessor as c, writeFileAtomic as ct, isTriggerDue as d, isContainedInWorkspace as dt, listItems as et, maybeSpawnSuccessor as f, itemFilePath as ft, ONE_SECOND_MS as g, safeSlugName as gt, successorId as h, safeRecordId as ht, deleteCustomView as i, readItem as it, validateRecordObject as j, firstMutateParamProblem as k, daysInMonth as l, SCHEMA_FILE$1 as lt, resolveEvery as m, resolveTemplatePath as mt, MAX_UNSELECTIVE_ITEMS as n, readCustomViewHtml as nt, deleteCollectionRefusalMessage as o, resolveCreateItemId as ot, parseCivil as p, resolveDataDir as pt, DEFAULT_QUERY_ROWS as q, makeManageCollectionTool as r, readCustomViewI18n as rt, advanceTriggerDate as s, writeItem as st, MAX_SCHEMA_ISSUES as t, promptPathsFor as tt, formatCivil as u, isContainedInRoot as ut, computeCollectionIcon as v, configureCollectionHost as vt, loadCollection as w, schemaRelations as x, publishCollectionChange as xt, enrichItems as y, getWorkspaceRoot as yt, csvRowToItem as z };
2816
3509
 
2817
- //# sourceMappingURL=server-Bd8l1bhv.js.map
3510
+ //# sourceMappingURL=server-D2ibbixF.js.map