@avantf/dsh-mem 0.6.4 → 0.8.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 (150) hide show
  1. package/README.md +19 -10
  2. package/lib/client.js +1665 -3466
  3. package/lib/dsh-build.json +6 -5
  4. package/lib/engine/graph-recall/in-memory.d.ts +93 -0
  5. package/lib/engine/graph-recall/index.d.ts +22 -0
  6. package/lib/engine/graph-recall/traverse.d.ts +119 -0
  7. package/lib/engine/graph-recall/types.d.ts +188 -0
  8. package/lib/engine/mem/config/config_files.d.ts +1 -1
  9. package/lib/engine/mem/config/loader.d.ts +2 -3
  10. package/lib/engine/mem/config/paths.d.ts +2 -13
  11. package/lib/engine/mem/db/dao/contradictions.d.ts +4 -22
  12. package/lib/engine/mem/db/dao/entities.d.ts +80 -7
  13. package/lib/engine/mem/db/dao/fact_sources.d.ts +3 -13
  14. package/lib/engine/mem/db/dao/facts.d.ts +311 -56
  15. package/lib/engine/mem/db/dao/triples.d.ts +1 -26
  16. package/lib/engine/mem/db/graph.d.ts +249 -0
  17. package/lib/engine/mem/db/journal_notes.d.ts +35 -0
  18. package/lib/engine/mem/db/migrations.d.ts +9 -0
  19. package/lib/engine/mem/db/port.d.ts +22 -3
  20. package/lib/engine/mem/db/schema.d.ts +119 -0
  21. package/lib/engine/mem/db/tokenizer.d.ts +9 -0
  22. package/lib/engine/mem/dbdrivers/fake/driver.d.ts +75 -0
  23. package/lib/engine/mem/dbdrivers/fake/index.d.ts +7 -0
  24. package/lib/engine/mem/dbdrivers/fake/surface.d.ts +34 -0
  25. package/lib/engine/mem/dbdrivers/fake/transport.d.ts +61 -0
  26. package/lib/engine/mem/dbdrivers/sqlite/connection.d.ts +14 -0
  27. package/lib/engine/mem/dbdrivers/sqlite/contradictions.d.ts +35 -0
  28. package/lib/engine/mem/dbdrivers/sqlite/dao/index.d.ts +16 -0
  29. package/lib/engine/mem/dbdrivers/sqlite/driver.d.ts +54 -0
  30. package/lib/engine/mem/dbdrivers/sqlite/facts.d.ts +117 -0
  31. package/lib/engine/mem/dbdrivers/sqlite/index.d.ts +6 -0
  32. package/lib/engine/mem/dbdrivers/sqlite/migrations.d.ts +15 -0
  33. package/lib/engine/mem/dbdrivers/sqlite/relations.d.ts +62 -0
  34. package/lib/engine/mem/dbdrivers/sqlite/search.d.ts +56 -0
  35. package/lib/engine/mem/dbdrivers/sqlite/sources.d.ts +37 -0
  36. package/lib/engine/mem/defaults.d.ts +3 -4
  37. package/lib/engine/mem/dispatch.d.ts +7 -32
  38. package/lib/engine/mem/entities/extract.d.ts +44 -47
  39. package/lib/engine/mem/eval/derived_runner.d.ts +6 -1
  40. package/lib/engine/mem/eval/relation_gold.d.ts +251 -0
  41. package/lib/engine/mem/eval/write_metrics.d.ts +1 -1
  42. package/lib/engine/mem/facts_io.d.ts +328 -0
  43. package/lib/engine/mem/index.d.ts +10 -2
  44. package/lib/engine/mem/interfaces/capabilities.d.ts +30 -0
  45. package/lib/engine/mem/interfaces/common.d.ts +11 -0
  46. package/lib/engine/mem/interfaces/errors.d.ts +65 -0
  47. package/lib/engine/mem/interfaces/index.d.ts +18 -0
  48. package/lib/engine/mem/interfaces/migrations.d.ts +21 -0
  49. package/lib/engine/mem/interfaces/repositories.d.ts +471 -0
  50. package/lib/engine/mem/interfaces/search_index.d.ts +113 -0
  51. package/lib/engine/mem/interfaces/storage_backend.d.ts +84 -0
  52. package/lib/engine/mem/interfaces/storage_driver.d.ts +43 -0
  53. package/lib/engine/mem/interfaces/version.d.ts +34 -0
  54. package/lib/engine/mem/lifecycle/contradiction.d.ts +2 -2
  55. package/lib/engine/mem/maintenance_paths.d.ts +41 -0
  56. package/lib/engine/mem/modelBootstrap.d.ts +1 -1
  57. package/lib/engine/mem/relations/confidence.d.ts +39 -0
  58. package/lib/engine/mem/relations/extract.d.ts +128 -0
  59. package/lib/engine/mem/relations/index.d.ts +32 -0
  60. package/lib/engine/mem/relations/name-quality.d.ts +78 -0
  61. package/lib/engine/mem/relations/normalize.d.ts +275 -0
  62. package/lib/engine/mem/relations/patterns.d.ts +67 -0
  63. package/lib/engine/mem/relations/statement.d.ts +88 -0
  64. package/lib/engine/mem/relations/types.d.ts +79 -0
  65. package/lib/engine/mem/runtime.d.ts +117 -90
  66. package/lib/engine/mem/storage/create.d.ts +42 -0
  67. package/lib/engine/mem/storage/errors.d.ts +65 -0
  68. package/lib/engine/mem/storage/index.d.ts +15 -0
  69. package/lib/engine/mem/storage/ports.d.ts +472 -0
  70. package/lib/engine/mem/storage/registry.d.ts +37 -0
  71. package/lib/engine/mem/storage/sqlite.d.ts +28 -0
  72. package/lib/engine/mem/store/common.d.ts +1 -1
  73. package/lib/engine/mem/store/entity_leg.d.ts +59 -2
  74. package/lib/engine/mem/store/entity_sweep.d.ts +4 -3
  75. package/lib/engine/mem/store/explicit_relations.d.ts +52 -0
  76. package/lib/engine/mem/store/facts_restore.d.ts +124 -0
  77. package/lib/engine/mem/store/floors.d.ts +4 -1
  78. package/lib/engine/mem/store/graph_reader.d.ts +146 -0
  79. package/lib/engine/mem/store/hybrid.d.ts +50 -13
  80. package/lib/engine/mem/store/kinds.d.ts +47 -0
  81. package/lib/engine/mem/store/legs.d.ts +24 -29
  82. package/lib/engine/mem/store/lexical.d.ts +36 -45
  83. package/lib/engine/mem/store/memory.d.ts +486 -82
  84. package/lib/engine/mem/store/recall_policy.d.ts +49 -0
  85. package/lib/engine/mem/store/relation_backfill.d.ts +210 -0
  86. package/lib/engine/mem/store/relation_planner.d.ts +191 -0
  87. package/lib/engine/mem/store/secret_guard.d.ts +0 -1
  88. package/lib/engine/mem/store/self_query.d.ts +4 -2
  89. package/lib/engine/mem/store/time_filter.d.ts +121 -0
  90. package/lib/engine/mem/store/titles.d.ts +52 -0
  91. package/lib/engine/mem/store/vector_repair.d.ts +44 -54
  92. package/lib/engine/mem-contract/config.d.ts +44 -38
  93. package/lib/engine/mem-contract/text.d.ts +1 -1
  94. package/lib/engine/mem-contract/tools.d.ts +184 -117
  95. package/lib/engine/mem-contract/types.d.ts +364 -288
  96. package/lib/index.js +13898 -11500
  97. package/lib/interface-version.json +1 -1
  98. package/lib/types/client/index.d.ts +2 -2
  99. package/lib/types/client/index.d.ts.map +1 -1
  100. package/lib/types/client/paging.d.ts +3 -12
  101. package/lib/types/client/paging.d.ts.map +1 -1
  102. package/lib/types/client/summarize.d.ts.map +1 -1
  103. package/lib/types/command.d.ts +61 -0
  104. package/lib/types/command.d.ts.map +1 -0
  105. package/lib/types/envinit-bootstrap.d.ts +1 -1
  106. package/lib/types/envinit.d.ts +2 -6
  107. package/lib/types/envinit.d.ts.map +1 -1
  108. package/lib/types/hints.d.ts +28 -24
  109. package/lib/types/hints.d.ts.map +1 -1
  110. package/lib/types/index.d.ts +21 -40
  111. package/lib/types/index.d.ts.map +1 -1
  112. package/lib/types/inject.d.ts +3 -2
  113. package/lib/types/inject.d.ts.map +1 -1
  114. package/lib/types/interface_gate.d.ts +9 -9
  115. package/lib/types/prompt.d.ts +17 -41
  116. package/lib/types/prompt.d.ts.map +1 -1
  117. package/lib/types/provision.d.ts +3 -0
  118. package/lib/types/provision.d.ts.map +1 -1
  119. package/lib/types/remote.d.ts +23 -9
  120. package/lib/types/remote.d.ts.map +1 -1
  121. package/lib/types/render.d.ts +3 -3
  122. package/lib/types/tool_schema.d.ts +4 -0
  123. package/lib/types/tool_schema.d.ts.map +1 -1
  124. package/lib/types/vectorMigration.d.ts +5 -7
  125. package/lib/types/vectorMigration.d.ts.map +1 -1
  126. package/package.json +16 -15
  127. package/lib/engine/mem/config/domains.d.ts +0 -20
  128. package/lib/engine/mem/db/dao/chunks.d.ts +0 -231
  129. package/lib/engine/mem/db/dao/documents.d.ts +0 -51
  130. package/lib/engine/mem/db/hash.d.ts +0 -2
  131. package/lib/engine/mem/db/knowledge.d.ts +0 -17
  132. package/lib/engine/mem/eval/index.d.ts +0 -4
  133. package/lib/engine/mem/git.d.ts +0 -73
  134. package/lib/engine/mem/router.d.ts +0 -51
  135. package/lib/engine/mem/store/doc_files.d.ts +0 -125
  136. package/lib/engine/mem/store/document_text.d.ts +0 -47
  137. package/lib/engine/mem/store/ingest_guard.d.ts +0 -25
  138. package/lib/engine/mem/store/knowledge.d.ts +0 -598
  139. package/lib/engine/mem/store/source_picker.d.ts +0 -43
  140. package/lib/engine/mem-retrieval/adapters/local_reranker.d.ts +0 -52
  141. package/lib/engine/mem-retrieval/adapters/none_reranker.d.ts +0 -10
  142. package/lib/engine/mem-retrieval/rerank.d.ts +0 -13
  143. package/lib/types/client/domains.d.ts +0 -53
  144. package/lib/types/client/domains.d.ts.map +0 -1
  145. package/lib/types/client/ingest.d.ts +0 -24
  146. package/lib/types/client/ingest.d.ts.map +0 -1
  147. package/lib/types/open.d.ts +0 -77
  148. package/lib/types/open.d.ts.map +0 -1
  149. package/lib/types/reconcile.d.ts +0 -64
  150. package/lib/types/reconcile.d.ts.map +0 -1
@@ -6,8 +6,48 @@
6
6
  * would exceed SQLite's bind-parameter cap and, on the write path, silently disable
7
7
  * contradiction detection (see `db/chunk.ts`).
8
8
  */
9
+ import type { MemoryKind } from '../../../mem-contract/index.js';
9
10
  import type { Db } from '../port.js';
10
11
  import type { ExtractedEntity } from '../../entities/extract.js';
12
+ import type { TimeSql } from '../../store/time_filter.js';
13
+ import { type SurfaceMatch } from '../../store/relation_planner.js';
14
+ import type { FactContainingPage, FactsContainingAllQuery } from '../../interfaces/repositories.js';
15
+ /**
16
+ * The two vocabulary scans behind {@link EntitiesDao.factEntitiesIn}, as constants because the
17
+ * benchmark (`scripts/bench-facts.mjs`) EXPLAINs them: their `EXISTS` subquery is DRIVEN BY THE
18
+ * PLANNER'S STATISTICS, so a timing without the plan that produced it is not comparable. With no
19
+ * `sqlite_stat1` SQLite drives the EXISTS from `facts` by `status` (every ACTIVE fact visited once
20
+ * per matched name); with full statistics it seeks `idx_fact_entities_entity` instead. The same
21
+ * arrangement as `facts.ts#FTS_SEARCH_SQL`, and for the same reason: the measured SQL must be the
22
+ * text production runs, not a copy that can drift.
23
+ */
24
+ export declare const FACT_ENTITIES_IN_SQL = "SELECT n.name AS name\n FROM entities n\n WHERE instr(?, n.name) > 0\n AND EXISTS (\n SELECT 1\n FROM fact_entities fe\n JOIN facts fa ON fa.fact_id = fe.fact_id\n WHERE fe.entity_id = n.entity_id AND fa.status = 'active'\n )";
25
+ /** The alias half of {@link FACT_ENTITIES_IN_SQL} (aliases first, see `factEntitiesIn`). */
26
+ export declare const FACT_ENTITY_ALIASES_IN_SQL = "SELECT a.alias AS alias, n.name AS name\n FROM entity_aliases a\n JOIN entities n ON n.entity_id = a.entity_id\n WHERE instr(?, a.alias) > 0\n AND EXISTS (\n SELECT 1\n FROM fact_entities fe\n JOIN facts fa ON fa.fact_id = fe.fact_id\n WHERE fe.entity_id = a.entity_id AND fa.status = 'active'\n )";
27
+ /** One statement plus its positional binds, exactly as production runs it. */
28
+ export interface FactAndStatement {
29
+ sql: string;
30
+ params: unknown[];
31
+ }
32
+ /**
33
+ * The two statements one fact-level AND page runs — the `total` count and the page itself — as text
34
+ * plus binds, built from the RESOLVED ids.
35
+ *
36
+ * A function (not a constant like {@link FACT_ENTITIES_IN_SQL}) because the placeholder count, the
37
+ * time clause and limit/offset are per-call, and EXPORTED for the same reason as that constant: the
38
+ * benchmark (`scripts/bench-facts.mjs`) EXPLAINs the statements production runs, so the recorded plan
39
+ * cannot drift from the timed query. The AND page's plan is statistics-dependent too — a timing
40
+ * without the plan that produced it is not comparable.
41
+ *
42
+ * Bind order is the text's order: entity ids, kind ×2, source ×2, time params, the AND cardinality,
43
+ * then (page only) limit/offset.
44
+ */
45
+ export declare function factsContainingAllStatements(db: Db, query: Omit<FactsContainingAllQuery, 'time'> & {
46
+ time?: TimeSql;
47
+ }): {
48
+ total: FactAndStatement;
49
+ page: FactAndStatement;
50
+ } | null;
11
51
  export declare class EntitiesDao {
12
52
  private readonly db;
13
53
  constructor(db: Db);
@@ -61,11 +101,6 @@ export declare class EntitiesDao {
61
101
  * whole corpus. The hint makes the plan independent of ANALYZE timing.
62
102
  */
63
103
  candidateFacts(factId: number, minShared: number, maxEntities: number): number[];
64
- /** Active facts linked to `name`, optionally limited to one category (`related` leg). */
65
- activeFactsForEntity(name: string, category?: string): number[];
66
- /** Active facts linked to ALL of `names` (AND-join, `reason` leg). */
67
- activeFactsForAllEntities(names: readonly string[]): number[];
68
- /** Active facts sharing ANY of `names`, in one category (the entity leg). */
69
104
  /**
70
105
  * Candidate facts for an entity query, ordered by the leg's score and CAPPED.
71
106
  *
@@ -82,10 +117,48 @@ export declare class EntitiesDao {
82
117
  * deterministic tie-break among equal ratios. The caller still recomputes the score in JS for the
83
118
  * survivors, so one formula stays in charge.
84
119
  */
85
- candidateFactsForAnyEntity(names: readonly string[], category: string | undefined, limit: number, queryWidth: number, widthCap: number,
120
+ candidateFactsForAnyEntity(names: readonly string[], kind: MemoryKind | undefined, limit: number, queryWidth: number, widthCap: number,
86
121
  /**
87
122
  * P-08 provenance filter (`fact_sources.ref`). `EXISTS`, never a JOIN: a fact with several
88
123
  * sources would fan out into several rows and eat the leg's `LIMIT`, silently shrinking recall.
89
124
  */
90
- source?: string): number[];
125
+ source?: string,
126
+ /**
127
+ * The explicit `mem_recall.time` predicate (see `store/time_filter.ts`). It is part of the
128
+ * statement rather than a post-fusion filter for the same reason as P-08's `source`: the leg's
129
+ * `LIMIT` must apply WITHIN the window, or window-external facts eat the cap and the in-window
130
+ * ones never reach fusion.
131
+ */
132
+ time?: TimeSql): number[];
133
+ /**
134
+ * The FACT-level entity vocabulary for the query text (docs/GRAPH_RECALL_AND_SEMANTICS.md §7.9).
135
+ *
136
+ * Canonical names and registered aliases are BOTH reported (alias-first is what `resolveNode` and
137
+ * `relations/normalize.ts` do, so an alias and its canonical form are one entity to the planner),
138
+ * and both are narrowed to entities linked to at least one ACTIVE fact. A name whose facts are all
139
+ * archived is not part of the live corpus: reporting it would form a plan that can only come back
140
+ * empty, which is the fuzzy fallback with extra work.
141
+ *
142
+ * NOT the graph reader's `entitiesIn`: that one requires an `edges` row, so a journal entry (which
143
+ * never has one) or a note whose extraction produced no relation would be invisible here — exactly
144
+ * the facts a fact-level AND must cover. The `kind`/`source`/`time` filters are deliberately not
145
+ * applied (the caller's page applies them, and an empty page falls back to fuzzy).
146
+ */
147
+ factEntitiesIn(text: string): SurfaceMatch[];
148
+ /**
149
+ * One fact-level AND page (docs/GRAPH_RECALL_AND_SEMANTICS.md §7.9): ACTIVE facts that mention
150
+ * EVERY requested entity.
151
+ *
152
+ * The entity names are resolved to ids alias-first (`resolveNode`'s order), deduplicated, and an
153
+ * UNKNOWN name ends the query with an empty page: "all of these entities appear" cannot hold when
154
+ * one of them does not exist. The grouping is the AND itself — `COUNT(DISTINCT fe.entity_id)`
155
+ * equals the number of distinct entities — so a fact carrying some of them is not a candidate,
156
+ * unlike the entity leg's `minSupport`.
157
+ *
158
+ * `ORDER BY fact_id DESC` is the exact page's own convention (newest row first), and
159
+ * `total`/`offset`/`limit` are the FACT-level page, not an edge page.
160
+ */
161
+ factsContainingAll(query: Omit<FactsContainingAllQuery, 'time'> & {
162
+ time?: TimeSql;
163
+ }): FactContainingPage;
91
164
  }
@@ -1,14 +1,3 @@
1
- /**
2
- * `fact_sources` — where a memory fact came from (P-08).
3
- *
4
- * One row per `(fact_id, kind, ref)`. `kind` is a closed vocabulary (`session` / `kb_doc` /
5
- * `tool` / `manual`) so a reader can trust what a reference IS; `ref` is opaque to this layer
6
- * (a session id, a document id, a tool name, or a free-form note).
7
- *
8
- * The SQL lives here like every other aggregate. The store decides WHICH source a write carries
9
- * (and refuses to invent one when the caller gave none); the DAO only records and reads rows.
10
- */
11
- import type { FactSourceView } from '../../../mem-contract/index.js';
12
1
  import type { Db } from '../port.js';
13
2
  /**
14
3
  * The kind axis is the CONTRACT's, not a second list: `FactSourceView['kind']` is what `admin detail`
@@ -16,7 +5,8 @@ import type { Db } from '../port.js';
16
5
  * vocabulary" one thing. (Before, the array here and the union in `types.ts` were two independent
17
6
  * spellings that happened to agree.)
18
7
  */
19
- export type FactSourceKind = FactSourceView['kind'];
8
+ export type { FactSourceKind } from '../../interfaces/repositories.js';
9
+ import type { FactSourceKind } from '../../interfaces/repositories.js';
20
10
  /**
21
11
  * The closed `kind` vocabulary, spelled once so the store and the tests share it.
22
12
  *
@@ -42,7 +32,7 @@ export interface FactSource {
42
32
  * `kind:` prefix — `session:…` / `kb_doc:…` / `tool:…` — and anything else, INCLUDING the fallback
43
33
  * kind's own name, is `manual` with the reference kept VERBATIM. No shape guessing: a bare
44
34
  * `domain:source:title` becomes a `manual` source whose ref is exactly what the caller wrote, which
45
- * is strictly more honest than inventing a category from how the string looks.
35
+ * is strictly more honest than inventing a provenance kind from how the string looks.
46
36
  *
47
37
  * The prefix table comes from {@link FACT_SOURCE_KINDS} (see {@link FACT_SOURCE_PREFIX_KINDS}) rather
48
38
  * than from a hand-written alternation, which is what keeps the recognized set and the recorded set
@@ -6,6 +6,8 @@
6
6
  * the store ends up holding business rules only and no SQL. See DESIGN §19.
7
7
  */
8
8
  import type { Db } from '../port.js';
9
+ import { type TimeRange, type TimeSql } from '../../store/time_filter.js';
10
+ import type { MemoryKind } from '../../../mem-contract/index.js';
9
11
  /**
10
12
  * Every `facts` column EXCEPT the two BLOBs (`hrr_vector` ~8 KB, `semantic_vector` ~2 KB per row)
11
13
  * and the two DEPRECATED provenance columns (`mirror_source` / `mirror_target`, see below).
@@ -26,39 +28,8 @@ import type { Db } from '../port.js';
26
28
  * `test/db_lifecycle.spec.ts` asserts this list against `PRAGMA table_info(facts)` MINUS those
27
29
  * two deprecations, so a new column fails a test instead of silently vanishing from these reads.
28
30
  */
29
- export declare const FACT_COLUMNS_NO_BLOB = "fact_id, content, category, tags, trust_score, settle_clock,\n pinned, pinned_at, bonus_count, bonus_window_at, last_reinforced_at, archived_clock,\n retrieval_count, helpful_count, last_retrieved_at, embedding_model, vector_store, status,\n supersedes_id, entities_version, conflict_checked, archived_at, archive_reason, ttl_days,\n valid_from, valid_to, assert_count, created_at, updated_at";
30
- /**
31
- * The FTS leg's exact SQL, exported so the plan guard (`test/fts_plan.spec.ts`) and the bench
32
- * control group EXPLAIN the production text itself instead of a copy that can silently drift.
33
- *
34
- * `NOT INDEXED` on the `facts` join is LOAD-BEARING, not decoration — this query is the one place
35
- * where a healthy-looking covering index (`idx_facts_status_category (status, category)`) is the
36
- * WRONG drive. Without planner statistics SQLite cannot know that `MATCH` is the selective access
37
- * path, so it drives from `facts` and RE-RUNS the whole external-content MATCH once per row:
38
- *
39
- * bad : SEARCH fa USING COVERING INDEX idx_facts_status_category (status=?)
40
- * | SCAN f VIRTUAL TABLE INDEX 0:=M1 <- the inner `=M1`
41
- * good: SCAN f VIRTUAL TABLE INDEX 0:M1
42
- * | SEARCH fa USING INTEGER PRIMARY KEY (rowid=?)
43
- *
44
- * Measured on the synthetic corpus at n=1000 (selective query, ~10 hits) in the fresh-session
45
- * shape: 574 ms → 4.6 ms for the leg; the hint's `LIMIT 1` shape is the SAME statement, 568 ms
46
- * → 1.9 ms.
47
- *
48
- * Why the missing statistics are not a startup-only blip: the open tick's `PRAGMA optimize` writes
49
- * no stats for an EMPTY database, so on a fresh install the whole first session runs without them
50
- * while the corpus grows in it; the next tick (≤60 min heartbeat) or a restart is what writes them.
51
- * `bench-memory` used to hide this because it seeds, then REOPENS the runtime (whose open tick
52
- * analyzes). Hence "ANALYZE facts at open when `sqlite_stat1` lacks a `facts` row and `facts` has
53
- * rows" does NOT close the window either: at open the fresh database has zero rows (the guard is
54
- * false), and once rows exist the next optimize is up to an hour away. `NOT INDEXED` pins the
55
- * FTS-driven plan independently of `sqlite_stat1`, so there is no window at all. It cannot change
56
- * results — only the join order — because the rowid join to `facts` is an INTEGER PRIMARY KEY
57
- * lookup (still available under `NOT INDEXED`), and `status`/`category` stay residual filters on
58
- * that one joined row, exactly as in the good plan above. Same spirit as §19 `page()`'s
59
- * `INDEXED BY`, which pins the plan against `PRAGMA optimize` flipping it back.
60
- */
61
- export declare const FTS_SEARCH_SQL = "SELECT f.rowid AS id, bm25(facts_fts) AS rank FROM facts_fts f\n JOIN facts fa NOT INDEXED ON fa.fact_id = f.rowid\n WHERE facts_fts MATCH ? AND fa.status = 'active' AND (? IS NULL OR fa.category = ?)\n ORDER BY rank ASC\n LIMIT ?";
31
+ export declare const FACT_COLUMNS_NO_BLOB = "fact_id, content, kind, title, tags, trust_score, settle_clock,\n pinned, pinned_at, bonus_count, bonus_window_at, last_reinforced_at, archived_clock,\n retrieval_count, helpful_count, last_retrieved_at, embedding_model, vector_store, status,\n supersedes_id, entities_version, conflict_checked, archived_at, archive_reason, ttl_days,\n valid_from, valid_to, valid_from_ms, valid_to_ms, assert_count, created_at, updated_at";
32
+ export declare const FTS_SEARCH_SQL: string;
62
33
  /**
63
34
  * {@link FTS_SEARCH_SQL} with the P-08 source predicate, as a SEPARATE statement rather than one
64
35
  * more `(? IS NULL OR …)` branch in the shared text.
@@ -73,7 +44,106 @@ export declare const FTS_SEARCH_SQL = "SELECT f.rowid AS id, bm25(facts_fts) AS
73
44
  * rows, and this leg is `ORDER BY bm25 … LIMIT cap` — the duplicates would eat cap slots and shrink
74
45
  * recall behind the caller's back.
75
46
  */
76
- export declare const FTS_SEARCH_SQL_FILTERED = "SELECT f.rowid AS id, bm25(facts_fts) AS rank FROM facts_fts f\n JOIN facts fa NOT INDEXED ON fa.fact_id = f.rowid\n WHERE facts_fts MATCH ? AND fa.status = 'active' AND (? IS NULL OR fa.category = ?)\n AND (? IS NULL OR EXISTS (\n SELECT 1 FROM fact_sources fs WHERE fs.fact_id = fa.fact_id AND fs.ref = ?\n ))\n ORDER BY rank ASC\n LIMIT ?";
47
+ export declare const FTS_SEARCH_SQL_FILTERED: string;
48
+ /**
49
+ * One row of a TITLE search (`mem_recall.titles`), as the DAO returns it.
50
+ *
51
+ * `title` is non-null here by construction (`title IS NOT NULL`); the store maps the row onto the
52
+ * contract's `TitleCandidate`, which is where `kind` is renamed to `memory_kind` and `valid_from` to
53
+ * `event_date`. `excerpt` is the content's first 200 characters, computed in SQL so a candidate page
54
+ * never drags whole documents through the driver.
55
+ *
56
+ * `matched_title` is the title text the query actually matched — the CURRENT title, or one of the
57
+ * aliases a corrected transaction used to carry (v17). It equals `title` unless the row was found
58
+ * through an old handle, which is exactly what the caller needs to know before it decides "same
59
+ * transaction or not".
60
+ */
61
+ export interface TitleRow {
62
+ fact_id: number;
63
+ title: string;
64
+ matched_title: string;
65
+ kind: string;
66
+ status: string;
67
+ created_at: string | null;
68
+ updated_at: string | null;
69
+ valid_from: string | null;
70
+ excerpt: string | null;
71
+ }
72
+ /**
73
+ * The filters BOTH title statements apply — one shape, so the FTS path and the LIKE fallback cannot
74
+ * drift into two meanings of `status`/`kind`/`time`.
75
+ *
76
+ * `status: 'all'` is the one value that adds no predicate (it is what `titles`'s documented
77
+ * "include the old revisions" means); `kind` undefined means no layer restriction — the STORE applies
78
+ * the action's `journal` default, because that is a product decision, not a SQL one.
79
+ */
80
+ export interface TitleFilters {
81
+ kind?: MemoryKind;
82
+ status: 'active' | 'archived' | 'all';
83
+ time?: TimeSql;
84
+ }
85
+ export interface TitleQuery extends TitleFilters {
86
+ /** The normalized query, used for the exact/prefix comparison. */
87
+ query: string;
88
+ /** The query's keywords, used by `matched_terms` and by the LIKE fallback. */
89
+ terms: readonly string[];
90
+ limit: number;
91
+ }
92
+ /** The exact-title filter: the same `kind`/`status`/`time` axis, one literal query. */
93
+ export interface ExactTitleQuery extends TitleFilters {
94
+ query: string;
95
+ }
96
+ /** The exact-content filter (docs/RECALL_INTERFACE.md §2.2): one literal substring, active rows only. */
97
+ export interface ExactContentQuery {
98
+ /** The normalized literal a fact's content must CONTAIN. */
99
+ query: string;
100
+ kind?: MemoryKind;
101
+ source?: string;
102
+ time?: TimeSql;
103
+ }
104
+ /**
105
+ * One SQL text WITH its bind values — the ONLY way the title statements leave this module.
106
+ *
107
+ * The caller used to bind `rank.params`, the MATCH expression, the filter values and the LIMIT by
108
+ * hand, which meant the parameter order was a comment instead of a fact (REVIEW-2026-10-24 P2). The
109
+ * builders below assemble the values in the order the `?` placeholders appear in the text they
110
+ * produce, so a statement and its params cannot come from two different shapes.
111
+ */
112
+ export interface TitleStatement {
113
+ readonly sql: string;
114
+ readonly params: (string | number | null)[];
115
+ }
116
+ /** The status/kind/time predicate text plus its bind values, built by {@link titleFilter}. */
117
+ export interface TitleFilterSql {
118
+ readonly sql: string;
119
+ readonly params: (string | number | null)[];
120
+ }
121
+ /** What the fuzzy title statements need from their caller (everything but the SQL itself). */
122
+ export interface TitleFuzzyInput {
123
+ /** The normalized query (the `=`/prefix comparison). */
124
+ readonly query: string;
125
+ /** The query's keywords (the LIKE predicate and `matched_terms`). */
126
+ readonly terms: readonly string[];
127
+ /** The status/kind/time predicate, over the `fa.` alias both statements use for `facts`. */
128
+ readonly filter: TitleFilterSql;
129
+ readonly limit: number;
130
+ }
131
+ /** {@link TitleFuzzyInput} plus the FTS5 MATCH expression from `store/titles.ts#titleFtsQuery`. */
132
+ export interface TitleFtsSearchInput extends TitleFuzzyInput {
133
+ readonly fts: string;
134
+ }
135
+ /**
136
+ * The FTS title statement, exported so the plan guard (`test/journal_title.spec.ts`) EXPLAINs the
137
+ * production text (and binds the production params) instead of a copy — the same arrangement
138
+ * {@link FTS_SEARCH_SQL} uses.
139
+ */
140
+ export declare function titleFtsSearchSql(input: TitleFtsSearchInput): TitleStatement;
141
+ /**
142
+ * The short-query fallback: a `title LIKE` scan instead of the FTS index, plus the same alias
143
+ * source — a corrected chain's 1–2 character old handle has to stay findable too, and the trigram
144
+ * table cannot express a run that short.
145
+ */
146
+ export declare function titleLikeSearchSql(input: TitleFuzzyInput): TitleStatement;
77
147
  export declare class FactsDao {
78
148
  private readonly db;
79
149
  constructor(db: Db);
@@ -110,12 +180,19 @@ export declare class FactsDao {
110
180
  rowsByIds(ids: readonly number[]): Record<string, unknown>[];
111
181
  /** `fact_id → content` for a hit set (never N+1). */
112
182
  textsByIds(ids: readonly number[]): Map<number, string>;
113
- /** `fact_id → created_at/updated_at/validity` for a hit set (the recall hit's timestamps). */
114
- timesByIds(ids: readonly number[]): Map<number, {
183
+ /**
184
+ * `fact_id →` the scalar fields a `RecallHit` renders (timestamps, validity, title) for a hit set.
185
+ *
186
+ * Renamed from `timesByIds` when `title` joined the projection: the read is the hit's META, not
187
+ * only its clocks. One statement for the whole hit set — never N+1 — and the title arrives here
188
+ * rather than in a second query because it is on the same row.
189
+ */
190
+ hitMetaByIds(ids: readonly number[]): Map<number, {
115
191
  created_at: string;
116
192
  updated_at: string | null;
117
193
  valid_from: string | null;
118
194
  valid_to: string | null;
195
+ title: string | null;
119
196
  }>;
120
197
  /** Active rows that carry a persisted vector (index classification). */
121
198
  activeVectorRows(): {
@@ -162,7 +239,7 @@ export declare class FactsDao {
162
239
  */
163
240
  insertRevision(v: {
164
241
  content: string;
165
- category: string;
242
+ kind: MemoryKind;
166
243
  ttlDays: number;
167
244
  supersedesId: number | null;
168
245
  hrr: Uint8Array;
@@ -172,6 +249,12 @@ export declare class FactsDao {
172
249
  pinnedAt: string | null;
173
250
  windowAt: string;
174
251
  createdAt: string;
252
+ /**
253
+ * The journal title of this revision (JOURNAL_TITLE_SEARCH §2.2). `null` = none (a note, a
254
+ * legacy journal, a CLI/import write) — and that is why the column is nullable. The FTS index
255
+ * follows through the `facts_title_ai` trigger; this statement never touches it by hand.
256
+ */
257
+ title?: string | null;
175
258
  /**
176
259
  * P-13: the caller's event time / known end, written straight onto the new row. `null` means
177
260
  * "unknown" and is what an omitted argument leaves (the column has no default on purpose).
@@ -180,6 +263,15 @@ export declare class FactsDao {
180
263
  */
181
264
  validFrom?: string | null;
182
265
  validTo?: string | null;
266
+ /**
267
+ * The INSTANTS the two text bounds name (`valid_from_ms`/`valid_to_ms`).
268
+ *
269
+ * Written in the same statement as the text, never computed later: the text is what a reader
270
+ * displays and the epoch is what an `event` window compares, so writing them apart would let the
271
+ * two describe different times. `null` = "no event time" (the axis then excludes the row, D5).
272
+ */
273
+ validFromMs?: number | null;
274
+ validToMs?: number | null;
183
275
  }): {
184
276
  changes: number;
185
277
  lastInsertRowid: number | bigint;
@@ -187,9 +279,15 @@ export declare class FactsDao {
187
279
  /**
188
280
  * P-10: land an explicitly given validity on a row that already exists (a duplicate `add` whose
189
281
  * caller named `event_date` / `valid_until`). Omitted arguments keep what the row has, exactly
190
- * like `category`/`ttl_days` (see `add`'s doc).
282
+ * like `kind`/`ttl_days` (see `add`'s doc).
191
283
  */
192
- applyValidity(factId: number, validFrom: string | undefined, validTo: string | undefined): void;
284
+ applyValidity(factId: number, validFrom: {
285
+ text: string;
286
+ ms: number;
287
+ } | undefined, validTo: {
288
+ text: string;
289
+ ms: number;
290
+ } | undefined): void;
193
291
  /** P-10: the row's assertion counter (`1` when the column is somehow NULL on a legacy row). */
194
292
  assertCount(factId: number): number;
195
293
  /**
@@ -206,7 +304,7 @@ export declare class FactsDao {
206
304
  * contradiction verdict). `COALESCE` keeps an end the caller already declared (`valid_until`):
207
305
  * overwriting it with the archival instant would replace a real answer with an administrative one.
208
306
  */
209
- markValidTo(factId: number, at: string): void;
307
+ markValidTo(factId: number, at: string, atMs?: number | null): void;
210
308
  /**
211
309
  * P-07: "who superseded this fact", derived from the existing `supersedes_id` link — no
212
310
  * redundant column. `MIN` is deterministic when several revisions named the same predecessor
@@ -240,7 +338,7 @@ export declare class FactsDao {
240
338
  * `EXISTS`, never a JOIN on `fact_sources` (the {@link FTS_SEARCH_SQL_FILTERED} rule): the cap is
241
339
  * `LIMIT`, and a multi-source fact would eat cap slots with duplicate rows.
242
340
  */
243
- windowRows(fromDate: string, toDate: string, category: string | undefined, cap: number, source?: string): number[];
341
+ windowRows(fromDate: string, toDate: string, kind: MemoryKind | undefined, cap: number, source?: string): number[];
244
342
  findByContent(content: string): Record<string, unknown> | undefined;
245
343
  /**
246
344
  * ACTIVE facts whose entity/triple rows were produced by OLDER extraction rules.
@@ -254,10 +352,15 @@ export declare class FactsDao {
254
352
  * sorted every matching row in a temp b-tree before applying the LIMIT — i.e. the budget
255
353
  * bounded what was rebuilt but not what was read. This order also drains the oldest vintage
256
354
  * first, which is the more useful one when several rule changes are pending.
355
+ *
356
+ * `kind` rides along because the sweep's edge write depends on it (STRUCTURED_RELATIONS §四: a
357
+ * journal gets no graph edges). Reading it here costs nothing — the row is already being decoded,
358
+ * and a per-fact lookup would be one more query per sweep item.
257
359
  */
258
360
  staleEntityRows(version: number, limit: number): {
259
361
  fact_id: number;
260
362
  content: string;
363
+ kind: string;
261
364
  }[];
262
365
  countStaleEntities(version: number): number;
263
366
  /** Stamp the rules that produced a batch of rows' entity/triple data. */
@@ -316,9 +419,47 @@ export declare class FactsDao {
316
419
  */
317
420
  requeueConflictCheck(ids: readonly number[]): number;
318
421
  /** Revive an archived row (R18/R19: refresh the idle clock, clear the archive bookkeeping). */
319
- revive(factId: number, category: string, ttlDays: number, trust: number, clock: number): number;
320
- /** Land explicit category/TTL on a row that already exists (pure duplicate). */
321
- applyValues(factId: number, category: string, ttlDays: number): void;
422
+ revive(factId: number, kind: MemoryKind, ttlDays: number, trust: number, clock: number): number;
423
+ /** Land explicit kind/TTL on a row that already exists (pure duplicate). */
424
+ applyValues(factId: number, kind: MemoryKind, ttlDays: number): void;
425
+ /**
426
+ * Land a title on a row that already exists — the ONE write behind every "backfill" path
427
+ * (JOURNAL_TITLE_SEARCH §11.2/§11.3): a duplicate `add` that names a title for an existing
428
+ * untitled row, and an update that merges into another row whose title is NULL.
429
+ *
430
+ * It MUST be an `UPDATE`, not a second `INSERT OR IGNORE`: the insert trigger only fires for a row
431
+ * that is actually inserted, so a backfill written that way would leave `facts_title_fts` holding
432
+ * the old (empty) title forever. `AFTER UPDATE OF title` is what moves the index here.
433
+ */
434
+ setTitle(factId: number, title: string): void;
435
+ /**
436
+ * Record `title` as a handle `factId` USED to carry (v17, the controlled `retitle: true`).
437
+ *
438
+ * `INSERT OR IGNORE` because the alias is derived search state, not a log: a correction that lands
439
+ * on the same old handle twice (retitle A→B, retitle B→A, retitle A→B) must not fail on the
440
+ * `(fact_id, title)` primary key. The row belongs to the revision that NOW carries the title, which
441
+ * is what makes the old search find the ACTIVE revision rather than an archived predecessor.
442
+ */
443
+ insertTitleAlias(factId: number, title: string): void;
444
+ /**
445
+ * Drop an alias row that has become the fact's CURRENT title (v17).
446
+ *
447
+ * Reached by a correction that goes BACK to an earlier handle (A → B, then B → A): the alias table
448
+ * exists to hold handles the row does not carry any more, and a row repeating the current title
449
+ * would be a second source of truth for the same string. `INSERT OR IGNORE` alone cannot express
450
+ * that (it adds, never removes), hence the explicit delete.
451
+ */
452
+ dropTitleAlias(factId: number, title: string): void;
453
+ /**
454
+ * Carry a revision's aliases onto the revision that replaces it (v17).
455
+ *
456
+ * Aliases are chain-level knowledge — "this transaction was once called X" — stored per revision
457
+ * because that is the row a search joins. Without this copy, the second update after a correction
458
+ * would move the title onto a new row and strand every alias on the archived predecessor, so the
459
+ * old handle would stop finding the live transaction. `currentTitle` is excluded so the table never
460
+ * holds a row that merely repeats the current handle.
461
+ */
462
+ copyTitleAliases(fromFactId: number, toFactId: number, currentTitle: string): void;
322
463
  statusOf(factId: number): string | undefined;
323
464
  /**
324
465
  * Land a (re-)encoded vector, and RE-QUEUE the fact for the conflict check.
@@ -359,20 +500,35 @@ export declare class FactsDao {
359
500
  * One page, newest first (the `list` action).
360
501
  *
361
502
  * TWO shapes, deliberately, because they want different indexes and the shared
362
- * `(? IS NULL OR category = ?)` form made the planner sort EVERY active row for the unfiltered
503
+ * `(? IS NULL OR kind = ?)` form made the planner sort EVERY active row for the unfiltered
363
504
  * one (measured 124 ms at 33k facts against 2.1 ms with an index that carries the order):
364
505
  *
365
506
  * - unfiltered: pinned to `idx_facts_created(status, created_at DESC)`. `INDEXED BY` is
366
507
  * load-bearing, not decoration — the lifecycle tick runs `PRAGMA optimize`, and once
367
508
  * `sqlite_stat1` exists the planner can switch this query back to `SCAN facts` + a temp
368
509
  * b-tree (verified at 33k rows), silently undoing the fix at exactly the scale it matters.
369
- * - category-filtered: left to the planner, which seeks `idx_facts_status_category` and sorts
370
- * only that category's rows. Pinning the created-at index here would instead walk the whole
371
- * index in order and filter per row (measured 9.8 ms for a category with 17 rows at 33k),
510
+ * - kind-filtered: left to the planner, which seeks `idx_facts_status_kind` and sorts
511
+ * only that kind's rows. Pinning the created-at index here would instead walk the whole
512
+ * index in order and filter per row (measured 9.8 ms for a kind with 17 rows at 33k),
372
513
  * i.e. it would trade a fast path for a slow one to save an index we do not need.
373
514
  */
374
- page(status: string, category: string | undefined, limit: number, offset: number, source?: string): Record<string, unknown>[];
375
- countInStatus(status: string, category?: string, source?: string): number;
515
+ page(status: string, kind: MemoryKind | undefined, limit: number, offset: number, source?: string): Record<string, unknown>[];
516
+ /**
517
+ * Run one of `page()`'s INDEXED BY-pinned statements, degrading to the unpinned form when the
518
+ * pinned index is missing.
519
+ *
520
+ * The `INDEXED BY` pins the PLAN, not the RESULT: without the hint the same rows come back, the
521
+ * planner just chooses for itself. `idx_facts_created` comes from migration 4 / the base DDL, and
522
+ * both create it idempotently — but a database whose migration was interrupted (or whose index was
523
+ * dropped out of band) at an already-current `user_version` has no pending step left to recreate
524
+ * it, and without this fallback the WHOLE list path errors out on "no such index". Degrading is
525
+ * preferred over an open-time self-check/rebuild: the migrations are the open path's only repair
526
+ * mechanism (each step idempotent), a missing index hurts exactly ONE query's plan choice, and
527
+ * rebuilding is a DDL write the open path should not take on for it. Only the "no such index"
528
+ * failure degrades — every other error still propagates.
529
+ */
530
+ private pageByCreatedIndex;
531
+ countInStatus(status: string, kind?: MemoryKind, source?: string): number;
376
532
  /** Every ACTIVE fact (the report's `active`). */
377
533
  countActive(): number;
378
534
  /** ACTIVE and permanent — pinned facts are exempt from decay (the report's `pinned`). */
@@ -432,7 +588,7 @@ export declare class FactsDao {
432
588
  * facts with a cap of 200 it silently made 32.8k of them unreachable. Recency is the honest
433
589
  * prior for a memory store and `idx_facts_created(status, created_at DESC)` already serves it.
434
590
  */
435
- activeHrrRows(category?: string, limit?: number, source?: string): {
591
+ activeHrrRows(kind?: MemoryKind, limit?: number, source?: string, time?: TimeSql): {
436
592
  fact_id: number;
437
593
  hrr_vector: Uint8Array;
438
594
  }[];
@@ -444,12 +600,12 @@ export declare class FactsDao {
444
600
  * which is the same information the HRR bundle encodes (its atoms ARE the entity names), so the
445
601
  * narrowing removes the O(N) part rather than a meaningful part of the ranking.
446
602
  */
447
- hrrRowsForFacts(ids: readonly number[], category?: string, source?: string): {
603
+ hrrRowsForFacts(ids: readonly number[], kind?: MemoryKind, source?: string): {
448
604
  fact_id: number;
449
605
  hrr_vector: Uint8Array;
450
606
  }[];
451
- /** ACTIVE subset of `ids` in one category/source (`null` = any) — the semantic leg's filter. */
452
- activeIdsIn(ids: readonly number[], category?: string, source?: string): number[];
607
+ /** ACTIVE subset of `ids` in one kind/source (`null` = any) — the semantic leg's filter. */
608
+ activeIdsIn(ids: readonly number[], kind?: MemoryKind, source?: string, time?: TimeSql): number[];
453
609
  /** `retrieval_count` + the "last used" clock (spec §2.5), batched. */
454
610
  touchUsage(ids: readonly number[]): void;
455
611
  /** Recall bonus not granted: only the settled trust moved. */
@@ -464,7 +620,7 @@ export declare class FactsDao {
464
620
  * that JOIN from becoming the drive (see {@link FTS_SEARCH_SQL}, which carries the plans and
465
621
  * measurements).
466
622
  */
467
- ftsSearch(ftsQuery: string, category?: string, limit?: number, source?: string): {
623
+ ftsSearch(ftsQuery: string, kind?: MemoryKind, limit?: number, source?: string, time?: TimeSql): {
468
624
  id: number;
469
625
  rank: number;
470
626
  }[];
@@ -479,10 +635,109 @@ export declare class FactsDao {
479
635
  * single-term query (all ranks 1) has a deterministic order. `ORDER BY rank` must read the SELECT
480
636
  * alias, so the LIKE expressions are built once and bound twice — same fragment, same order.
481
637
  */
482
- ftsSubstringSearch(terms: readonly string[], category?: string, limit?: number, source?: string): {
638
+ ftsSubstringSearch(terms: readonly string[], kind?: MemoryKind, limit?: number, source?: string, time?: TimeSql): {
483
639
  id: number;
484
640
  rank: number;
485
641
  }[];
642
+ /**
643
+ * One statement's worth of candidates, from the TITLE FTS index (JOURNAL_TITLE_SEARCH §11.4).
644
+ *
645
+ * The FTS table only PRODUCES candidates: `bm25` is the last, tie-breaking key, and the three keys
646
+ * above it are computed on `facts.title` itself (exact match, prefix, how many of the query's
647
+ * keywords the title contains). That split is the design's whole point — a title's bm25 score is
648
+ * not a content-relevance score, and the ordering the caller acts on ("is this the same
649
+ * transaction?") is about the title text, not about a corpus-wide term weight.
650
+ *
651
+ * `NOT INDEXED` on the `facts` join is the same load-bearing hint as {@link FTS_SEARCH_SQL}: without
652
+ * statistics the planner can drive from `facts` and re-run the external-content MATCH per row.
653
+ * `fa.title IS NOT NULL` keeps the empty-string rows FTS5 indexes for NULL out of the answer.
654
+ *
655
+ * Since v17 the same statement also consults `title_aliases` (see {@link titleAliasSource}): a
656
+ * corrected transaction must not become unfindable by the handle it used to carry, so the candidate
657
+ * set is the union of "the current title matches" and "an alias matches", deduped with the current
658
+ * title winning, and `matched_title` says which text the query actually hit.
659
+ */
660
+ searchTitleRowsByFts(q: TitleQuery & {
661
+ fts: string;
662
+ }): TitleRow[];
663
+ /**
664
+ * The SHORT-QUERY fallback: a `title LIKE` scan over the same filters and the same ordering.
665
+ *
666
+ * Reached when the FTS table's tokenizer cannot express the query (a 1–2 character CJK run, a
667
+ * 1–2 character latin word, or a unicode61 table — see `store/titles.ts`). The predicate matches
668
+ * ANY keyword (not the whole query contiguously), exactly like the content leg's substring
669
+ * fallback; `matched_terms` is what ranks a row containing all keywords above one containing a
670
+ * single one. The alias source rides along for the same reason it does in the FTS statement.
671
+ */
672
+ searchTitleRowsByLike(q: TitleQuery): TitleRow[];
673
+ /**
674
+ * The WHERE body of the content-exact statements: active facts whose content CONTAINS the query.
675
+ *
676
+ * `lower()` is SQLite's — ASCII-only case folding. Latin queries are therefore case-insensitive
677
+ * (`DSH` finds `dsh`) while CJK is compared as-is, which is exactly the rule; a non-ASCII accented
678
+ * letter stays case-sensitive, and that is stated in the spec's "拉丁部分大小写不敏感".
679
+ */
680
+ /**
681
+ * How many active facts contain `q.query` (the exact page's `total`).
682
+ */
683
+ countContentContaining(q: ExactContentQuery): number;
684
+ /** One page of active facts containing `q.query`, newest row first. */
685
+ contentContainingRows(q: ExactContentQuery & {
686
+ limit: number;
687
+ offset: number;
688
+ }): number[];
689
+ /**
690
+ * How many rows carry exactly `q.query` as their title — OR as one of their aliases (v17), under
691
+ * the same filters as the fuzzy path.
692
+ *
693
+ * Including the alias set here is what makes an old handle answer the EXACT stage instead of
694
+ * falling through to the fuzzy shortlist: "the title of this transaction used to be X" is exactly
695
+ * the same question as "a transaction titled X", and the caller cannot tell the two apart.
696
+ */
697
+ countTitleEqual(q: ExactTitleQuery): number;
698
+ /**
699
+ * One page of title-equal rows.
700
+ *
701
+ * `=`, not `LIKE`: the contract is "title EQUALS the query" (case-sensitive, because title is an
702
+ * immutable handle), so no wildcard and no folding — `fa.title = ?` under SQLite's BINARY collation
703
+ * is exactly that comparison. An ALIAS that equals the query counts too (v17); `matched_title`
704
+ * carries the text that matched, while `title` stays the row's current handle.
705
+ *
706
+ * Bind order (the statement's, not a rule of thumb): the SELECT's `matched_title` expression first
707
+ * (query, query), then the filter, then the WHERE's equality pair (query, query), then the page.
708
+ */
709
+ titleEqualRows(q: ExactTitleQuery & {
710
+ limit: number;
711
+ offset: number;
712
+ }): TitleRow[];
713
+ /**
714
+ * How many ACTIVE facts fall inside the window (the `mode:"time"` envelope's `total`).
715
+ */
716
+ countTimeWindow(range: TimeRange, opts?: {
717
+ kind?: MemoryKind;
718
+ source?: string;
719
+ }): number;
720
+ /**
721
+ * One page of that window, newest first along the axis the caller filtered on.
722
+ *
723
+ * The order follows the SAME axis as the WHERE (one mapping in {@link timeAxisColumn}), so the
724
+ * first row is the newest one the window contains; `fact_id ASC` is the deterministic tie-break
725
+ * among equal instants (an import can produce several).
726
+ */
727
+ timeWindowRows(range: TimeRange, opts: {
728
+ kind?: MemoryKind;
729
+ source?: string;
730
+ limit: number;
731
+ offset: number;
732
+ }): number[];
733
+ /**
734
+ * `mem_admin list` narrowed by a window: the same page shape and order as {@link page}, plus the
735
+ * caller's time predicate. Separate from `page` because that method pins two different indexes by
736
+ * hand and a window is exactly the shape where the indexed range should drive instead.
737
+ */
738
+ pageTimeWindow(status: string, kind: MemoryKind | undefined, range: TimeRange, limit: number, offset: number, source?: string): Record<string, unknown>[];
739
+ /** How many rows `pageTimeWindow` would page through (the `admin list` `total`). */
740
+ countStatusTimeWindow(status: string, kind: MemoryKind | undefined, range: TimeRange, source?: string): number;
486
741
  /**
487
742
  * ①'s predicate: the active-day counter crossed an integer boundary since the last settle.
488
743
  * Kept next to its four siblings so the tick's ORDER and the SQL stay in one place.