@avantf/dsh-mem 0.3.0 → 0.4.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 (145) hide show
  1. package/README.md +89 -28
  2. package/cordis.patch.yml +17 -0
  3. package/lib/client.js +155 -14
  4. package/lib/dsh-build.json +5 -5
  5. package/lib/engine/mem/config/config_files.d.ts +26 -0
  6. package/lib/engine/mem/config/domains.d.ts +20 -0
  7. package/lib/engine/mem/config/loader.d.ts +35 -0
  8. package/lib/engine/mem/config/paths.d.ts +53 -0
  9. package/lib/engine/mem/db/chunk.d.ts +26 -0
  10. package/lib/engine/mem/db/conn.d.ts +13 -0
  11. package/lib/engine/mem/db/dao/chunks.d.ts +150 -0
  12. package/lib/engine/mem/db/dao/contradictions.d.ts +98 -0
  13. package/lib/engine/mem/db/dao/documents.d.ts +51 -0
  14. package/lib/engine/mem/db/dao/entities.d.ts +67 -0
  15. package/lib/engine/mem/db/dao/facts.d.ts +472 -0
  16. package/lib/engine/mem/db/dao/stats.d.ts +14 -0
  17. package/lib/engine/mem/db/dao/triples.d.ts +80 -0
  18. package/lib/engine/mem/db/hash.d.ts +2 -0
  19. package/lib/engine/mem/db/knowledge.d.ts +17 -0
  20. package/lib/engine/mem/db/migrations.d.ts +71 -0
  21. package/lib/engine/mem/db/port.d.ts +57 -0
  22. package/lib/engine/mem/db/schema.d.ts +26 -0
  23. package/lib/engine/mem/db/sqlite.d.ts +89 -0
  24. package/lib/engine/mem/db/store.d.ts +35 -0
  25. package/lib/engine/mem/db/tokenizer.d.ts +61 -0
  26. package/lib/engine/mem/db/vectors.d.ts +50 -0
  27. package/lib/engine/mem/dispatch.d.ts +51 -0
  28. package/lib/engine/mem/entities/extract.d.ts +84 -0
  29. package/lib/engine/mem/eval/loader.d.ts +19 -0
  30. package/lib/engine/mem/eval/metrics.d.ts +25 -0
  31. package/lib/engine/mem/eval/runner.d.ts +10 -0
  32. package/lib/engine/mem/eval/write_metrics.d.ts +88 -0
  33. package/lib/engine/mem/git.d.ts +73 -0
  34. package/lib/engine/mem/hrr/algebra.d.ts +30 -0
  35. package/lib/engine/mem/hrr/atoms.d.ts +22 -0
  36. package/lib/engine/mem/hrr/encode.d.ts +32 -0
  37. package/lib/engine/mem/hrr/index.d.ts +3 -0
  38. package/lib/engine/mem/index.d.ts +27 -0
  39. package/lib/engine/mem/lifecycle/contradiction.d.ts +156 -0
  40. package/lib/engine/mem/lifecycle/index.d.ts +5 -0
  41. package/lib/engine/mem/lifecycle/maintenance.d.ts +48 -0
  42. package/lib/engine/mem/lifecycle/presence.d.ts +29 -0
  43. package/lib/engine/mem/lifecycle/tick.d.ts +43 -0
  44. package/lib/engine/mem/lifecycle/trust.d.ts +90 -0
  45. package/lib/engine/mem/modelBootstrap.d.ts +207 -0
  46. package/lib/engine/mem/router.d.ts +51 -0
  47. package/lib/engine/mem/runtime.d.ts +232 -0
  48. package/lib/engine/mem/store/common.d.ts +82 -0
  49. package/lib/engine/mem/store/doc_files.d.ts +125 -0
  50. package/lib/engine/mem/store/document_text.d.ts +47 -0
  51. package/lib/engine/mem/store/floors.d.ts +191 -0
  52. package/lib/engine/mem/store/hybrid.d.ts +232 -0
  53. package/lib/engine/mem/store/ingest_guard.d.ts +25 -0
  54. package/lib/engine/mem/store/knowledge.d.ts +483 -0
  55. package/lib/engine/mem/store/lexical.d.ts +67 -0
  56. package/lib/engine/mem/store/memory.d.ts +543 -0
  57. package/lib/engine/mem/store/source_picker.d.ts +43 -0
  58. package/lib/engine/mem-contract/config.d.ts +129 -0
  59. package/lib/engine/mem-contract/env.d.ts +31 -0
  60. package/lib/engine/mem-contract/family.d.ts +25 -0
  61. package/lib/engine/mem-contract/index.d.ts +8 -0
  62. package/lib/engine/mem-contract/log.d.ts +29 -0
  63. package/lib/engine/mem-contract/remote.d.ts +80 -0
  64. package/lib/engine/mem-contract/text.d.ts +92 -0
  65. package/lib/engine/mem-contract/tools.d.ts +284 -0
  66. package/lib/engine/mem-contract/types.d.ts +615 -0
  67. package/lib/engine/mem-retrieval/adapters/auto_vstore.d.ts +47 -0
  68. package/lib/engine/mem-retrieval/adapters/hnswlib.d.ts +174 -0
  69. package/lib/engine/mem-retrieval/adapters/local_bge.d.ts +121 -0
  70. package/lib/engine/mem-retrieval/adapters/local_numpy.d.ts +28 -0
  71. package/lib/engine/mem-retrieval/adapters/local_reranker.d.ts +52 -0
  72. package/lib/engine/mem-retrieval/adapters/none_reranker.d.ts +10 -0
  73. package/lib/engine/mem-retrieval/adapters/warm_gate.d.ts +46 -0
  74. package/lib/engine/mem-retrieval/budget.d.ts +30 -0
  75. package/lib/engine/mem-retrieval/fusion.d.ts +53 -0
  76. package/lib/engine/mem-retrieval/index.d.ts +14 -0
  77. package/lib/engine/mem-retrieval/interfaces.d.ts +82 -0
  78. package/lib/engine/mem-retrieval/log.d.ts +11 -0
  79. package/lib/engine/mem-retrieval/registry.d.ts +23 -0
  80. package/lib/engine/mem-retrieval/rerank.d.ts +13 -0
  81. package/lib/engine/mem-retrieval/stats.d.ts +64 -0
  82. package/lib/engine/mem-retrieval/text_budget.d.ts +52 -0
  83. package/lib/index.js +1773 -473
  84. package/lib/interface-version.json +2 -2
  85. package/lib/types/client/domains.d.ts +5 -4
  86. package/lib/types/client/domains.d.ts.map +1 -1
  87. package/lib/types/client/index.d.ts.map +1 -1
  88. package/lib/types/client/ingest.d.ts +1 -1
  89. package/lib/types/client/wire.d.ts +36 -0
  90. package/lib/types/client/wire.d.ts.map +1 -0
  91. package/lib/types/envinit-bootstrap.d.ts +3 -2
  92. package/lib/types/envinit.d.ts +2 -19
  93. package/lib/types/envinit.d.ts.map +1 -1
  94. package/lib/types/hints.d.ts +1 -1
  95. package/lib/types/index.d.ts +8 -3
  96. package/lib/types/index.d.ts.map +1 -1
  97. package/lib/types/open.d.ts +61 -11
  98. package/lib/types/open.d.ts.map +1 -1
  99. package/lib/types/prompt.d.ts +19 -1
  100. package/lib/types/prompt.d.ts.map +1 -1
  101. package/lib/types/provision.d.ts +1 -1
  102. package/lib/types/provision.d.ts.map +1 -1
  103. package/lib/types/reconcile.d.ts +64 -0
  104. package/lib/types/reconcile.d.ts.map +1 -0
  105. package/lib/types/remote.d.ts +53 -0
  106. package/lib/types/remote.d.ts.map +1 -1
  107. package/lib/types/render.d.ts +35 -0
  108. package/lib/types/render.d.ts.map +1 -0
  109. package/lib/types/tool_schema.d.ts +3 -13
  110. package/lib/types/tool_schema.d.ts.map +1 -1
  111. package/package.json +27 -18
  112. package/lib/client.js.map +0 -1
  113. package/lib/types/client/domains.js +0 -25
  114. package/lib/types/client/domains.js.map +0 -1
  115. package/lib/types/client/index.js +0 -1371
  116. package/lib/types/client/index.js.map +0 -1
  117. package/lib/types/client/ingest.js +0 -30
  118. package/lib/types/client/ingest.js.map +0 -1
  119. package/lib/types/client/paging.js +0 -36
  120. package/lib/types/client/paging.js.map +0 -1
  121. package/lib/types/client/summarize.js +0 -79
  122. package/lib/types/client/summarize.js.map +0 -1
  123. package/lib/types/data_home.js +0 -26
  124. package/lib/types/data_home.js.map +0 -1
  125. package/lib/types/envinit-bootstrap.js +0 -333
  126. package/lib/types/envinit.js +0 -402
  127. package/lib/types/envinit.js.map +0 -1
  128. package/lib/types/hints.js +0 -53
  129. package/lib/types/hints.js.map +0 -1
  130. package/lib/types/index.js +0 -755
  131. package/lib/types/index.js.map +0 -1
  132. package/lib/types/inject.js +0 -14
  133. package/lib/types/inject.js.map +0 -1
  134. package/lib/types/interface_gate.js +0 -84
  135. package/lib/types/interface_gate.js.map +0 -1
  136. package/lib/types/open.js +0 -133
  137. package/lib/types/open.js.map +0 -1
  138. package/lib/types/prompt.js +0 -148
  139. package/lib/types/prompt.js.map +0 -1
  140. package/lib/types/provision.js +0 -177
  141. package/lib/types/provision.js.map +0 -1
  142. package/lib/types/remote.js +0 -247
  143. package/lib/types/remote.js.map +0 -1
  144. package/lib/types/tool_schema.js +0 -149
  145. package/lib/types/tool_schema.js.map +0 -1
@@ -0,0 +1,483 @@
1
+ import { type AvantfLogger, type Config, type DocumentDetail, type DocumentSummary, type DomainCatalog, type FloorProfile, type ImportResult, type IngestResult, type KbConflictReport, type KbIngestConflict, type KbSyncReport, type KnowledgeConfig, type RecallHit, type ReindexReport } from '../../mem-contract/index.js';
2
+ import type { Reranker, SemanticBackend, VectorStore } from '../../mem-retrieval/index.js';
3
+ import { type LexicalProbe } from './lexical.js';
4
+ import { type FloorLeg } from './floors.js';
5
+ import { type HybridResult } from './hybrid.js';
6
+ /** Options for {@link KnowledgeStore.search}. */
7
+ export interface KnowledgeSearchOptions {
8
+ domain?: string;
9
+ source?: string;
10
+ limit?: number;
11
+ /** Overrides the config-derived fusion pool size; never smaller than `limit`. */
12
+ overFetch?: number;
13
+ /**
14
+ * Pre-encoded query vector, when the caller already has one — the cross-store router encodes a
15
+ * query ONCE and hands the same vector to both stores. Not verified, so it must come from the
16
+ * same backend.
17
+ */
18
+ queryVector?: Float32Array;
19
+ /** Per-call output token budget; `0` = unlimited, omitted = `retriever.max_output_tokens`. */
20
+ maxTokens?: number;
21
+ /**
22
+ * Relevance-floor profile: omitted = the default policy (configured floors, plus one relaxed pass
23
+ * if they empty the result — see `MemoryStore`'s `SearchInput.floors`), `'strict'` = no fallback,
24
+ * `'loose'` = the relaxed floors outright. The cross-store router passes a profile explicitly for
25
+ * each pass, because only the MERGED result can decide whether relaxing is warranted.
26
+ */
27
+ floors?: FloorProfile;
28
+ /**
29
+ * Which legs a `'loose'` pass may lower (see `store/floors.ts`; same contract as
30
+ * `SearchInput.relaxLegs`). Internal: the cross-store router pins the MERGED strict pass's
31
+ * dropping legs on its second call.
32
+ */
33
+ relaxLegs?: readonly FloorLeg[];
34
+ /** See `SearchInput.recordStats`: the cross-store router records the merged query once. */
35
+ recordStats?: boolean;
36
+ /**
37
+ * Receives the full `HybridResult` (floors, per-leg floor drops, used tokens) after the search.
38
+ *
39
+ * `search` answers with the hits only, because that is what its callers want; the cross-store
40
+ * router still has to fold THIS store's floor drops into the one merged `kb_query` result, and a
41
+ * store-level "last result" field would race two concurrent queries.
42
+ */
43
+ onResult?: (result: HybridResult<RecallHit>) => void;
44
+ }
45
+ /**
46
+ * One identity a write will land on: the title it will use, the resolved local path it would read
47
+ * (absent for text/URL), and — when the identity is taken — the document already sitting on it.
48
+ */
49
+ export interface IngestTarget {
50
+ title: string;
51
+ path?: string;
52
+ existing?: KbIngestConflict;
53
+ }
54
+ /**
55
+ * What the plan stage resolved, with NO file content read and NO network touched (see
56
+ * {@link KnowledgeStore.ingestRequest}). A directory is expanded to its ingestable FILES here, so
57
+ * the `existing` test uses the basename identity `importPaths` actually writes.
58
+ */
59
+ export interface IngestPlan {
60
+ kind: 'text' | 'url' | 'file' | 'paths';
61
+ /** The pasted body (kind `text`). */
62
+ text?: string;
63
+ /** The URL, or the `realpath` of the local file (kinds `url`/`file`). */
64
+ uri?: string;
65
+ /** The caller's paths (kind `paths`), for the replace dispatch through `importPaths`. */
66
+ paths?: string[];
67
+ /** Every ingestable file the request expands to. */
68
+ files: string[];
69
+ /** Files a directory walk refused by format (reported by a batch import). */
70
+ skipped: string[];
71
+ targets: IngestTarget[];
72
+ }
73
+ /** One ingestion request, whichever field it came from. `overwrite` only matters in replace mode. */
74
+ export interface IngestRequest {
75
+ text?: string;
76
+ source_uri?: string;
77
+ paths?: string[];
78
+ domain: string;
79
+ source?: string;
80
+ title?: string;
81
+ overwrite?: boolean;
82
+ }
83
+ /** The two runtime faces of {@link KnowledgeStore.ingestRequest}. */
84
+ export type IngestMode = 'add' | 'replace';
85
+ export declare class KnowledgeStore {
86
+ private readonly semantic;
87
+ private readonly vstore;
88
+ private readonly reranker;
89
+ /** The store config file `addDomain` writes the allowlist back to (`knowledgeConfigPath(home)`). */
90
+ private readonly domainConfigPath;
91
+ private readonly logger?;
92
+ private readonly db;
93
+ /**
94
+ * The tokenizer `doc_chunks_fts` was ACTUALLY built with, read from the database at open.
95
+ *
96
+ * Not `resolveFtsTokenizer()`: `CREATE VIRTUAL TABLE IF NOT EXISTS` never rebuilds an existing
97
+ * table, so a database created under another SQLite build keeps the tokenizer it was born with,
98
+ * and building MATCH expressions for the wrong one is silent zero recall (see `db/tokenizer.ts`).
99
+ */
100
+ private readonly ftsTokenizer;
101
+ private readonly config;
102
+ private readonly kbConfig;
103
+ /** All SQL for this store lives behind these (DESIGN §19). */
104
+ private readonly docs;
105
+ private readonly chunks;
106
+ /** The editable `.md` copy of every document (`knowledge.docs.dir`). */
107
+ private readonly docFiles;
108
+ /** > 0 while a batch (directory import) is writing: commits and baseline refreshes wait. */
109
+ private deferCorpusWrites;
110
+ /**
111
+ * Per-file stamps (`path → mtimeMs:size`) and the file-set they were taken from, so a reconcile
112
+ * can ask "what changed?" for the price of one `stat` per document instead of reading and
113
+ * hashing the whole corpus. `null` = no baseline yet.
114
+ */
115
+ private corpusStamps;
116
+ private corpusFileSet;
117
+ /** Version control for the corpus (`knowledge.git`); a failure here is never fatal. */
118
+ private readonly corpusGit;
119
+ /**
120
+ * The LIVE domain allowlist — seeded at boot from `knowledge.domains` ∪ the library's existing
121
+ * domains, then kept in step with the store config by {@link addDomain}. The config itself is
122
+ * read once at startup (layer ③ of `loader.ts`), so a freshly added domain must land HERE or the
123
+ * very next write would refuse it until a restart.
124
+ */
125
+ private readonly allowedDomains;
126
+ /** Whether the config allowlist is active (non-empty); `false` = `domains: []` = no restriction. */
127
+ private readonly domainsRestricted;
128
+ constructor(dbPath: string, config: Config, kbConfig: KnowledgeConfig, semantic: SemanticBackend, vstore: VectorStore, reranker: Reranker,
129
+ /** The store config file `addDomain` writes the allowlist back to (`knowledgeConfigPath(home)`). */
130
+ domainConfigPath: string, logger?: AvantfLogger | undefined);
131
+ /**
132
+ * Rebuild the in-memory vstore from persisted chunk vectors.
133
+ *
134
+ * Also detects a VECTOR-SPACE change, which the reload itself cannot see: `reloadVectorIndex`
135
+ * checks the width, and a different model at the same width produces dim-valid vectors that are
136
+ * then ranked against the current query's — two spaces mixed in one index, with no error anywhere.
137
+ * The memory store always said something about this; the knowledge store did not, so a model swap
138
+ * degraded every semantic query here until someone happened to run `kb_reindex`.
139
+ */
140
+ private reloadIndex;
141
+ /**
142
+ * The write-side domain allowlist AND the 知识页 picker's data, in one place: the configured
143
+ * `knowledge.domains` allowlist ∪ the domains already present in `documents` ∪ the domains added
144
+ * through {@link addDomain} this session.
145
+ *
146
+ * `restricted: false` means the allowlist is EXPLICITLY empty (`domains: []`), the documented
147
+ * "no restriction" value. The union half is not a compatibility layer: without it, narrowing
148
+ * the allowlist would make every existing document un-re-ingestable, and the picker would offer
149
+ * a name the store then refuses.
150
+ */
151
+ domainCatalog(): DomainCatalog;
152
+ /** Refuse a `domain` outside the allowlist, naming every value that IS allowed. */
153
+ private assertDomainAllowed;
154
+ /**
155
+ * Add one domain to the live allowlist AND to the store config's `domains` list.
156
+ *
157
+ * The UI-only path (`kbAddDomain`) — deliberately NOT an agent tool: the asymmetry is the point,
158
+ * because an autonomous writer is exactly where domain sprawl would come from. The store owns the
159
+ * rules (trim / reject path separators / treat an existing name as a selection) so both halves
160
+ * agree, and it REFUSES while the allowlist is `[]` ("no restriction"): appending to an unfettered
161
+ * list would silently turn it into a restricted one.
162
+ */
163
+ addDomain(raw: string): DomainCatalog;
164
+ /**
165
+ * The shared ingestion entry: **① plan → ② classify → ③ dispatch**, in that fixed order.
166
+ *
167
+ * Stage ① resolves the domain allowlist, insists on one of `text`/`source_uri`/`paths`, derives
168
+ * the identity every target will be written under, and looks each one up — using the SAME
169
+ * {@link classifySource} the source picker uses, so the type (text / URL / local file / directory)
170
+ * is decided once. Stage ① MUST NOT read a file or touch the network: identity only needs
171
+ * `realpath`/`stat`/`readdir` plus the body of a pasted `text`, which the caller already holds. That
172
+ * is what makes "conflict before read" observable (a colliding `source_uri` answers with the
173
+ * collision even when the URL is unreachable or the file is a binary the reader would refuse).
174
+ *
175
+ * Stage ③ dispatches on the plan: `ingest` / `ingestUri` / `importPaths`. Those keep their
176
+ * replace-by-identity semantics; this entry only puts the plan in front of them.
177
+ *
178
+ * The two modes are the two public faces:
179
+ * - `add` (`rt.kbAdd`, the model's `kb_add`): never replaces. A single collision throws an
180
+ * actionable Chinese error; a batch collision is per-file (see `importNew`), because one
181
+ * existing file in a 100-file import must not fail the other 99.
182
+ * - `replace` (`rt.kb`, the UI/CLI): a collision comes back as a {@link KbConflictReport} and
183
+ * writes NOTHING unless the caller confirmed with `overwrite: true`; then it re-runs the whole
184
+ * request. All-or-nothing, so a partial write can never sit behind a confirmation dialog.
185
+ */
186
+ ingestRequest(req: IngestRequest, mode: IngestMode): Promise<IngestResult | ImportResult | KbConflictReport>;
187
+ /**
188
+ * Stage ①/②: resolve the request to its targets without reading anything.
189
+ *
190
+ * `paths` wins over `text`, which wins over `source_uri` — the same precedence the old
191
+ * `kb_add`/`rt.kb` split applied, kept so a call that sends two fields means the same thing.
192
+ */
193
+ private planIngest;
194
+ /** One target's identity plus whatever document already owns it. */
195
+ private target;
196
+ private planText;
197
+ private planUri;
198
+ /**
199
+ * A batch request: each caller path is classified (file or directory) and a directory is WALKED
200
+ * for its ingestable files. Only the walk filters by extension — an explicitly named file is taken
201
+ * as given, exactly like `importPaths`. Target identity is each FILE's `basename`, which is the
202
+ * identity `importPaths` actually writes (never the directory name).
203
+ */
204
+ private planPaths;
205
+ /** Stage ③, add mode: refuse a single collision, and split a batch's collisions into `failed`. */
206
+ private runAdd;
207
+ /**
208
+ * Batch add: the plan already knows which targets exist, so those are reported in `failed` (with
209
+ * the actionable text) and only the NEW files are handed to `importPaths`. Passing the expanded
210
+ * file list — not the caller's directories — is what keeps a walk from re-writing a colliding
211
+ * file; the walk's own `skipped` list is carried over because explicit files are never filtered.
212
+ */
213
+ private importNew;
214
+ /** Stage ③, replace mode (the caller confirmed, or nothing collided). */
215
+ private runReplace;
216
+ /** The machine-readable "nothing was written; confirm to overwrite" answer (UI + CLI). */
217
+ private conflictReport;
218
+ /**
219
+ * Add one document from TEXT, and never replace an existing one.
220
+ *
221
+ * This is the PASTE entry point of the add-only face, and the distinction is the whole point:
222
+ * `ingest` is replace-by-identity (what `ingestUri`, `importPaths`, `sync`'s re-ingest and `adopt`
223
+ * need), while a paste that lands on an existing identity is an ADD mistake — the user meant to
224
+ * save a new note, not to overwrite the one already filed. So this refuses instead of writing
225
+ * anything, naming the document it collided with (its `doc_id`, title and managed file) so the
226
+ * caller can choose a different title or edit that file.
227
+ */
228
+ ingestNew(text: string, domain: string, source?: string, title?: string): Promise<IngestResult>;
229
+ /**
230
+ * Ingest one document: chunk (paragraph/heading aware), persist chunks + entities,
231
+ * then encode vectors. Fully awaited — when ingest resolves, the document is
232
+ * searchable through every path (FTS, entity, semantic).
233
+ */
234
+ ingest(text: string, domain: string, source?: string, title?: string, sourceUri?: string, encoding?: string,
235
+ /** What the automatic git commit should say this was (`adopt` = recovered from an overwrite). */
236
+ gitAction?: 'ingest' | 'sync' | 'adopt',
237
+ /** The converter that produced `text` (absent for text/PDF and for a body re-read from disk). */
238
+ converter?: string,
239
+ /** What that conversion could not carry over. */
240
+ warnings?: string[]): Promise<IngestResult>;
241
+ /** Materialize one document's editable copy; returns the `file` / `file_error` result fields. */
242
+ private writeManagedFile;
243
+ /** Batched eviction from the live vector index (removeMany when the backend supports it). */
244
+ private evictVectors;
245
+ /** The resolved ingestion boundary (see `ingest_guard.ts`). */
246
+ private get ingestLimits();
247
+ /**
248
+ * Ingest from a source URI (DESIGN D5): local file paths (workspace files are the
249
+ * priority source) are read from disk; `http(s)` URIs are fetched. The URI itself
250
+ * is NEVER stored as the document body — that was the old broken behavior.
251
+ *
252
+ * Both paths are bounded BEFORE materializing the content (the `MAX_DOC_CHARS`
253
+ * guard inside `ingest` can only run once the whole body is already in memory),
254
+ * and both are checked against the ingestion boundary: a local path must resolve
255
+ * inside the allowed roots and a URL must not address private space (see
256
+ * `ingest_guard.ts`).
257
+ *
258
+ * The URL-vs-path decision goes through the store's ONE classifier ({@link classifySource}, the
259
+ * same one the source picker and the plan stage use) — a second `^https?://` test here would be a
260
+ * second answer to "what is this string".
261
+ */
262
+ ingestUri(uri: string, domain: string, source?: string, title?: string): Promise<IngestResult>;
263
+ /**
264
+ * Extract entities per chunk (persisted) and encode/persist chunk vectors.
265
+ *
266
+ * Returns how many chunks did NOT get a vector, so the caller can put it in the tool result. A
267
+ * missing model counts every chunk that had no reusable vector: the document is still searchable by
268
+ * text and entity, but not semantically, and "入库成功 N 段" must not imply otherwise.
269
+ */
270
+ private indexChunks;
271
+ /**
272
+ * The doc's stored vectors that still APPLY, keyed by content hash (see {@link vectorReusable}).
273
+ *
274
+ * Called inside the ingest transaction, before the replace: two chunks with the same text are one
275
+ * hash, and the first row wins — the reuse is only a cache lookup, so a duplicate entry cannot be
276
+ * distinguished from a hit and need not be.
277
+ */
278
+ private reusableByHash;
279
+ /**
280
+ * Write back the vectors a re-ingest can reuse, and report which rows still need the model.
281
+ *
282
+ * A row is only reused when the lookup misses nothing: any row whose write fails, or whose text has
283
+ * no reusable vector, comes back in `remaining` and is encoded as before — so the reuse path cannot
284
+ * leave a chunk unindexed that the plain path would have indexed.
285
+ */
286
+ private writeReusedVectors;
287
+ /**
288
+ * Encode texts ONE AT A TIME, writing every `WRITE_BATCH` vectors in one transaction.
289
+ *
290
+ * The model call is deliberately NOT batched, and that is a MEASURED decision, not an omission:
291
+ * a batched forward pass pads each item to the longest in its group, and real chunks span 53–550
292
+ * characters (median 303). On 299 real chunks: serial 31.2 ms/text, batched in document order
293
+ * 55.7 ms/text (1.79x SLOWER), batched after sorting by length 31.9 ms/text (a wash). Batching
294
+ * only pays for SHORT homogeneous texts, which chunks are not — so the review's "batch the
295
+ * encode" suggestion was tested and rejected. `LocalBgeBackend.encodeBatch` remains correct (and
296
+ * length-sorted) for callers whose texts suit it.
297
+ *
298
+ * What DID pay off is the write side: one commit per `WRITE_BATCH` chunks instead of one per
299
+ * chunk (measured 14.2 µs/row vs 2.2 µs/row), and the transaction never spans an `await`.
300
+ */
301
+ private encodeAndStore;
302
+ /**
303
+ * Identity of the vector space this store currently writes into (DESIGN §20): a change of
304
+ * backend, model or width makes every stored chunk vector stale, and the whole point of
305
+ * recording it is that such a change is DETECTED instead of silently mixing two spaces.
306
+ */
307
+ private vectorSpace;
308
+ /**
309
+ * A stored vector is reusable when it exists, matches the space, and its text is unchanged.
310
+ *
311
+ * The three checks answer different questions, and the hash is deliberately the RAREST of them
312
+ * to fire (see `db/hash.ts`): `ingest` replaces chunks rather than editing their text, so a
313
+ * re-ingested change is caught by the missing vector, and a rule change by `entities_version`.
314
+ * The hash is what catches a text mutation that skipped the ingest path entirely — without it
315
+ * the store would keep serving a vector for text it no longer holds.
316
+ */
317
+ private vectorReusable;
318
+ /** One transaction replacing the entity rows of the given chunks (shared by ingest + reindex). */
319
+ private replaceChunkEntities;
320
+ /**
321
+ * Ingest local files/directories. Every path goes through the SAME boundary as
322
+ * `ingestUri` — a bare `kb_import` on `~/.ssh` would otherwise be the exact
323
+ * exfiltration hole the `source_uri` guard closes.
324
+ *
325
+ * Failure split: a caller-supplied path that is missing or out of bounds THROWS
326
+ * (validating the arguments up front, and the operator needs to know), while a file
327
+ * reached by walking a directory is reported in `failed` — the previous `catch {}`
328
+ * dropped those silently, so "imported 60 of 100 files" was indistinguishable from
329
+ * a complete import.
330
+ */
331
+ importPaths(paths: string[], domain: string, source?: string): Promise<ImportResult>;
332
+ /**
333
+ * One corpus commit + one baseline refresh, unless a batch is in flight.
334
+ *
335
+ * Every owned write calls this; `deferCorpusWrites` is what turns N writes into 1 commit during
336
+ * an import. Never throws (`GitRepo` swallows), and the baseline refresh is a no-op until one
337
+ * exists.
338
+ */
339
+ private commitCorpus;
340
+ list(domain?: string, source?: string, limit?: number, offset?: number): DocumentSummary[];
341
+ detail(docId: number): DocumentDetail | null;
342
+ remove(docId: number): boolean;
343
+ /**
344
+ * The managed path for a document ROW the caller already holds — skips the `docs.get` that
345
+ * `docFilePath` does, which matters because `kb_list` needs a path for every row it renders.
346
+ */
347
+ docFilePathOf(doc: {
348
+ doc_id: number;
349
+ domain: string;
350
+ source: string;
351
+ title: string;
352
+ }): string;
353
+ /** The managed file this document owns (whether or not it exists yet), or `null` for a missing doc. */
354
+ docFilePath(docId: number): string | null;
355
+ /**
356
+ * Reconcile the managed files with what the KB indexed (DESIGN: knowledge maintenance).
357
+ *
358
+ * `dryRun` answers "what changed?" without touching the index — that is what the 知识 tab's list
359
+ * uses to mark documents whose file was edited. Otherwise every STALE document is re-ingested
360
+ * from its file body, which replaces its chunks and re-encodes its vectors (the document keeps
361
+ * its `doc_id`: identity is `domain → source → title`, and the file records the same one).
362
+ *
363
+ * `missing` documents are reported, never invented: a file that was deleted by hand leaves the
364
+ * indexed copy alone until the user deletes the document itself. `orphans` are managed files no
365
+ * document claims — leftovers from a row deleted outside `remove` — and are only reported.
366
+ */
367
+ /**
368
+ * Stat-only reconcile check: which managed files look changed since the last call, and whether
369
+ * the file SET changed (a new or vanished `.md` — an orphan or a restore).
370
+ *
371
+ * Nothing here reads a file, so the common answer ("nothing changed") costs one `stat` per
372
+ * document. A stamp is a TRIGGER, not a judgement: the caller re-ingests through `sync`, which
373
+ * hashes the body and decides. The first call only seeds the baseline and reports nothing —
374
+ * use `sync` once at startup if a process may have been down while the corpus changed.
375
+ *
376
+ * ASYNC because one `stat` per document is exactly the cost that has to leave the event loop to
377
+ * something else once in a while: at 10k documents the walk blocked the host for >0.1 s per trip
378
+ * (measured — performance review §7.5 / P7), and this runs after every tool result behind a 2 s
379
+ * throttle. The baseline is still taken ATOMICALLY at the end, so a caller sees the same
380
+ * all-or-nothing update as before.
381
+ */
382
+ corpusDrift(): Promise<{
383
+ changed: number[];
384
+ missing: number[];
385
+ fileSetChanged: boolean;
386
+ }>;
387
+ /**
388
+ * Re-take the baseline after a write this store made ITSELF.
389
+ *
390
+ * Without this, every `ingest` would look like an external edit on the next drift check and cost
391
+ * a redundant (if harmless) sync; with it, only edits from OUTSIDE the store are reported. Cheap:
392
+ * one `stat` per document, no reads. A no-op until a baseline exists.
393
+ */
394
+ private noteCorpusWrite;
395
+ /**
396
+ * Take back a file that lost its frontmatter, when the caller explicitly asks for it.
397
+ *
398
+ * Cheap refusals, all of them deliberate: a file that CLAIMS another document is a collision and
399
+ * is left alone; an EMPTY body is refused because `ingest` has no lower bound (`chunkText('')`
400
+ * returns no chunks), so adopting one would silently replace a good document with nothing.
401
+ */
402
+ private adoptUnclaimed;
403
+ sync(opts?: {
404
+ docId?: number;
405
+ dryRun?: boolean;
406
+ adopt?: boolean;
407
+ }): Promise<KbSyncReport>;
408
+ /**
409
+ * Rebuild the read model: FTS, per-chunk entities, and vectors — incrementally.
410
+ *
411
+ * `domain` scopes the rebuild to one knowledge domain. One corpus SELECT feeds both legs, and
412
+ * each row is skipped when its derivation is provably still valid (same text digest, same
413
+ * vector space, same extractor version). Without that, the only way to know whether a chunk
414
+ * needed mission was to redo the mission: an ONNX forward pass plus a jieba pass per chunk over the
415
+ * whole corpus, every time.
416
+ *
417
+ * `dryRun` answers "how much would this cost?" without writing anything — including without
418
+ * the FTS rebuild, which is a write.
419
+ */
420
+ reindex(domain?: string, opts?: {
421
+ dryRun?: boolean;
422
+ }): Promise<ReindexReport>;
423
+ close(): void;
424
+ count(): {
425
+ documents: number;
426
+ chunks: number;
427
+ };
428
+ /** Chunk search over the knowledge corpus (semantic + FTS + entity, joint fusion). */
429
+ search(query: string, opts?: KnowledgeSearchOptions): Promise<RecallHit[]>;
430
+ /**
431
+ * This store's half of the shared orchestration (`store/hybrid.ts`): the legs, the chunk-row load
432
+ * and the chunk→hit mapping.
433
+ *
434
+ * The limit normalization, the over-fetch factor, the leg cap, the capped-leg counter, the
435
+ * degraded weights, the rerank and the output budget all live there now. They used to be copied
436
+ * here, and every fix landed on the memory side only — a `NaN` limit reached this store's SQL and
437
+ * took the whole cross-store query down with it, `retriever.over_fetch_factor` meant something
438
+ * different per store, and a capped knowledge leg was invisible to the health counters.
439
+ */
440
+ private hybridDeps;
441
+ /**
442
+ * The knowledge legs: semantic, FTS, and entity overlap against the entities extracted AT INGEST
443
+ * time (`chunk_entities`) — no corpus scan.
444
+ *
445
+ * Same cap as the memory store: fusion keeps `overFetch` entries, so a leg that returns the whole
446
+ * matching corpus only makes `fuse` normalize and sort it (see `MemoryStore.searchLegs`).
447
+ */
448
+ private searchLegs;
449
+ /** The texts of a candidate set, for the per-row FTS floor (one batched query). */
450
+ private chunkTexts;
451
+ /**
452
+ * Semantic leg. `queryVector` short-circuits the encode when the caller already
453
+ * encoded this exact query (the cross-store router does, for both stores at once); `onVector`
454
+ * publishes the vector this leg actually used, so the relaxed retry pass can reuse it instead of
455
+ * re-encoding (performance review §7.7 / P8).
456
+ */
457
+ private semanticPath;
458
+ /**
459
+ * Sync relevance probe for the plugin's conditional hint (DESIGN §12). Same contract as
460
+ * `MemoryStore.lexicalProbe` — see `store/lexical.ts`.
461
+ */
462
+ lexicalProbe(text: string, stopAt?: number): LexicalProbe;
463
+ private ftsPath;
464
+ /** Entity overlap against entities extracted AT INGEST time (chunk_entities) — no corpus scan. */
465
+ private jaccardPath;
466
+ }
467
+ export interface Chunk {
468
+ text: string;
469
+ headingsPath: string;
470
+ /** Inclusive start offset in the source text. */
471
+ start: number;
472
+ /** Exclusive end offset in the source text. */
473
+ end: number;
474
+ }
475
+ /**
476
+ * Paragraph + heading aware chunking (DESIGN §8): markdown headings open a new
477
+ * block and maintain a headings stack; blank lines separate blocks; blocks are
478
+ * packed greedily up to `chunkSize`; oversized blocks are hard-split with
479
+ * `overlap`; consecutive chunks finally share `overlap` characters of context.
480
+ * Offsets always refer to the ORIGINAL text so `source_ref`/char positions stay
481
+ * faithful for provenance.
482
+ */
483
+ export declare function chunkText(text: string, chunkSize?: number, overlap?: number): Chunk[];
@@ -0,0 +1,67 @@
1
+ /**
2
+ * The sync relevance probe behind the plugin's conditional hints (DESIGN §12).
3
+ *
4
+ * WHY THIS IS LEXICAL, AND WHY IT MUST BE SYNC. The hint has to be in place *before* the step's
5
+ * prompt is assembled — `dsh-agent-loop`'s `preStep` calls `systemPrompt.assemble()` immediately
6
+ * after the message is admitted, and the section/context provider that renders the hint is
7
+ * SYNCHRONOUS (it cannot await). Of the retrieval legs, only the FTS one is synchronous: the
8
+ * semantic leg needs an `encode()`, and a warm encode is ~10–20 ms, which loses the race and lands
9
+ * the hint one step late — i.e. after the model already decided whether to call a tool. So the
10
+ * gate is a lexical one, and paraphrase-style questions score 0 here. That is the accepted trade:
11
+ * it stays SILENT when unsure, and the always-on usage sections remain the fallback that tells the
12
+ * model to query anyway.
13
+ *
14
+ * THE BAR, AND HOW IT WAS MEASURED. Terms are latin/digit words of >= 5 chars plus every CJK
15
+ * 3-gram (the FTS tables are trigram-tokenised, so a 2-char CJK term cannot be expressed and a
16
+ * whitespace split leaves a whole Chinese sentence as ONE token — a single OR'd trigram query for
17
+ * such a sentence matches incidentally and its bm25 rank is *not* discriminative: measured
18
+ * -3.14 for an unrelated question vs -2.12 for a genuine one-word `cgroup` query). Counting how
19
+ * many DISTINCT terms each store actually holds separates cleanly instead. Measured on the live
20
+ * stores, 10 probes: the three genuinely-covered questions scored 6, 6 and 2 (`cgroup v2 的内存保护…`,
21
+ * its question form, `MGLRU 是怎么回收页面的`), the seven uncovered ones scored 0 or 1 — including
22
+ * an English question that matched common words — it scored 3 matched terms with a 4-char floor
23
+ * (`unit`, `test`, `this`, `for`) and only 1 once the floor was 5 (`write`), so the floor alone
24
+ * would not have saved it; BOTH the floor and the two-term bar are load-bearing. Hence
25
+ * `matched >= 2`, with the margin between 2 and 1 rather than a tuned threshold inside a
26
+ * continuum. A single-word query (`cgroup`) scores 1 and stays silent on purpose: one term is not
27
+ * evidence.
28
+ */
29
+ /** How many of an input's terms a store holds. `terms === 0` means the input had nothing to test. */
30
+ export interface LexicalProbe {
31
+ terms: number;
32
+ matched: number;
33
+ }
34
+ /**
35
+ * Which stores hold something relevant — the ONE answer the plugin's hint layer consumes.
36
+ *
37
+ * A boolean on purpose: `kb_query` retrieves from BOTH stores, so the hint only has to decide
38
+ * "worth mentioning or not". Which store matched is nobody's business downstream — and asking for
39
+ * it cost a distinction the probe would have to keep alive in two places.
40
+ */
41
+ export type RelevanceHit = boolean;
42
+ /**
43
+ * The distinct terms of `text` that the FTS tables can express.
44
+ *
45
+ * Exported for the store probes and for the tests that pin the measured behaviour above; the
46
+ * caller runs one `LIMIT 1` MATCH per term and counts the hits.
47
+ */
48
+ export declare function relevanceTerms(text: string, cap?: number): string[];
49
+ /**
50
+ * The gate itself: is this store worth telling the model about?
51
+ *
52
+ * Two distinct terms is the measured bar (see the module comment). Deliberately not "at least one"
53
+ * — a single hit is what a common word produces — and deliberately not a score threshold, because
54
+ * bm25 sums over OR'd terms and therefore rewards a long question for merely containing many
55
+ * 3-grams.
56
+ */
57
+ export declare function looksRelevant(probe: LexicalProbe): boolean;
58
+ /**
59
+ * Count how many of `text`'s terms ONE store holds.
60
+ *
61
+ * The loop both stores used to carry verbatim. `holds` is handed the raw TERM, not a built MATCH
62
+ * expression: turning a term into a query depends on the tokenizer the store's FTS table was actually
63
+ * built with (see `db/tokenizer.ts`), which is the store's business, not this module's. Each store
64
+ * asks its own table with `LIMIT 1` — the question is whether a term exists at all, not where it ranks. `stopAt` lets a caller that only asks "≥ 2?" stop early (`relevance()` does, and the prompt
65
+ * path is synchronous); the default keeps an exact count for the calibration recorded above.
66
+ */
67
+ export declare function probeTerms(text: string, holds: (term: string) => boolean, stopAt?: number): LexicalProbe;