dsh-continual-evolve 0.14.1 → 0.15.0

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.
package/client/client.js CHANGED
@@ -76,6 +76,7 @@ window.__ModuleLoader__.load({
76
76
  var createElement = react.createElement;
77
77
  var useState = react.useState;
78
78
  var useEffect = react.useEffect;
79
+ var useRef = react.useRef;
79
80
 
80
81
  var NS = "dsh-continual-evolve";
81
82
  var SLOT = "plugins.bundle.config";
@@ -84,25 +85,24 @@ window.__ModuleLoader__.load({
84
85
 
85
86
  var zh = {
86
87
  title: "工作区记忆",
87
- desc: "各工作区 .evolve/memory/ 的只读投影。",
88
+ desc: "中央记忆库(~/.dsh/evolve/memory.db)的只读投影。",
88
89
  refresh: "刷新",
89
- hint: "事实源是各工作区 .evolve/memory/ 下的 markdown 文件——模型原生读写;本卡片只展示,编辑请直接改文件。",
90
+ hint: "事实源是中央记忆库——模型经 memory_write 提案、代码校验后落库(代码是唯一写者);本卡片只展示。",
90
91
  loading: "读取中…",
91
92
  loadFailed: "卡片数据加载失败:",
92
- empty: "还没有任何工作区有记忆库——在某个工作区开一次会话后回来刷新。",
93
- absent: "该工作区尚无记忆库(开一次会话自动创建)。",
93
+ empty: "还没有任何工作区写过记忆——在某个工作区正常使用后回来刷新。",
94
+ absent: "该工作区还没有记忆。",
94
95
  readFailed: "记忆库读取失败:",
95
- stats: "{files} 条记忆 · 索引 {index} 行",
96
+ stats: "{files} 条记忆",
96
97
  filesHeading: "文件",
97
98
  scopeLabel: "选择工作区",
98
- searchPlaceholder: "搜索记忆文件…",
99
+ searchPlaceholder: "搜索记忆…",
99
100
  searchEmpty: "没有匹配的记忆文件。",
100
- missing: "索引失联(索引引用但文件已删):",
101
- unindexed: "未入索引(文件存在但索引没有行):",
102
- readError: "读取失败:",
103
- previewLoading: "正在加载文件…",
101
+ quarantined: "巡检隔离(疑似密钥泄漏等异常,已隐藏):",
102
+ readError: "读取失败:",
103
+ previewLoading: "正在加载记忆…",
104
104
  previewTooLarge: "该记忆文件超过 5 MiB 预览上限。",
105
- previewDeleted: "该记忆文件已被删除,请刷新文件列表。",
105
+ previewDeleted: "该记忆已被删除,请刷新列表。",
106
106
  previewFailed: "内容读取失败:",
107
107
  justNow: "刚刚",
108
108
  minutesAgo: "{count} 分钟前",
@@ -112,25 +112,24 @@ window.__ModuleLoader__.load({
112
112
  };
113
113
  var en = {
114
114
  title: "Workspace Memory",
115
- desc: "Read-only projection of each workspace's .evolve/memory/.",
115
+ desc: "Read-only projection of the central memory store (~/.dsh/evolve/memory.db).",
116
116
  refresh: "Refresh",
117
- hint: "The source of truth is the markdown files under each workspace's .evolve/memory/ — read and written by the model; this card only displays them, edit the files directly.",
117
+ hint: "The source of truth is the central memory store — the model proposes through memory_write, code validates and lands it (code is the sole writer); this card only displays it.",
118
118
  loading: "Loading…",
119
119
  loadFailed: "Failed to load card data: ",
120
- empty: "No workspace has a memory store yet — open a session in a workspace, then come back and refresh.",
121
- absent: "No memory store in this workspace yet (one is created when you open a session).",
120
+ empty: "No workspace has memories yet — use a workspace normally, then come back and refresh.",
121
+ absent: "No memories in this workspace yet.",
122
122
  readFailed: "Failed to read the memory store: ",
123
- stats: "{files} memories · {index} index rows",
123
+ stats: "{files} memories",
124
124
  filesHeading: "Files",
125
125
  scopeLabel: "Choose workspace",
126
126
  searchPlaceholder: "Search memory files…",
127
127
  searchEmpty: "No matching memory files.",
128
- missing: "Dangling index (indexed but deleted): ",
129
- unindexed: "Unindexed (on disk, missing from the index): ",
130
- readError: "Read failed: ",
131
- previewLoading: "Loading file…",
128
+ quarantined: "Quarantined by patrol (suspected secrets, hidden): ",
129
+ readError: "Read failed: ",
130
+ previewLoading: "Loading memory…",
132
131
  previewTooLarge: "This memory file exceeds the 5 MiB preview limit.",
133
- previewDeleted: "This memory file has been deleted — refresh the file list.",
132
+ previewDeleted: "This memory has been deleted — refresh the list.",
134
133
  previewFailed: "Failed to read the content: ",
135
134
  justNow: "Just now",
136
135
  minutesAgo: "{count} min ago",
@@ -230,11 +229,11 @@ window.__ModuleLoader__.load({
230
229
  *
231
230
  * @param {typeof fetch} fetchImpl - injectable for tests.
232
231
  * @param {string} root - known workspace root.
233
- * @param {string} file - plain .md file name inside that memory store.
232
+ * @param {string} id - the memory id inside that workspace's partition.
234
233
  * @returns {Promise<{status: "ready"|"deleted"|"tooLarge"|"failed", content: string, message: string}>}
235
234
  */
236
- async function loadFileContent(fetchImpl, root, file) {
237
- var url = API_ROOT + "/memory/file?root=" + encodeURIComponent(root) + "&file=" + encodeURIComponent(file);
235
+ async function loadFileContent(fetchImpl, root, id) {
236
+ var url = API_ROOT + "/memory/file?root=" + encodeURIComponent(root) + "&id=" + encodeURIComponent(id);
238
237
  try {
239
238
  var response = await fetchImpl(url);
240
239
  if (response.ok) {
@@ -298,6 +297,7 @@ window.__ModuleLoader__.load({
298
297
  ".dce-hint{color:var(--dsw-alias-label-tertiary,#8b93a1);font-size:12px;line-height:18px}",
299
298
  ".dce-empty{border:1px dashed var(--dsw-alias-border-l3,#d1d9e0);border-radius:10px;padding:16px;text-align:center;color:var(--dsw-alias-label-tertiary,#8b93a1);font-size:13px;line-height:20px}",
300
299
  ".dce-scope-wrap{position:relative;display:inline-flex;min-width:0;max-width:100%;align-self:flex-start}",
300
+ ".dce-scope-pill{border-radius:999px}",
301
301
  ".dce-scope{display:inline-flex;align-items:center;gap:8px;max-width:100%;padding:4px 10px;border:1px solid var(--dsw-alias-border-l2,#e5e7eb);border-radius:999px;background:var(--dsw-alias-bg-layer-1,#fff);color:var(--dsw-alias-label-primary,#1f2328);font:inherit;font-size:12px;line-height:18px;cursor:pointer}",
302
302
  ".dce-scope:hover{border-color:var(--dsw-alias-label-dimmed,#c8ccd4)}",
303
303
  ".dce-scope:focus-visible{outline:2px solid var(--dsw-alias-brand-primary,#4f6ef7);outline-offset:1px}",
@@ -307,8 +307,6 @@ window.__ModuleLoader__.load({
307
307
  ".dce-menu-item{display:flex;flex-direction:column;gap:2px;width:100%;padding:6px 10px;border:0;border-radius:8px;background:0 0;font:inherit;color:inherit;text-align:left;cursor:pointer}",
308
308
  ".dce-menu-item:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(0,0,0,.03))}",
309
309
  '.dce-menu-item[data-active="true"]{background:var(--dsw-alias-bg-module-platform,#eef1f4)}',
310
- ".dce-menu-label{color:var(--dsw-alias-label-primary,#1f2328);font-size:13px;line-height:18px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}",
311
- ".dce-menu-path{color:var(--dsw-alias-label-tertiary,#8b93a1);font-size:11px;line-height:16px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}",
312
310
  ".dce-search{width:100%;box-sizing:border-box;border:1px solid var(--dsw-alias-border-l4,#d8dee4);border-radius:var(--dsw-radius-md,8px);background:var(--dsw-alias-bg-layer-1,#fff);height:32px;color:var(--dsw-alias-label-primary,#1f2328);font:inherit;font-size:12px;padding:0 10px;outline:none}",
313
311
  ".dce-search::placeholder{color:var(--dsw-alias-label-tertiary,#8b93a1)}",
314
312
  ".dce-search:focus-visible{border-color:var(--dsw-alias-brand-primary,#4f6ef7)}",
@@ -321,6 +319,7 @@ window.__ModuleLoader__.load({
321
319
  ".dce-row{display:flex;width:100%;align-items:center;gap:10px;padding:8px 12px;background:0 0;border:0;font:inherit;color:inherit;text-align:left;cursor:pointer}",
322
320
  ".dce-row:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(0,0,0,.03))}",
323
321
  ".dce-row:focus-visible{outline:2px solid var(--dsw-alias-brand-primary,#4f6ef7);outline-offset:-2px}",
322
+ ".dce-file-icon{display:inline-flex;flex:none;align-items:center;justify-content:center;width:28px;height:28px;border-radius:8px;background:var(--dsw-alias-bg-module-platform,#eef1f4)}",
324
323
  ".dce-file-name{min-width:0;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--dsw-alias-label-primary,#1f2328);font-size:13px;font-weight:500;line-height:18px}",
325
324
  ".dce-file-time{flex:none;color:var(--dsw-alias-label-tertiary,#8b93a1);font-size:11px;line-height:16px}",
326
325
  ".dce-preview{margin:0;padding:8px 12px 10px;background:var(--dsw-alias-bg-module-platform,#eef1f4);border-top:1px solid var(--dsw-alias-border-l2,#e5e7eb)}",
@@ -369,9 +368,20 @@ window.__ModuleLoader__.load({
369
368
  var expanded = props.expanded;
370
369
  var preview = props.preview;
371
370
  var onToggle = props.onToggle;
372
- var rowChildren = [
373
- createElement("span", { key: "name", className: "dce-file-name" }, file.file),
374
- ];
371
+ var UI = props.UI;
372
+ var rowChildren = [];
373
+ if (UI !== null && UI !== undefined && UI.FileIcon !== undefined && UI.FileIcon !== null) {
374
+ // ZCode's memory rows open with a category tile; the host classifies
375
+ // the file itself, so a memory .md gets the markdown glyph.
376
+ rowChildren.push(
377
+ createElement(
378
+ "span",
379
+ { key: "icon", className: "dce-file-icon" },
380
+ createElement(UI.FileIcon, { path: file.id + ".md", size: 16 }),
381
+ ),
382
+ );
383
+ }
384
+ rowChildren.push(createElement("span", { key: "name", className: "dce-file-name" }, file.title));
375
385
  var time = formatRelative(file.updatedAt, now, t);
376
386
  if (time !== "") {
377
387
  rowChildren.push(createElement("span", { key: "time", className: "dce-file-time" }, time));
@@ -391,7 +401,7 @@ window.__ModuleLoader__.load({
391
401
  ];
392
402
  if (expanded) {
393
403
  var body = null;
394
- if (preview === null || preview.file !== file.file) {
404
+ if (preview === null || preview.file !== file.id) {
395
405
  body = createElement("div", { className: "dce-hint" }, t("previewLoading"));
396
406
  } else if (preview.status === "loading") {
397
407
  body = createElement("div", { className: "dce-hint" }, t("previewLoading"));
@@ -409,26 +419,31 @@ window.__ModuleLoader__.load({
409
419
  return createElement("li", { className: "dce-item" }, children);
410
420
  }
411
421
 
412
- /** Leading "switch scope" marker from the host primitives, or nothing. */
413
- function ScopeMark() {
414
- if (
415
- primitives !== undefined &&
416
- primitives !== null &&
417
- typeof primitives.IconChevronsUpDownOutlineRegular !== "undefined" &&
418
- primitives.IconChevronsUpDownOutlineRegular !== null
419
- ) {
420
- return createElement(primitives.IconChevronsUpDownOutlineRegular, { size: 14, "aria-hidden": "true" });
422
+ /** The host's folder marker for a workspace, or nothing on an old host. */
423
+ function FolderMark(props) {
424
+ var UI = props.UI;
425
+ if (UI !== null && UI !== undefined && UI.Folder !== undefined && UI.Folder !== null) {
426
+ return createElement(UI.Folder, { size: 16, "aria-hidden": "true" });
421
427
  }
422
428
  return null;
423
429
  }
424
430
 
425
431
  /**
426
432
  * The workspace scope selector, ZCode's Settings → Memory shape: one
427
- * scope at a time behind a dropdown (leading marker + current workspace
428
- * label + chevron), never a row of chips. A chip row can only render
433
+ * scope at a time behind a dropdown (folder marker + current workspace
434
+ * label + chevron), never a row of chips — a chip row can only render
429
435
  * when several workspaces are known, so in the common single-workspace
430
436
  * case it renders nothing at all and the capability reads as missing.
431
- * The menu closes on select, on Escape and on a click outside.
437
+ *
438
+ * The list is the host's own `Menu` primitive whenever the host provides
439
+ * it: the same surface the rest of the settings UI uses, and it owns
440
+ * outside-click/Escape handling. A hand-rolled document listener gets
441
+ * that wrong — React flushes this component's effect synchronously for
442
+ * the discrete click that opens the list, so a listener attached there
443
+ * catches that very click and closes the list again (the button then
444
+ * looks dead). The hand-rolled list stays as the old-host fallback and
445
+ * closes on a pointerdown outside its wrapper, which cannot be the click
446
+ * that opened it.
432
447
  */
433
448
  function WorkspaceScopeMenu(props) {
434
449
  var workspaces = props.workspaces;
@@ -439,27 +454,41 @@ window.__ModuleLoader__.load({
439
454
  var openState = useState(false);
440
455
  var open = openState[0];
441
456
  var setOpen = openState[1];
457
+ var wrapRef = useRef(null);
458
+ var hostMenu =
459
+ UI !== null && UI !== undefined && UI.Menu !== undefined && UI.Menu !== null ? UI.Menu : null;
442
460
  useEffect(
443
461
  function () {
444
- if (!open || typeof document === "undefined") {
462
+ if (hostMenu !== null || !open || typeof document === "undefined") {
445
463
  return undefined;
446
464
  }
447
- var close = function () {
448
- setOpen(false);
465
+ var onPointerDown = function (event) {
466
+ var node = wrapRef.current;
467
+ var target = event.target;
468
+ var inside =
469
+ node !== null &&
470
+ node !== undefined &&
471
+ typeof node.contains === "function" &&
472
+ target !== null &&
473
+ target !== undefined &&
474
+ node.contains(target);
475
+ if (!inside) {
476
+ setOpen(false);
477
+ }
449
478
  };
450
479
  var onKeyDown = function (event) {
451
480
  if (event.key === "Escape") {
452
- close();
481
+ setOpen(false);
453
482
  }
454
483
  };
455
- document.addEventListener("click", close);
484
+ document.addEventListener("pointerdown", onPointerDown);
456
485
  document.addEventListener("keydown", onKeyDown);
457
486
  return function () {
458
- document.removeEventListener("click", close);
487
+ document.removeEventListener("pointerdown", onPointerDown);
459
488
  document.removeEventListener("keydown", onKeyDown);
460
489
  };
461
490
  },
462
- [open],
491
+ [open, hostMenu],
463
492
  );
464
493
  var current = null;
465
494
  for (var index = 0; index < workspaces.length; index++) {
@@ -473,18 +502,11 @@ window.__ModuleLoader__.load({
473
502
  }
474
503
  var label = current === null ? "" : current.label || workspaceLabel(current.root);
475
504
  var scopeChildren = [
476
- createElement(ScopeMark, { key: "mark" }),
477
505
  createElement("span", { key: "label", className: "dce-scope-label" }, label),
478
506
  createElement("span", { key: "caret", className: "dce-scope-caret" }, createElement(Chevron, null)),
479
507
  ];
480
- var triggerProps = {
481
- type: "button",
482
- "aria-haspopup": "menu",
483
- "aria-expanded": open ? "true" : "false",
484
- "aria-label": t("scopeLabel"),
485
- onClick: function () {
486
- setOpen(!open);
487
- },
508
+ var toggle = function () {
509
+ setOpen(!open);
488
510
  };
489
511
  var trigger = null;
490
512
  if (UI !== null && UI !== undefined && typeof UI.Button !== "undefined" && UI.Button !== null) {
@@ -493,12 +515,14 @@ window.__ModuleLoader__.load({
493
515
  {
494
516
  key: "trigger",
495
517
  variant: "outline",
496
- size: "sm",
497
- type: triggerProps.type,
498
- "aria-haspopup": triggerProps["aria-haspopup"],
499
- "aria-expanded": triggerProps["aria-expanded"],
500
- "aria-label": triggerProps["aria-label"],
501
- onClick: triggerProps.onClick,
518
+ size: "md",
519
+ icon: createElement(FolderMark, { UI: UI }),
520
+ className: "dce-scope-pill",
521
+ type: "button",
522
+ "aria-haspopup": "menu",
523
+ "aria-expanded": open ? "true" : "false",
524
+ "aria-label": t("scopeLabel"),
525
+ onClick: toggle,
502
526
  },
503
527
  scopeChildren,
504
528
  );
@@ -507,16 +531,44 @@ window.__ModuleLoader__.load({
507
531
  "button",
508
532
  {
509
533
  key: "trigger",
510
- type: triggerProps.type,
534
+ type: "button",
511
535
  className: "dce-scope",
512
- "aria-haspopup": triggerProps["aria-haspopup"],
513
- "aria-expanded": triggerProps["aria-expanded"],
514
- "aria-label": triggerProps["aria-label"],
515
- onClick: triggerProps.onClick,
536
+ "aria-haspopup": "menu",
537
+ "aria-expanded": open ? "true" : "false",
538
+ "aria-label": t("scopeLabel"),
539
+ onClick: toggle,
516
540
  },
517
541
  scopeChildren,
518
542
  );
519
543
  }
544
+ if (hostMenu !== null) {
545
+ return createElement(hostMenu, {
546
+ key: "scope",
547
+ open: open,
548
+ anchor: trigger,
549
+ items: workspaces.map(function (workspace) {
550
+ return {
551
+ id: workspace.root,
552
+ label: workspace.label || workspaceLabel(workspace.root),
553
+ icon: createElement(FolderMark, { UI: UI }),
554
+ };
555
+ }),
556
+ selectedId: selected,
557
+ onSelect: function (id) {
558
+ setOpen(false);
559
+ if (id !== selected) {
560
+ onSelect(id);
561
+ }
562
+ },
563
+ onClose: function () {
564
+ setOpen(false);
565
+ },
566
+ align: "start",
567
+ side: "bottom",
568
+ portal: true,
569
+ selection: "check",
570
+ });
571
+ }
520
572
  var menu = null;
521
573
  if (open) {
522
574
  menu = createElement(
@@ -539,15 +591,12 @@ window.__ModuleLoader__.load({
539
591
  }
540
592
  },
541
593
  },
542
- // The registry allows duplicate titles, so the root
543
- // disambiguates two workspaces that read the same.
544
- createElement("span", { key: "label", className: "dce-menu-label" }, workspace.label || workspaceLabel(workspace.root)),
545
- createElement("span", { key: "root", className: "dce-menu-path" }, workspace.root),
594
+ workspace.label || workspaceLabel(workspace.root),
546
595
  );
547
596
  }),
548
597
  );
549
598
  }
550
- return createElement("div", { key: "scope", className: "dce-scope-wrap" }, trigger, menu);
599
+ return createElement("div", { key: "scope", className: "dce-scope-wrap", ref: wrapRef }, trigger, menu);
551
600
  }
552
601
 
553
602
  /** One workspace's memory store: stats, drift warnings, searchable file list with previews. */
@@ -600,7 +649,7 @@ window.__ModuleLoader__.load({
600
649
  if (normalizedQuery.length === 0) {
601
650
  return true;
602
651
  }
603
- return [file.file, file.name, file.description, file.type]
652
+ return [file.id, file.title, file.description, file.type]
604
653
  .filter(function (value) {
605
654
  return typeof value === "string";
606
655
  })
@@ -612,21 +661,12 @@ window.__ModuleLoader__.load({
612
661
  createElement(
613
662
  "div",
614
663
  { key: "stats", className: "dce-stats" },
615
- t("stats", { files: snapshot.fileCount, index: snapshot.indexEntryCount }),
664
+ t("stats", { files: snapshot.fileCount }),
616
665
  ),
617
666
  ];
618
- if (snapshot.missingFiles.length > 0) {
619
- parts.push(
620
- createElement("div", { key: "missing", className: "dce-warn" }, t("missing") + snapshot.missingFiles.join("、")),
621
- );
622
- }
623
- if (snapshot.unindexedFiles.length > 0) {
667
+ if (snapshot.quarantined.length > 0) {
624
668
  parts.push(
625
- createElement(
626
- "div",
627
- { key: "unindexed", className: "dce-warn" },
628
- t("unindexed") + snapshot.unindexedFiles.join("、"),
629
- ),
669
+ createElement("div", { key: "quarantined", className: "dce-warn" }, t("quarantined") + snapshot.quarantined.map(function (entry) { return entry.id; }).join("、")),
630
670
  );
631
671
  }
632
672
  parts.push(
@@ -665,14 +705,15 @@ window.__ModuleLoader__.load({
665
705
  { key: "files", className: "dce-list" },
666
706
  files.map(function (file) {
667
707
  return createElement(FileRow, {
668
- key: file.file,
708
+ key: file.id,
669
709
  file: file,
670
710
  now: now,
671
711
  t: t,
672
- expanded: expandedFile === file.file,
712
+ UI: UI,
713
+ expanded: expandedFile === file.id,
673
714
  preview: preview,
674
715
  onToggle: function () {
675
- onToggleFile(file.file);
716
+ onToggleFile(file.id);
676
717
  },
677
718
  });
678
719
  }),
@@ -896,7 +937,15 @@ window.__ModuleLoader__.load({
896
937
  }
897
938
  var UI = null;
898
939
  if (primitives !== undefined && primitives !== null && typeof primitives.Button !== "undefined") {
899
- UI = { Button: primitives.Button };
940
+ // The scope selector prefers the host's own Menu and folder icon;
941
+ // each stays optional so a host with only Button still gets the
942
+ // hand-rolled list and a plain trigger.
943
+ UI = {
944
+ Button: primitives.Button,
945
+ Menu: primitives.Menu,
946
+ Folder: primitives.IconFolderOpenOutlineRegular,
947
+ FileIcon: primitives.FileTypeIcon,
948
+ };
900
949
  }
901
950
  ctx.slots.inject(SLOT, function () {
902
951
  return ctx.slots.register(
@@ -1,4 +1,4 @@
1
- import type { WorkspaceCatalog } from "./workspace-catalog.js";
1
+ import type { MemoryStore } from "./store.js";
2
2
  /** URL prefix of the card API (kind-exact routes registered under it). */
3
3
  export declare const CARD_API_WORKSPACES_PATH = "/dsh-continual-evolve/api/v1/workspaces";
4
4
  export declare const CARD_API_MEMORY_PATH = "/dsh-continual-evolve/api/v1/memory";
@@ -24,10 +24,10 @@ export interface CardWebServer {
24
24
  register(route: CardRoute): unknown;
25
25
  }
26
26
  /**
27
- * Register the two card routes and return a disposer that removes them.
27
+ * Register the card routes and return a disposer that removes them.
28
28
  * Registration return values are passed through untouched: whatever disposer
29
29
  * shape the host hands back (or none, as on some host versions), the
30
30
  * disposer calls only the functions among them.
31
31
  */
32
- export declare function mountCardRoutes(webServer: CardWebServer, catalog: WorkspaceCatalog): () => void;
32
+ export declare function mountCardRoutes(webServer: CardWebServer, store: MemoryStore): () => void;
33
33
  //# sourceMappingURL=card-routes.d.ts.map
@@ -3,17 +3,15 @@
3
3
  *
4
4
  * Mounted through the host's `webServer` service (same registration shape
5
5
  * dshmarket uses: `kind: "exact"` routes on the profile's web server), the
6
- * three endpoints are GET-only projections: the workspace catalogue, the
7
- * memory snapshot for a known root, and one memory file's body for the card's
8
- * content preview. The catalogue IS the security fence — the memory endpoints
9
- * resolve their `root` parameter only against workspaces the host's registry
10
- * (or, on hosts without one, a root this process has served) knows about, and
11
- * the file endpoint additionally accepts only plain .md entries of that
12
- * workspace's memory directory, so the API cannot read arbitrary paths.
13
- * Handlers are synchronous and never throw past the response.
6
+ * three endpoints are GET-only projections of the central SQLite store: the
7
+ * workspace list (active partitions whose directory still exists), the
8
+ * memory snapshot for a known workspace, and one record's body by id. The
9
+ * store itself is the security fence — a `root` parameter only resolves
10
+ * when the database has rows for it, so the API cannot probe the
11
+ * filesystem; the content endpoint takes an id, never a path. Handlers are
12
+ * synchronous and never throw past the response.
14
13
  */
15
- import { resolve } from "node:path";
16
- import { memoryFileContent, memorySnapshot } from "./memory-snapshot.js";
14
+ import { memoryEntryContent, listCardWorkspaces, memorySnapshot } from "./memory-snapshot.js";
17
15
  /** URL prefix of the card API (kind-exact routes registered under it). */
18
16
  export const CARD_API_WORKSPACES_PATH = "/dsh-continual-evolve/api/v1/workspaces";
19
17
  export const CARD_API_MEMORY_PATH = "/dsh-continual-evolve/api/v1/memory";
@@ -25,13 +23,17 @@ function sendJson(response, status, body) {
25
23
  function isGet(request) {
26
24
  return (request.method ?? "GET") === "GET";
27
25
  }
26
+ /** The card's read fence: a root only resolves when the store has rows for it. */
27
+ function isKnownRoot(store, root) {
28
+ return listCardWorkspaces(store).some((workspace) => workspace.root === root);
29
+ }
28
30
  /**
29
- * Register the two card routes and return a disposer that removes them.
31
+ * Register the card routes and return a disposer that removes them.
30
32
  * Registration return values are passed through untouched: whatever disposer
31
33
  * shape the host hands back (or none, as on some host versions), the
32
34
  * disposer calls only the functions among them.
33
35
  */
34
- export function mountCardRoutes(webServer, catalog) {
36
+ export function mountCardRoutes(webServer, store) {
35
37
  const disposers = [];
36
38
  disposers.push(webServer.register({
37
39
  kind: "exact",
@@ -42,9 +44,7 @@ export function mountCardRoutes(webServer, catalog) {
42
44
  response.end();
43
45
  return;
44
46
  }
45
- // The listed set is the display projection (workspaces with a
46
- // memory directory); the read fence is `known` below.
47
- sendJson(response, 200, { workspaces: catalog.list() });
47
+ sendJson(response, 200, { workspaces: listCardWorkspaces(store) });
48
48
  },
49
49
  }));
50
50
  disposers.push(webServer.register({
@@ -61,14 +61,13 @@ export function mountCardRoutes(webServer, catalog) {
61
61
  sendJson(response, 400, { error: "root query parameter is required and must be an absolute path" });
62
62
  return;
63
63
  }
64
- const known = knownEntryOf(catalog, root);
65
- if (known === undefined) {
66
- // The catalogue answers 404 without distinguishing "not a
67
- // workspace here" from "wrong path": no filesystem oracle.
64
+ if (!isKnownRoot(store, root)) {
65
+ // The store answers 404 without distinguishing "no rows" from
66
+ // "wrong path": no filesystem oracle.
68
67
  sendJson(response, 404, { error: "unknown workspace root" });
69
68
  return;
70
69
  }
71
- sendJson(response, 200, memorySnapshot(known.root));
70
+ sendJson(response, 200, memorySnapshot(store, root));
72
71
  },
73
72
  }));
74
73
  disposers.push(webServer.register({
@@ -85,39 +84,33 @@ export function mountCardRoutes(webServer, catalog) {
85
84
  sendJson(response, 400, { error: "root query parameter is required and must be an absolute path" });
86
85
  return;
87
86
  }
88
- const file = fileParamOf(request);
89
- if (file === null) {
90
- sendJson(response, 400, { error: "file query parameter is required" });
87
+ const id = idParamOf(request);
88
+ if (id === null) {
89
+ sendJson(response, 400, { error: "id query parameter is required" });
91
90
  return;
92
91
  }
93
- const known = knownEntryOf(catalog, root);
94
- if (known === undefined) {
95
- // Same fence as the snapshot route: 404 without distinguishing
96
- // "not a workspace here" from "wrong path".
92
+ if (!isKnownRoot(store, root)) {
93
+ // Same fence as the snapshot route.
97
94
  sendJson(response, 404, { error: "unknown workspace root" });
98
95
  return;
99
96
  }
100
- const content = memoryFileContent(known.root, file);
97
+ const content = memoryEntryContent(store, root, id);
101
98
  if (!content.ok) {
102
- // The fence reasons stay indistinguishable from absence in status
103
- // terms only where safe: "outside" answers 400 (a client bug),
104
- // absence 404, oversized 413 — never a body leak either way.
105
- if (content.reason === "outside") {
106
- sendJson(response, 400, { error: "file must be a plain .md entry of the memory directory" });
107
- }
108
- else if (content.reason === "too-large") {
109
- sendJson(response, 413, { error: "memory file exceeds the 5 MiB preview limit" });
99
+ // Absence answers 404; the oversized case is theoretically
100
+ // unreachable (bodies are schema-capped at 64KB) but keeps its
101
+ // own status for the day the preview limit shrinks.
102
+ if (content.reason === "too-large") {
103
+ sendJson(response, 413, { error: "memory body exceeds the 5 MiB preview limit" });
110
104
  }
111
105
  else {
112
- sendJson(response, 404, { error: "memory file not found" });
106
+ sendJson(response, 404, { error: "memory not found" });
113
107
  }
114
108
  return;
115
109
  }
116
110
  sendJson(response, 200, {
117
- file: content.file,
111
+ id: content.id,
118
112
  content: content.content,
119
- mtimeMs: content.mtimeMs,
120
- changed: content.changed,
113
+ updatedAtMs: content.updatedAtMs,
121
114
  });
122
115
  },
123
116
  }));
@@ -129,11 +122,6 @@ export function mountCardRoutes(webServer, catalog) {
129
122
  }
130
123
  };
131
124
  }
132
- /** The catalogued workspace a `root` query parameter resolves to, if any. */
133
- function knownEntryOf(catalog, root) {
134
- const target = resolve(root);
135
- return catalog.known().find((workspace) => workspace.root === target);
136
- }
137
125
  /** The decoded `root` query parameter, or null when absent/relative. */
138
126
  function rootParamOf(request) {
139
127
  try {
@@ -150,15 +138,15 @@ function rootParamOf(request) {
150
138
  return null;
151
139
  }
152
140
  }
153
- /** The decoded `file` query parameter, or null when absent. */
154
- function fileParamOf(request) {
141
+ /** The decoded `id` query parameter, or null when absent. */
142
+ function idParamOf(request) {
155
143
  try {
156
144
  const url = new URL(request.url ?? "/", "http://dsh-card.invalid");
157
- const file = url.searchParams.get("file");
158
- if (file === null || file.trim().length === 0) {
145
+ const id = url.searchParams.get("id");
146
+ if (id === null || id.trim().length === 0) {
159
147
  return null;
160
148
  }
161
- return file;
149
+ return id;
162
150
  }
163
151
  catch (error) {
164
152
  void error;