@avantf/dsh-mem 0.8.1 → 0.10.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.
Files changed (57) hide show
  1. package/README.md +42 -43
  2. package/lib/client.js +1181 -327
  3. package/lib/engine/graph-recall/index.d.ts +2 -2
  4. package/lib/engine/graph-recall/traverse.d.ts +2 -2
  5. package/lib/engine/graph-recall/types.d.ts +1 -1
  6. package/lib/engine/mem/config/config_files.d.ts +1 -1
  7. package/lib/engine/mem/db/dao/facts.d.ts +80 -82
  8. package/lib/engine/mem/dbdrivers/sqlite/facts.d.ts +6 -13
  9. package/lib/engine/mem/defaults.d.ts +4 -1
  10. package/lib/engine/mem/entities/extract.d.ts +34 -14
  11. package/lib/engine/mem/facts_io.d.ts +4 -0
  12. package/lib/engine/mem/index.d.ts +1 -0
  13. package/lib/engine/mem/interfaces/repositories.d.ts +5 -19
  14. package/lib/engine/mem/lifecycle/maintenance.d.ts +8 -4
  15. package/lib/engine/mem/lifecycle/tick.d.ts +11 -10
  16. package/lib/engine/mem/lifecycle/trust.d.ts +24 -37
  17. package/lib/engine/mem/modelBootstrap.d.ts +29 -0
  18. package/lib/engine/mem/runtime.d.ts +4 -8
  19. package/lib/engine/mem/store/facts_restore.d.ts +8 -6
  20. package/lib/engine/mem/store/graph_reader.d.ts +2 -2
  21. package/lib/engine/mem/store/memory.d.ts +98 -37
  22. package/lib/engine/mem/store/recall_policy.d.ts +10 -0
  23. package/lib/engine/mem/store/time_filter.d.ts +1 -1
  24. package/lib/engine/mem/trustHeartbeat.d.ts +1 -1
  25. package/lib/engine/mem-contract/config.d.ts +144 -9
  26. package/lib/engine/mem-contract/index.d.ts +2 -0
  27. package/lib/engine/mem-contract/time.d.ts +75 -0
  28. package/lib/engine/mem-contract/tools.d.ts +9 -15
  29. package/lib/engine/mem-contract/trust_curve.d.ts +119 -0
  30. package/lib/engine/mem-contract/types.d.ts +73 -12
  31. package/lib/engine/mem-retrieval/adapters/auto_vstore.d.ts +10 -0
  32. package/lib/engine/mem-retrieval/adapters/hnswlib.d.ts +28 -0
  33. package/lib/engine/mem-retrieval/adapters/local_bge.d.ts +48 -6
  34. package/lib/engine/mem-retrieval/interfaces.d.ts +22 -2
  35. package/lib/engine/mem-retrieval/text_budget.d.ts +31 -0
  36. package/lib/index.js +2349 -665
  37. package/lib/interface-version.json +2 -2
  38. package/lib/types/client/index.d.ts.map +1 -1
  39. package/lib/types/client/paging.d.ts +58 -8
  40. package/lib/types/client/paging.d.ts.map +1 -1
  41. package/lib/types/client/time_filter.d.ts +54 -0
  42. package/lib/types/client/time_filter.d.ts.map +1 -0
  43. package/lib/types/command.d.ts.map +1 -1
  44. package/lib/types/deps_audit.d.ts +88 -0
  45. package/lib/types/deps_audit.d.ts.map +1 -0
  46. package/lib/types/envinit-bootstrap.d.ts +1 -1
  47. package/lib/types/envinit.d.ts +224 -4
  48. package/lib/types/envinit.d.ts.map +1 -1
  49. package/lib/types/hints.d.ts +58 -0
  50. package/lib/types/hints.d.ts.map +1 -1
  51. package/lib/types/index.d.ts.map +1 -1
  52. package/lib/types/remote.d.ts.map +1 -1
  53. package/lib/types/render.d.ts.map +1 -1
  54. package/lib/types/runtime_deps_resource.d.ts +29 -0
  55. package/lib/types/runtime_deps_resource.d.ts.map +1 -0
  56. package/package.json +26 -24
  57. package/res/runtime-deps.yaml +99 -0
package/lib/client.js CHANGED
@@ -29,34 +29,25 @@ window.__ModuleLoader__.load({
29
29
  let react = require("react");
30
30
  react = __toESM(react, 1);
31
31
  //#region \0dsh-css:src/client/pages.module.css.mjs
32
- const css = ".HvfIsq_section{color:var(--dsw-alias-label-primary);width:100%;max-width:var(--dsh-chat-content-width);flex-direction:column;gap:12px;margin:0 auto;display:flex}.HvfIsq_headbar{z-index:4;border-bottom:.5px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-base);flex-wrap:wrap;align-items:flex-end;gap:8px;padding-bottom:8px;display:flex;position:sticky;top:0}.HvfIsq_warn{color:var(--dsw-alias-state-warn-label);background:color-mix(in srgb, var(--dsw-alias-state-warn-primary) 16%, transparent);border-radius:9px;margin-left:6px;padding:0 6px;font-size:11px;line-height:16px;display:inline-block}.HvfIsq_search{width:100%;color:var(--dsw-alias-label-tertiary);align-items:center;display:flex;position:relative}.HvfIsq_searchField{flex:260px;width:auto}.HvfIsq_searchPanel{border:.5px solid var(--dsw-alias-state-business-primary);background:color-mix(in srgb, var(--dsw-alias-state-business-primary) 7%, transparent);border-radius:10px;flex-direction:column;gap:8px;padding:10px;display:flex}.HvfIsq_searchTitle{color:var(--dsw-alias-label-primary);flex-wrap:wrap;align-items:baseline;gap:6px;font-size:13px;font-weight:600;display:flex}.HvfIsq_searchQuery{color:var(--dsw-alias-label-tertiary);font-size:12px;font-weight:400}.HvfIsq_select{border:.5px solid var(--dsw-alias-border-l4);background:var(--dsw-alias-bg-layer-1);height:28px;color:var(--dsw-alias-label-primary);font:inherit;border-radius:8px;outline:none;padding:0 8px;font-size:12px}.HvfIsq_select:focus-visible{border-color:var(--dsw-alias-state-business-primary);box-shadow:0 0 0 2px color-mix(in srgb, var(--dsw-alias-state-business-primary) 18%, transparent)}.HvfIsq_search>svg{pointer-events:none;position:absolute;left:12px}.HvfIsq_search input{border:.5px solid var(--dsw-alias-border-l4);background:var(--dsw-alias-bg-layer-1);width:100%;height:36px;color:var(--dsw-alias-label-primary);font:inherit;border-radius:10px;outline:none;padding:0 34px 0 36px;font-size:13px}.HvfIsq_search input::placeholder{color:var(--dsw-alias-label-tertiary)}.HvfIsq_search input:focus-visible{border-color:var(--dsw-alias-state-business-primary);box-shadow:0 0 0 2px color-mix(in srgb, var(--dsw-alias-state-business-primary) 18%, transparent)}.HvfIsq_clear{width:20px;height:20px;color:var(--dsw-alias-label-tertiary);font:inherit;cursor:pointer;background:0 0;border:0;border-radius:50%;justify-content:center;align-items:center;padding:0;font-size:16px;line-height:1;display:inline-flex;position:absolute;top:50%;right:8px;transform:translateY(-50%)}.HvfIsq_clear:hover{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-interactive-bg-hover)}.HvfIsq_clear:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary);outline-offset:1px}.HvfIsq_status{color:var(--dsw-alias-label-tertiary);font-size:12px}.HvfIsq_error{color:var(--dsw-alias-label-error);white-space:pre-wrap;font-size:12px}.HvfIsq_list{flex-direction:column;gap:8px;margin:0;padding:0;list-style:none;display:flex}.HvfIsq_item{border:.5px solid var(--dsw-alias-border-l4);background:var(--dsw-alias-bg-layer-1);border-radius:10px;padding:8px 10px;font-size:13px;line-height:1.5}.HvfIsq_meta{color:var(--dsw-alias-label-tertiary);margin-top:2px;font-size:12px;display:block}.HvfIsq_detail{flex-direction:column;gap:4px;display:flex}.HvfIsq_more{border:.5px solid var(--dsw-alias-border-l3);height:28px;color:var(--dsw-alias-label-primary);font:inherit;cursor:pointer;background:0 0;border-radius:14px;justify-content:center;align-self:flex-start;align-items:center;gap:4px;padding:0 12px;font-size:12px;line-height:18px;display:inline-flex}.HvfIsq_more:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}.HvfIsq_more:disabled{cursor:not-allowed;opacity:.4}.HvfIsq_toolbar{flex-wrap:wrap;align-items:flex-end;gap:8px;display:flex}.HvfIsq_form{flex-direction:column;gap:8px;display:flex}.HvfIsq_row{flex-wrap:wrap;align-items:center;gap:6px;margin-top:6px;display:flex}.HvfIsq_btn{border:.5px solid var(--dsw-alias-border-l3);height:24px;color:var(--dsw-alias-label-primary);font:inherit;cursor:pointer;background:0 0;border-radius:12px;justify-content:center;align-items:center;gap:4px;padding:0 10px;font-size:12px;line-height:16px;display:inline-flex}.HvfIsq_btn:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}.HvfIsq_btn:disabled{cursor:not-allowed;opacity:.4}.HvfIsq_btnActive{border-color:var(--dsw-alias-state-business-primary);color:var(--dsw-alias-state-business-primary)}.HvfIsq_btnDanger{color:var(--dsw-alias-label-error)}.HvfIsq_fieldHead{justify-content:space-between;align-items:center;gap:8px;display:flex}.HvfIsq_fieldHead>*+*{flex:none}.HvfIsq_field{color:var(--dsw-alias-label-tertiary);flex-direction:column;flex:auto;gap:4px;min-width:120px;font-size:12px;display:flex}.HvfIsq_input{border:.5px solid var(--dsw-alias-border-l4);background:var(--dsw-alias-bg-layer-1);height:28px;color:var(--dsw-alias-label-primary);font:inherit;border-radius:8px;outline:none;min-width:0;padding:0 8px;font-size:12px}.HvfIsq_input:focus-visible{border-color:var(--dsw-alias-state-business-primary);box-shadow:0 0 0 2px color-mix(in srgb, var(--dsw-alias-state-business-primary) 18%, transparent)}.HvfIsq_textarea{border:.5px solid var(--dsw-alias-border-l4);background:var(--dsw-alias-bg-layer-1);width:100%;color:var(--dsw-alias-label-primary);font:inherit;resize:vertical;box-sizing:border-box;border-radius:8px;outline:none;margin-top:6px;padding:6px 8px;font-size:12px;line-height:1.5}.HvfIsq_textarea:focus-visible{border-color:var(--dsw-alias-state-business-primary);box-shadow:0 0 0 2px color-mix(in srgb, var(--dsw-alias-state-business-primary) 18%, transparent)}.HvfIsq_groupHead{color:var(--dsw-alias-label-secondary);margin:4px 0 2px;font-size:12px;font-weight:600}.HvfIsq_badge{color:var(--dsw-alias-label-tertiary);margin-bottom:2px;font-size:11px;display:inline-block}.HvfIsq_chunkBox{border:.5px solid var(--dsw-alias-border-l4);border-radius:8px;margin-top:6px;padding:6px 8px;font-size:12px;line-height:1.5}.HvfIsq_picker{border:.5px solid var(--dsw-alias-border-l4);background:var(--dsw-alias-bg-layer-1);border-radius:10px;flex-direction:column;gap:8px;padding:8px 10px;display:flex}.HvfIsq_pickerHead{flex-direction:column;gap:2px;display:flex}.HvfIsq_pickerPath{overflow-wrap:anywhere;font-family:ui-monospace,monospace;font-size:12px}.HvfIsq_pickerList{border:.5px solid var(--dsw-alias-border-l4);border-radius:8px;flex-direction:column;gap:1px;max-height:260px;margin:0;padding:0;list-style:none;display:flex;overflow:auto}.HvfIsq_pickerEntry{width:100%;color:inherit;font:inherit;text-align:left;cursor:pointer;background:0 0;border:0;padding:4px 8px;font-size:12px;display:block}.HvfIsq_pickerEntry:hover:not(:disabled){background:color-mix(in srgb, currentColor 8%, transparent)}.HvfIsq_pickerDisabled{opacity:.45;cursor:default}.HvfIsq_hintWarn{color:var(--dsw-alias-state-warn-label)}";
33
- const tagId = "@avantf/dsh-mem/pages.module.css";
34
- if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId) + "]") === null) {
32
+ const css$1 = ".HvfIsq_section{color:var(--dsw-alias-label-primary);width:100%;max-width:var(--dsh-chat-content-width);flex-direction:column;gap:12px;margin:0 auto;display:flex}.HvfIsq_stickyTop{z-index:4;border-bottom:.5px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-base);flex-direction:column;gap:8px;padding-bottom:8px;display:flex;position:sticky;top:0}.HvfIsq_headbar{flex-wrap:wrap;align-items:flex-end;gap:8px;display:flex}.HvfIsq_warn{color:var(--dsw-alias-state-warn-label);background:color-mix(in srgb, var(--dsw-alias-state-warn-primary) 16%, transparent);border-radius:9px;margin-left:6px;padding:0 6px;font-size:11px;line-height:16px;display:inline-block}.HvfIsq_search{width:100%;color:var(--dsw-alias-label-tertiary);align-items:center;display:flex;position:relative}.HvfIsq_searchField{flex:260px;width:auto}.HvfIsq_edit{margin-top:6px}.HvfIsq_search>svg{pointer-events:none;position:absolute;left:12px}.HvfIsq_search input{border:.5px solid var(--dsw-alias-border-l4);border-radius:var(--dsw-radius-md);background:var(--dsw-alias-bg-layer-1);width:100%;height:36px;color:var(--dsw-alias-label-primary);font:inherit;outline:none;padding:0 34px 0 36px;font-size:13px}.HvfIsq_search input::placeholder{color:var(--dsw-alias-label-tertiary)}.HvfIsq_search input:focus-visible{border-color:var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));box-shadow:0 0 0 2px color-mix(in srgb, var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary)) 18%, transparent)}.HvfIsq_search>button{position:absolute;top:50%;right:8px;transform:translateY(-50%)}.HvfIsq_detail{flex-direction:column;gap:4px;display:flex}.HvfIsq_more{align-self:flex-start}.HvfIsq_toolbar{flex-wrap:wrap;align-items:flex-end;gap:8px;display:flex}.HvfIsq_form{flex-direction:column;gap:8px;display:flex}.HvfIsq_row{flex-wrap:wrap;align-items:center;gap:6px;margin-top:6px;display:flex}.HvfIsq_fieldHead{justify-content:space-between;align-items:center;gap:8px;display:flex}.HvfIsq_fieldHead>*+*{flex:none}.HvfIsq_field{color:var(--dsw-alias-label-tertiary);flex-direction:column;flex:auto;gap:4px;min-width:120px;font-size:12px;display:flex}.HvfIsq_groupHead{color:var(--dsw-alias-label-secondary);margin:4px 0 2px;font-size:12px;font-weight:600}.HvfIsq_chunkBox{border:.5px solid var(--dsw-alias-border-l4);border-radius:8px;margin-top:6px;padding:6px 8px;font-size:12px;line-height:1.5}.HvfIsq_picker{border:.5px solid var(--dsw-alias-border-l4);background:var(--dsw-alias-bg-layer-1);border-radius:10px;flex-direction:column;gap:8px;padding:8px 10px;display:flex}.HvfIsq_pickerHead{flex-direction:column;gap:2px;display:flex}.HvfIsq_pickerPath{overflow-wrap:anywhere;font-family:ui-monospace,monospace;font-size:12px}.HvfIsq_pickerList{border:.5px solid var(--dsw-alias-border-l4);border-radius:8px;flex-direction:column;gap:1px;max-height:260px;margin:0;padding:0;list-style:none;display:flex;overflow:auto}.HvfIsq_pickerEntry{width:100%;color:inherit;font:inherit;text-align:left;cursor:pointer;background:0 0;border:0;padding:4px 8px;font-size:12px;display:block}.HvfIsq_pickerEntry:hover:not(:disabled){background:color-mix(in srgb, currentColor 8%, transparent)}.HvfIsq_pickerDisabled{opacity:.45;cursor:default}.HvfIsq_hintWarn{color:var(--dsw-alias-state-warn-label)}";
33
+ const tagId$1 = "@avantf/dsh-mem/pages.module.css";
34
+ if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId$1) + "]") === null) {
35
35
  const tag = document.createElement("style");
36
36
  tag.dataset.plugin = "@avantf/dsh-mem";
37
- tag.dataset.pluginCss = tagId;
38
- tag.textContent = css;
37
+ tag.dataset.pluginCss = tagId$1;
38
+ tag.textContent = css$1;
39
39
  document.head.appendChild(tag);
40
40
  }
41
41
  var pages_module_css_default = {
42
- "badge": "HvfIsq_badge",
43
- "btn": "HvfIsq_btn",
44
- "btnActive": "HvfIsq_btnActive",
45
- "btnDanger": "HvfIsq_btnDanger",
46
42
  "chunkBox": "HvfIsq_chunkBox",
47
- "clear": "HvfIsq_clear",
48
43
  "detail": "HvfIsq_detail",
49
- "error": "HvfIsq_error",
44
+ "edit": "HvfIsq_edit",
50
45
  "field": "HvfIsq_field",
51
46
  "fieldHead": "HvfIsq_fieldHead",
52
47
  "form": "HvfIsq_form",
53
48
  "groupHead": "HvfIsq_groupHead",
54
49
  "headbar": "HvfIsq_headbar",
55
50
  "hintWarn": "HvfIsq_hintWarn",
56
- "input": "HvfIsq_input",
57
- "item": "HvfIsq_item",
58
- "list": "HvfIsq_list",
59
- "meta": "HvfIsq_meta",
60
51
  "more": "HvfIsq_more",
61
52
  "picker": "HvfIsq_picker",
62
53
  "pickerDisabled": "HvfIsq_pickerDisabled",
@@ -67,13 +58,8 @@ window.__ModuleLoader__.load({
67
58
  "row": "HvfIsq_row",
68
59
  "search": "HvfIsq_search",
69
60
  "searchField": "HvfIsq_searchField",
70
- "searchPanel": "HvfIsq_searchPanel",
71
- "searchQuery": "HvfIsq_searchQuery",
72
- "searchTitle": "HvfIsq_searchTitle",
73
61
  "section": "HvfIsq_section",
74
- "select": "HvfIsq_select",
75
- "status": "HvfIsq_status",
76
- "textarea": "HvfIsq_textarea",
62
+ "stickyTop": "HvfIsq_stickyTop",
77
63
  "toolbar": "HvfIsq_toolbar",
78
64
  "warn": "HvfIsq_warn"
79
65
  };
@@ -4174,11 +4160,9 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
4174
4160
  content: optionalText.optional(),
4175
4161
  fact_id: count.optional(),
4176
4162
  kind: optionalText.optional(),
4177
- ttl_days: count.optional(),
4178
4163
  reason: optionalText.optional(),
4179
4164
  source_ref: optionalText.optional(),
4180
4165
  event_date: optionalText.optional(),
4181
- valid_until: optionalText.optional(),
4182
4166
  title: optionalText.optional(),
4183
4167
  retitle: bool.optional(),
4184
4168
  relations: array(object({
@@ -4328,6 +4312,86 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
4328
4312
  return violations && violations.length > 0 ? violations.join(";") : "";
4329
4313
  }
4330
4314
  //#endregion
4315
+ //#region ../contract/lib/time.js
4316
+ /**
4317
+ * The ONE place a stored timestamp turns into a string a human (or a model) reads.
4318
+ *
4319
+ * ── THE BUG THIS MODULE EXISTS FOR ───────────────────────────────────────────────────────────────
4320
+ *
4321
+ * `facts.created_at` / `updated_at` / `archived_at` (and `contradiction_log.detected_at`, and the
4322
+ * retrieval-health stamp) are written by SQLite's `DEFAULT CURRENT_TIMESTAMP`, which is **UTC**, and
4323
+ * `store/memory.ts` used to hand the raw text up to every surface. Two consequences, both reported as
4324
+ * "记忆时间显示不对":
4325
+ *
4326
+ * 1. the UI printed `2026-10-28 11:58:48` as if it were a local wall clock (Asia/Shanghai really
4327
+ * meant `19:58:48`), because a zone-less string cannot say otherwise;
4328
+ * 2. the MODEL received the same zone-less string next to `event_date`, which is a calendar DATE
4329
+ * with no time and no zone. Two different kinds of value, one shape — the model had to guess
4330
+ * which one it was looking at, and it cannot.
4331
+ *
4332
+ * ── THE SPLIT THIS MODULE ENFORCES ───────────────────────────────────────────────────────────────
4333
+ *
4334
+ * Storage stays UTC and stays byte-identical (`facts_io` export/import/snapshot is a compatibility
4335
+ * surface and is deliberately NOT routed through here). The fix is in the two DISPLAY faces:
4336
+ *
4337
+ * - {@link formatOffsetIso} — the MODEL face (`mem_recall` hits, `mem_admin list/detail/
4338
+ * contradictions`, `mem_admin stats`, the CLI and MCP that print those payloads): an ISO-8601
4339
+ * instant **with an explicit offset**, e.g. `2026-10-28T19:58:48+08:00`, rendered in the host's
4340
+ * local zone. The offset is part of the string, so the model needs no conversion and the value is
4341
+ * never ambiguous again — while `event_date` stays `2026-10-28`, a calendar date with no zone.
4342
+ * - {@link formatLocalHuman} — the HUMAN face (the 记忆 tab): `2026-10-28 19:58`, the same instant,
4343
+ * local, to the minute.
4344
+ *
4345
+ * Both read the input through the SAME parser ({@link parseUtcTs}) and the same instant, so the two
4346
+ * faces cannot disagree. The UI deliberately formats the model face's OWN string rather than
4347
+ * re-deriving one from the raw column: the input it gets already carries an offset, `parseUtcTs`
4348
+ * leaves an explicit offset alone, and there is therefore exactly ONE parse rule and ONE pair of
4349
+ * renderers in the repository — not a second opinion in the browser.
4350
+ *
4351
+ * Dependency-free on purpose: this module is reachable from the DSH client bundle through the
4352
+ * `@avantf/mem-contract/time` subpath (the bundle must not pull in the tool schemas / zod). It has no
4353
+ * DSH host and no DB, so the engine, the CLI and the MCP face can all use it.
4354
+ *
4355
+ * @module contract/time
4356
+ */
4357
+ /** Two-digit, zero-padded — the only formatting primitive the two renderers share. */
4358
+ function pad2(value) {
4359
+ return String(value).padStart(2, "0");
4360
+ }
4361
+ /**
4362
+ * `'YYYY-MM-DD HH:MM:SS'` (SQLite UTC) or ISO → epoch ms; null/garbage → null.
4363
+ *
4364
+ * A stamp with NO zone designator is read as UTC, not as local time. `Date.parse` treats a zone-less
4365
+ * ISO string as LOCAL, and everything in this store is written in UTC (`CURRENT_TIMESTAMP`, and
4366
+ * {@link formatUtcTs}), so handing one over as-is would shift every quota window and every decay
4367
+ * measurement by the host's UTC offset — a fact reinforced at 23:00 UTC+8 would look reinforced 8
4368
+ * hours earlier, and the 24h cap would open or close at the wrong moment. An explicit `Z` or
4369
+ * `±HH:MM` is left alone (that is what makes a `formatOffsetIso` output safe to feed back in).
4370
+ */
4371
+ function parseUtcTs(ts) {
4372
+ if (ts === null || ts === void 0 || ts === "") return null;
4373
+ const iso = ts.includes("T") ? ts : ts.replace(" ", "T");
4374
+ const zoned = /(?:Z|[+-]\d{2}:?\d{2})$/i.test(iso) ? iso : `${iso}Z`;
4375
+ const ms = Date.parse(zoned);
4376
+ return Number.isFinite(ms) ? ms : null;
4377
+ }
4378
+ /**
4379
+ * The HUMAN face: the same instant as local wall-clock text to the minute (`2026-10-28 19:58`).
4380
+ *
4381
+ * Seconds are deliberately dropped: the list/detail lines and the health line are read at a glance,
4382
+ * and `HH:MM` is the whole useful resolution there. `null` for an absent/unreadable value (same
4383
+ * contract as {@link formatOffsetIso}), so a caller prints its own 「未知」/`—` placeholder.
4384
+ *
4385
+ * NOT for `event_date` / `valid_from`: those are calendar dates the CALLER wrote (`2026-10-28`), with
4386
+ * no time and no zone. Running one through here would invent a midnight in the host's zone.
4387
+ */
4388
+ function formatLocalHuman(ts) {
4389
+ const ms = parseUtcTs(ts);
4390
+ if (ms === null) return null;
4391
+ const at = new Date(ms);
4392
+ return `${String(at.getFullYear())}-${pad2(at.getMonth() + 1)}-${pad2(at.getDate())} ${pad2(at.getHours())}:${pad2(at.getMinutes())}`;
4393
+ }
4394
+ //#endregion
4331
4395
  //#region src/client/summarize.ts
4332
4396
  /**
4333
4397
  * Short, page-friendly rendering of a tool result — never a JSON wall.
@@ -4383,31 +4447,135 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
4383
4447
  //#endregion
4384
4448
  //#region src/client/paging.ts
4385
4449
  /**
4386
- * Page merging for the fact list.
4450
+ * Page merging for the tab's ONE list.
4387
4451
  *
4388
4452
  * Kept out of `client/index.ts` so it can be tested without a DOM (the same reason `summarize.ts`
4389
4453
  * lives on its own): this is where page drift is handled, and page drift is the kind of bug that
4390
4454
  * only shows up as a React key warning plus a row nobody can scroll to.
4391
4455
  *
4456
+ * There used to be TWO helpers here — one keyed by `fact_id` for the admin list and one by `ref_id` for
4457
+ * a time window's results — because the tab kept two lists. It keeps one now (`ListState`), so there is
4458
+ * one merge: the row's React key is the identity, and both sources are appended through it.
4459
+ *
4392
4460
  * @module client/paging
4393
4461
  */
4394
4462
  /**
4395
- * Append one page of facts, dropping ids already present.
4463
+ * Append one page of list rows, dropping keys already present.
4464
+ *
4465
+ * Both sources page by `OFFSET` over `created_at DESC`, and `created_at` has second precision: a fact
4466
+ * written (or archived) between two requests shifts every later row by one, so the next page repeats a
4467
+ * row that is already on screen. The duplicate is not cosmetic — it is a React key collision
4468
+ * (`key={row.key}`) and a row that can never be scrolled to.
4396
4469
  *
4397
- * `admin.list` pages by `OFFSET` over `created_at DESC`, and `created_at` has second precision: a
4398
- * fact written (or archived) between two requests shifts every later row by one, so the next page
4399
- * repeats a row that is already on screen. The duplicate is not cosmetic — it is a React key
4400
- * collision (`key={fact_id}`) and a row that can never be scrolled to.
4470
+ * A fact-shaped row's `key` is `fact:<id>`, so this stays exactly as strict as the two helpers it
4471
+ * replaced; a relation path row is keyed by its position and cannot collide with one.
4401
4472
  */
4402
- function mergePage(previous, page) {
4403
- return mergeById(previous, page, (row) => row.fact_id);
4404
- }
4405
- /** Append the rows whose id is not already loaded, preserving order; identity when nothing is new. */
4406
- function mergeById(previous, page, idOf) {
4407
- const seen = new Set(previous.map(idOf));
4408
- const added = page.filter((row) => !seen.has(idOf(row)));
4473
+ function mergeRows(previous, page) {
4474
+ const seen = new Set(previous.map((row) => row.key));
4475
+ const added = page.filter((row) => !seen.has(row.key));
4409
4476
  return added.length === 0 ? previous : [...previous, ...added];
4410
4477
  }
4478
+ /**
4479
+ * Fold the pages of a WALKED window into the list's rows (REVIEW-2026-10-27 P1-1).
4480
+ *
4481
+ * The walk in `runTimeWindow` requests page after page from `offset` 0, and the drift the merge above
4482
+ * guards against happens BETWEEN those requests just as it does between two scroll-triggered pages: a
4483
+ * fact written while page 2 is in flight shifts the window, so page 3 repeats a row of page 2. Collecting
4484
+ * the raw hits and mapping them once (what the walk did) therefore duplicated a row inside the FIRST
4485
+ * render — a duplicate React key, a card that cannot be scrolled to, and a `total - rows.length` that
4486
+ * under-reports. Folding through {@link mergeRows} makes the walk and the continuation the same rule.
4487
+ *
4488
+ * Note what this does NOT touch: the host-handed offsets (`nextOffset`) still advance by the page's own
4489
+ * length, never by the de-duplicated row count — the cursor follows the HOST's paging, the rows follow
4490
+ * what is actually on screen.
4491
+ */
4492
+ function mergeRowPages(pages) {
4493
+ return pages.reduce((rows, page) => mergeRows(rows, [...page]), []);
4494
+ }
4495
+ /**
4496
+ * Append ONE continuation page of a walked time window (`loadSearchPage`), and decide whether the window
4497
+ * still has a page to fetch (REVIEW-2026-10-27 P1-2).
4498
+ *
4499
+ * `truncated` is the host's own rule — `offset + hits.length < total` — and NOT "did the row count grow".
4500
+ * A page whose rows are all already on screen (extreme, but real: many facts inserted at the top between
4501
+ * two requests shift every later row) adds nothing to `rows`, yet the anchor has moved, so claiming "no
4502
+ * more rows" there stops the window for good while half of it is still unread.
4503
+ *
4504
+ * The one case that DOES stop is a page with no rows at all: the anchor cannot advance
4505
+ * (`offset + 0 === offset`), so keeping `truncated` true would re-ask the same offset on every scroll.
4506
+ * That is the empty page the host answers with when it has nothing more, not a repeated page.
4507
+ */
4508
+ function appendWindowPage(previous, page) {
4509
+ const nextOffset = page.offset + page.rows.length;
4510
+ return {
4511
+ rows: mergeRows(previous.rows, page.rows),
4512
+ nextOffset,
4513
+ truncated: page.rows.length > 0 && nextOffset < page.total
4514
+ };
4515
+ }
4516
+ //#endregion
4517
+ //#region src/client/time_filter.ts
4518
+ /**
4519
+ * The three time axes, with the labels the toolbar shows. The axis is REQUIRED by the contract (D1)
4520
+ * and the three do not substitute for one another, so the control never defaults to "any time".
4521
+ */
4522
+ const TIME_AXES = [
4523
+ {
4524
+ value: "created",
4525
+ label: "入库时间"
4526
+ },
4527
+ {
4528
+ value: "updated",
4529
+ label: "修改时间"
4530
+ },
4531
+ {
4532
+ value: "event",
4533
+ label: "事件时间"
4534
+ }
4535
+ ];
4536
+ /** The panel-facing name of one axis (same list as {@link TIME_AXES}). */
4537
+ function axisLabel(field) {
4538
+ const axis = TIME_AXES.find((entry) => entry.value === field);
4539
+ return axis === void 0 ? String(field) : axis.label;
4540
+ }
4541
+ /**
4542
+ * Build the `time` argument from the controls. `undefined` = no time condition at all.
4543
+ *
4544
+ * In `on` mode the range inputs are IGNORED (the contract refuses `on` together with `from`/`to`,
4545
+ * so a stale value in the hidden inputs must never leak into a request). Values are the native
4546
+ * `YYYY-MM-DD` a date input produces; they are passed through untouched — the engine owns the
4547
+ * grammar, including the timezone reading of a date without an offset.
4548
+ */
4549
+ function buildTimeFilter(field, mode, on, from, to) {
4550
+ if (mode === "on") {
4551
+ const day = on.trim();
4552
+ return day === "" ? void 0 : {
4553
+ field,
4554
+ on: day
4555
+ };
4556
+ }
4557
+ const lower = from.trim();
4558
+ const upper = to.trim();
4559
+ if (lower === "" && upper === "") return void 0;
4560
+ return {
4561
+ field,
4562
+ ...lower === "" ? {} : { from: lower },
4563
+ ...upper === "" ? {} : { to: upper }
4564
+ };
4565
+ }
4566
+ /**
4567
+ * The panel's one-line statement of the window the visible results were narrowed to.
4568
+ *
4569
+ * WHY THE PANEL NEEDS IT: a time-filtered answer is shorter than the list, and without a line naming
4570
+ * the window, "fewer rows than expected" is indistinguishable from a broken query. It is rendered
4571
+ * from the filter the RESULTS were fetched with (not from the live controls), so editing a date
4572
+ * input cannot relabel results that were fetched with the previous window.
4573
+ */
4574
+ function timeFilterLabel(time) {
4575
+ const axis = axisLabel(time.field);
4576
+ if (time.on !== void 0) return `${axis} ${time.on}`;
4577
+ return `${axis} ${time.from ?? "最早"} ~ ${time.to ?? "现在"}`;
4578
+ }
4411
4579
  //#endregion
4412
4580
  //#region src/client/wire.ts
4413
4581
  /**
@@ -4473,6 +4641,199 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
4473
4641
  if (!/404|not found/iu.test(text)) return text;
4474
4642
  return `宿主可能没有注册这个接口(旧版本 / 未重启):客户端会随页面刷新,宿主只在 dsh web 启动时加载一次,所以 dsh web 很可能还在跑旧的宿主代码。重启 dsh web 后再试。(原始错误:${text})`;
4475
4643
  }
4644
+ /**
4645
+ * The nearest element that actually scrolls `node`, at or above it.
4646
+ *
4647
+ * The criterion is the computed `overflow-y` (`auto`/`scroll`) AND "it really overflows"
4648
+ * (`scrollHeight > clientHeight`): a host's wrapper elements are `overflow-y:auto` long before their
4649
+ * content is taller than they are, and binding to one of those would listen to an element that never
4650
+ * emits a scroll. A `null` result is therefore honest — nothing here scrolls — and the listener falls
4651
+ * back to `window` (see {@link createAutoLoadMore}).
4652
+ *
4653
+ * `node` ITSELF is considered first. A panel normally passes its root (the scrollport is an ancestor:
4654
+ * the host's conversation scroll body), but a list that owns its scroller — a virtualized list whose
4655
+ * `getScrollElement()` is its own container — passes that container, which is the very element
4656
+ * scroll events fire on. Scroll events do not bubble between scrollers, so getting THIS element (not
4657
+ * some ancestor) is what makes such a list work.
4658
+ */
4659
+ function scrollParentOf(node) {
4660
+ let current = node;
4661
+ while (current !== null) {
4662
+ const overflowY = computedOverflowY(current);
4663
+ if ((overflowY === "auto" || overflowY === "scroll") && current.scrollHeight > current.clientHeight) return current;
4664
+ current = current.parentElement;
4665
+ }
4666
+ return null;
4667
+ }
4668
+ /**
4669
+ * The computed `overflow-y` of `element`, read off ITS OWN document's view.
4670
+ *
4671
+ * Not the bare `getComputedStyle` global: this module also runs under a test double or inside a
4672
+ * detached document, and both need the style to come from somewhere explicit. An element with no
4673
+ * view (no document, or a document without a window) reports `''` — "not a scrollport", the safe
4674
+ * answer.
4675
+ */
4676
+ function computedOverflowY(element) {
4677
+ const view = element.ownerDocument?.defaultView;
4678
+ if (view === null || view === void 0) return "";
4679
+ return view.getComputedStyle(element).overflowY;
4680
+ }
4681
+ /**
4682
+ * Where to listen: the scrollport when one was found, else `window`.
4683
+ *
4684
+ * The `window` fallback is the case the seat's own history taught: a panel can be mounted in a
4685
+ * document whose scrollport is the page itself. Listening there costs nothing, but the POSITION check
4686
+ * still needs a scrollport, so nothing fires until an ancestor with its own scrollbar appears (or the
4687
+ * caller drives {@link AutoLoadMore.recheck}). `null` only in a host without a `window` at all.
4688
+ */
4689
+ function scrollEventSource(scroller) {
4690
+ if (scroller !== null) return scroller;
4691
+ const view = globalThis["window"];
4692
+ if (view === null || view === void 0 || typeof view !== "object") return null;
4693
+ const candidate = view;
4694
+ if (typeof candidate.addEventListener !== "function") return null;
4695
+ if (typeof candidate.removeEventListener !== "function") return null;
4696
+ return view;
4697
+ }
4698
+ /**
4699
+ * The host's animation-frame pair, read off `globalThis` (`lib.dom` is not in this package's types, and
4700
+ * a node host has neither function — the controller then checks synchronously, its pre-rAF behaviour).
4701
+ */
4702
+ function animationFrames() {
4703
+ const host = globalThis;
4704
+ const candidate = host;
4705
+ if (typeof candidate.requestAnimationFrame !== "function") return null;
4706
+ if (typeof candidate.cancelAnimationFrame !== "function") return null;
4707
+ return host;
4708
+ }
4709
+ /**
4710
+ * Build the controller: listen for scroll (passive + one rAF per burst), check once immediately, and
4711
+ * stay silent while `isEnabled()` is false.
4712
+ *
4713
+ * The immediate first check is not an optimisation: the first page of a short list does not fill the
4714
+ * scrollport, so waiting for a scroll event means waiting forever.
4715
+ *
4716
+ * @param options - see {@link AutoLoadMoreOptions}. Read at creation: `scrollParentOf` runs once, so a
4717
+ * panel that later gets its own scroller must dispose and recreate the controller.
4718
+ */
4719
+ function createAutoLoadMore(options) {
4720
+ const marginPx = options.marginPx ?? 300;
4721
+ let scroller = scrollParentOf(options.target);
4722
+ let source = scrollEventSource(scroller);
4723
+ const frames = animationFrames();
4724
+ let onLoadMore = options.onLoadMore;
4725
+ let version = options.version ?? 0;
4726
+ let disposed = false;
4727
+ let queued = false;
4728
+ let frame = null;
4729
+ /** Bumped whenever a queued frame is superseded, so a callback the host still runs is a no-op. */
4730
+ let generation = 0;
4731
+ /** Move the listener onto `next`: the window fallback may be holding it, and a stale element listener
4732
+ * would keep listening on a node that no longer scrolls. */
4733
+ const bind = (next) => {
4734
+ if (next === scroller) return;
4735
+ source?.removeEventListener("scroll", onScroll);
4736
+ scroller = next;
4737
+ source = scrollEventSource(next);
4738
+ source?.addEventListener("scroll", onScroll, { passive: true });
4739
+ };
4740
+ const check = () => {
4741
+ frame = null;
4742
+ queued = false;
4743
+ if (disposed || onLoadMore === null) return;
4744
+ if (scroller === null) {
4745
+ const found = scrollParentOf(options.target);
4746
+ if (found === null) return;
4747
+ bind(found);
4748
+ }
4749
+ if (scroller === null) return;
4750
+ if (!options.isEnabled()) return;
4751
+ if (scroller.scrollTop + scroller.clientHeight >= scroller.scrollHeight - marginPx) onLoadMore();
4752
+ };
4753
+ /**
4754
+ * Drop the queued frame, if there is one, and make any callback already handed to the host inert.
4755
+ *
4756
+ * Called before a SYNC check (`recheck`/`setVersion`): the frame a scroll just scheduled reads the
4757
+ * same position, so leaving it scheduled asks for the same page twice — the second `onLoadMore`
4758
+ * arrives after the caller's own guard may already have cleared. The generation bump is belt and
4759
+ * braces: a host (or a test) that runs a cancelled callback anyway must not ask again.
4760
+ */
4761
+ const cancelQueued = () => {
4762
+ if (frame !== null && frames !== null) frames.cancelAnimationFrame(frame);
4763
+ frame = null;
4764
+ queued = false;
4765
+ generation += 1;
4766
+ };
4767
+ const onScroll = () => {
4768
+ if (disposed || queued) return;
4769
+ queued = true;
4770
+ if (frames === null) check();
4771
+ else {
4772
+ const scheduled = generation;
4773
+ frame = frames.requestAnimationFrame(() => {
4774
+ if (scheduled !== generation) return;
4775
+ check();
4776
+ });
4777
+ }
4778
+ };
4779
+ source?.addEventListener("scroll", onScroll, { passive: true });
4780
+ check();
4781
+ return {
4782
+ recheck() {
4783
+ if (disposed) return;
4784
+ cancelQueued();
4785
+ check();
4786
+ },
4787
+ setVersion(next) {
4788
+ if (disposed || next === version) return false;
4789
+ version = next;
4790
+ cancelQueued();
4791
+ check();
4792
+ return true;
4793
+ },
4794
+ dispose() {
4795
+ if (disposed) return;
4796
+ disposed = true;
4797
+ onLoadMore = null;
4798
+ source?.removeEventListener("scroll", onScroll);
4799
+ if (frame !== null && frames !== null) frames.cancelAnimationFrame(frame);
4800
+ frame = null;
4801
+ queued = false;
4802
+ }
4803
+ };
4804
+ }
4805
+ //#endregion
4806
+ //#region \0dsh-css:../../../base/src/client/controls.module.css.mjs
4807
+ const css = ".-GlBVa_button{box-sizing:border-box;border-radius:var(--dsw-radius-md);color:var(--dsw-alias-label-primary);font:inherit;cursor:pointer;background:0 0;border:none;justify-content:center;align-items:center;gap:4px;padding:0 14px;font-size:14px;line-height:22px;display:inline-flex}.-GlBVa_button:disabled{cursor:not-allowed;opacity:.4}.-GlBVa_md{height:36px}.-GlBVa_sm{border-radius:var(--dsw-radius-sm);height:28px;padding:0 10px;font-size:12px;line-height:18px}.-GlBVa_primary{background:var(--dsw-alias-button-primary-fill);color:var(--dsw-alias-label-primary-foreground)}.-GlBVa_outline{box-shadow:inset 0 0 0 var(--dsh-hairline-width,.5px) var(--dsw-alias-border-l3);background:0 0;border:.5px solid #0000}.-GlBVa_danger{color:var(--dsw-alias-state-error-primary)}.-GlBVa_active{background:var(--dsw-alias-button-ghost-active-fill);box-shadow:inset 0 0 0 var(--dsh-hairline-width,.5px) var(--dsw-alias-button-ghost-active-border);color:var(--dsw-alias-label-primary)}@media (hover:hover){.-GlBVa_primary:hover:not(:disabled){background:var(--dsw-alias-button-primary-hover)}.-GlBVa_ghost:hover:not(:disabled),.-GlBVa_outline:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}.-GlBVa_danger:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover-danger)}.-GlBVa_active:hover:not(:disabled){background:var(--dsw-alias-button-ghost-active-hover)}}.-GlBVa_ghost:active:not(:disabled){background:var(--dsw-alias-interactive-bg-active)}.-GlBVa_badge{border-radius:var(--dsw-radius-xs);background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-secondary);align-items:center;gap:4px;padding:0 6px;font-size:11px;line-height:18px;display:inline-flex}.-GlBVa_list{flex-direction:column;gap:8px;margin:0;padding:0;list-style:none;display:flex}.-GlBVa_item{border:.5px solid var(--dsw-alias-settings-card-stroke);border-radius:var(--dsw-radius-md);background:var(--dsw-alias-settings-card-fill);color:var(--dsw-alias-label-primary);padding:10px 12px;font-size:13px;line-height:20px}.-GlBVa_meta{color:var(--dsw-alias-label-tertiary);margin-top:2px;font-size:12px;line-height:18px;display:block}.-GlBVa_status{color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px}.-GlBVa_error{color:var(--dsw-alias-state-error-primary);white-space:pre-wrap;font-size:12px;line-height:18px}.-GlBVa_input,.-GlBVa_textarea,.-GlBVa_select,.-GlBVa_dateInput{box-sizing:border-box;border:.5px solid var(--dsw-alias-border-l4);border-radius:var(--dsw-radius-md);background:var(--dsw-alias-bg-layer-1);min-width:0;color:var(--dsw-alias-label-primary);font:inherit;padding:0 10px;font-size:14px;line-height:22px}.-GlBVa_input,.-GlBVa_select,.-GlBVa_dateInput{height:32px}.-GlBVa_input:focus-visible,.-GlBVa_textarea:focus-visible,.-GlBVa_select:focus-visible,.-GlBVa_dateInput:focus-visible{border-color:var(--dsw-alias-state-business-primary)}.-GlBVa_input::placeholder{color:var(--dsw-alias-label-dimmed)}.-GlBVa_input:disabled,.-GlBVa_textarea:disabled,.-GlBVa_select:disabled,.-GlBVa_dateInput:disabled{opacity:.6;cursor:default}.-GlBVa_textarea{resize:vertical;width:100%;padding:6px 10px}.-GlBVa_select{cursor:pointer}.-GlBVa_dateInput{font-variant-numeric:tabular-nums;padding:0 8px}.-GlBVa_clear{width:20px;height:20px;color:var(--dsw-alias-label-tertiary);font:inherit;cursor:pointer;background:0 0;border:none;border-radius:999px;justify-content:center;align-items:center;padding:0;font-size:14px;line-height:1;display:inline-flex}@media (hover:hover){.-GlBVa_clear:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}}";
4808
+ const tagId = "@avantf/dsh-mem/controls.module.css";
4809
+ if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId) + "]") === null) {
4810
+ const tag = document.createElement("style");
4811
+ tag.dataset.plugin = "@avantf/dsh-mem";
4812
+ tag.dataset.pluginCss = tagId;
4813
+ tag.textContent = css;
4814
+ document.head.appendChild(tag);
4815
+ }
4816
+ var controls_module_css_default = {
4817
+ "active": "-GlBVa_active",
4818
+ "badge": "-GlBVa_badge",
4819
+ "button": "-GlBVa_button",
4820
+ "clear": "-GlBVa_clear",
4821
+ "danger": "-GlBVa_danger",
4822
+ "dateInput": "-GlBVa_dateInput",
4823
+ "error": "-GlBVa_error",
4824
+ "ghost": "-GlBVa_ghost",
4825
+ "input": "-GlBVa_input",
4826
+ "item": "-GlBVa_item",
4827
+ "list": "-GlBVa_list",
4828
+ "md": "-GlBVa_md",
4829
+ "meta": "-GlBVa_meta",
4830
+ "outline": "-GlBVa_outline",
4831
+ "primary": "-GlBVa_primary",
4832
+ "select": "-GlBVa_select",
4833
+ "sm": "-GlBVa_sm",
4834
+ "status": "-GlBVa_status",
4835
+ "textarea": "-GlBVa_textarea"
4836
+ };
4476
4837
  //#endregion
4477
4838
  //#region src/client/index.ts
4478
4839
  /**
@@ -4492,7 +4853,7 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
4492
4853
  * 2. Every remote call is normalized and surfaced: the raw response is logged
4493
4854
  * (prefixed `[avantf-mem]`) and rendered as an explicit status/error line, so
4494
4855
  * an empty result is never indistinguishable from a failed call.
4495
- * 3. Full DESIGN §12 surface: the tab does CRUD (edit/archive/restore/feedback) + fact detail
4856
+ * 3. Full DESIGN §12 surface: the tab does CRUD (edit/archive/restore) + fact detail
4496
4857
  * (entities/triples) + open contradictions. Generation stays external (Plan A) — no answer
4497
4858
  * synthesis.
4498
4859
  *
@@ -4501,20 +4862,52 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
4501
4862
  * resolved from the platform module table.
4502
4863
  */
4503
4864
  const h = react.createElement;
4865
+ /**
4866
+ * The panel's compact button, composed ONCE: the shared control geometry from the family base
4867
+ * (`controls` is `@avantf/dsh-plugin-base/internal/controls.module.css`, hashed into this bundle at
4868
+ * build time) + the outline variant. Every 按钮 in the panel used to carry its own copy of these
4869
+ * numbers; the sizes/radius/hover now come from the base, so the 记忆 tab and the other family
4870
+ * panels cannot drift apart.
4871
+ */
4872
+ const BTN = `${controls_module_css_default.button} ${controls_module_css_default.sm} ${controls_module_css_default.outline}`;
4873
+ /**
4874
+ * Every timestamp the 记忆 tab prints goes through here: local wall-clock text to the minute
4875
+ * (`2026-10-28 19:58`), or the caller's placeholder when the host sent nothing readable.
4876
+ *
4877
+ * This is a PLACEHOLDER policy, not a formatter — the formatting itself is
4878
+ * `formatLocalHuman` from `@avantf/mem-contract/time`, the same module the host renders the model
4879
+ * face with. The value arriving here is an instant WITH an offset (the host's `created_at` etc.), so
4880
+ * `Date.parse` inside that one renderer is unambiguous; nothing in this file parses a time itself.
4881
+ *
4882
+ * A calendar date (`event_date` / `valid_from`) must NOT come through here: it has no time and no
4883
+ * zone, and rendering it would invent a midnight in the viewer's zone.
4884
+ */
4885
+ const localTime = (ts, fallback = "—") => formatLocalHuman(ts) ?? fallback;
4504
4886
  const NOT_MOUNTED = "avantfMem Remote 未挂载:本插件自带的 typert contribution 挂载失败(详见控制台错误),暂时无法读取数据。";
4505
4887
  /** One 记忆 tab; the host `admin` list default is the same. */
4506
4888
  const MEMORY_PAGE_SIZE = 50;
4507
- /** The exact page default; fuzzy is fixed at five by the engine. */
4889
+ /**
4890
+ * The page size of a KEYWORD search; fuzzy is fixed at five by the engine.
4891
+ *
4892
+ * A pure time window sends no `limit` at all: it is walked by `offset` instead (`runTimeWindow`), so
4893
+ * the host's own window page applies and no client constant can cut a day's history short.
4894
+ */
4508
4895
  const SEARCH_LIMIT = 10;
4509
4896
  /**
4897
+ * How many pages one time-window search may walk before the panel stops.
4898
+ *
4899
+ * A wall against a host that never reports `remaining: 0` (or never returns rows): 200 pages of 50 is
4900
+ * 10 000 facts, far beyond any hand-readable window. Stopping early is visible — the panel prints how
4901
+ * many rows are still unseen.
4902
+ */
4903
+ const SEARCH_PAGES_MAX = 200;
4904
+ /**
4510
4905
  * How many `admin.detail` calls one search answer may trigger, to give its results the list's action
4511
4906
  * row (status / pin state / full text). A hard cap so a large exact page or a wide relation answer
4512
4907
  * cannot turn one keystroke into an unbounded fan-out; anything past the cap keeps the id-only
4513
- * actions (详情 / 编辑 when the excerpt is complete / 归档 / 反馈), which still work.
4908
+ * actions (详情 / 编辑 when the excerpt is complete / 归档), which still work.
4514
4909
  */
4515
4910
  const SEARCH_FACT_LOOKUPS_MAX = 20;
4516
- /** How close (px) the list's end must come to the scrollport before the next page loads. */
4517
- const AUTO_LOAD_MARGIN_PX = 300;
4518
4911
  /**
4519
4912
  * The wire revision the host last reported, and whether its absence/mismatch was logged.
4520
4913
  *
@@ -4587,67 +4980,57 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
4587
4980
  isCurrent: (seq) => seq === ref.current
4588
4981
  }), []);
4589
4982
  }
4590
- /** The element that actually scrolls this panel, walking up to it (the conversation's scroll body). */
4591
- function scrollParentOf(node) {
4592
- let current = node?.parentElement ?? null;
4593
- while (current !== null) {
4594
- const overflowY = getComputedStyle(current).overflowY;
4595
- if ((overflowY === "auto" || overflowY === "scroll") && current.scrollHeight > current.clientHeight) return current;
4596
- current = current.parentElement;
4597
- }
4598
- return null;
4599
- }
4600
4983
  /**
4601
4984
  * Call `onLoadMore` when the user scrolls near the end of the panel.
4602
4985
  *
4603
- * A scroll listener, not an `IntersectionObserver`, for the three reasons this seat makes an
4604
- * observer the wrong tool:
4986
+ * The behaviour itself now lives in the base — `createAutoLoadMore` on
4987
+ * `@avantf/dsh-plugin-base/internal` — and this function is only its React half: refs for the two
4988
+ * values the controller reads live (`onLoadMore`, `enabled`), one effect that owns the controller's
4989
+ * lifetime, and the two effects that drive the checks React knows about. Why the kit is in the base:
4990
+ * mission pages its lists the same way, and the DOM detail (walking up to the element that really
4991
+ * scrolls, the passive listener with its rAF throttle, the window fallback) must exist ONCE. The base
4992
+ * has no React peer, so the kit is plain DOM and each plugin keeps this thin wrapper.
4605
4993
  *
4606
- * 1. **`rootMargin` cannot prefetch here.** The panel does not own a scrollport — the conversation's
4607
- * `.scrollBody` does, and an ancestor's clip rect is NOT expanded by `rootMargin` (only the
4608
- * intersection root's is). The promised look-ahead simply never happened.
4609
- * 2. **An observer only reports threshold CHANGES.** One page that failed to load left the sentinel
4610
- * visible, so no new callback arrived and auto-load stayed dead until something else moved it.
4611
- * A scroll listener is level-triggered: the next scroll event tries again.
4612
- * 3. **The scrollport is found, not assumed.** `scrollParentOf` walks up to whatever element really
4613
- * scrolls, so this keeps working if the panel ever gets its own scroller.
4994
+ * The two facts this wrapper is responsible for keeping (the kit cannot see them by itself):
4614
4995
  *
4615
- * `version` re-checks after a page lands: a page that does not fill the scrollport produces no
4616
- * scroll event, and without the re-check loading would stop after the first page.
4996
+ * - **`version` re-checks after a page lands.** A page that does not fill the scrollport produces no
4997
+ * scroll event; without the re-check loading would stop after the first page.
4998
+ * - **`enabled` flipping back on retries once.** That is how a page that landed (or a list that
4999
+ * gained a continuation) keeps loading while the user sits still.
5000
+ *
5001
+ * Both are one effect on purpose: a page landing changes `rows.length` AND clears `loading`, so the
5002
+ * two reasons arrive in the SAME commit — and two checks would send the next page twice. The kit's
5003
+ * `setVersion` reports whether it already checked, which is what keeps it at one.
5004
+ *
5005
+ * The observer-vs-listener reasoning and the margin live in the kit's own doc (one copy).
4617
5006
  */
4618
5007
  function useLoadMoreOnScroll(rootRef, enabled, onLoadMore, version) {
4619
5008
  const latest = react.useRef(onLoadMore);
4620
5009
  latest.current = onLoadMore;
4621
5010
  const enabledRef = react.useRef(enabled);
4622
5011
  enabledRef.current = enabled;
4623
- const checkRef = react.useRef(() => {});
5012
+ const controllerRef = react.useRef(null);
4624
5013
  react.useEffect(() => {
4625
5014
  const root = rootRef.current;
4626
5015
  if (root === null) return void 0;
4627
- const scroller = scrollParentOf(root);
4628
- const target = scroller ?? window;
4629
- let queued = false;
4630
- const check = () => {
4631
- queued = false;
4632
- if (!enabledRef.current || scroller === null) return;
4633
- if (scroller.scrollTop + scroller.clientHeight >= scroller.scrollHeight - AUTO_LOAD_MARGIN_PX) latest.current();
4634
- };
4635
- checkRef.current = check;
4636
- const onScroll = () => {
4637
- if (queued) return;
4638
- queued = true;
4639
- if (typeof requestAnimationFrame === "function") requestAnimationFrame(check);
4640
- else check();
4641
- };
4642
- target.addEventListener("scroll", onScroll, { passive: true });
4643
- check();
5016
+ const controller = createAutoLoadMore({
5017
+ target: root,
5018
+ isEnabled: () => enabledRef.current,
5019
+ onLoadMore: () => {
5020
+ latest.current();
5021
+ },
5022
+ version
5023
+ });
5024
+ controllerRef.current = controller;
4644
5025
  return () => {
4645
- checkRef.current = () => {};
4646
- target.removeEventListener("scroll", onScroll);
5026
+ controllerRef.current = null;
5027
+ controller.dispose();
4647
5028
  };
4648
5029
  }, [rootRef]);
4649
5030
  react.useEffect(() => {
4650
- checkRef.current();
5031
+ const controller = controllerRef.current;
5032
+ if (controller === null) return;
5033
+ if (!controller.setVersion(version)) controller.recheck();
4651
5034
  }, [version, enabled]);
4652
5035
  }
4653
5036
  /**
@@ -4675,7 +5058,7 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
4675
5058
  function Btn(props) {
4676
5059
  return h("button", {
4677
5060
  type: "button",
4678
- className: props.danger ? `${pages_module_css_default.btn} ${pages_module_css_default.btnDanger}` : pages_module_css_default.btn,
5061
+ className: props.danger ? `${BTN} ${controls_module_css_default.danger}` : BTN,
4679
5062
  disabled: props.disabled === true,
4680
5063
  onClick: props.onClick
4681
5064
  }, props.label);
@@ -4700,7 +5083,7 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
4700
5083
  });
4701
5084
  const complete = draft.subject.trim() !== "" && draft.predicate.trim() !== "" && draft.object.trim() !== "";
4702
5085
  const field = (slot, label) => h("input", {
4703
- className: pages_module_css_default.input,
5086
+ className: controls_module_css_default.input,
4704
5087
  value: draft[slot],
4705
5088
  placeholder: label,
4706
5089
  "aria-label": `关系${label}`,
@@ -4712,9 +5095,9 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
4712
5095
  }));
4713
5096
  }
4714
5097
  });
4715
- return h("div", { className: pages_module_css_default.detail }, h("div", { className: pages_module_css_default.meta }, "声明的显式关系(仅 note;图上的 source=explicit):"), relations.length === 0 ? h("div", { className: pages_module_css_default.status }, "当前没有显式关系。") : h("ul", { className: pages_module_css_default.list }, relations.map((relation, index) => h("li", {
5098
+ return h("div", { className: pages_module_css_default.detail }, h("div", { className: controls_module_css_default.meta }, "声明的显式关系(仅 note;图上的 source=explicit):"), relations.length === 0 ? h("div", { className: controls_module_css_default.status }, "当前没有显式关系。") : h("ul", { className: controls_module_css_default.list }, relations.map((relation, index) => h("li", {
4716
5099
  key: `relation-${String(index)}`,
4717
- className: pages_module_css_default.item
5100
+ className: controls_module_css_default.item
4718
5101
  }, `${relation.subject} —${relation.predicate}→ ${relation.object}`, h("div", { className: pages_module_css_default.row }, h(Btn, {
4719
5102
  label: "删除",
4720
5103
  danger: true,
@@ -4743,6 +5126,15 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
4743
5126
  }
4744
5127
  })));
4745
5128
  }
5129
+ /** A list with nothing in it: the list view before its first page, and a search that was cleared. */
5130
+ const EMPTY_LIST = {
5131
+ rows: [],
5132
+ total: void 0,
5133
+ truncated: false,
5134
+ nextOffset: 0,
5135
+ loading: false,
5136
+ error: void 0
5137
+ };
4746
5138
  /**
4747
5139
  * The detail body of one fact — the SAME view under an admin list row and under the search results
4748
5140
  * (the search view used to hide the list, which is why its 「详情」 button appeared to do nothing).
@@ -4752,7 +5144,7 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
4752
5144
  */
4753
5145
  function FactDetailView(props) {
4754
5146
  const fact = props.fact;
4755
- return h("div", { className: pages_module_css_default.meta }, `标题:${fact.title ?? "(无标题)"}`, h("br"), `创建于 ${fact.created_at} / 更新于 ${fact.updated_at ?? "—"}`, h("br"), `实体:${(fact.entities ?? []).join("、") || "(无)"}`, h("br"), `三元组:${(fact.triples ?? []).map((t) => `${t.subj} —${t.pred}→ ${t.obj}(${t.confidence.toFixed(2)})`).join(";") || "(无)"}`, h("br"), `检索次数:${String(fact.retrieval_count ?? 0)}${fact.supersedes_id ? ` · 修订自 #${String(fact.supersedes_id)}` : ""} · 被断言 ${String(fact.assert_count ?? 1)} 次`, h("br"), `有效期:${fact.valid_from ?? "未知"} → ${fact.valid_to ?? (fact.status === "active" ? "仍在有效" : "未知")}` + (fact.superseded_by === null || fact.superseded_by === void 0 ? "" : ` · 被 #${String(fact.superseded_by)} 取代`), h("br"), `来源:${(fact.sources ?? []).length === 0 ? "(未记录)" : (fact.sources ?? []).map((s) => `${s.kind}:${s.ref}`).join(";")}`, h("br"), `关系:${(fact.relations ?? []).length === 0 ? "(无)" : (fact.relations ?? []).map((r) => `${r.subject} —${r.predicate}→ ${r.object}`).join(";")}`, fact.kind === "note" && fact.status === "active" ? h(RelationEditor, {
5147
+ return h("div", { className: controls_module_css_default.meta }, `标题:${fact.title ?? "(无标题)"}`, h("br"), `创建于 ${localTime(fact.created_at)} / 更新于 ${localTime(fact.updated_at)}`, h("br"), `实体:${(fact.entities ?? []).join("、") || "(无)"}`, h("br"), `三元组:${(fact.triples ?? []).map((t) => `${t.subj} —${t.pred}→ ${t.obj}(${t.confidence.toFixed(2)})`).join(";") || "(无)"}`, h("br"), `检索次数:${String(fact.retrieval_count ?? 0)}${fact.supersedes_id ? ` · 修订自 #${String(fact.supersedes_id)}` : ""} · 被断言 ${String(fact.assert_count ?? 1)} 次`, h("br"), `事件时间:${fact.valid_from ?? "未知"}` + (fact.superseded_by === null || fact.superseded_by === void 0 ? "" : ` · 被 #${String(fact.superseded_by)} 取代`), h("br"), `来源:${(fact.sources ?? []).length === 0 ? "(未记录)" : (fact.sources ?? []).map((s) => `${s.kind}:${s.ref}`).join(";")}`, h("br"), `关系:${(fact.relations ?? []).length === 0 ? "(无)" : (fact.relations ?? []).map((r) => `${r.subject} —${r.predicate}→ ${r.object}`).join(";")}`, fact.kind === "note" && fact.status === "active" ? h(RelationEditor, {
4756
5148
  fact,
4757
5149
  disabled: props.disabled,
4758
5150
  onSave: (relations) => {
@@ -4760,78 +5152,265 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
4760
5152
  }
4761
5153
  }) : null);
4762
5154
  }
5155
+ /**
5156
+ * An `admin.list` row, in the unified shape.
5157
+ *
5158
+ * The meta line is the list's own (trust, retention, counts); a search answer's row states what THAT
5159
+ * answer can honestly say instead (see the four builders below), which is why this text belongs to the
5160
+ * row and not to the renderer.
5161
+ */
5162
+ function listRow(f) {
5163
+ return {
5164
+ kind: "fact",
5165
+ key: `fact:${String(f.fact_id)}`,
5166
+ fact_id: f.fact_id,
5167
+ title: f.title ?? (f.kind === "journal" ? "(无标题)" : void 0),
5168
+ text: f.content,
5169
+ truncated: false,
5170
+ meta: `#${String(f.fact_id)} · ${f.kind} · trust ${f.trust_score.toFixed(2)}` + (f.pinned === true ? " · 永久" : typeof f.remaining_days === "number" ? ` · 剩余 ≈${String(Math.ceil(f.remaining_days))} 活跃日` : "") + ` · ${localTime(f.created_at)}` + (f.status === "archived" && f.archive_reason ? ` · 归档原因 ${f.archive_reason}` : "") + (f.status === "archived" && f.pinned === true ? " · 永久保留(不会被清理)" : ""),
5171
+ action: {
5172
+ fact_id: f.fact_id,
5173
+ status: f.status,
5174
+ content: f.content,
5175
+ pinned: f.pinned === true
5176
+ },
5177
+ fromSearch: false
5178
+ };
5179
+ }
5180
+ /** A keyword (`exact`/`fuzzy`) hit: the one result shape that carries a relevance score. */
5181
+ function hitRow(hit, action) {
5182
+ return {
5183
+ kind: "fact",
5184
+ key: `fact:${String(hit.ref_id)}`,
5185
+ fact_id: hit.ref_id,
5186
+ title: hit.title,
5187
+ text: hit.text,
5188
+ truncated: hit.truncated === true,
5189
+ meta: `#${String(hit.ref_id)} · score ${hit.score.toFixed(2)} · ${hit.source_ref}` + (hit.valid_from === void 0 ? "" : ` · ${hit.valid_from}`),
5190
+ action,
5191
+ fromSearch: true
5192
+ };
5193
+ }
5194
+ /** One row of a fact-level AND page (`mode:"facts"`) — a fact, not a ranked hit: no score. */
5195
+ function factPageRow(entry, action) {
5196
+ return {
5197
+ kind: "fact",
5198
+ key: `fact:${String(entry.fact_id)}`,
5199
+ fact_id: entry.fact_id,
5200
+ title: entry.title,
5201
+ text: entry.content,
5202
+ truncated: entry.truncated === true,
5203
+ meta: `#${String(entry.fact_id)} · ${entry.memory_kind} · ${entry.status} · ${entry.event_date ?? localTime(entry.created_at)}` + (entry.source_ref === void 0 ? "" : ` · ${entry.source_ref}`),
5204
+ action,
5205
+ fromSearch: true
5206
+ };
5207
+ }
5208
+ /** One evidence fact of a relation answer — again a fact, not a ranked hit: no score. */
5209
+ function evidenceRow(entry, action) {
5210
+ return {
5211
+ kind: "fact",
5212
+ key: `fact:${String(entry.fact_id)}`,
5213
+ fact_id: entry.fact_id,
5214
+ title: entry.title,
5215
+ text: entry.text,
5216
+ truncated: entry.truncated === true,
5217
+ meta: `#${String(entry.fact_id)} · ${entry.memory_kind} · ${localTime(entry.created_at)}`,
5218
+ action,
5219
+ fromSearch: true
5220
+ };
5221
+ }
5222
+ /** A relation answer's path: the ONE row body that is not a fact, still a row of the same list. */
5223
+ function pathRow(path, index) {
5224
+ return {
5225
+ kind: "path",
5226
+ key: `path:${String(index)}`,
5227
+ path
5228
+ };
5229
+ }
5230
+ /**
5231
+ * The ONE list renderer of the 记忆 tab: the admin list and every search answer draw through it.
5232
+ *
5233
+ * It owns the parts that used to be written twice — the row body, the 「加载更多(还有 N 条)」 control at
5234
+ * the end, the empty state and the error line — and it reads ONE state ({@link ListState}), so the list
5235
+ * and a result set cannot disagree about whether more rows exist, why the last page failed, or whether
5236
+ * scrolling should load.
5237
+ *
5238
+ * The rows arrive already normalized (`listRow` / `hitRow` / `factPageRow` / `evidenceRow` / `pathRow`):
5239
+ * a fact row carries its text, meta line and resolved action set, so this component never needs to know
5240
+ * which face it came from. The row actions themselves come from the panel (`actionRow`), which is where
5241
+ * the ONE action builder lives (`factButtons`).
5242
+ */
5243
+ function FactList(props) {
5244
+ const state = props.state;
5245
+ const remaining = state.total === void 0 ? 0 : Math.max(state.total - state.rows.length, 0);
5246
+ return h("div", null, props.note === void 0 ? null : h("div", { className: controls_module_css_default.status }, props.note), state.error === void 0 ? null : h("div", { className: controls_module_css_default.error }, `错误:${state.error}`), state.rows.length === 0 ? state.error === void 0 ? h("div", { className: controls_module_css_default.status }, state.loading ? props.loadingText : props.emptyText) : null : h("ul", { className: controls_module_css_default.list }, state.rows.map((row) => h("li", {
5247
+ key: row.key,
5248
+ className: controls_module_css_default.item
5249
+ }, row.kind === "path" ? [h("div", { key: "route" }, [row.path.start.name, ...row.path.edges.flatMap((edge) => [edge.predicate, edge.object])].join(" → ")), h("span", {
5250
+ key: "meta",
5251
+ className: controls_module_css_default.meta
5252
+ }, `${String(row.path.hops)} 跳 · #${row.path.edges.map((edge) => String(edge.fact_id)).join(" / ")} · 置信度 ${row.path.confidence.toFixed(2)}`)] : [
5253
+ row.title === void 0 ? null : h("div", {
5254
+ key: "title",
5255
+ className: controls_module_css_default.meta
5256
+ }, `标题:${row.title}`),
5257
+ props.editing?.id === row.fact_id ? h("textarea", {
5258
+ key: "edit",
5259
+ className: `${controls_module_css_default.textarea} ${pages_module_css_default.edit}`,
5260
+ value: props.editing.text,
5261
+ rows: 3,
5262
+ onChange: (e) => {
5263
+ props.onEdit({
5264
+ id: row.fact_id,
5265
+ text: e.target.value
5266
+ });
5267
+ }
5268
+ }) : row.text,
5269
+ row.truncated ? h("span", {
5270
+ key: "cut",
5271
+ className: controls_module_css_default.meta
5272
+ }, "(已截断)") : null,
5273
+ h("span", {
5274
+ key: "meta",
5275
+ className: controls_module_css_default.meta
5276
+ }, row.meta),
5277
+ h("div", {
5278
+ key: "actions",
5279
+ className: pages_module_css_default.row
5280
+ }, props.actionRow(row.action, row.fromSearch)),
5281
+ props.detail?.id === row.fact_id ? h(FactDetailView, {
5282
+ key: "detail",
5283
+ fact: props.detail.data,
5284
+ disabled: props.busy,
5285
+ onSaveRelations: props.onSaveRelations
5286
+ }) : null
5287
+ ]))), state.truncated ? h("button", {
5288
+ type: "button",
5289
+ className: `${BTN} ${pages_module_css_default.more}`,
5290
+ disabled: state.loading,
5291
+ onClick: props.onLoadMore
5292
+ }, state.loading ? "加载中…" : state.total === void 0 ? "加载更多" : `加载更多(还有 ${String(remaining)} 条)`) : null);
5293
+ }
4763
5294
  function MemoryPanel(props) {
4764
5295
  const [statusFilter, setStatusFilter] = react.useState("active");
4765
- const [rows, setRows] = react.useState([]);
4766
- const [total, setTotal] = react.useState();
4767
- const [truncated, setTruncated] = react.useState(false);
4768
5296
  /**
4769
- * How many rows the HOST has handed over — the pagination anchor. It is not `rows.length`:
4770
- * appends are de-duplicated (a row inserted between two page requests shifts the window and
4771
- * repeats one row), and using the de-duplicated length as the next offset would re-request it.
5297
+ * The list the panel renders — the ONE list state (see {@link ListState}). `admin.list` fills it
5298
+ * while no search is applied; `recall.search` fills it while one is.
4772
5299
  */
4773
- const [nextOffset, setNextOffset] = react.useState(0);
5300
+ const [list, setList] = react.useState({
5301
+ ...EMPTY_LIST,
5302
+ loading: props.remote !== void 0
5303
+ });
5304
+ /** The search the visible rows came from (`null` = the list view). See {@link AppliedSearch}. */
5305
+ const [applied, setApplied] = react.useState(null);
5306
+ /** Everything that is NOT the list: a mutation's message, a detail/health/conflict failure. */
4774
5307
  const [error, setError] = react.useState();
4775
5308
  const [notice, setNotice] = react.useState();
4776
- const [loading, setLoading] = react.useState(props.remote !== void 0);
5309
+ /** A mutation in flight: the panel's buttons and the row actions obey it. */
5310
+ const [busy, setBusy] = react.useState(false);
4777
5311
  const [editing, setEditing] = react.useState(null);
4778
5312
  const [detail, setDetail] = react.useState(null);
4779
5313
  const [contradictions, setContradictions] = react.useState(null);
4780
5314
  const [health, setHealth] = react.useState(null);
4781
5315
  const [searchText, setSearchText] = react.useState("");
4782
5316
  const [searchKind, setSearchKind] = react.useState("all");
4783
- const [searchHits, setSearchHits] = react.useState(null);
4784
- const [searchRelation, setSearchRelation] = react.useState(null);
4785
- const [searchFactPage, setSearchFactPage] = react.useState(null);
5317
+ const [timeField, setTimeField] = react.useState("created");
5318
+ const [timeMode, setTimeMode] = react.useState("on");
5319
+ const [timeOn, setTimeOn] = react.useState("");
5320
+ const [timeFrom, setTimeFrom] = react.useState("");
5321
+ const [timeTo, setTimeTo] = react.useState("");
4786
5322
  /**
4787
- * What the CURRENT search results need so their action row can match an admin list row:
5323
+ * What a search result needs so its action row can match an admin list row:
4788
5324
  * status (归档 vs 恢复), pin state (永久 vs 解除) and the FULL content (a hit's `text` may be
4789
5325
  * truncated by the output budget, and editing a truncated excerpt would corrupt the fact).
4790
5326
  * Read through `admin.detail` — one call per result, bounded by {@link SEARCH_FACT_LOOKUPS_MAX}.
4791
5327
  */
4792
5328
  const [searchFacts, setSearchFacts] = react.useState({});
5329
+ /** The query is on its way: the title line and the 搜索 button say so. */
4793
5330
  const [searching, setSearching] = react.useState(false);
4794
5331
  const detailGuard = useSeqGuard();
4795
5332
  const contradictionGuard = useSeqGuard();
4796
5333
  const healthGuard = useSeqGuard();
4797
- const searchGuard = useSeqGuard();
5334
+ /**
5335
+ * The list's guard — and the search's: both write the SAME state now, so one guard is what keeps a
5336
+ * late page from overwriting a newer one. It matters most on the scroll path, which calls `loadMore()`
5337
+ * with no user action: a page requested for 活动 can land after the user switched to 归档 (appending
5338
+ * active rows to the archived list) or after a search replaced the rows.
5339
+ */
4798
5340
  const listGuard = useSeqGuard();
4799
5341
  /**
4800
- * Load one page and append it (offset 0 replaces the list). Resolves once the
4801
- * page settled, so callers can order their own state updates after the refresh.
5342
+ * The action-state guard: a `loadSearchFacts` reply belongs to the SEARCH that asked for it, not to the
5343
+ * page request that happened to carry it. `listGuard` cannot serve here — every page begins a new one,
5344
+ * so a continuation page would drop the lookups the previous page had in flight (their rows are on
5345
+ * screen, so their actions must land). ONE generation per search; the generation is kept in a ref
5346
+ * because `loadSearchPage` asks for it long after `runSearch` began it.
4802
5347
  */
4803
- const load = react.useCallback(async (offset, status) => {
5348
+ const searchFactsGuard = useSeqGuard();
5349
+ const searchFactsSeq = react.useRef(0);
5350
+ /**
5351
+ * The re-entrancy wall of `loadMore`, as a REF (REVIEW-2026-10-27 P2-1).
5352
+ *
5353
+ * `list.loading` is React state: two checks in the same tick (the scroll controller's queued frame plus
5354
+ * a re-check, or the end-of-list button and a scroll) both read the value from the render that is
5355
+ * already committed and would send the same page twice. `listGuard` then throws away the older reply,
5356
+ * but the RPC has been made. Set on entry, cleared in `finally`, so the wall is visible synchronously.
5357
+ */
5358
+ const loadingMoreRef = react.useRef(false);
5359
+ /**
5360
+ * Load one page of the ADMIN list into the unified state (offset 0 replaces it), resolving once it
5361
+ * settled so callers can order their own updates after the refresh.
5362
+ */
5363
+ const loadListPage = react.useCallback(async (offset) => {
4804
5364
  if (props.remote === void 0) return;
4805
5365
  const seq = listGuard.begin();
4806
- setLoading(true);
4807
- if (offset === 0) setError(void 0);
4808
- const outcome = await callRemote(`admin.list(status=${status}, offset=${String(offset)})`, props.remote.admin({
5366
+ setList((previous) => ({
5367
+ ...previous,
5368
+ loading: true
5369
+ }));
5370
+ const outcome = await callRemote(`admin.list(status=${statusFilter}, offset=${String(offset)})`, props.remote.admin({
4809
5371
  action: "list",
4810
- status,
5372
+ status: statusFilter,
4811
5373
  limit: MEMORY_PAGE_SIZE,
4812
5374
  offset
4813
5375
  }));
4814
5376
  if (!listGuard.isCurrent(seq)) return;
4815
- if (outcome.ok) {
5377
+ setList((previous) => {
5378
+ if (!outcome.ok) return offset === 0 ? {
5379
+ ...EMPTY_LIST,
5380
+ error: outcome.error
5381
+ } : {
5382
+ ...previous,
5383
+ loading: false,
5384
+ error: outcome.error
5385
+ };
4816
5386
  const page = outcome.value?.facts ?? [];
4817
- setRows((previous) => offset === 0 ? page : mergePage(previous, page));
4818
- setNextOffset(offset + page.length);
4819
- setTotal(outcome.value?.total);
4820
- setTruncated(outcome.value?.truncated === true);
4821
- } else {
4822
- if (offset === 0) {
4823
- setRows([]);
4824
- setNextOffset(0);
4825
- setTotal(void 0);
4826
- setTruncated(false);
4827
- }
4828
- setError(outcome.error);
4829
- }
4830
- setLoading(false);
4831
- }, [listGuard, props.remote]);
5387
+ const rows = page.map(listRow);
5388
+ return {
5389
+ rows: offset === 0 ? rows : mergeRows(previous.rows, rows),
5390
+ total: outcome.value?.total,
5391
+ truncated: outcome.value?.truncated === true,
5392
+ nextOffset: offset + page.length,
5393
+ loading: false,
5394
+ error: void 0
5395
+ };
5396
+ });
5397
+ }, [
5398
+ listGuard,
5399
+ props.remote,
5400
+ statusFilter
5401
+ ]);
5402
+ /**
5403
+ * The list view's source: `admin.list`, page 0.
5404
+ *
5405
+ * It runs on mount, on a status switch and when a search is cleared — and NEVER while a search owns
5406
+ * the list, or an admin page would overwrite the rows the search put on screen. `applied` decides the
5407
+ * source here exactly as it does for the next page.
5408
+ */
5409
+ const listView = applied === null;
4832
5410
  react.useEffect(() => {
4833
- load(0, statusFilter);
4834
- }, [load, statusFilter]);
5411
+ if (!listView) return;
5412
+ loadListPage(0);
5413
+ }, [listView, loadListPage]);
4835
5414
  /**
4836
5415
  * Resolve the state of the facts a search answer points at (`admin.detail`, one call each).
4837
5416
  *
@@ -4839,15 +5418,21 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
4839
5418
  * know the id and an excerpt, and would have to guess 「归档/恢复」 and 「永久/解除」 — two labels
4840
5419
  * whose opposite is a different operation. Results are bounded (search is a fixed shortlist, and
4841
5420
  * relation evidence is the paths' facts), deduplicated, and dropped if a newer search started.
5421
+ *
5422
+ * A CONTINUATION page calls it too (REVIEW-2026-10-27 P2-2): without that, every row a scroll brought
5423
+ * in kept the id-only action set until the query was run again. The lookup is per page, so the bound
5424
+ * above still holds — one page can resolve at most {@link SEARCH_FACT_LOOKUPS_MAX} of its rows and the
5425
+ * rest keep the id-only actions (详情 / 编辑 / 归档), exactly like the first page's overflow.
5426
+ * The record is MERGED, so an append never drops what the first page resolved.
5427
+ *
5428
+ * The guard is the SEARCH's, not the page request's (`searchFactsGuard`): a page landing while the
5429
+ * previous page's lookups are still in flight must not throw them away — those facts are on screen.
4842
5430
  */
4843
5431
  const loadSearchFacts = (seq, ids) => {
4844
5432
  const remote = props.remote;
4845
5433
  if (remote === void 0) return;
4846
5434
  const unique = [...new Set(ids)].slice(0, SEARCH_FACT_LOOKUPS_MAX);
4847
- if (unique.length === 0) {
4848
- setSearchFacts({});
4849
- return;
4850
- }
5435
+ if (unique.length === 0) return;
4851
5436
  Promise.all(unique.map(async (id) => ({
4852
5437
  id,
4853
5438
  outcome: await callRemote(`admin.detail(${String(id)})`, remote.admin({
@@ -4855,7 +5440,7 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
4855
5440
  fact_id: id
4856
5441
  }))
4857
5442
  }))).then((results) => {
4858
- if (!searchGuard.isCurrent(seq)) return;
5443
+ if (!searchFactsGuard.isCurrent(seq)) return;
4859
5444
  const next = {};
4860
5445
  for (const { id, outcome } of results) {
4861
5446
  if (!outcome.ok) continue;
@@ -4867,104 +5452,319 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
4867
5452
  content: value.content
4868
5453
  };
4869
5454
  }
4870
- setSearchFacts(next);
5455
+ setSearchFacts((previous) => ({
5456
+ ...previous,
5457
+ ...next
5458
+ }));
4871
5459
  });
4872
5460
  };
4873
- /** Search memory through the same `recall` remote the model tool uses. */
5461
+ /**
5462
+ * Walk a pure time window page by page and hand back every row in it.
5463
+ *
5464
+ * The request sends NO `limit`: the host pages a window at its own default (`mode:"time"`, 50 rows)
5465
+ * and says how much is left in `remaining`. The panel shows the WHOLE window — "what happened that
5466
+ * day" is the question, and answering it with the first 50 of 286 rows would hide most of the day.
5467
+ * Walking `offset` (never a client-side `limit`) is what reconciles the two: 286 facts arrive as six
5468
+ * pages, which is exactly what `offset` is for, and the host's cap stays untouched.
5469
+ *
5470
+ * `null` rows means a newer search took over (`listGuard`); the caller must then stop without touching
5471
+ * the list. `error` is reported with whatever rows were already collected, so a failure on page 3
5472
+ * still shows pages 1-2 rather than nothing.
5473
+ *
5474
+ * The rows are de-duplicated ACROSS pages (see the loop's note): a walk is one render of the whole
5475
+ * window, so a row repeated because the offset drifted would be a duplicate React key on the very
5476
+ * first paint, not just on a later scroll.
5477
+ *
5478
+ * A walk that stops early is RESUMABLE: it hands back the host-handed `nextOffset` it reached and the
5479
+ * window's `total`, and the unified list state keeps `truncated = nextOffset < total` so `loadMore()`
5480
+ * continues from there on the next scroll instead of leaving 「还有 K 条未显示」 as a dead end.
5481
+ */
5482
+ const runTimeWindow = async (remote, seq, time) => {
5483
+ const pages = [];
5484
+ let total = null;
5485
+ let offset = 0;
5486
+ for (let page = 0; page < SEARCH_PAGES_MAX; page++) {
5487
+ const outcome = await callRemote(`recall.search(time, offset=${String(offset)}${searchKind === "all" ? "" : `, kind=${searchKind}`})`, remote.recall({
5488
+ action: "search",
5489
+ ...searchKind === "all" ? {} : { kind: searchKind },
5490
+ time,
5491
+ offset
5492
+ }));
5493
+ if (!listGuard.isCurrent(seq)) return {
5494
+ rows: null,
5495
+ total: null,
5496
+ nextOffset: offset
5497
+ };
5498
+ if (!outcome.ok) return {
5499
+ rows: mergeRowPages(pages),
5500
+ total,
5501
+ nextOffset: offset,
5502
+ error: outcome.error
5503
+ };
5504
+ const value = outcome.value;
5505
+ if (value.mode !== "time") break;
5506
+ pages.push(value.hits.map((hit) => hitRow(hit, actionFact(hit.ref_id, hit.text, hit.truncated === true))));
5507
+ total = value.total;
5508
+ offset = value.offset + value.hits.length;
5509
+ const remaining = typeof value.remaining === "number" ? value.remaining : Math.max(0, value.total - offset);
5510
+ if (value.hits.length === 0 || remaining <= 0) break;
5511
+ }
5512
+ return {
5513
+ rows: mergeRowPages(pages),
5514
+ total,
5515
+ nextOffset: offset
5516
+ };
5517
+ };
5518
+ /**
5519
+ * Search memory through the same `recall` remote the model tool uses, and put the answer into the
5520
+ * ONE list state (the result set REPLACES the list's rows; `clearSearch` puts the list back).
5521
+ */
4874
5522
  const runSearch = async () => {
4875
- if (props.remote === void 0) return;
5523
+ const remote = props.remote;
5524
+ if (remote === void 0) return;
4876
5525
  const query = searchText.trim();
4877
- if (query === "") {
4878
- searchGuard.begin();
4879
- setSearchHits(null);
4880
- setSearchRelation(null);
4881
- setSearchFactPage(null);
4882
- setSearchFacts({});
4883
- setSearching(false);
4884
- setError(void 0);
5526
+ const time = buildTimeFilter(timeField, timeMode, timeOn, timeFrom, timeTo);
5527
+ if (query === "" && time === void 0) {
5528
+ if (applied !== null || searchText !== "") clearSearch();
4885
5529
  return;
4886
5530
  }
4887
- const seq = searchGuard.begin();
5531
+ const seq = listGuard.begin();
5532
+ const factsSeq = searchFactsGuard.begin();
5533
+ searchFactsSeq.current = factsSeq;
4888
5534
  setSearching(true);
4889
5535
  setError(void 0);
4890
- const outcome = await callRemote(`recall.search(${query}${searchKind === "all" ? "" : `, kind=${searchKind}`})`, props.remote.recall({
5536
+ setSearchFacts({});
5537
+ setApplied({
5538
+ query,
5539
+ time: time ?? null,
5540
+ kind: searchKind,
5541
+ mode: query === "" ? "time" : "hits"
5542
+ });
5543
+ setList({
5544
+ ...EMPTY_LIST,
5545
+ loading: true
5546
+ });
5547
+ if (query === "" && time !== void 0) {
5548
+ const walked = await runTimeWindow(remote, seq, time);
5549
+ if (!listGuard.isCurrent(seq)) return;
5550
+ const rows = walked.rows ?? [];
5551
+ setList({
5552
+ rows,
5553
+ total: walked.total ?? void 0,
5554
+ truncated: walked.total !== null && walked.nextOffset < walked.total,
5555
+ nextOffset: walked.nextOffset,
5556
+ loading: false,
5557
+ error: walked.error
5558
+ });
5559
+ loadSearchFacts(factsSeq, rows.flatMap((row) => row.kind === "fact" ? [row.fact_id] : []));
5560
+ setSearching(false);
5561
+ return;
5562
+ }
5563
+ const outcome = await callRemote(`recall.search(${query}${searchKind === "all" ? "" : `, kind=${searchKind}`})`, remote.recall({
4891
5564
  action: "search",
4892
5565
  query,
4893
5566
  limit: SEARCH_LIMIT,
4894
- ...searchKind === "all" ? {} : { kind: searchKind }
5567
+ ...searchKind === "all" ? {} : { kind: searchKind },
5568
+ ...time === void 0 ? {} : { time }
4895
5569
  }));
4896
- if (!searchGuard.isCurrent(seq)) return;
5570
+ if (!listGuard.isCurrent(seq)) return;
4897
5571
  const value = outcome.ok ? outcome.value : void 0;
4898
5572
  if (value !== void 0 && value.mode === "relation") {
4899
- setSearchRelation({
4900
- paths: value.paths,
4901
- evidence: value.evidence
5573
+ setApplied((current) => current === null ? null : {
5574
+ ...current,
5575
+ mode: "relation"
5576
+ });
5577
+ const rows = [...value.paths.map(pathRow), ...value.evidence.map((entry) => evidenceRow(entry, actionFact(entry.fact_id, entry.text, entry.truncated === true)))];
5578
+ setList({
5579
+ rows,
5580
+ total: void 0,
5581
+ truncated: false,
5582
+ nextOffset: rows.length,
5583
+ loading: false,
5584
+ error: void 0
4902
5585
  });
4903
- setSearchHits(null);
4904
- setSearchFactPage(null);
4905
- loadSearchFacts(seq, value.evidence.map((entry) => entry.fact_id));
5586
+ loadSearchFacts(factsSeq, value.evidence.map((entry) => entry.fact_id));
4906
5587
  } else if (value !== void 0 && value.mode === "facts") {
4907
- setSearchFactPage(value.facts);
4908
- setSearchHits(null);
4909
- setSearchRelation(null);
4910
- loadSearchFacts(seq, value.facts.map((entry) => entry.fact_id));
5588
+ setApplied((current) => current === null ? null : {
5589
+ ...current,
5590
+ mode: "facts"
5591
+ });
5592
+ const rows = value.facts.map((entry) => factPageRow(entry, actionFact(entry.fact_id, entry.content, entry.truncated === true)));
5593
+ setList({
5594
+ rows,
5595
+ total: void 0,
5596
+ truncated: false,
5597
+ nextOffset: rows.length,
5598
+ loading: false,
5599
+ error: void 0
5600
+ });
5601
+ loadSearchFacts(factsSeq, value.facts.map((entry) => entry.fact_id));
4911
5602
  } else if (value !== void 0) {
4912
- setSearchHits(value.hits);
4913
- setSearchRelation(null);
4914
- setSearchFactPage(null);
4915
- loadSearchFacts(seq, value.hits.map((hit) => hit.ref_id));
4916
- } else {
4917
- setSearchHits([]);
4918
- setSearchRelation(null);
4919
- setSearchFactPage(null);
4920
- setSearchFacts({});
4921
- if (!outcome.ok) setError(outcome.error);
4922
- }
5603
+ setApplied((current) => current === null ? null : {
5604
+ ...current,
5605
+ mode: "hits"
5606
+ });
5607
+ const rows = value.hits.map((hit) => hitRow(hit, actionFact(hit.ref_id, hit.text, hit.truncated === true)));
5608
+ setList({
5609
+ rows,
5610
+ total: void 0,
5611
+ truncated: false,
5612
+ nextOffset: rows.length,
5613
+ loading: false,
5614
+ error: void 0
5615
+ });
5616
+ loadSearchFacts(factsSeq, value.hits.map((hit) => hit.ref_id));
5617
+ } else setList({
5618
+ ...EMPTY_LIST,
5619
+ error: outcome.ok ? void 0 : outcome.error
5620
+ });
4923
5621
  setSearching(false);
4924
5622
  };
4925
- /** Clear the query and the search result view (never the memory rows themselves). */
5623
+ /**
5624
+ * Fetch the NEXT page of the window whose rows are on screen, and APPEND it to the same state.
5625
+ *
5626
+ * `runTimeWindow` normally walks a window to its end in one user action, so this is the path for the
5627
+ * case it could not finish: a page that failed (the rows before it stay, and so does the shortfall
5628
+ * line) or the `SEARCH_PAGES_MAX` wall. Without it the panel would print 「还有 K 条未显示」 and stop
5629
+ * there — the rows would be named but unreachable.
5630
+ *
5631
+ * The question is NOT rebuilt from the live controls: it is the one the visible rows were fetched with
5632
+ * (`applied.time` + `applied.kind` + the empty query). A date edited after the search must not change
5633
+ * what page 2 means, or the list would concatenate two different windows. `offset` is the host-handed
5634
+ * count from the list state, so a page that repeated a row (page drift) cannot shift what comes next.
5635
+ *
5636
+ * `listGuard` is the re-entry wall: this request becomes the newest one, and a search or a clear that
5637
+ * starts while it is in flight drops its reply. `loadingMoreRef` (in `loadMore`) is the burst wall —
5638
+ * one page in flight.
5639
+ */
5640
+ const loadSearchPage = async (offset) => {
5641
+ const remote = props.remote;
5642
+ const search = applied;
5643
+ if (remote === void 0 || search === null || search.time === null) return;
5644
+ const seq = listGuard.begin();
5645
+ setList((previous) => ({
5646
+ ...previous,
5647
+ loading: true
5648
+ }));
5649
+ const outcome = await callRemote(`recall.search(time, offset=${String(offset)}${search.kind === "all" ? "" : `, kind=${search.kind}`})`, remote.recall({
5650
+ action: "search",
5651
+ ...search.kind === "all" ? {} : { kind: search.kind },
5652
+ time: search.time,
5653
+ offset
5654
+ }));
5655
+ if (!listGuard.isCurrent(seq)) return;
5656
+ if (!outcome.ok) {
5657
+ setList((previous) => ({
5658
+ ...previous,
5659
+ loading: false,
5660
+ error: outcome.error
5661
+ }));
5662
+ return;
5663
+ }
5664
+ const value = outcome.value;
5665
+ if (value.mode !== "time") {
5666
+ setList((previous) => ({
5667
+ ...previous,
5668
+ loading: false,
5669
+ truncated: false
5670
+ }));
5671
+ return;
5672
+ }
5673
+ const pageRows = value.hits.map((hit) => hitRow(hit, actionFact(hit.ref_id, hit.text, hit.truncated === true)));
5674
+ setList((previous) => ({
5675
+ ...previous,
5676
+ ...appendWindowPage(previous, {
5677
+ rows: pageRows,
5678
+ offset: value.offset,
5679
+ total: value.total
5680
+ }),
5681
+ total: value.total,
5682
+ loading: false,
5683
+ error: void 0
5684
+ }));
5685
+ loadSearchFacts(searchFactsSeq.current, value.hits.map((hit) => hit.ref_id));
5686
+ };
5687
+ /**
5688
+ * Load the next page of WHATEVER owns the list.
5689
+ *
5690
+ * The single paging entry: the end-of-list button and the scroll controller both call it, so there is
5691
+ * no "button version" and "scroll version" to diverge. The source is decided by `applied` — the search
5692
+ * the visible rows came from — and only a time window has a next page, because a keyword / fact-level /
5693
+ * relation answer is a shortlist whose offset the host does not page.
5694
+ *
5695
+ * Two walls, and both are needed: `loadingMoreRef` is synchronous (two checks in ONE tick — a queued
5696
+ * scroll frame and a re-check — must not send the same page twice), `list.loading` is the committed
5697
+ * state (a page started elsewhere, e.g. the refresh after a mutation, is already in flight).
5698
+ */
5699
+ const loadMore = async () => {
5700
+ if (props.remote === void 0 || loadingMoreRef.current || list.loading || !list.truncated) return;
5701
+ loadingMoreRef.current = true;
5702
+ try {
5703
+ if (applied === null) {
5704
+ await loadListPage(list.nextOffset);
5705
+ return;
5706
+ }
5707
+ if (applied.mode !== "time") return;
5708
+ await loadSearchPage(list.nextOffset);
5709
+ } finally {
5710
+ loadingMoreRef.current = false;
5711
+ }
5712
+ };
5713
+ /** Clear the query, the time window and the result view; the list view comes back on its own. */
4926
5714
  const clearSearch = () => {
4927
- searchGuard.begin();
5715
+ listGuard.begin();
5716
+ searchFactsGuard.begin();
4928
5717
  setSearchText("");
4929
- setSearchHits(null);
4930
- setSearchRelation(null);
4931
- setSearchFactPage(null);
5718
+ setTimeOn("");
5719
+ setTimeFrom("");
5720
+ setTimeTo("");
5721
+ setApplied(null);
4932
5722
  setSearchFacts({});
4933
5723
  setSearching(false);
5724
+ setEditing(null);
5725
+ setDetail(null);
4934
5726
  setError(void 0);
5727
+ setList({
5728
+ ...EMPTY_LIST,
5729
+ loading: props.remote !== void 0
5730
+ });
4935
5731
  };
4936
- /** Switch the status tab: clear the other tab's page immediately, and drop its in-flight page. */
5732
+ /** Switch the status tab: drop the other tab's page immediately, and its in-flight page too. */
4937
5733
  const switchStatus = (next) => {
4938
5734
  if (next === statusFilter) return;
4939
5735
  listGuard.begin();
4940
- setRows([]);
4941
- setNextOffset(0);
4942
- setTotal(void 0);
4943
- setTruncated(false);
5736
+ if (listView) setList({
5737
+ ...EMPTY_LIST,
5738
+ loading: props.remote !== void 0
5739
+ });
4944
5740
  setDetail(null);
4945
5741
  setEditing(null);
4946
5742
  contradictionGuard.begin();
4947
5743
  setContradictions(null);
4948
5744
  setStatusFilter(next);
4949
5745
  };
4950
- /** Run one mutation, refresh page 0, then surface the mutation's own outcome. */
5746
+ /**
5747
+ * Re-read whatever the list is SHOWING: the admin page, or nothing while a search owns it.
5748
+ *
5749
+ * A search result set is re-read by the query that produced it (`factButtons`' `fromSearch`), so
5750
+ * refreshing the admin list underneath it would be a page nobody is looking at — and, now that both
5751
+ * sources write ONE state, it would overwrite the rows on screen.
5752
+ */
5753
+ const refreshVisible = react.useCallback(() => listView ? loadListPage(0) : Promise.resolve(), [listView, loadListPage]);
5754
+ /** Run one mutation, re-read the visible list, then surface the mutation's own outcome. */
4951
5755
  const mutate = react.useCallback((label, call, opts) => {
4952
5756
  const remote = props.remote;
4953
5757
  if (remote === void 0) return;
4954
5758
  runRemoteAction({
4955
- setLoading,
5759
+ setLoading: setBusy,
4956
5760
  setNotice,
4957
5761
  setError
4958
5762
  }, label, call(remote), {
4959
5763
  summarize: opts?.summarize,
4960
- refreshFirst: () => load(0, statusFilter),
5764
+ refreshFirst: refreshVisible,
4961
5765
  after: opts?.after
4962
5766
  });
4963
- }, [
4964
- props.remote,
4965
- load,
4966
- statusFilter
4967
- ]);
5767
+ }, [props.remote, refreshVisible]);
4968
5768
  /**
4969
5769
  * Read one fact's detail. Kept separate from the toggle because a relation mutation changes the
4970
5770
  * detail it was performed in: the panel stays open and re-reads it, instead of leaving a stale
@@ -5021,13 +5821,14 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
5021
5821
  } });
5022
5822
  };
5023
5823
  /**
5024
- * The per-fact action buttons — ONE implementation for the admin list and the search results.
5824
+ * The per-fact action buttons — ONE implementation for the admin list and every result shape.
5025
5825
  *
5026
- * The regression this replaced: the actions were written inline in the list row, so when
5027
- * `searchActive` hid the list, a hit's 「详情」 had nowhere to render and there was no way to
5028
- * edit/archive/feedback a fact found by search. `fromSearch` only decides what happens AFTER the
5029
- * write: a search view must re-run the query (its results are the thing on screen), while the
5030
- * list refresh (`mutate`'s `refreshFirst`) stays the same in both cases.
5826
+ * The regression this replaced: the actions were written inline in the list row, so when a search
5827
+ * owned the view, a hit's 「详情」 had nowhere to render and there was no way to edit/archive
5828
+ * a fact found by search.
5829
+ *
5830
+ * `fromSearch` only decides what happens AFTER the write: a result set is what is on screen, so it
5831
+ * re-runs the query, while a list row is re-read by `mutate`'s `refreshVisible`.
5031
5832
  *
5032
5833
  * A field the caller does not know is `undefined` and its button is OMITTED — never shown with a
5033
5834
  * guessed label (「恢复」 on an active fact or 「永久记忆」 on a pinned one is a different write).
@@ -5044,7 +5845,7 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
5044
5845
  if (editing?.id === fact.fact_id) return [h(Btn, {
5045
5846
  key: "save",
5046
5847
  label: "保存",
5047
- disabled: loading || editing.text.trim().length === 0,
5848
+ disabled: busy || editing.text.trim().length === 0,
5048
5849
  onClick: () => {
5049
5850
  const text = editing.text.trim();
5050
5851
  setEditing(null);
@@ -5057,7 +5858,7 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
5057
5858
  }), h(Btn, {
5058
5859
  key: "cancel",
5059
5860
  label: "取消",
5060
- disabled: loading,
5861
+ disabled: busy,
5061
5862
  onClick: () => {
5062
5863
  setEditing(null);
5063
5864
  }
@@ -5067,7 +5868,7 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
5067
5868
  h(Btn, {
5068
5869
  key: "detail",
5069
5870
  label: detail?.id === fact.fact_id ? "收起详情" : "详情",
5070
- disabled: loading,
5871
+ disabled: busy,
5071
5872
  onClick: () => {
5072
5873
  showDetail(fact.fact_id);
5073
5874
  }
@@ -5075,7 +5876,7 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
5075
5876
  fact.status === "active" && content !== void 0 ? h(Btn, {
5076
5877
  key: "edit",
5077
5878
  label: "编辑",
5078
- disabled: loading,
5879
+ disabled: busy,
5079
5880
  onClick: () => {
5080
5881
  setEditing({
5081
5882
  id: fact.fact_id,
@@ -5084,32 +5885,10 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
5084
5885
  setDetail(null);
5085
5886
  }
5086
5887
  }) : null,
5087
- fact.status === "active" ? h(Btn, {
5088
- key: "helpful",
5089
- label: "有用 +1",
5090
- disabled: loading,
5091
- onClick: () => {
5092
- run(`反馈有用 #${String(fact.fact_id)}`, (remote) => remote.remember({
5093
- action: "helpful",
5094
- fact_id: fact.fact_id
5095
- }));
5096
- }
5097
- }) : null,
5098
- fact.status === "active" ? h(Btn, {
5099
- key: "unhelpful",
5100
- label: "没用 −1",
5101
- disabled: loading,
5102
- onClick: () => {
5103
- run(`反馈没用 #${String(fact.fact_id)}`, (remote) => remote.remember({
5104
- action: "unhelpful",
5105
- fact_id: fact.fact_id
5106
- }));
5107
- }
5108
- }) : null,
5109
5888
  fact.status === "active" ? h(Btn, {
5110
5889
  key: "archive",
5111
5890
  label: "归档",
5112
- disabled: loading,
5891
+ disabled: busy,
5113
5892
  onClick: () => {
5114
5893
  run(`归档 #${String(fact.fact_id)}`, (remote) => remote.admin({
5115
5894
  action: "archive",
@@ -5120,7 +5899,7 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
5120
5899
  }) : h(Btn, {
5121
5900
  key: "restore",
5122
5901
  label: "恢复",
5123
- disabled: loading,
5902
+ disabled: busy,
5124
5903
  onClick: () => {
5125
5904
  run(`恢复 #${String(fact.fact_id)}`, (remote) => remote.admin({
5126
5905
  action: "restore",
@@ -5131,7 +5910,7 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
5131
5910
  fact.status === "active" && fact.pinned !== void 0 ? fact.pinned ? h(Btn, {
5132
5911
  key: "unpin",
5133
5912
  label: "解除永久",
5134
- disabled: loading,
5913
+ disabled: busy,
5135
5914
  onClick: () => {
5136
5915
  run(`解除永久 #${String(fact.fact_id)}`, (remote) => remote.admin({
5137
5916
  action: "unpin",
@@ -5141,7 +5920,7 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
5141
5920
  }) : h(Btn, {
5142
5921
  key: "pin",
5143
5922
  label: "永久记忆",
5144
- disabled: loading,
5923
+ disabled: busy,
5145
5924
  onClick: () => {
5146
5925
  run(`固化永久 #${String(fact.fact_id)}`, (remote) => remote.admin({
5147
5926
  action: "pin",
@@ -5223,8 +6002,59 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
5223
6002
  } else setError(outcome.error);
5224
6003
  });
5225
6004
  };
5226
- const shown = rows.length;
5227
- const summary = total === void 0 ? `已显示 ${String(shown)} 条` : `共 ${String(total)} 条${statusFilter === "active" ? "活动" : "归档"}事实,已显示 ${String(shown)} 条` + (truncated ? "(继续滚动自动加载)" : "");
6005
+ const shown = list.rows.length;
6006
+ /**
6007
+ * The NAME of the answer on screen, for the unified info line (see {@link metaLine}).
6008
+ *
6009
+ * It says which KIND of answer the rows are (a fact-level AND page is not a hit list, and a
6010
+ * relation answer is structure), plus the memory layer it was fetched with — `applied`, never the
6011
+ * live selector. `null` for a pure time window: there the WINDOW is the question, so naming it
6012
+ * twice would only repeat the line's first piece.
6013
+ */
6014
+ const answerTitle = applied === null ? null : searching ? "搜索中…" : applied.mode === "time" ? null : applied.mode === "facts" ? `多实体事实:${String(shown)} 条${applied.kind === "all" ? "" : `(${applied.kind})`}` : applied.mode === "relation" ? `关系结果:${String(list.rows.filter((row) => row.kind === "path").length)} 条路径 · 证据 ${String(list.rows.filter((row) => row.kind === "fact").length)} 条${applied.kind === "all" ? "" : `(${applied.kind})`}` : `搜索结果:${String(shown)} 条${applied.kind === "all" ? "" : `(${applied.kind})`}${applied.query === "" ? "" : `“${applied.query}”`}`;
6015
+ /**
6016
+ * How many rows the SOURCE holds and how many are on screen — the count piece of the info line.
6017
+ *
6018
+ * `list.total` is only set when the source counts its rows (`admin.list`'s own `total`, or a walked
6019
+ * time window's), so a RANKED keyword page / fact-level page / relation answer prints no count here:
6020
+ * labelling a ten-row shortlist with a corpus-wide number would claim a completeness it lacks.
6021
+ *
6022
+ * The 「继续滚动自动加载」 hint appears only when scrolling really loads (the same `truncated` the
6023
+ * pager and the scroll hook read), and 「正在加载…」 says when a page is on its way, so a shortfall
6024
+ * never reads as a dead end.
6025
+ */
6026
+ const countText = list.total === void 0 ? applied === null ? `已显示 ${String(shown)} 条` : null : applied === null ? `共 ${String(list.total)} 条${statusFilter === "active" ? "活动" : "归档"}事实,已显示 ${String(shown)} 条` + (list.truncated ? "(继续滚动自动加载)" : "") : `共 ${String(list.total)} 条,已显示 ${String(shown)} 条` + (list.total > shown ? `,还有 ${String(list.total - shown)} 条未显示` : "") + (list.loading ? "(正在加载…)" : list.truncated ? "(继续滚动自动加载)" : "");
6027
+ /**
6028
+ * THE top information line of the 记忆 tab — ONE line, in ONE place, for the list and for every
6029
+ * search answer.
6030
+ *
6031
+ * It used to be TWO interfaces' worth: the list had this sentence in the sticky bar, while a search
6032
+ * wrapped the SAME rows in `css.searchPanel` with three blocks of its own (`css.searchTitle`,
6033
+ * `css.searchWindow` and a second count line). Two renderings of one fact is what made a search look
6034
+ * like a separate screen. The composition below is the fix: the pieces are joined in a fixed order,
6035
+ * and a source that cannot state a piece omits it.
6036
+ *
6037
+ * 时间窗口:… · <answer name> · 共 N 条,已显示 M 条[,还有 K 条未显示] (继续滚动自动加载)
6038
+ *
6039
+ * The window is the one the VISIBLE rows were narrowed to (`applied.time`, never the live draft).
6040
+ */
6041
+ const metaLine = [
6042
+ applied === null || applied.time === null ? null : `时间窗口:${timeFilterLabel(applied.time)}`,
6043
+ answerTitle,
6044
+ countText
6045
+ ].filter((part) => part !== null).join(" · ");
6046
+ /** The list view before its first page has nothing to count yet: say what is happening instead. */
6047
+ const statusLine = applied === null && list.loading && shown === 0 ? "读取中…" : metaLine;
6048
+ /** What the list says when its source returned nothing at all. */
6049
+ const emptyText = listView ? statusFilter === "active" ? "当前没有活动的事实。可在对话里让 agent 用 mem_remember 写入。" : "当前没有归档的事实。" : applied?.mode === "facts" ? "没有同时提到这些实体的事实。" : applied?.mode === "relation" ? "没有关系路径。" : "没有命中。";
6050
+ /** What it says while the source's first page is on its way. */
6051
+ const loadingText = listView ? "读取中…" : "正在检索记忆…";
6052
+ /**
6053
+ * A relation answer's paths and its evidence are two groups of the SAME list. When the graph found no
6054
+ * path but the evidence facts are still there, the sentence is printed above the rows instead of
6055
+ * being dropped along with the (empty) path group.
6056
+ */
6057
+ const listNote = applied?.mode === "relation" && shown > 0 && list.rows.every((row) => row.kind === "fact") ? "没有关系路径。" : void 0;
5228
6058
  /** Bring the panel's top into view — used when a block opens below the sticky bar. */
5229
6059
  const rootRef = react.useRef(null);
5230
6060
  const reveal = () => {
@@ -5233,38 +6063,67 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
5233
6063
  react.useEffect(() => {
5234
6064
  reveal();
5235
6065
  }, []);
5236
- /** Whether the search view owns the panel (the admin list must not compete with it). */
5237
- const searchActive = searching || searchHits !== null || searchRelation !== null || searchFactPage !== null;
5238
- /** Load the next page once the list's end is within reach of the scrollport. */
5239
- const maybeLoadMore = () => {
5240
- if (searchActive || !truncated || loading || props.remote === void 0) return;
5241
- load(nextOffset, statusFilter);
5242
- };
5243
- useLoadMoreOnScroll(rootRef, truncated && !loading, maybeLoadMore, rows.length);
6066
+ /** The window the CONTROLS currently describe (`undefined` = no time condition). */
6067
+ const timeCondition = buildTimeFilter(timeField, timeMode, timeOn, timeFrom, timeTo);
6068
+ /**
6069
+ * The ONE scroll loader.
6070
+ *
6071
+ * Its `enabled` and its `version` come from the unified list state, and its callback is the same
6072
+ * `loadMore()` the end-of-list button calls — there is no second hook, no second enabled rule and no
6073
+ * separate "continue the results" path, which is where the two views used to diverge. `version` is the
6074
+ * row count, so a page that lands re-checks (a short page may not fill the scrollport, and then no
6075
+ * scroll event would ever come).
6076
+ *
6077
+ * `list.error === undefined` is part of the gate on purpose: the hook retries once whenever `enabled`
6078
+ * flips back on, so an unreported page failure would re-fire the same request on every round trip.
6079
+ * A failure is a STATE, not a retry trigger — the end-of-list button (still rendered, because
6080
+ * `truncated` is unchanged) is how the user asks again, and the next successful refresh re-arms the
6081
+ * loader.
6082
+ */
6083
+ useLoadMoreOnScroll(rootRef, list.truncated && !list.loading && list.error === void 0, () => {
6084
+ loadMore();
6085
+ }, shown);
6086
+ /**
6087
+ * The rows the renderer gets: a SEARCH row re-reads its action state HERE.
6088
+ *
6089
+ * `hitRow` / `evidenceRow` / `factPageRow` take an {@link ActionFact} as an argument, so a row carries
6090
+ * a SNAPSHOT of `searchFacts` from the moment it was built — and the lookups (`loadSearchFacts`) answer
6091
+ * AFTER the rows exist. Without this the panel would render every result with the fallback set
6092
+ * (`status:'active'`, pin state unknown) for the life of the result set: 「归档」 on an archived fact,
6093
+ * and no 「永久记忆」 at all — the exact guess the lookups exist to avoid. The pre-unification view read
6094
+ * `searchFacts` at render for the same reason; the unified row shape moved that read into the fetch
6095
+ * handler, so it is restored once, here, for every search row at once.
6096
+ *
6097
+ * A list row needs no derivation: `admin.list` already hands over every action field.
6098
+ */
6099
+ const rows = list.rows.map((row) => row.kind === "fact" && row.fromSearch ? {
6100
+ ...row,
6101
+ action: actionFact(row.fact_id, row.text, row.truncated)
6102
+ } : row);
5244
6103
  return h("div", {
5245
6104
  className: pages_module_css_default.section,
5246
6105
  ref: rootRef
5247
- }, props.remote === void 0 ? h("div", { className: pages_module_css_default.error }, NOT_MOUNTED) : null, props.remote === void 0 ? null : h("div", { className: pages_module_css_default.headbar }, h("button", {
6106
+ }, props.remote === void 0 ? h("div", { className: controls_module_css_default.error }, NOT_MOUNTED) : null, props.remote === void 0 ? null : h("div", { className: pages_module_css_default.stickyTop }, h("div", { className: pages_module_css_default.headbar }, h("button", {
5248
6107
  type: "button",
5249
- className: statusFilter === "active" ? `${pages_module_css_default.btn} ${pages_module_css_default.btnActive}` : pages_module_css_default.btn,
6108
+ className: statusFilter === "active" ? `${BTN} ${controls_module_css_default.active}` : BTN,
5250
6109
  onClick: () => {
5251
6110
  switchStatus("active");
5252
6111
  }
5253
6112
  }, "活动"), h("button", {
5254
6113
  type: "button",
5255
- className: statusFilter === "archived" ? `${pages_module_css_default.btn} ${pages_module_css_default.btnActive}` : pages_module_css_default.btn,
6114
+ className: statusFilter === "archived" ? `${BTN} ${controls_module_css_default.active}` : BTN,
5256
6115
  onClick: () => {
5257
6116
  switchStatus("archived");
5258
6117
  }
5259
6118
  }, "归档"), h(Btn, {
5260
6119
  label: "立即维护",
5261
- disabled: loading,
6120
+ disabled: busy,
5262
6121
  onClick: () => {
5263
6122
  mutate("生命周期维护", (remote) => remote.admin({ action: "maintenance" }));
5264
6123
  }
5265
6124
  }), h(Btn, {
5266
6125
  label: contradictions === null ? "查看未处理矛盾" : "收起矛盾",
5267
- disabled: loading,
6126
+ disabled: busy,
5268
6127
  onClick: () => {
5269
6128
  const opening = contradictions === null;
5270
6129
  loadContradictions();
@@ -5272,13 +6131,13 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
5272
6131
  }
5273
6132
  }), h(Btn, {
5274
6133
  label: health === null ? "查看检索健康度" : "收起检索健康度",
5275
- disabled: loading,
6134
+ disabled: busy,
5276
6135
  onClick: () => {
5277
6136
  const opening = health === null;
5278
6137
  loadHealth();
5279
6138
  if (opening) reveal();
5280
6139
  }
5281
- }), h("div", { className: pages_module_css_default.status }, loading && shown === 0 ? "读取中…" : summary)), props.remote === void 0 ? null : h("div", { className: pages_module_css_default.toolbar }, h("div", { className: `${pages_module_css_default.search} ${pages_module_css_default.searchField}` }, h("input", {
6140
+ }), h("div", { className: controls_module_css_default.meta }, statusLine)), props.remote === void 0 ? null : h("div", { className: pages_module_css_default.toolbar }, h("div", { className: `${pages_module_css_default.search} ${pages_module_css_default.searchField}` }, h("input", {
5282
6141
  value: searchText,
5283
6142
  placeholder: "搜索记忆…(Enter)",
5284
6143
  "aria-label": "搜索记忆",
@@ -5291,120 +6150,115 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
5291
6150
  runSearch();
5292
6151
  }
5293
6152
  }
5294
- }), searchText !== "" || searchHits !== null || searchFactPage !== null || searchRelation !== null ? h("button", {
6153
+ }), searchText !== "" || timeCondition !== void 0 || applied !== null ? h("button", {
5295
6154
  type: "button",
5296
- className: pages_module_css_default.clear,
6155
+ className: controls_module_css_default.clear,
5297
6156
  "aria-label": "清除搜索",
5298
6157
  title: "清除搜索",
5299
6158
  onClick: clearSearch
5300
6159
  }, "×") : null), h("select", {
5301
- className: pages_module_css_default.select,
6160
+ className: controls_module_css_default.select,
5302
6161
  value: searchKind,
5303
6162
  "aria-label": "记忆层",
5304
6163
  onChange: (e) => {
5305
6164
  setSearchKind(e.target.value);
5306
6165
  }
5307
- }, h("option", { value: "all" }, "全部"), h("option", { value: "journal" }, "journal"), h("option", { value: "note" }, "note"))), searchActive ? h("div", { className: pages_module_css_default.searchPanel }, h("div", { className: pages_module_css_default.searchTitle }, searching ? "搜索中…" : searchFactPage !== null ? `多实体事实:${String(searchFactPage.length)} 条${searchKind === "all" ? "" : `(${searchKind})`}` : searchRelation !== null ? `关系结果:${String(searchRelation.paths.length)} 条路径${searchKind === "all" ? "" : `(${searchKind})`}` : `搜索结果:${String(searchHits?.length ?? 0)} 条${searchKind === "all" ? "" : `(${searchKind})`}`, searchText.trim() === "" ? null : h("span", { className: pages_module_css_default.searchQuery }, `“${searchText.trim()}”`)), searchFactPage !== null ? h("div", null, searchFactPage.length === 0 ? h("div", { className: pages_module_css_default.status }, "没有同时提到这些实体的事实。") : h("ul", { className: pages_module_css_default.list }, searchFactPage.map((entry) => h("li", {
5308
- key: `facts-${String(entry.fact_id)}`,
5309
- className: pages_module_css_default.item
5310
- }, entry.title === void 0 ? null : h("div", { className: pages_module_css_default.meta }, `标题:${entry.title}`), editing?.id === entry.fact_id ? h("textarea", {
5311
- className: pages_module_css_default.textarea,
5312
- value: editing.text,
5313
- rows: 3,
6166
+ }, h("option", { value: "all" }, "全部"), h("option", { value: "journal" }, "journal"), h("option", { value: "note" }, "note")), h("select", {
6167
+ className: controls_module_css_default.select,
6168
+ value: timeField,
6169
+ "aria-label": "时间轴",
6170
+ title: "时间轴:created=入库时间 · updated=修改时间 · event=事件发生时间",
5314
6171
  onChange: (e) => {
5315
- setEditing({
5316
- id: entry.fact_id,
5317
- text: e.target.value
5318
- });
6172
+ setTimeField(e.target.value);
5319
6173
  }
5320
- }) : entry.content, entry.truncated === true ? h("span", { className: pages_module_css_default.meta }, "(已截断)") : null, h("span", { className: pages_module_css_default.meta }, `#${String(entry.fact_id)} · ${entry.memory_kind} · ${entry.status} · ${entry.event_date ?? entry.created_at}` + (entry.source_ref === void 0 ? "" : ` · ${entry.source_ref}`)), h("div", { className: pages_module_css_default.row }, factButtons(actionFact(entry.fact_id, entry.content, entry.truncated === true), true)), detail?.id === entry.fact_id ? h(FactDetailView, {
5321
- fact: detail.data,
5322
- disabled: loading,
5323
- onSaveRelations: saveRelations
5324
- }) : null)))) : searchRelation !== null ? h("div", null, searchRelation.paths.length === 0 ? h("div", { className: pages_module_css_default.status }, "没有关系路径。") : h("ul", { className: pages_module_css_default.list }, searchRelation.paths.map((path, index) => h("li", {
5325
- key: `relation-path-${String(index)}`,
5326
- className: pages_module_css_default.item
5327
- }, [path.start.name, ...path.edges.flatMap((edge) => [edge.predicate, edge.object])].join(" → "), h("span", { className: pages_module_css_default.meta }, `${String(path.hops)} 跳 · #${path.edges.map((edge) => String(edge.fact_id)).join(" / ")} · 置信度 ${path.confidence.toFixed(2)}`)))), searchRelation.evidence.length === 0 ? null : h("div", null, h("div", { className: pages_module_css_default.meta }, "证据事实:"), h("ul", { className: pages_module_css_default.list }, searchRelation.evidence.map((entry) => h("li", {
5328
- key: `relation-evidence-${String(entry.fact_id)}`,
5329
- className: pages_module_css_default.item
5330
- }, entry.title === void 0 ? null : h("div", { className: pages_module_css_default.meta }, `标题:${entry.title}`), editing?.id === entry.fact_id ? h("textarea", {
5331
- className: pages_module_css_default.textarea,
5332
- value: editing.text,
5333
- rows: 3,
6174
+ }, TIME_AXES.map((axis) => h("option", {
6175
+ key: axis.value,
6176
+ value: axis.value
6177
+ }, axis.label))), h("select", {
6178
+ className: controls_module_css_default.select,
6179
+ value: timeMode,
6180
+ "aria-label": "时间范围形态",
6181
+ title: "单日 = 这一整天;区间 = from~to(to 当天也包含)",
5334
6182
  onChange: (e) => {
5335
- setEditing({
5336
- id: entry.fact_id,
5337
- text: e.target.value
5338
- });
6183
+ setTimeMode(e.target.value);
5339
6184
  }
5340
- }) : entry.text, entry.truncated === true ? h("span", { className: pages_module_css_default.meta }, "(已截断)") : null, h("span", { className: pages_module_css_default.meta }, `#${String(entry.fact_id)} · ${entry.memory_kind} · ${entry.created_at}`), h("div", { className: pages_module_css_default.row }, factButtons(actionFact(entry.fact_id, entry.text, entry.truncated === true), true)), detail?.id === entry.fact_id ? h(FactDetailView, {
5341
- fact: detail.data,
5342
- disabled: loading,
5343
- onSaveRelations: saveRelations
5344
- }) : null))))) : searchHits === null || searchHits.length === 0 ? h("div", { className: pages_module_css_default.status }, searching ? "正在检索记忆…" : "没有命中。") : h("ul", { className: pages_module_css_default.list }, searchHits.map((hit) => h("li", {
5345
- key: hit.ref_id,
5346
- className: pages_module_css_default.item
5347
- }, hit.title === void 0 ? null : h("div", { className: pages_module_css_default.meta }, `标题:${hit.title}`), editing?.id === hit.ref_id ? h("textarea", {
5348
- className: pages_module_css_default.textarea,
5349
- value: editing.text,
5350
- rows: 3,
6185
+ }, h("option", { value: "on" }, "单日"), h("option", { value: "range" }, "区间")), timeMode === "on" ? h("input", {
6186
+ type: "date",
6187
+ className: controls_module_css_default.dateInput,
6188
+ value: timeOn,
6189
+ "aria-label": "单日日期",
6190
+ title: "这一整天(本地时区)",
5351
6191
  onChange: (e) => {
5352
- setEditing({
5353
- id: hit.ref_id,
5354
- text: e.target.value
5355
- });
6192
+ setTimeOn(e.target.value);
6193
+ }
6194
+ }) : [
6195
+ h("input", {
6196
+ key: "from",
6197
+ type: "date",
6198
+ className: controls_module_css_default.dateInput,
6199
+ value: timeFrom,
6200
+ "aria-label": "起始日期",
6201
+ title: "起始日(包含;留空 = 不限起点)",
6202
+ onChange: (e) => {
6203
+ setTimeFrom(e.target.value);
6204
+ }
6205
+ }),
6206
+ h("span", {
6207
+ key: "sep",
6208
+ className: controls_module_css_default.status
6209
+ }, "~"),
6210
+ h("input", {
6211
+ key: "to",
6212
+ type: "date",
6213
+ className: controls_module_css_default.dateInput,
6214
+ value: timeTo,
6215
+ "aria-label": "结束日期",
6216
+ title: "结束日(包含当天;留空 = 不限终点)",
6217
+ onChange: (e) => {
6218
+ setTimeTo(e.target.value);
6219
+ }
6220
+ })
6221
+ ], h(Btn, {
6222
+ label: searching ? "搜索中…" : "搜索",
6223
+ disabled: searching,
6224
+ onClick: () => {
6225
+ runSearch();
5356
6226
  }
5357
- }) : hit.text, hit.truncated === true ? h("span", { className: pages_module_css_default.meta }, "(已截断)") : null, h("span", { className: pages_module_css_default.meta }, `#${String(hit.ref_id)} · score ${hit.score.toFixed(2)} · ${hit.source_ref}` + (hit.valid_from === void 0 ? "" : ` · ${hit.valid_from}`)), h("div", { className: pages_module_css_default.row }, factButtons(actionFact(hit.ref_id, hit.text, hit.truncated === true), true)), detail?.id === hit.ref_id ? h(FactDetailView, {
5358
- fact: detail.data,
5359
- disabled: loading,
5360
- onSaveRelations: saveRelations
5361
- }) : null)))) : null, error !== void 0 ? h("div", { className: pages_module_css_default.error }, `错误:${error}`) : null, notice !== void 0 ? h("div", { className: pages_module_css_default.status }, notice) : null, props.remote !== void 0 && !searchActive && !loading && error === void 0 && shown === 0 ? h("div", { className: pages_module_css_default.status }, statusFilter === "active" ? "当前没有活动的事实。可在对话里让 agent 用 mem_remember 写入。" : "当前没有归档的事实。") : null, health !== null ? h(HealthBlock, { stats: health }) : null, contradictions !== null ? contradictions.length === 0 ? h("div", { className: pages_module_css_default.status }, "没有未处理的矛盾。") : h("ul", { className: pages_module_css_default.list }, contradictions.map((c) => h("li", {
6227
+ }))), error !== void 0 ? h("div", { className: controls_module_css_default.error }, `错误:${error}`) : null, notice !== void 0 ? h("div", { className: controls_module_css_default.status }, notice) : null, health !== null ? h(HealthBlock, { stats: health }) : null, contradictions !== null ? contradictions.length === 0 ? h("div", { className: controls_module_css_default.status }, "没有未处理的矛盾。") : h("ul", { className: controls_module_css_default.list }, contradictions.map((c) => h("li", {
5362
6228
  key: c.contradiction_id,
5363
- className: pages_module_css_default.item
5364
- }, `#${String(c.fact_a)}「${c.content_a}」 ↔ #${String(c.fact_b)}「${c.content_b}」`, h("span", { className: pages_module_css_default.meta }, `score ${c.score.toFixed(2)} · ${c.detected_at ?? ""}`), props.remote !== void 0 ? h("div", { className: pages_module_css_default.row }, h(Btn, {
6229
+ className: controls_module_css_default.item
6230
+ }, `#${String(c.fact_a)}「${c.content_a}」 ↔ #${String(c.fact_b)}「${c.content_b}」`, h("span", { className: controls_module_css_default.meta }, `score ${c.score.toFixed(2)} · ${localTime(c.detected_at, "")}`), props.remote !== void 0 ? h("div", { className: pages_module_css_default.row }, h(Btn, {
5365
6231
  label: `判 #${String(c.fact_a)} 错`,
5366
6232
  danger: true,
5367
- disabled: loading,
6233
+ disabled: busy,
5368
6234
  onClick: () => resolveConflict(c, "true_positive", c.fact_a)
5369
6235
  }), h(Btn, {
5370
6236
  label: `判 #${String(c.fact_b)} 错`,
5371
6237
  danger: true,
5372
- disabled: loading,
6238
+ disabled: busy,
5373
6239
  onClick: () => resolveConflict(c, "true_positive", c.fact_b)
5374
6240
  }), h(Btn, {
5375
6241
  label: "误报(两条都对)",
5376
- disabled: loading,
6242
+ disabled: busy,
5377
6243
  onClick: () => resolveConflict(c, "false_positive")
5378
- })) : null))) : null, searchActive ? null : h("ul", { className: pages_module_css_default.list }, rows.map((f) => h("li", {
5379
- key: f.fact_id,
5380
- className: pages_module_css_default.item
5381
- }, f.title === null || f.title === void 0 ? f.kind === "journal" ? h("div", { className: pages_module_css_default.meta }, "标题:(无标题)") : null : h("div", { className: pages_module_css_default.meta }, `标题:${f.title}`), editing?.id === f.fact_id ? h("textarea", {
5382
- className: pages_module_css_default.textarea,
5383
- value: editing.text,
5384
- rows: 3,
5385
- onChange: (e) => {
5386
- setEditing({
5387
- id: f.fact_id,
5388
- text: e.target.value
5389
- });
5390
- }
5391
- }) : f.content, h("span", { className: pages_module_css_default.meta }, `#${String(f.fact_id)} · ${f.kind} · trust ${f.trust_score.toFixed(2)}` + (f.pinned === true ? " · 永久" : typeof f.remaining_days === "number" ? ` · 剩余 ≈${String(Math.ceil(f.remaining_days))} 活跃日` : "") + ` · 有用 ${String(f.helpful_count)} · ${f.created_at}` + (f.status === "archived" && f.archive_reason ? ` · 归档原因 ${f.archive_reason}` : "") + (f.status === "archived" && f.pinned === true ? " · 永久保留(不会被清理)" : "")), h("div", { className: pages_module_css_default.row }, factButtons({
5392
- fact_id: f.fact_id,
5393
- status: f.status,
5394
- content: f.content,
5395
- pinned: f.pinned === true
5396
- }, false)), detail?.id === f.fact_id ? h(FactDetailView, {
5397
- fact: detail.data,
5398
- disabled: loading,
5399
- onSaveRelations: saveRelations
5400
- }) : null))), searchActive || !truncated ? null : h("button", {
5401
- type: "button",
5402
- className: pages_module_css_default.more,
5403
- disabled: loading,
5404
- onClick: () => {
5405
- load(nextOffset, statusFilter);
6244
+ })) : null))) : null, h(FactList, {
6245
+ state: {
6246
+ ...list,
6247
+ rows
6248
+ },
6249
+ note: listNote,
6250
+ emptyText,
6251
+ loadingText,
6252
+ busy,
6253
+ editing,
6254
+ onEdit: setEditing,
6255
+ detail,
6256
+ actionRow: factButtons,
6257
+ onSaveRelations: saveRelations,
6258
+ onLoadMore: () => {
6259
+ loadMore();
5406
6260
  }
5407
- }, loading ? "加载中…" : total === void 0 ? "加载更多" : `加载更多(还有 ${String(Math.max(total - shown, 0))} 条)`));
6261
+ }));
5408
6262
  }
5409
6263
  /**
5410
6264
  * Retrieval health (DESIGN §20): the numbers that say whether retrieval is actually working —
@@ -5413,14 +6267,14 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
5413
6267
  */
5414
6268
  function HealthBlock(props) {
5415
6269
  const r = props.stats.retrieval;
5416
- if (r === void 0) return h("div", { className: pages_module_css_default.status }, "检索健康度不可用。");
6270
+ if (r === void 0) return h("div", { className: controls_module_css_default.status }, "检索健康度不可用。");
5417
6271
  const vectors = props.stats.vectors;
5418
6272
  const pendingVectors = vectors === void 0 ? 0 : vectors.stale + vectors.space_stale;
5419
6273
  const wal = props.stats.wal;
5420
6274
  const pct = (v) => `${String(Math.round(v * 100))}%`;
5421
6275
  const kinds = Object.entries(r.by_kind).map(([kind, v]) => `${kind} ${String(v.queries)} 次(空 ${String(v.zero_results)})`).join(" · ");
5422
6276
  const truncated = r.embedding_truncated + r.output_truncated;
5423
- return h("div", { className: pages_module_css_default.detail }, pendingVectors === 0 ? null : h("div", { className: pages_module_css_default.status }, `⚠ 向量空间待迁移:${String(pendingVectors)} 条 ACTIVE 向量来自旧嵌入空间(宽度不符 ${String(vectors?.stale ?? 0)} · 其他表示 ${String(vectors?.space_stale ?? 0)})——语义腿对它们失效,正在后台分批重算;手动入口 \`avantf-mem vectors --fix\``), wal?.warning === null || wal?.warning === void 0 ? null : h("div", { className: pages_module_css_default.status }, `⚠ ${wal.warning}(当前 ${String(wal.bytes)} 字节)`), h("div", { className: pages_module_css_default.status }, `检索 ${String(r.queries)} 次 · 空结果 ${pct(r.zero_result_rate)} · 平均 ${r.avg_latency_ms.toFixed(1)}ms · 峰值 ${r.max_latency_ms.toFixed(1)}ms`), h("div", { className: pages_module_css_default.meta }, `语义腿在线 ${pct(r.semantic_live_rate)} · 平均命中 ${String(r.avg_results_per_query)} 条`), kinds === "" ? null : h("div", { className: pages_module_css_default.meta }, kinds), props.stats.sources === void 0 && props.stats.validity === void 0 ? null : h("div", { className: pages_module_css_default.meta }, `来源覆盖 ${props.stats.sources === void 0 ? "—" : pct(props.stats.sources.coverage)}(${String(props.stats.sources?.facts_with_source ?? 0)}/${String(props.stats.sources?.active ?? 0)}) · 事件时间覆盖 ${props.stats.validity === void 0 ? "—" : pct(props.stats.validity.coverage)}(${String(props.stats.validity?.facts_with_valid_from ?? 0)}/${String(props.stats.validity?.active ?? 0)})`), h("div", { className: pages_module_css_default.meta }, truncated === 0 ? "未被任何预算截断。" : `截断:嵌入 ${String(r.embedding_truncated)} · 输出 ${String(r.output_truncated)}${r.output_truncated > 0 ? "(调大 retrieval.max_output_tokens 或用 max_tokens 参数)" : ""}`), r.updated_at === null ? null : h("div", { className: pages_module_css_default.meta }, `更新于 ${r.updated_at}`));
6277
+ return h("div", { className: pages_module_css_default.detail }, pendingVectors === 0 ? null : h("div", { className: controls_module_css_default.status }, `⚠ 向量空间待迁移:${String(pendingVectors)} 条 ACTIVE 向量来自旧嵌入空间(宽度不符 ${String(vectors?.stale ?? 0)} · 其他表示 ${String(vectors?.space_stale ?? 0)})——语义腿对它们失效,正在后台分批重算;手动入口 \`avantf-mem vectors --fix\``), wal?.warning === null || wal?.warning === void 0 ? null : h("div", { className: controls_module_css_default.status }, `⚠ ${wal.warning}(当前 ${String(wal.bytes)} 字节)`), h("div", { className: controls_module_css_default.status }, `检索 ${String(r.queries)} 次 · 空结果 ${pct(r.zero_result_rate)} · 平均 ${r.avg_latency_ms.toFixed(1)}ms · 峰值 ${r.max_latency_ms.toFixed(1)}ms`), h("div", { className: controls_module_css_default.meta }, `语义腿在线 ${pct(r.semantic_live_rate)} · 平均命中 ${String(r.avg_results_per_query)} 条`), kinds === "" ? null : h("div", { className: controls_module_css_default.meta }, kinds), props.stats.sources === void 0 && props.stats.validity === void 0 ? null : h("div", { className: controls_module_css_default.meta }, `来源覆盖 ${props.stats.sources === void 0 ? "—" : pct(props.stats.sources.coverage)}(${String(props.stats.sources?.facts_with_source ?? 0)}/${String(props.stats.sources?.active ?? 0)}) · 事件时间覆盖 ${props.stats.validity === void 0 ? "—" : pct(props.stats.validity.coverage)}(${String(props.stats.validity?.facts_with_valid_from ?? 0)}/${String(props.stats.validity?.active ?? 0)})`), h("div", { className: controls_module_css_default.meta }, truncated === 0 ? "未被任何预算截断。" : `截断:嵌入 ${String(r.embedding_truncated)} · 输出 ${String(r.output_truncated)}${r.output_truncated > 0 ? "(调大 retrieval.max_output_tokens 或用 max_tokens 参数)" : ""}`), r.updated_at === null ? null : h("div", { className: controls_module_css_default.meta }, `更新于 ${localTime(r.updated_at)}`));
5424
6278
  }
5425
6279
  /** Client cordis-plugin name shown in boot diagnostics. */
5426
6280
  const name = "avantf-mem-client";