@tenphi/akno-core 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +83 -0
- package/README.md +26 -0
- package/config/default.jsonc +548 -0
- package/dist/bench/answer-corpus.d.ts +48 -0
- package/dist/bench/answer-corpus.d.ts.map +1 -0
- package/dist/bench/answer-corpus.js +491 -0
- package/dist/bench/answer-corpus.js.map +1 -0
- package/dist/bench/answer.d.ts +156 -0
- package/dist/bench/answer.d.ts.map +1 -0
- package/dist/bench/answer.js +608 -0
- package/dist/bench/answer.js.map +1 -0
- package/dist/bench/auto-recall-answer-corpus.d.ts +21 -0
- package/dist/bench/auto-recall-answer-corpus.d.ts.map +1 -0
- package/dist/bench/auto-recall-answer-corpus.js +245 -0
- package/dist/bench/auto-recall-answer-corpus.js.map +1 -0
- package/dist/bench/auto-recall-answer.d.ts +198 -0
- package/dist/bench/auto-recall-answer.d.ts.map +1 -0
- package/dist/bench/auto-recall-answer.js +720 -0
- package/dist/bench/auto-recall-answer.js.map +1 -0
- package/dist/bench/auto-recall-corpus.d.ts +45 -0
- package/dist/bench/auto-recall-corpus.d.ts.map +1 -0
- package/dist/bench/auto-recall-corpus.js +221 -0
- package/dist/bench/auto-recall-corpus.js.map +1 -0
- package/dist/bench/auto-recall.d.ts +171 -0
- package/dist/bench/auto-recall.d.ts.map +1 -0
- package/dist/bench/auto-recall.js +651 -0
- package/dist/bench/auto-recall.js.map +1 -0
- package/dist/bench/entity-resolution.d.ts +37 -0
- package/dist/bench/entity-resolution.d.ts.map +1 -0
- package/dist/bench/entity-resolution.js +121 -0
- package/dist/bench/entity-resolution.js.map +1 -0
- package/dist/bench/graph.d.ts +57 -0
- package/dist/bench/graph.d.ts.map +1 -0
- package/dist/bench/graph.js +573 -0
- package/dist/bench/graph.js.map +1 -0
- package/dist/bench/llm-ranking-probe.d.ts +27 -0
- package/dist/bench/llm-ranking-probe.d.ts.map +1 -0
- package/dist/bench/llm-ranking-probe.js +105 -0
- package/dist/bench/llm-ranking-probe.js.map +1 -0
- package/dist/bench/merge-discovery-corpus.d.ts +26 -0
- package/dist/bench/merge-discovery-corpus.d.ts.map +1 -0
- package/dist/bench/merge-discovery-corpus.js +114 -0
- package/dist/bench/merge-discovery-corpus.js.map +1 -0
- package/dist/bench/merge-discovery-review.d.ts +81 -0
- package/dist/bench/merge-discovery-review.d.ts.map +1 -0
- package/dist/bench/merge-discovery-review.js +149 -0
- package/dist/bench/merge-discovery-review.js.map +1 -0
- package/dist/bench/merge-discovery.d.ts +122 -0
- package/dist/bench/merge-discovery.d.ts.map +1 -0
- package/dist/bench/merge-discovery.js +390 -0
- package/dist/bench/merge-discovery.js.map +1 -0
- package/dist/bench/mixed-retrieval.d.ts +35 -0
- package/dist/bench/mixed-retrieval.d.ts.map +1 -0
- package/dist/bench/mixed-retrieval.js +355 -0
- package/dist/bench/mixed-retrieval.js.map +1 -0
- package/dist/bench/ranking-corpus.d.ts +29 -0
- package/dist/bench/ranking-corpus.d.ts.map +1 -0
- package/dist/bench/ranking-corpus.js +514 -0
- package/dist/bench/ranking-corpus.js.map +1 -0
- package/dist/bench/ranking-end-to-end.d.ts +112 -0
- package/dist/bench/ranking-end-to-end.d.ts.map +1 -0
- package/dist/bench/ranking-end-to-end.js +479 -0
- package/dist/bench/ranking-end-to-end.js.map +1 -0
- package/dist/bench/ranking-latency.d.ts +71 -0
- package/dist/bench/ranking-latency.d.ts.map +1 -0
- package/dist/bench/ranking-latency.js +130 -0
- package/dist/bench/ranking-latency.js.map +1 -0
- package/dist/bench/ranking-matrix.d.ts +141 -0
- package/dist/bench/ranking-matrix.d.ts.map +1 -0
- package/dist/bench/ranking-matrix.js +526 -0
- package/dist/bench/ranking-matrix.js.map +1 -0
- package/dist/bench/ranking-review.d.ts +93 -0
- package/dist/bench/ranking-review.d.ts.map +1 -0
- package/dist/bench/ranking-review.js +249 -0
- package/dist/bench/ranking-review.js.map +1 -0
- package/dist/bench/ranking.d.ts +122 -0
- package/dist/bench/ranking.d.ts.map +1 -0
- package/dist/bench/ranking.js +547 -0
- package/dist/bench/ranking.js.map +1 -0
- package/dist/bench.d.ts +60 -0
- package/dist/bench.d.ts.map +1 -0
- package/dist/bench.js +167 -0
- package/dist/bench.js.map +1 -0
- package/dist/config/jsonc.d.ts +8 -0
- package/dist/config/jsonc.d.ts.map +1 -0
- package/dist/config/jsonc.js +85 -0
- package/dist/config/jsonc.js.map +1 -0
- package/dist/config/load.d.ts +43 -0
- package/dist/config/load.d.ts.map +1 -0
- package/dist/config/load.js +608 -0
- package/dist/config/load.js.map +1 -0
- package/dist/config/paths.d.ts +10 -0
- package/dist/config/paths.d.ts.map +1 -0
- package/dist/config/paths.js +38 -0
- package/dist/config/paths.js.map +1 -0
- package/dist/config/schema.d.ts +698 -0
- package/dist/config/schema.d.ts.map +1 -0
- package/dist/config/schema.js +394 -0
- package/dist/config/schema.js.map +1 -0
- package/dist/config/write-rules.d.ts +41 -0
- package/dist/config/write-rules.d.ts.map +1 -0
- package/dist/config/write-rules.js +225 -0
- package/dist/config/write-rules.js.map +1 -0
- package/dist/config/write-setup.d.ts +42 -0
- package/dist/config/write-setup.d.ts.map +1 -0
- package/dist/config/write-setup.js +358 -0
- package/dist/config/write-setup.js.map +1 -0
- package/dist/context.d.ts +64 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +14 -0
- package/dist/context.js.map +1 -0
- package/dist/doctor.d.ts +115 -0
- package/dist/doctor.d.ts.map +1 -0
- package/dist/doctor.js +388 -0
- package/dist/doctor.js.map +1 -0
- package/dist/index/chunk.d.ts +62 -0
- package/dist/index/chunk.d.ts.map +1 -0
- package/dist/index/chunk.js +307 -0
- package/dist/index/chunk.js.map +1 -0
- package/dist/index/defer.d.ts +32 -0
- package/dist/index/defer.d.ts.map +1 -0
- package/dist/index/defer.js +66 -0
- package/dist/index/defer.js.map +1 -0
- package/dist/index/derive.d.ts +99 -0
- package/dist/index/derive.d.ts.map +1 -0
- package/dist/index/derive.js +378 -0
- package/dist/index/derive.js.map +1 -0
- package/dist/index/entity-resolution.d.ts +68 -0
- package/dist/index/entity-resolution.d.ts.map +1 -0
- package/dist/index/entity-resolution.js +305 -0
- package/dist/index/entity-resolution.js.map +1 -0
- package/dist/index/graph.d.ts +46 -0
- package/dist/index/graph.d.ts.map +1 -0
- package/dist/index/graph.js +618 -0
- package/dist/index/graph.js.map +1 -0
- package/dist/index/indexer.d.ts +301 -0
- package/dist/index/indexer.d.ts.map +1 -0
- package/dist/index/indexer.js +1398 -0
- package/dist/index/indexer.js.map +1 -0
- package/dist/index/revision-barrier.d.ts +30 -0
- package/dist/index/revision-barrier.d.ts.map +1 -0
- package/dist/index/revision-barrier.js +140 -0
- package/dist/index/revision-barrier.js.map +1 -0
- package/dist/index.d.ts +55 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +37 -0
- package/dist/index.js.map +1 -0
- package/dist/ingest/adoption-eligibility.d.ts +4 -0
- package/dist/ingest/adoption-eligibility.d.ts.map +1 -0
- package/dist/ingest/adoption-eligibility.js +18 -0
- package/dist/ingest/adoption-eligibility.js.map +1 -0
- package/dist/ingest/availability.d.ts +15 -0
- package/dist/ingest/availability.d.ts.map +1 -0
- package/dist/ingest/availability.js +37 -0
- package/dist/ingest/availability.js.map +1 -0
- package/dist/ingest/extract.d.ts +58 -0
- package/dist/ingest/extract.d.ts.map +1 -0
- package/dist/ingest/extract.js +255 -0
- package/dist/ingest/extract.js.map +1 -0
- package/dist/ingest/fetch.d.ts +35 -0
- package/dist/ingest/fetch.d.ts.map +1 -0
- package/dist/ingest/fetch.js +142 -0
- package/dist/ingest/fetch.js.map +1 -0
- package/dist/ingest/inbox.d.ts +53 -0
- package/dist/ingest/inbox.d.ts.map +1 -0
- package/dist/ingest/inbox.js +110 -0
- package/dist/ingest/inbox.js.map +1 -0
- package/dist/ingest/name.d.ts +72 -0
- package/dist/ingest/name.d.ts.map +1 -0
- package/dist/ingest/name.js +208 -0
- package/dist/ingest/name.js.map +1 -0
- package/dist/ingest/parts.d.ts +78 -0
- package/dist/ingest/parts.d.ts.map +1 -0
- package/dist/ingest/parts.js +52 -0
- package/dist/ingest/parts.js.map +1 -0
- package/dist/ingest/rendition.d.ts +75 -0
- package/dist/ingest/rendition.d.ts.map +1 -0
- package/dist/ingest/rendition.js +92 -0
- package/dist/ingest/rendition.js.map +1 -0
- package/dist/ingest/store.d.ts +66 -0
- package/dist/ingest/store.d.ts.map +1 -0
- package/dist/ingest/store.js +92 -0
- package/dist/ingest/store.js.map +1 -0
- package/dist/kb/folders.d.ts +37 -0
- package/dist/kb/folders.d.ts.map +1 -0
- package/dist/kb/folders.js +133 -0
- package/dist/kb/folders.js.map +1 -0
- package/dist/kb/frontmatter.d.ts +91 -0
- package/dist/kb/frontmatter.d.ts.map +1 -0
- package/dist/kb/frontmatter.js +274 -0
- package/dist/kb/frontmatter.js.map +1 -0
- package/dist/kb/line-facts.d.ts +29 -0
- package/dist/kb/line-facts.d.ts.map +1 -0
- package/dist/kb/line-facts.js +23 -0
- package/dist/kb/line-facts.js.map +1 -0
- package/dist/kb/page.d.ts +92 -0
- package/dist/kb/page.d.ts.map +1 -0
- package/dist/kb/page.js +243 -0
- package/dist/kb/page.js.map +1 -0
- package/dist/kb/scan.d.ts +38 -0
- package/dist/kb/scan.d.ts.map +1 -0
- package/dist/kb/scan.js +109 -0
- package/dist/kb/scan.js.map +1 -0
- package/dist/kb/words.d.ts +30 -0
- package/dist/kb/words.d.ts.map +1 -0
- package/dist/kb/words.js +107 -0
- package/dist/kb/words.js.map +1 -0
- package/dist/maintenance/adopt.d.ts +65 -0
- package/dist/maintenance/adopt.d.ts.map +1 -0
- package/dist/maintenance/adopt.js +238 -0
- package/dist/maintenance/adopt.js.map +1 -0
- package/dist/maintenance/budget.d.ts +55 -0
- package/dist/maintenance/budget.d.ts.map +1 -0
- package/dist/maintenance/budget.js +61 -0
- package/dist/maintenance/budget.js.map +1 -0
- package/dist/maintenance/conflicts.d.ts +105 -0
- package/dist/maintenance/conflicts.d.ts.map +1 -0
- package/dist/maintenance/conflicts.js +368 -0
- package/dist/maintenance/conflicts.js.map +1 -0
- package/dist/maintenance/contradictions.d.ts +34 -0
- package/dist/maintenance/contradictions.d.ts.map +1 -0
- package/dist/maintenance/contradictions.js +199 -0
- package/dist/maintenance/contradictions.js.map +1 -0
- package/dist/maintenance/curate.d.ts +156 -0
- package/dist/maintenance/curate.d.ts.map +1 -0
- package/dist/maintenance/curate.js +2070 -0
- package/dist/maintenance/curate.js.map +1 -0
- package/dist/maintenance/dream.d.ts +139 -0
- package/dist/maintenance/dream.d.ts.map +1 -0
- package/dist/maintenance/dream.js +1632 -0
- package/dist/maintenance/dream.js.map +1 -0
- package/dist/maintenance/graph-candidates.d.ts +19 -0
- package/dist/maintenance/graph-candidates.d.ts.map +1 -0
- package/dist/maintenance/graph-candidates.js +120 -0
- package/dist/maintenance/graph-candidates.js.map +1 -0
- package/dist/maintenance/housekeeping.d.ts +81 -0
- package/dist/maintenance/housekeeping.d.ts.map +1 -0
- package/dist/maintenance/housekeeping.js +281 -0
- package/dist/maintenance/housekeeping.js.map +1 -0
- package/dist/maintenance/link-repairs.d.ts +58 -0
- package/dist/maintenance/link-repairs.d.ts.map +1 -0
- package/dist/maintenance/link-repairs.js +281 -0
- package/dist/maintenance/link-repairs.js.map +1 -0
- package/dist/maintenance/log.d.ts +41 -0
- package/dist/maintenance/log.d.ts.map +1 -0
- package/dist/maintenance/log.js +72 -0
- package/dist/maintenance/log.js.map +1 -0
- package/dist/maintenance/managed-item-routing.d.ts +48 -0
- package/dist/maintenance/managed-item-routing.d.ts.map +1 -0
- package/dist/maintenance/managed-item-routing.js +281 -0
- package/dist/maintenance/managed-item-routing.js.map +1 -0
- package/dist/maintenance/managed-item-sources.d.ts +38 -0
- package/dist/maintenance/managed-item-sources.d.ts.map +1 -0
- package/dist/maintenance/managed-item-sources.js +324 -0
- package/dist/maintenance/managed-item-sources.js.map +1 -0
- package/dist/maintenance/managed-items.d.ts +162 -0
- package/dist/maintenance/managed-items.d.ts.map +1 -0
- package/dist/maintenance/managed-items.js +1329 -0
- package/dist/maintenance/managed-items.js.map +1 -0
- package/dist/maintenance/merge-classifier.d.ts +19 -0
- package/dist/maintenance/merge-classifier.d.ts.map +1 -0
- package/dist/maintenance/merge-classifier.js +49 -0
- package/dist/maintenance/merge-classifier.js.map +1 -0
- package/dist/maintenance/model-telemetry.d.ts +45 -0
- package/dist/maintenance/model-telemetry.d.ts.map +1 -0
- package/dist/maintenance/model-telemetry.js +107 -0
- package/dist/maintenance/model-telemetry.js.map +1 -0
- package/dist/maintenance/observe.d.ts +72 -0
- package/dist/maintenance/observe.d.ts.map +1 -0
- package/dist/maintenance/observe.js +265 -0
- package/dist/maintenance/observe.js.map +1 -0
- package/dist/maintenance/path-policy.d.ts +83 -0
- package/dist/maintenance/path-policy.d.ts.map +1 -0
- package/dist/maintenance/path-policy.js +360 -0
- package/dist/maintenance/path-policy.js.map +1 -0
- package/dist/maintenance/plans.d.ts +366 -0
- package/dist/maintenance/plans.d.ts.map +1 -0
- package/dist/maintenance/plans.js +4060 -0
- package/dist/maintenance/plans.js.map +1 -0
- package/dist/maintenance/profile.d.ts +33 -0
- package/dist/maintenance/profile.d.ts.map +1 -0
- package/dist/maintenance/profile.js +99 -0
- package/dist/maintenance/profile.js.map +1 -0
- package/dist/maintenance/recovery.d.ts +38 -0
- package/dist/maintenance/recovery.d.ts.map +1 -0
- package/dist/maintenance/recovery.js +217 -0
- package/dist/maintenance/recovery.js.map +1 -0
- package/dist/maintenance/repair.d.ts +37 -0
- package/dist/maintenance/repair.d.ts.map +1 -0
- package/dist/maintenance/repair.js +183 -0
- package/dist/maintenance/repair.js.map +1 -0
- package/dist/maintenance/rule-drift.d.ts +104 -0
- package/dist/maintenance/rule-drift.d.ts.map +1 -0
- package/dist/maintenance/rule-drift.js +507 -0
- package/dist/maintenance/rule-drift.js.map +1 -0
- package/dist/maintenance/run-verification.d.ts +44 -0
- package/dist/maintenance/run-verification.d.ts.map +1 -0
- package/dist/maintenance/run-verification.js +253 -0
- package/dist/maintenance/run-verification.js.map +1 -0
- package/dist/maintenance/runs.d.ts +142 -0
- package/dist/maintenance/runs.d.ts.map +1 -0
- package/dist/maintenance/runs.js +439 -0
- package/dist/maintenance/runs.js.map +1 -0
- package/dist/maintenance/semantic-merge-discovery.d.ts +54 -0
- package/dist/maintenance/semantic-merge-discovery.d.ts.map +1 -0
- package/dist/maintenance/semantic-merge-discovery.js +308 -0
- package/dist/maintenance/semantic-merge-discovery.js.map +1 -0
- package/dist/maintenance/temporal.d.ts +44 -0
- package/dist/maintenance/temporal.d.ts.map +1 -0
- package/dist/maintenance/temporal.js +295 -0
- package/dist/maintenance/temporal.js.map +1 -0
- package/dist/models/client.d.ts +214 -0
- package/dist/models/client.d.ts.map +1 -0
- package/dist/models/client.js +904 -0
- package/dist/models/client.js.map +1 -0
- package/dist/models/provider-api.d.ts +28 -0
- package/dist/models/provider-api.d.ts.map +1 -0
- package/dist/models/provider-api.js +320 -0
- package/dist/models/provider-api.js.map +1 -0
- package/dist/open.d.ts +163 -0
- package/dist/open.d.ts.map +1 -0
- package/dist/open.js +382 -0
- package/dist/open.js.map +1 -0
- package/dist/ops/adopt.d.ts +12 -0
- package/dist/ops/adopt.d.ts.map +1 -0
- package/dist/ops/adopt.js +138 -0
- package/dist/ops/adopt.js.map +1 -0
- package/dist/ops/answer.d.ts +29 -0
- package/dist/ops/answer.d.ts.map +1 -0
- package/dist/ops/answer.js +557 -0
- package/dist/ops/answer.js.map +1 -0
- package/dist/ops/context.d.ts +18 -0
- package/dist/ops/context.d.ts.map +1 -0
- package/dist/ops/context.js +713 -0
- package/dist/ops/context.js.map +1 -0
- package/dist/ops/folder.d.ts +23 -0
- package/dist/ops/folder.d.ts.map +1 -0
- package/dist/ops/folder.js +130 -0
- package/dist/ops/folder.js.map +1 -0
- package/dist/ops/forget.d.ts +14 -0
- package/dist/ops/forget.d.ts.map +1 -0
- package/dist/ops/forget.js +212 -0
- package/dist/ops/forget.js.map +1 -0
- package/dist/ops/graph.d.ts +10 -0
- package/dist/ops/graph.d.ts.map +1 -0
- package/dist/ops/graph.js +480 -0
- package/dist/ops/graph.js.map +1 -0
- package/dist/ops/ingest.d.ts +40 -0
- package/dist/ops/ingest.d.ts.map +1 -0
- package/dist/ops/ingest.js +434 -0
- package/dist/ops/ingest.js.map +1 -0
- package/dist/ops/list.d.ts +9 -0
- package/dist/ops/list.d.ts.map +1 -0
- package/dist/ops/list.js +217 -0
- package/dist/ops/list.js.map +1 -0
- package/dist/ops/move.d.ts +20 -0
- package/dist/ops/move.d.ts.map +1 -0
- package/dist/ops/move.js +173 -0
- package/dist/ops/move.js.map +1 -0
- package/dist/ops/read.d.ts +9 -0
- package/dist/ops/read.d.ts.map +1 -0
- package/dist/ops/read.js +271 -0
- package/dist/ops/read.js.map +1 -0
- package/dist/ops/recall.d.ts +13 -0
- package/dist/ops/recall.d.ts.map +1 -0
- package/dist/ops/recall.js +297 -0
- package/dist/ops/recall.js.map +1 -0
- package/dist/ops/remember.d.ts +34 -0
- package/dist/ops/remember.d.ts.map +1 -0
- package/dist/ops/remember.js +660 -0
- package/dist/ops/remember.js.map +1 -0
- package/dist/ops/timeline.d.ts +11 -0
- package/dist/ops/timeline.d.ts.map +1 -0
- package/dist/ops/timeline.js +111 -0
- package/dist/ops/timeline.js.map +1 -0
- package/dist/ops/undo.d.ts +9 -0
- package/dist/ops/undo.d.ts.map +1 -0
- package/dist/ops/undo.js +38 -0
- package/dist/ops/undo.js.map +1 -0
- package/dist/ops/write.d.ts +55 -0
- package/dist/ops/write.d.ts.map +1 -0
- package/dist/ops/write.js +463 -0
- package/dist/ops/write.js.map +1 -0
- package/dist/recall/assemble.d.ts +81 -0
- package/dist/recall/assemble.d.ts.map +1 -0
- package/dist/recall/assemble.js +603 -0
- package/dist/recall/assemble.js.map +1 -0
- package/dist/recall/expand.d.ts +55 -0
- package/dist/recall/expand.d.ts.map +1 -0
- package/dist/recall/expand.js +282 -0
- package/dist/recall/expand.js.map +1 -0
- package/dist/recall/graph-arm.d.ts +17 -0
- package/dist/recall/graph-arm.d.ts.map +1 -0
- package/dist/recall/graph-arm.js +227 -0
- package/dist/recall/graph-arm.js.map +1 -0
- package/dist/recall/llm-rerank.d.ts +55 -0
- package/dist/recall/llm-rerank.d.ts.map +1 -0
- package/dist/recall/llm-rerank.js +213 -0
- package/dist/recall/llm-rerank.js.map +1 -0
- package/dist/recall/reranker-calibration.d.ts +23 -0
- package/dist/recall/reranker-calibration.d.ts.map +1 -0
- package/dist/recall/reranker-calibration.js +161 -0
- package/dist/recall/reranker-calibration.js.map +1 -0
- package/dist/recall/search.d.ts +107 -0
- package/dist/recall/search.d.ts.map +1 -0
- package/dist/recall/search.js +530 -0
- package/dist/recall/search.js.map +1 -0
- package/dist/reserved.d.ts +39 -0
- package/dist/reserved.d.ts.map +1 -0
- package/dist/reserved.js +63 -0
- package/dist/reserved.js.map +1 -0
- package/dist/rules/compile.d.ts +27 -0
- package/dist/rules/compile.d.ts.map +1 -0
- package/dist/rules/compile.js +105 -0
- package/dist/rules/compile.js.map +1 -0
- package/dist/setup/model-free.d.ts +15 -0
- package/dist/setup/model-free.d.ts.map +1 -0
- package/dist/setup/model-free.js +26 -0
- package/dist/setup/model-free.js.map +1 -0
- package/dist/setup/openai.d.ts +46 -0
- package/dist/setup/openai.d.ts.map +1 -0
- package/dist/setup/openai.js +165 -0
- package/dist/setup/openai.js.map +1 -0
- package/dist/store/db.d.ts +61 -0
- package/dist/store/db.d.ts.map +1 -0
- package/dist/store/db.js +265 -0
- package/dist/store/db.js.map +1 -0
- package/dist/store/ids.d.ts +29 -0
- package/dist/store/ids.d.ts.map +1 -0
- package/dist/store/ids.js +0 -0
- package/dist/store/ids.js.map +1 -0
- package/dist/store/migrations.d.ts +54 -0
- package/dist/store/migrations.d.ts.map +1 -0
- package/dist/store/migrations.js +923 -0
- package/dist/store/migrations.js.map +1 -0
- package/dist/store/vectors.d.ts +34 -0
- package/dist/store/vectors.d.ts.map +1 -0
- package/dist/store/vectors.js +158 -0
- package/dist/store/vectors.js.map +1 -0
- package/dist/timeline/documents.d.ts +23 -0
- package/dist/timeline/documents.d.ts.map +1 -0
- package/dist/timeline/documents.js +262 -0
- package/dist/timeline/documents.js.map +1 -0
- package/dist/watch/watcher.d.ts +43 -0
- package/dist/watch/watcher.d.ts.map +1 -0
- package/dist/watch/watcher.js +143 -0
- package/dist/watch/watcher.js.map +1 -0
- package/dist/write/atomic.d.ts +34 -0
- package/dist/write/atomic.d.ts.map +1 -0
- package/dist/write/atomic.js +58 -0
- package/dist/write/atomic.js.map +1 -0
- package/dist/write/conflict.d.ts +43 -0
- package/dist/write/conflict.d.ts.map +1 -0
- package/dist/write/conflict.js +149 -0
- package/dist/write/conflict.js.map +1 -0
- package/dist/write/edit.d.ts +59 -0
- package/dist/write/edit.d.ts.map +1 -0
- package/dist/write/edit.js +231 -0
- package/dist/write/edit.js.map +1 -0
- package/dist/write/gate.d.ts +59 -0
- package/dist/write/gate.d.ts.map +1 -0
- package/dist/write/gate.js +99 -0
- package/dist/write/gate.js.map +1 -0
- package/dist/write/journal.d.ts +90 -0
- package/dist/write/journal.d.ts.map +1 -0
- package/dist/write/journal.js +187 -0
- package/dist/write/journal.js.map +1 -0
- package/dist/write/ledger.d.ts +28 -0
- package/dist/write/ledger.d.ts.map +1 -0
- package/dist/write/ledger.js +141 -0
- package/dist/write/ledger.js.map +1 -0
- package/dist/write/placement.d.ts +29 -0
- package/dist/write/placement.d.ts.map +1 -0
- package/dist/write/placement.js +140 -0
- package/dist/write/placement.js.map +1 -0
- package/dist/write/remember-fallback.d.ts +18 -0
- package/dist/write/remember-fallback.d.ts.map +1 -0
- package/dist/write/remember-fallback.js +48 -0
- package/dist/write/remember-fallback.js.map +1 -0
- package/dist/write/retain.d.ts +61 -0
- package/dist/write/retain.d.ts.map +1 -0
- package/dist/write/retain.js +272 -0
- package/dist/write/retain.js.map +1 -0
- package/package.json +50 -0
- package/swift/extract.swift +232 -0
|
@@ -0,0 +1,1398 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import fsp from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { indexScanIgnore } from "../config/load.js";
|
|
5
|
+
import { ledgerSlug } from "../reserved.js";
|
|
6
|
+
import { extract } from "../ingest/extract.js";
|
|
7
|
+
import { documentPart, documentRendition } from "../ingest/parts.js";
|
|
8
|
+
import { looksLikeRendition, renditionBody, renditionPathFor, renditionWanted } from "../ingest/rendition.js";
|
|
9
|
+
import { writeFileAtomic } from "../write/atomic.js";
|
|
10
|
+
import { effectiveRule } from "../rules/compile.js";
|
|
11
|
+
import { hashFile, mapWithConcurrency, scanTree } from "../kb/scan.js";
|
|
12
|
+
import { ATTACHMENT_NAME, parsePage, resolvePagePolicy, } from "../kb/page.js";
|
|
13
|
+
import { withId } from "../kb/frontmatter.js";
|
|
14
|
+
import { applySourceFence, chunkDocument, chunkPage, embeddingText, } from "./chunk.js";
|
|
15
|
+
import { bodyItemIds, bodyLineHashes, derivePage, summarizeDocument } from "./derive.js";
|
|
16
|
+
import { eventId, factId, managedFactId, newPageId, sha256 } from "../store/ids.js";
|
|
17
|
+
import { rebuildEvidenceGraph } from "./graph.js";
|
|
18
|
+
import { resolveContextualEntityMentions } from "./entity-resolution.js";
|
|
19
|
+
import { IndexRevisionCoordinator } from "./revision-barrier.js";
|
|
20
|
+
/**
|
|
21
|
+
* Reconciles the index against the knowledge base. Both directions are
|
|
22
|
+
* first class: the user restructures everything on a Sunday afternoon and this
|
|
23
|
+
* reconciles, without ever asking them to go through Akno.
|
|
24
|
+
*
|
|
25
|
+
* A restart does **not** re-index — it stats. Only files whose mtime or size
|
|
26
|
+
* moved get hashed, so a full restart with nothing changed is a 1.2ms sweep.
|
|
27
|
+
*/
|
|
28
|
+
export class Indexer {
|
|
29
|
+
#config;
|
|
30
|
+
#store;
|
|
31
|
+
#models;
|
|
32
|
+
#revision = new IndexRevisionCoordinator();
|
|
33
|
+
constructor(config, store, models) {
|
|
34
|
+
this.#config = config;
|
|
35
|
+
this.#store = store;
|
|
36
|
+
this.#models = models;
|
|
37
|
+
}
|
|
38
|
+
async run(options = {}) {
|
|
39
|
+
return this.#revision.run(() => this.runPass(options));
|
|
40
|
+
}
|
|
41
|
+
/** Finish a caller-visible memory mutation even when a background dream owns a planner revision. */
|
|
42
|
+
async runForeground(options = {}) {
|
|
43
|
+
return this.#revision.runForeground(() => this.runPass(options));
|
|
44
|
+
}
|
|
45
|
+
/** Notify a planner barrier about a foreground policy mutation that has no index pass. */
|
|
46
|
+
invalidateRevisionBarrier() {
|
|
47
|
+
return this.#revision.invalidateForForeground();
|
|
48
|
+
}
|
|
49
|
+
/** Hold one indexed revision while a full dream run constructs its complete planner wave. */
|
|
50
|
+
acquireRevisionBarrier() {
|
|
51
|
+
return this.#revision.acquire();
|
|
52
|
+
}
|
|
53
|
+
async runPass(options) {
|
|
54
|
+
const started = performance.now();
|
|
55
|
+
// Per pass: the folder may have changed since the last one, and a stale listing would
|
|
56
|
+
// decide a rendition against files that are no longer there.
|
|
57
|
+
this.#entriesCache.clear();
|
|
58
|
+
const report = {
|
|
59
|
+
scanned: 0,
|
|
60
|
+
hashed: 0,
|
|
61
|
+
pagesIndexed: 0,
|
|
62
|
+
pagesUnchanged: 0,
|
|
63
|
+
pagesRemoved: 0,
|
|
64
|
+
pagesRenamed: 0,
|
|
65
|
+
chunksWritten: 0,
|
|
66
|
+
chunksEmbedded: 0,
|
|
67
|
+
pagesDerived: 0,
|
|
68
|
+
documentsLinked: 0,
|
|
69
|
+
documentsExtracted: 0,
|
|
70
|
+
documentsSummarized: 0,
|
|
71
|
+
renditionsWritten: 0,
|
|
72
|
+
eventsIndexed: 0,
|
|
73
|
+
factsDerived: 0,
|
|
74
|
+
graphNodes: 0,
|
|
75
|
+
graphEdges: 0,
|
|
76
|
+
graphEntities: 0,
|
|
77
|
+
graphMentions: 0,
|
|
78
|
+
graphAmbiguousMentions: 0,
|
|
79
|
+
graphUnresolvedMentions: 0,
|
|
80
|
+
graphContextualMentions: 0,
|
|
81
|
+
graphContextualResolved: 0,
|
|
82
|
+
graphContextualAbstained: 0,
|
|
83
|
+
graphContextualCached: 0,
|
|
84
|
+
graphContextualFailed: 0,
|
|
85
|
+
graphFacts: 0,
|
|
86
|
+
graphFactEdges: 0,
|
|
87
|
+
graphNonTraversableFacts: 0,
|
|
88
|
+
ignored: 0,
|
|
89
|
+
warnings: [],
|
|
90
|
+
durationMs: 0,
|
|
91
|
+
};
|
|
92
|
+
const progress = options.onProgress ?? (() => { });
|
|
93
|
+
progress({ phase: 'scan', done: 0, total: 0 });
|
|
94
|
+
const scanned = await scanTree({
|
|
95
|
+
root: this.#config.aknoPath,
|
|
96
|
+
ignore: this.scanIgnore(),
|
|
97
|
+
pageExtensions: this.#config.pageExtensions,
|
|
98
|
+
maxPageBytes: this.#config.maxPageBytes,
|
|
99
|
+
});
|
|
100
|
+
const files = options.only ? scanned.filter((file) => options.only.includes(file.relPath)) : scanned;
|
|
101
|
+
report.scanned = files.length;
|
|
102
|
+
const known = this.knownFiles();
|
|
103
|
+
// ── Rule changes ───────────────────────────────────────────────────────
|
|
104
|
+
// A rule edit is not a file edit. Without this, assigning `role: ignored` to a folder
|
|
105
|
+
// and re-indexing reports "223 pages unchanged" and leaves those pages indexed,
|
|
106
|
+
// searchable and asserted as facts — the config silently doing nothing, which is
|
|
107
|
+
// exactly the failure Akno exists to avoid. A `--only` pass sees a fraction of the
|
|
108
|
+
// tree and is not the place to conclude anything about the rest of it.
|
|
109
|
+
const reclassified = options.only ? new Set() : this.reclassify(report);
|
|
110
|
+
if (!options.only)
|
|
111
|
+
for (const relPath of this.pageFilesWithNoPage(report))
|
|
112
|
+
reclassified.add(relPath);
|
|
113
|
+
// ── Stat fast path ─────────────────────────────────────────────────────
|
|
114
|
+
// mtime is a fast path, not a correctness guarantee — sync clients and
|
|
115
|
+
// restored backups can preserve it across a real content change. The
|
|
116
|
+
// full hash sweep on `--verify` and the periodic backstop, not every start.
|
|
117
|
+
const changed = [];
|
|
118
|
+
for (const file of files) {
|
|
119
|
+
const prior = known.get(file.relPath);
|
|
120
|
+
const moved = !prior || prior.size !== file.size || prior.mtime_ns !== file.mtimeNs;
|
|
121
|
+
if (options.verify || moved || options.reindexUnchanged || reclassified.has(file.relPath)) {
|
|
122
|
+
changed.push(file);
|
|
123
|
+
}
|
|
124
|
+
else {
|
|
125
|
+
file.sha256 = prior.sha256;
|
|
126
|
+
if (file.kind === 'page')
|
|
127
|
+
report.pagesUnchanged++;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
progress({ phase: 'hash', done: 0, total: changed.length });
|
|
131
|
+
let hashed = 0;
|
|
132
|
+
await mapWithConcurrency(changed, this.#config.index.hashConcurrency, async (file) => {
|
|
133
|
+
try {
|
|
134
|
+
file.sha256 = await hashFile(file.absPath);
|
|
135
|
+
}
|
|
136
|
+
catch (err) {
|
|
137
|
+
report.warnings.push(`could not hash ${file.relPath}: ${errorMessage(err)}`);
|
|
138
|
+
}
|
|
139
|
+
progress({ phase: 'hash', done: ++hashed, total: changed.length });
|
|
140
|
+
});
|
|
141
|
+
report.hashed = changed.filter((f) => f.sha256).length;
|
|
142
|
+
// A hash that matches what we recorded means mtime lied — nothing to do.
|
|
143
|
+
const needsIndex = changed.filter((file) => {
|
|
144
|
+
if (!file.sha256)
|
|
145
|
+
return false;
|
|
146
|
+
const prior = known.get(file.relPath);
|
|
147
|
+
if (prior &&
|
|
148
|
+
prior.sha256 === file.sha256 &&
|
|
149
|
+
!options.reindexUnchanged &&
|
|
150
|
+
!reclassified.has(file.relPath)) {
|
|
151
|
+
this.touchFile(file);
|
|
152
|
+
if (file.kind === 'page')
|
|
153
|
+
report.pagesUnchanged++;
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
return true;
|
|
157
|
+
});
|
|
158
|
+
// ── Deletions and renames ──────────────────────────────────────────────
|
|
159
|
+
//
|
|
160
|
+
// A full pass may conclude a file is gone about *any* file. A scoped pass may conclude it
|
|
161
|
+
// about **the files it was handed**: the caller named those paths, and one that has a
|
|
162
|
+
// record and is not on disk has genuinely vanished.
|
|
163
|
+
//
|
|
164
|
+
// That distinction is what lets a move keep its identity. The watcher sees a folder
|
|
165
|
+
// renamed as a batch of departures and arrivals in one scoped pass, and refusing to look
|
|
166
|
+
// at the departures split the pair across passes — by the time a full pass ran, the
|
|
167
|
+
// arrival had already been indexed as a brand new page, and the vanished one could only
|
|
168
|
+
// be deleted. Twenty-seven notes moved in Obsidian lost their ids that way, and with them
|
|
169
|
+
// every fact, link and journal entry hanging off them.
|
|
170
|
+
const present = new Set(files.map((file) => file.relPath));
|
|
171
|
+
const inScope = options.only ? new Set(options.only) : null;
|
|
172
|
+
const vanished = [...known.values()].filter((row) => !present.has(row.rel_path) && (!inScope || inScope.has(row.rel_path)));
|
|
173
|
+
if (vanished.length > 0)
|
|
174
|
+
this.reconcileDeletions(vanished, needsIndex, report);
|
|
175
|
+
// A rendition policy that moved has to ask again about every document it already
|
|
176
|
+
// declined, or lowering the threshold does nothing at all. Same problem as a rule
|
|
177
|
+
// change, same shape of fix.
|
|
178
|
+
this.reconsiderRenditions(report);
|
|
179
|
+
if (!options.only)
|
|
180
|
+
this.reconcileRenditionClaims(report);
|
|
181
|
+
// ── Pages ──────────────────────────────────────────────────────────────
|
|
182
|
+
const pageFiles = needsIndex.filter((file) => file.kind === 'page');
|
|
183
|
+
progress({ phase: 'pages', done: 0, total: pageFiles.length });
|
|
184
|
+
let pageIndex = 0;
|
|
185
|
+
for (const file of pageFiles) {
|
|
186
|
+
try {
|
|
187
|
+
await this.indexPage(file, report);
|
|
188
|
+
}
|
|
189
|
+
catch (err) {
|
|
190
|
+
report.warnings.push(`could not index ${file.relPath}: ${errorMessage(err)}`);
|
|
191
|
+
}
|
|
192
|
+
progress({ phase: 'pages', done: ++pageIndex, total: pageFiles.length, detail: file.relPath });
|
|
193
|
+
}
|
|
194
|
+
// ── Attachments ────────────────────────────────────────────────────────
|
|
195
|
+
const attachments = needsIndex.filter((file) => file.kind === 'attachment');
|
|
196
|
+
progress({ phase: 'documents', done: 0, total: attachments.length });
|
|
197
|
+
for (const file of attachments) {
|
|
198
|
+
this.registerAttachment(file, report);
|
|
199
|
+
}
|
|
200
|
+
// Broken-link resolution needs every page present, so it runs last.
|
|
201
|
+
//
|
|
202
|
+
// On a scoped pass too, which it did not used to be. A scoped pass is what every `write` runs,
|
|
203
|
+
// so creating the page a link pointed at left that link marked broken until the next full index
|
|
204
|
+
// — the file was right and the index disagreed with it, which is the one thing the index may
|
|
205
|
+
// not do. It is a single UPDATE over a table with one row per link; scoping it would save
|
|
206
|
+
// nothing worth the inconsistency.
|
|
207
|
+
this.resolveLinks();
|
|
208
|
+
// ── Model-backed passes ────────────────────────────────────────────────
|
|
209
|
+
// Scoped to the pages this pass touched when the caller named files. Without
|
|
210
|
+
// that scope, a single `write` into a knowledge base with an embedding backlog
|
|
211
|
+
// blocks on the *whole* backlog — 223 pages of model calls to save one line.
|
|
212
|
+
// The indexer follows the write; that does not mean it catches up on
|
|
213
|
+
// everything else first.
|
|
214
|
+
if (!options.structuralOnly) {
|
|
215
|
+
const scoped = options.modelPaths ?? options.only;
|
|
216
|
+
const scope = scoped ? this.#pageIdsFor(scoped) : null;
|
|
217
|
+
// Before embedding, so the chunks it produces are embedded in the same pass rather
|
|
218
|
+
// than sitting unsearchable until the next one.
|
|
219
|
+
await this.extractPending(report, progress, options.only ?? null);
|
|
220
|
+
// After extraction, so a document read this pass gets its text beside it in the same
|
|
221
|
+
// pass, and before embedding, because a rendition produces nothing to embed.
|
|
222
|
+
await this.writeRenditions(report, progress, options.only ?? null);
|
|
223
|
+
await this.embedPending(report, progress, scope);
|
|
224
|
+
await this.summarizeDocuments(report, progress);
|
|
225
|
+
await this.derivePending(report, progress, options.rederive ?? false, scope);
|
|
226
|
+
}
|
|
227
|
+
// Graph rows are cheap, private derived state. Build after derivation so facts produced
|
|
228
|
+
// by this pass become relationships immediately, while structural-only passes still
|
|
229
|
+
// reconcile the complete graph from the facts already present.
|
|
230
|
+
progress({ phase: 'graph', done: 0, total: 1 });
|
|
231
|
+
const contextualModelId = this.#config.graph.contextualResolution.enabled
|
|
232
|
+
? this.#models.derive.modelId
|
|
233
|
+
: null;
|
|
234
|
+
let graph = rebuildEvidenceGraph(this.#store, {
|
|
235
|
+
conflictModelId: this.#models.derive.modelId,
|
|
236
|
+
contextualModelId,
|
|
237
|
+
});
|
|
238
|
+
if (!options.structuralOnly && this.#config.graph.contextualResolution.enabled) {
|
|
239
|
+
const contextual = await resolveContextualEntityMentions(this.#store, this.#models.derive, {
|
|
240
|
+
maxCandidates: this.#config.graph.contextualResolution.maxCandidates,
|
|
241
|
+
maxMentions: this.#config.graph.contextualResolution.maxMentionsPerIndex,
|
|
242
|
+
...(this.#config.graph.contextualResolution.reasoningEffort
|
|
243
|
+
? { reasoningEffort: this.#config.graph.contextualResolution.reasoningEffort }
|
|
244
|
+
: {}),
|
|
245
|
+
});
|
|
246
|
+
report.graphContextualResolved = contextual.resolved;
|
|
247
|
+
report.graphContextualAbstained = contextual.abstained;
|
|
248
|
+
report.graphContextualCached = contextual.cached;
|
|
249
|
+
report.graphContextualFailed = contextual.failed;
|
|
250
|
+
report.warnings.push(...contextual.warnings);
|
|
251
|
+
// A new selected verdict becomes an edge only through the same complete graph rebuild as
|
|
252
|
+
// exact evidence. Abstentions need no projection; they intentionally remain ambiguous.
|
|
253
|
+
if (contextual.resolved > 0) {
|
|
254
|
+
graph = rebuildEvidenceGraph(this.#store, {
|
|
255
|
+
conflictModelId: this.#models.derive.modelId,
|
|
256
|
+
contextualModelId,
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
report.graphNodes = graph.nodes;
|
|
261
|
+
report.graphEdges = graph.edges;
|
|
262
|
+
report.graphEntities = graph.entities;
|
|
263
|
+
report.graphMentions = graph.mentions;
|
|
264
|
+
report.graphAmbiguousMentions = graph.ambiguousMentions;
|
|
265
|
+
report.graphUnresolvedMentions = graph.unresolvedMentions;
|
|
266
|
+
report.graphContextualMentions = graph.contextualMentions;
|
|
267
|
+
report.graphFacts = graph.facts;
|
|
268
|
+
report.graphFactEdges = graph.factEdges;
|
|
269
|
+
report.graphNonTraversableFacts = graph.nonTraversableFacts;
|
|
270
|
+
progress({ phase: 'graph', done: 1, total: 1 });
|
|
271
|
+
report.durationMs = performance.now() - started;
|
|
272
|
+
progress({ phase: 'done', done: 1, total: 1 });
|
|
273
|
+
return report;
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Pages whose policy may have moved because the *rules* moved, not because the file did.
|
|
277
|
+
*
|
|
278
|
+
* The resolved rules are fingerprinted in `meta`. While the fingerprint holds this is
|
|
279
|
+
* one query that finds nothing; when it moves, every page is re-resolved and the ones
|
|
280
|
+
* are handed back to be re-indexed. Their derivations are dropped in the same breath:
|
|
281
|
+
* a page that just became `source` was fact-mined under the old
|
|
282
|
+
* rule, and those facts assert claims the rules now say it never made.
|
|
283
|
+
*
|
|
284
|
+
* Re-indexing all pages on the rare rule change is deliberate: rules also supply
|
|
285
|
+
* management and about metadata, so comparing only role would leave policy stale.
|
|
286
|
+
*/
|
|
287
|
+
reclassify(report) {
|
|
288
|
+
const fingerprint = sha256(JSON.stringify(this.#config.rules));
|
|
289
|
+
if (this.#store.meta(RULES_FINGERPRINT) === fingerprint)
|
|
290
|
+
return new Set();
|
|
291
|
+
const rows = this.#store.db.prepare('SELECT id, rel_path FROM pages').all();
|
|
292
|
+
const moved = new Set();
|
|
293
|
+
const clearDerived = this.#store.db.prepare('UPDATE pages SET derived_hash = NULL WHERE id = ?');
|
|
294
|
+
for (const row of rows) {
|
|
295
|
+
moved.add(row.rel_path);
|
|
296
|
+
clearDerived.run(row.id);
|
|
297
|
+
}
|
|
298
|
+
// An ignored page has no `pages` row at all — that is what ignored means.
|
|
299
|
+
// Walking only `pages` would therefore make exclusion a one-way door: deleting the
|
|
300
|
+
// rule would leave the file recorded, unchanged on disk, and permanently invisible.
|
|
301
|
+
const dropped = this.#store.db
|
|
302
|
+
.prepare("SELECT rel_path FROM files WHERE kind = 'page' AND page_id IS NULL")
|
|
303
|
+
.all();
|
|
304
|
+
for (const row of dropped) {
|
|
305
|
+
const slug = row.rel_path.replace(/\.(md|markdown)$/i, '');
|
|
306
|
+
if (this.policyFor(slug).role === 'ignored')
|
|
307
|
+
continue;
|
|
308
|
+
moved.add(row.rel_path);
|
|
309
|
+
}
|
|
310
|
+
if (moved.size > 0) {
|
|
311
|
+
// Default to visible: a pass that quietly re-indexed a third of the knowledge
|
|
312
|
+
// base because a rule changed should say so.
|
|
313
|
+
report.warnings.push(`the rules changed since the last pass: ${moved.size} page policy/policies were re-indexed`);
|
|
314
|
+
}
|
|
315
|
+
this.#store.setMeta(RULES_FINGERPRINT, fingerprint);
|
|
316
|
+
return moved;
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* Page files the index has a record of and no page for.
|
|
320
|
+
*
|
|
321
|
+
* The stat fast path asks `files` whether anything moved, so a file whose `files` row is
|
|
322
|
+
* intact is never looked at again — and that is exactly the state a reorganization can
|
|
323
|
+
* leave behind, with the `pages` row deleted underneath a `files` row that still points at
|
|
324
|
+
* it. The file is on disk, the index says "unchanged", and recall cannot return it: the
|
|
325
|
+
* folder and the index disagree, and the index is the one that must yield.
|
|
326
|
+
*
|
|
327
|
+
* `reclassify` looks for the same failure one shape over — `page_id IS NULL`, a file the
|
|
328
|
+
* rules excluded — and misses this one, where the id is set and the page it names is gone.
|
|
329
|
+
*/
|
|
330
|
+
pageFilesWithNoPage(report) {
|
|
331
|
+
const rows = this.#store.db
|
|
332
|
+
.prepare(`SELECT f.rel_path FROM files f
|
|
333
|
+
WHERE f.kind = 'page'
|
|
334
|
+
AND f.page_id IS NOT NULL
|
|
335
|
+
AND NOT EXISTS (SELECT 1 FROM pages p WHERE p.id = f.page_id)`)
|
|
336
|
+
.all();
|
|
337
|
+
if (rows.length > 0) {
|
|
338
|
+
report.warnings.push(`${rows.length} page(s) were on disk with nothing indexed for them, and were re-read`);
|
|
339
|
+
}
|
|
340
|
+
return rows.map((row) => row.rel_path);
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* What the scan skips: the configured list, plus Akno's own files inside the knowledge
|
|
344
|
+
* base.
|
|
345
|
+
*
|
|
346
|
+
* `akno.jsonc` is not memory — it is the rules that decide what memory *is*. It was
|
|
347
|
+
* being registered as an attachment of the root, which is how a taxonomy ends up
|
|
348
|
+
* described in `doctor` as a document whose contents could not be extracted.
|
|
349
|
+
*/
|
|
350
|
+
scanIgnore() {
|
|
351
|
+
return indexScanIgnore(this.#config.ignore);
|
|
352
|
+
}
|
|
353
|
+
/** The policy current rules give a slug, ignoring page-local declarations. */
|
|
354
|
+
policyFor(slug) {
|
|
355
|
+
const rule = effectiveRule(slug, this.#config.rules);
|
|
356
|
+
return resolvePagePolicy({ slug, declaredRole: null, declaredManagement: {}, about: [], aliases: [] }, rule, this.#config.paths.observations);
|
|
357
|
+
}
|
|
358
|
+
// ─── Files table ──────────────────────────────────────────────────────────
|
|
359
|
+
knownFiles() {
|
|
360
|
+
const rows = this.#store.db
|
|
361
|
+
.prepare('SELECT rel_path, size, mtime_ns, sha256, kind, page_id FROM files')
|
|
362
|
+
.all();
|
|
363
|
+
return new Map(rows.map((row) => [row.rel_path, row]));
|
|
364
|
+
}
|
|
365
|
+
touchFile(file) {
|
|
366
|
+
this.#store.db
|
|
367
|
+
.prepare('UPDATE files SET mtime_ns = ?, size = ?, indexed_at = ? WHERE rel_path = ?')
|
|
368
|
+
.run(file.mtimeNs, file.size, nowIso(), file.relPath);
|
|
369
|
+
}
|
|
370
|
+
recordFile(file, pageId) {
|
|
371
|
+
this.#store.db
|
|
372
|
+
.prepare(`INSERT INTO files(rel_path, size, mtime_ns, sha256, kind, page_id, indexed_at)
|
|
373
|
+
VALUES(?, ?, ?, ?, ?, ?, ?)
|
|
374
|
+
ON CONFLICT(rel_path) DO UPDATE SET
|
|
375
|
+
size = excluded.size, mtime_ns = excluded.mtime_ns, sha256 = excluded.sha256,
|
|
376
|
+
kind = excluded.kind, page_id = excluded.page_id, indexed_at = excluded.indexed_at`)
|
|
377
|
+
.run(file.relPath, file.size, file.mtimeNs, file.sha256 ?? '', file.kind, pageId, nowIso());
|
|
378
|
+
}
|
|
379
|
+
// ─── Rename following ─────────────────────────────────────────────────────
|
|
380
|
+
/**
|
|
381
|
+
* Rename `people/ada.md` to `people/ada-marlow.md` in a file manager
|
|
382
|
+
* and content hashing sees a delete plus a create. Facts, journal entries and
|
|
383
|
+
* inbound links all point at a slug that no longer exists.
|
|
384
|
+
*
|
|
385
|
+
* In sidecar-identity mode the recovery is the body hash: a vanished file and a
|
|
386
|
+
* new file with identical content are the same page, so the id — and every fact
|
|
387
|
+
* and link hanging off it — survives the move. This is what `write_ids: false`
|
|
388
|
+
* trades away when the database is deleted, and nothing else.
|
|
389
|
+
*/
|
|
390
|
+
reconcileDeletions(vanished, arriving, report) {
|
|
391
|
+
const arrivingByHash = new Map();
|
|
392
|
+
for (const file of arriving) {
|
|
393
|
+
if (file.sha256 && !arrivingByHash.has(file.sha256))
|
|
394
|
+
arrivingByHash.set(file.sha256, file);
|
|
395
|
+
}
|
|
396
|
+
for (const row of vanished) {
|
|
397
|
+
const successor = arrivingByHash.get(row.sha256);
|
|
398
|
+
if (successor && successor.kind === row.kind && row.page_id) {
|
|
399
|
+
// Same bytes, new path: follow the id rather than retiring it.
|
|
400
|
+
const slug = successor.relPath.replace(/\.(md|markdown)$/i, '');
|
|
401
|
+
this.#store.transaction(() => {
|
|
402
|
+
this.#store.db
|
|
403
|
+
.prepare('UPDATE pages SET slug = ?, rel_path = ? WHERE id = ?')
|
|
404
|
+
.run(slug, successor.relPath, row.page_id);
|
|
405
|
+
this.#store.db.prepare('DELETE FROM files WHERE rel_path = ?').run(row.rel_path);
|
|
406
|
+
});
|
|
407
|
+
report.pagesRenamed++;
|
|
408
|
+
// The successor still needs indexing for its links and events, but its
|
|
409
|
+
// page row already exists under the right id.
|
|
410
|
+
arrivingByHash.delete(row.sha256);
|
|
411
|
+
continue;
|
|
412
|
+
}
|
|
413
|
+
// A missing attachment is not the same thing as an explicit forget. Its extracted
|
|
414
|
+
// text may be the only surviving readable copy, so keep the document and its chunks
|
|
415
|
+
// addressable while recording that the original can no longer be checked. A same-hash
|
|
416
|
+
// successor is still handled as a move below by retiring this path and registering the
|
|
417
|
+
// new one; preserving both would collide on the content-derived document id.
|
|
418
|
+
if (row.kind === 'attachment' && !successor) {
|
|
419
|
+
this.#store.transaction(() => {
|
|
420
|
+
this.#store.db
|
|
421
|
+
.prepare(`UPDATE documents
|
|
422
|
+
SET availability = 'missing', missing_since = COALESCE(missing_since, ?)
|
|
423
|
+
WHERE rel_path = ?`)
|
|
424
|
+
.run(nowIso(), row.rel_path);
|
|
425
|
+
this.#store.db.prepare('DELETE FROM files WHERE rel_path = ?').run(row.rel_path);
|
|
426
|
+
});
|
|
427
|
+
report.warnings.push(`${row.rel_path} is missing; retained indexed document evidence is now degraded`);
|
|
428
|
+
continue;
|
|
429
|
+
}
|
|
430
|
+
this.#store.transaction(() => {
|
|
431
|
+
if (row.page_id) {
|
|
432
|
+
// A page disappearing removes its own claims, not the evidence files that used
|
|
433
|
+
// to hang beneath it. Detach document chunks before the page delete so the FK
|
|
434
|
+
// cascade cannot erase a retained extraction when both files disappear together.
|
|
435
|
+
this.deleteChunkRows(BODY_CHUNKS_FOR_PAGE, row.page_id);
|
|
436
|
+
this.#store.db
|
|
437
|
+
.prepare('UPDATE chunks SET page_id = NULL WHERE page_id = ? AND document_id IS NOT NULL')
|
|
438
|
+
.run(row.page_id);
|
|
439
|
+
this.#store.db.prepare('UPDATE documents SET page_id = NULL WHERE page_id = ?').run(row.page_id);
|
|
440
|
+
this.#store.db.prepare('DELETE FROM pages WHERE id = ?').run(row.page_id);
|
|
441
|
+
}
|
|
442
|
+
else {
|
|
443
|
+
const document = this.#store.db
|
|
444
|
+
.prepare('SELECT id FROM documents WHERE rel_path = ?')
|
|
445
|
+
.get(row.rel_path);
|
|
446
|
+
if (document)
|
|
447
|
+
this.deleteChunkRows(CHUNKS_FOR_DOCUMENT, document.id);
|
|
448
|
+
this.#store.db.prepare('DELETE FROM documents WHERE rel_path = ?').run(row.rel_path);
|
|
449
|
+
}
|
|
450
|
+
this.#store.db.prepare('DELETE FROM files WHERE rel_path = ?').run(row.rel_path);
|
|
451
|
+
});
|
|
452
|
+
if (row.kind === 'page')
|
|
453
|
+
report.pagesRemoved++;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
// ─── One page ─────────────────────────────────────────────────────────────
|
|
457
|
+
async indexPage(file, report) {
|
|
458
|
+
if (file.dataless) {
|
|
459
|
+
report.warnings.push(`${file.relPath} reads as a placeholder — a sync client has evicted it. Not indexed; ` +
|
|
460
|
+
'download it or turn off storage optimization for this folder.');
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
const content = await fsp.readFile(file.absPath, 'utf8');
|
|
464
|
+
const page = parsePage(file.relPath, content);
|
|
465
|
+
const rule = effectiveRule(page.slug, this.#config.rules);
|
|
466
|
+
const resolved = resolvePagePolicy(page, { ...rule, glob: rule.glob }, this.#config.paths.observations);
|
|
467
|
+
if (resolved.role === 'ignored') {
|
|
468
|
+
// An ignored page must leave nothing behind, including from a pass that
|
|
469
|
+
// ran before the rule existed.
|
|
470
|
+
const existing = this.pageIdForPath(file.relPath);
|
|
471
|
+
if (existing)
|
|
472
|
+
this.removePage(existing);
|
|
473
|
+
this.recordFile(file, null);
|
|
474
|
+
report.ignored++;
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
const pageId = this.resolvePageId(page, file);
|
|
478
|
+
const chunks = applySourceFence(chunkPage(page, {
|
|
479
|
+
targetChars: this.#config.index.chunkTargetChars,
|
|
480
|
+
maxChars: this.#config.index.chunkMaxChars,
|
|
481
|
+
overlapChars: this.#config.index.chunkOverlapChars,
|
|
482
|
+
}), page.sourceFenceLine);
|
|
483
|
+
// A `source` page is evidence from top to bottom, fence or no fence.
|
|
484
|
+
const effectiveChunks = resolved.role === 'source' ? chunks.map((chunk) => ({ ...chunk, kind: 'source' })) : chunks;
|
|
485
|
+
this.#store.transaction(() => {
|
|
486
|
+
this.upsertPage(pageId, page, resolved, file);
|
|
487
|
+
this.replaceChunks(pageId, effectiveChunks);
|
|
488
|
+
this.replaceEvents(pageId, page);
|
|
489
|
+
this.replaceLinks(pageId, page);
|
|
490
|
+
this.recordFile(file, pageId);
|
|
491
|
+
});
|
|
492
|
+
report.pagesIndexed++;
|
|
493
|
+
report.chunksWritten += effectiveChunks.length;
|
|
494
|
+
report.eventsIndexed += page.events.length;
|
|
495
|
+
// The single write Akno ever makes into a page, and only when asked.
|
|
496
|
+
if (this.#config.writeIds && !page.frontmatterId) {
|
|
497
|
+
await this.writeIdIntoPage(file, content, pageId, report);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
/**
|
|
501
|
+
* Identity resolution, in order: a frontmatter `id` the user or a previous run
|
|
502
|
+
* wrote; the sidecar row for this path; the sidecar row for these exact bytes
|
|
503
|
+
* (a move the deletion pass did not catch, e.g. a copy); a fresh id.
|
|
504
|
+
*/
|
|
505
|
+
resolvePageId(page, file) {
|
|
506
|
+
if (page.frontmatterId) {
|
|
507
|
+
const existing = this.#store.db.prepare('SELECT id FROM pages WHERE id = ?').get(page.frontmatterId);
|
|
508
|
+
if (existing)
|
|
509
|
+
return existing.id;
|
|
510
|
+
// A page carrying an id from another machine keeps it — that is the point
|
|
511
|
+
// of writing one at all.
|
|
512
|
+
return page.frontmatterId;
|
|
513
|
+
}
|
|
514
|
+
const byPath = this.pageIdForPath(file.relPath);
|
|
515
|
+
if (byPath)
|
|
516
|
+
return byPath;
|
|
517
|
+
if (file.sha256) {
|
|
518
|
+
const byHash = this.#store.db
|
|
519
|
+
.prepare('SELECT page_id FROM files WHERE sha256 = ? AND kind = ? AND page_id IS NOT NULL LIMIT 1')
|
|
520
|
+
.get(file.sha256, 'page');
|
|
521
|
+
if (byHash?.page_id) {
|
|
522
|
+
const stillThere = this.#store.db
|
|
523
|
+
.prepare('SELECT rel_path FROM pages WHERE id = ?')
|
|
524
|
+
.get(byHash.page_id);
|
|
525
|
+
// Only adopt the id if the original file is actually gone; otherwise this
|
|
526
|
+
// is a genuine duplicate and both pages need their own identity.
|
|
527
|
+
if (stillThere && !fileExists(path.join(this.#config.aknoPath, stillThere.rel_path))) {
|
|
528
|
+
return byHash.page_id;
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
return newPageId();
|
|
533
|
+
}
|
|
534
|
+
pageIdForPath(relPath) {
|
|
535
|
+
const row = this.#store.db.prepare('SELECT id FROM pages WHERE rel_path = ?').get(relPath);
|
|
536
|
+
return row?.id ?? null;
|
|
537
|
+
}
|
|
538
|
+
async writeIdIntoPage(file, content, pageId, report) {
|
|
539
|
+
const updated = withId(content, pageId);
|
|
540
|
+
if (updated === content)
|
|
541
|
+
return;
|
|
542
|
+
try {
|
|
543
|
+
await fsp.writeFile(file.absPath, updated, 'utf8');
|
|
544
|
+
const stat = await fsp.stat(file.absPath, { bigint: true });
|
|
545
|
+
// Record the hash of our own write, so adding an `id` never re-triggers
|
|
546
|
+
// indexing on the next sweep.
|
|
547
|
+
this.recordFile({ ...file, size: Number(stat.size), mtimeNs: String(stat.mtimeNs), sha256: sha256(updated) }, pageId);
|
|
548
|
+
}
|
|
549
|
+
catch (err) {
|
|
550
|
+
report.warnings.push(`could not write id into ${file.relPath}: ${errorMessage(err)}`);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
upsertPage(pageId, page, policy, file) {
|
|
554
|
+
const existing = this.#store.db.prepare('SELECT created_at FROM pages WHERE id = ?').get(pageId);
|
|
555
|
+
this.#store.db
|
|
556
|
+
.prepare(`INSERT INTO pages(
|
|
557
|
+
id, slug, rel_path, title, type, tags, role, remember_management, dream_management,
|
|
558
|
+
about, aliases, frontmatter, body_hash, source_fence_line, body_line, line_count,
|
|
559
|
+
bytes, created_at, updated_at, indexed_at
|
|
560
|
+
) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
561
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
562
|
+
slug = excluded.slug, rel_path = excluded.rel_path, title = excluded.title,
|
|
563
|
+
type = excluded.type, tags = excluded.tags, role = excluded.role,
|
|
564
|
+
remember_management = excluded.remember_management,
|
|
565
|
+
dream_management = excluded.dream_management,
|
|
566
|
+
about = excluded.about, aliases = excluded.aliases,
|
|
567
|
+
frontmatter = excluded.frontmatter, body_hash = excluded.body_hash,
|
|
568
|
+
source_fence_line = excluded.source_fence_line, body_line = excluded.body_line,
|
|
569
|
+
line_count = excluded.line_count, bytes = excluded.bytes,
|
|
570
|
+
updated_at = excluded.updated_at, indexed_at = excluded.indexed_at`)
|
|
571
|
+
.run(pageId, page.slug, file.relPath, page.title, page.type, JSON.stringify(page.tags), policy.role, policy.remember, policy.dream, JSON.stringify(policy.about), JSON.stringify(page.aliases), JSON.stringify(page.frontmatter.data), page.bodyHash, page.sourceFenceLine, page.bodyLine, page.lines.length, file.size, existing?.created_at ?? nowIso(), mtimeIso(file.mtimeNs), nowIso());
|
|
572
|
+
}
|
|
573
|
+
replaceChunks(pageId, chunks) {
|
|
574
|
+
// Only the page's *body* chunks. Its documents' chunks come from the files beside it,
|
|
575
|
+
// are invalidated by those files' hashes, and would otherwise be destroyed by
|
|
576
|
+
// every edit to the page and rebuilt only on the next extraction pass.
|
|
577
|
+
this.deleteChunkRows(BODY_CHUNKS_FOR_PAGE, pageId);
|
|
578
|
+
const insert = this.#store.db.prepare(`INSERT INTO chunks(page_id, ord, kind, heading_path, text, line_start, line_end, embedded)
|
|
579
|
+
VALUES(?, ?, ?, ?, ?, ?, ?, 0)`);
|
|
580
|
+
const insertFts = this.#store.db.prepare('INSERT INTO chunks_fts(rowid, text, heading_path) VALUES(?, ?, ?)');
|
|
581
|
+
for (const chunk of chunks) {
|
|
582
|
+
const result = insert.run(pageId, chunk.ord, chunk.kind, chunk.headingPath, chunk.text, chunk.lineStart, chunk.lineEnd);
|
|
583
|
+
insertFts.run(Number(result.lastInsertRowid), chunk.text, chunk.headingPath);
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
/**
|
|
587
|
+
* FTS5 external-content tables need explicit deletes; there are no triggers. Vectors are
|
|
588
|
+
* removed per chunk id for the same reason a page's document chunks survive a page edit:
|
|
589
|
+
* `removeForPage` cannot tell the two kinds apart.
|
|
590
|
+
*/
|
|
591
|
+
deleteChunkRows(select, parameter) {
|
|
592
|
+
const rows = this.#store.db.prepare(select).all(parameter);
|
|
593
|
+
const deleteFts = this.#store.db.prepare('DELETE FROM chunks_fts WHERE rowid = ?');
|
|
594
|
+
const deleteChunk = this.#store.db.prepare('DELETE FROM chunks WHERE id = ?');
|
|
595
|
+
for (const row of rows) {
|
|
596
|
+
deleteFts.run(row.id);
|
|
597
|
+
this.#store.vectors.remove(row.id);
|
|
598
|
+
deleteChunk.run(row.id);
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
replaceEvents(pageId, page) {
|
|
602
|
+
this.#store.db.prepare('DELETE FROM events WHERE source_page = ?').run(pageId);
|
|
603
|
+
const insert = this.#store.db.prepare(`INSERT INTO events(id, date, summary, target_slug, source_slug, source_page, line)
|
|
604
|
+
VALUES(?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO NOTHING`);
|
|
605
|
+
for (const event of page.events) {
|
|
606
|
+
// Duplicates collapse on (date, target, summary), so an event that exists
|
|
607
|
+
// both in the ledger and on its page counts once.
|
|
608
|
+
const target = event.targetSlug ?? (isLedger(page.slug, this.#config) ? null : page.slug);
|
|
609
|
+
insert.run(eventId(event.date, target, event.summary), event.date, event.summary, target, page.slug, pageId, event.line);
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
replaceLinks(pageId, page) {
|
|
613
|
+
this.#store.db.prepare('DELETE FROM links WHERE from_page = ?').run(pageId);
|
|
614
|
+
const insert = this.#store.db.prepare('INSERT INTO links(from_page, to_slug, to_page, kind, line, broken) VALUES(?, ?, ?, ?, ?, ?)');
|
|
615
|
+
const findPage = this.#store.db.prepare('SELECT id FROM pages WHERE slug = ?');
|
|
616
|
+
for (const link of page.links) {
|
|
617
|
+
if (link.kind === 'embed') {
|
|
618
|
+
// A file embed is not a page reference and can never be a broken one.
|
|
619
|
+
insert.run(pageId, link.toSlug, null, 'embed', link.line, 0);
|
|
620
|
+
continue;
|
|
621
|
+
}
|
|
622
|
+
const target = findPage.get(link.toSlug);
|
|
623
|
+
insert.run(pageId, link.toSlug, target?.id ?? null, link.kind, link.line, target ? 0 : 1);
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
/**
|
|
627
|
+
* A link written before its target existed is not broken. Re-resolving after
|
|
628
|
+
* every page is present is the difference between a useful broken-link report
|
|
629
|
+
* and one nobody reads.
|
|
630
|
+
*/
|
|
631
|
+
resolveLinks() {
|
|
632
|
+
this.#store.db.exec(`
|
|
633
|
+
UPDATE links SET
|
|
634
|
+
to_page = (SELECT id FROM pages WHERE pages.slug = links.to_slug),
|
|
635
|
+
broken = CASE WHEN EXISTS (SELECT 1 FROM pages WHERE pages.slug = links.to_slug) THEN 0 ELSE 1 END
|
|
636
|
+
WHERE kind != 'embed'
|
|
637
|
+
`);
|
|
638
|
+
}
|
|
639
|
+
removePage(pageId) {
|
|
640
|
+
this.#store.transaction(() => {
|
|
641
|
+
this.deleteChunkRows(ALL_CHUNKS_FOR_PAGE, pageId);
|
|
642
|
+
this.#store.db.prepare('DELETE FROM pages WHERE id = ?').run(pageId);
|
|
643
|
+
});
|
|
644
|
+
}
|
|
645
|
+
// ─── Attachments ──────────────────────────────────────────────────────────
|
|
646
|
+
/**
|
|
647
|
+
* Extraction, OCR and naming land with `ingest`. What this cut does is
|
|
648
|
+
* *notice* attachments and attach them to their page, so a card can say "this
|
|
649
|
+
* page has a 9-page PDF" and `doctor` can report how many are un-extracted.
|
|
650
|
+
* Recording them now also means the write path inherits a populated table
|
|
651
|
+
* rather than starting from an empty one.
|
|
652
|
+
*/
|
|
653
|
+
registerAttachment(file, report) {
|
|
654
|
+
const onDisk = (relPath) => fs.existsSync(path.join(this.#config.aknoPath, relPath));
|
|
655
|
+
// A rendition is asked about first, and it settles both questions at once: `contract.pdf.txt`
|
|
656
|
+
// belongs to whatever group and page `contract.pdf` does. Asking the part rule first would
|
|
657
|
+
// give it a group of its own and make it a second document — which is the entire thing this
|
|
658
|
+
// column exists to prevent.
|
|
659
|
+
let rendition = documentRendition(file.relPath, { entries: (dir) => this.entriesOf(dir) });
|
|
660
|
+
if (!rendition) {
|
|
661
|
+
// A restored rendition cannot rediscover its source through the directory while that
|
|
662
|
+
// source is still missing. Its durable relationship is stronger evidence than today's
|
|
663
|
+
// incomplete folder listing, so keep it until the original returns or is forgotten.
|
|
664
|
+
const previous = this.#store.db
|
|
665
|
+
.prepare(`SELECT rendition.renders
|
|
666
|
+
FROM documents rendition
|
|
667
|
+
JOIN documents source ON source.rel_path = rendition.renders
|
|
668
|
+
WHERE rendition.rel_path = ? AND source.availability = 'missing'`)
|
|
669
|
+
.get(file.relPath);
|
|
670
|
+
if (previous)
|
|
671
|
+
rendition = { source: previous.renders };
|
|
672
|
+
}
|
|
673
|
+
const target = rendition?.source ?? file.relPath;
|
|
674
|
+
// Parts of one document resolve ownership through the group, so `passport-2.pdf` lands
|
|
675
|
+
// on the page that owns `passport.pdf` rather than nowhere at all.
|
|
676
|
+
const group = documentPart(target, {
|
|
677
|
+
// Asked of the disk rather than of `files`, so the answer does not depend on which of
|
|
678
|
+
// the two parts this pass happened to reach first.
|
|
679
|
+
hasPartOne: (groupKey) => onDisk(groupKey),
|
|
680
|
+
});
|
|
681
|
+
const pageId = this.attachmentOwner(group.groupKey) ?? this.attachmentOwner(target);
|
|
682
|
+
const id = `doc_${(file.sha256 ?? file.relPath).slice(0, 12)}`;
|
|
683
|
+
this.#store.transaction(() => {
|
|
684
|
+
this.#store.db
|
|
685
|
+
.prepare(`INSERT INTO documents(id, page_id, rel_path, mime, sha256, label, text, summary,
|
|
686
|
+
page_count, ocr, bytes, indexed_at, group_key, part, renders,
|
|
687
|
+
availability, missing_since, file_created_at, file_modified_at)
|
|
688
|
+
VALUES(?, ?, ?, ?, ?, NULL, NULL, NULL, NULL, 0, ?, ?, ?, ?, ?, 'available', NULL, ?, ?)
|
|
689
|
+
ON CONFLICT(rel_path) DO UPDATE SET
|
|
690
|
+
page_id = excluded.page_id, sha256 = excluded.sha256,
|
|
691
|
+
mime = excluded.mime, bytes = excluded.bytes, indexed_at = excluded.indexed_at,
|
|
692
|
+
group_key = excluded.group_key, part = excluded.part, renders = excluded.renders,
|
|
693
|
+
availability = 'available', missing_since = NULL,
|
|
694
|
+
file_created_at = COALESCE(documents.file_created_at, excluded.file_created_at),
|
|
695
|
+
file_modified_at = excluded.file_modified_at`)
|
|
696
|
+
.run(id, pageId, file.relPath, guessMime(file.relPath), file.sha256 ?? '', file.size, nowIso(), group.groupKey, rendition ? 1 : group.part, rendition?.source ?? null, filesystemIso(file.birthtimeNs), filesystemIso(file.mtimeNs));
|
|
697
|
+
// Ownership can change without the file bytes changing: adding or removing an embed
|
|
698
|
+
// changes the result container, but should not require extracting the document again.
|
|
699
|
+
const stored = this.#store.db
|
|
700
|
+
.prepare('SELECT id FROM documents WHERE rel_path = ?')
|
|
701
|
+
.get(file.relPath);
|
|
702
|
+
if (stored) {
|
|
703
|
+
this.#store.db.prepare('UPDATE chunks SET page_id = ? WHERE document_id = ?').run(pageId, stored.id);
|
|
704
|
+
}
|
|
705
|
+
// A file that has just *become* a rendition — someone's own `pdftotext` output, indexed
|
|
706
|
+
// as a document of its own before the rule could recognise it — is still carrying its
|
|
707
|
+
// own text and chunks, and those are the duplicate hits. Drop them: the words belong to
|
|
708
|
+
// the file it renders and are indexed there.
|
|
709
|
+
if (rendition) {
|
|
710
|
+
const row = this.#store.db
|
|
711
|
+
.prepare('SELECT id FROM documents WHERE rel_path = ?')
|
|
712
|
+
.get(file.relPath);
|
|
713
|
+
if (row) {
|
|
714
|
+
this.deleteChunkRows(CHUNKS_FOR_DOCUMENT, row.id);
|
|
715
|
+
this.#store.db
|
|
716
|
+
.prepare(`UPDATE documents SET text = NULL, summary = NULL, page_count = NULL, ocr = 0,
|
|
717
|
+
extracted_sha = NULL, extract_via = NULL, confidence = NULL
|
|
718
|
+
WHERE id = ?`)
|
|
719
|
+
.run(row.id);
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
this.recordFile(file, null);
|
|
723
|
+
});
|
|
724
|
+
if (pageId)
|
|
725
|
+
report.documentsLinked++;
|
|
726
|
+
}
|
|
727
|
+
/**
|
|
728
|
+
* Filenames in one directory of the knowledge base, memoized for the pass.
|
|
729
|
+
*
|
|
730
|
+
* Recognising `contract.txt` as the text of `contract.pdf` is a question about the folder
|
|
731
|
+
* rather than about the file, and it is asked once per attachment. Reading the directory
|
|
732
|
+
* each time would turn a folder of 200 scans into 200 readdirs of the same folder.
|
|
733
|
+
*/
|
|
734
|
+
#entriesCache = new Map();
|
|
735
|
+
entriesOf(directory) {
|
|
736
|
+
const cached = this.#entriesCache.get(directory);
|
|
737
|
+
if (cached)
|
|
738
|
+
return cached;
|
|
739
|
+
const absDir = directory === '.' ? this.#config.aknoPath : path.join(this.#config.aknoPath, directory);
|
|
740
|
+
let entries;
|
|
741
|
+
try {
|
|
742
|
+
entries = fs.readdirSync(absDir);
|
|
743
|
+
}
|
|
744
|
+
catch {
|
|
745
|
+
entries = [];
|
|
746
|
+
}
|
|
747
|
+
this.#entriesCache.set(directory, entries);
|
|
748
|
+
return entries;
|
|
749
|
+
}
|
|
750
|
+
/**
|
|
751
|
+
* Two ways an attachment belongs to a page. The content-addressed shape
|
|
752
|
+
* `<page-basename>-<8 hex>.<ext>` is the one Akno creates. A plain
|
|
753
|
+
* `passport.pdf` beside `passport.md` is the one people already have, and
|
|
754
|
+
* refusing to recognise it would make the feature useless on an existing
|
|
755
|
+
* knowledge base.
|
|
756
|
+
*/
|
|
757
|
+
/**
|
|
758
|
+
* The page a file belongs to: by Akno's own `<page>-<8 hex>.<ext>` naming, by a matching
|
|
759
|
+
* stem beside it, or because a page embeds it. Called with the group's part-one path
|
|
760
|
+
* first, so every part of a multi-part document lands on one page.
|
|
761
|
+
*/
|
|
762
|
+
attachmentOwner(relPath) {
|
|
763
|
+
const dir = path.posix.dirname(relPath.replace(/\\/g, '/'));
|
|
764
|
+
const base = path.posix.basename(relPath);
|
|
765
|
+
const find = this.#store.db.prepare('SELECT id FROM pages WHERE slug = ?');
|
|
766
|
+
const addressed = ATTACHMENT_NAME.exec(base);
|
|
767
|
+
if (addressed) {
|
|
768
|
+
const slug = dir === '.' ? addressed[1] : `${dir}/${addressed[1]}`;
|
|
769
|
+
const row = find.get(slug);
|
|
770
|
+
if (row)
|
|
771
|
+
return row.id;
|
|
772
|
+
}
|
|
773
|
+
const stem = base.replace(/\.[^.]+$/, '');
|
|
774
|
+
const slug = dir === '.' ? stem : `${dir}/${stem}`;
|
|
775
|
+
const row = find.get(slug);
|
|
776
|
+
if (row)
|
|
777
|
+
return row.id;
|
|
778
|
+
// A page that embeds the file says so itself, which beats any naming convention: this
|
|
779
|
+
// is how `passport-2.pdf` belongs to `passport.md`, and it is what the author wrote.
|
|
780
|
+
// Scoped to the same folder, because two people's pages can each embed a file called
|
|
781
|
+
// `residence-permit-2.jpg` and they are not the same file.
|
|
782
|
+
const embedded = this.#store.db
|
|
783
|
+
.prepare(`SELECT p.id, p.rel_path FROM links l
|
|
784
|
+
JOIN pages p ON p.id = l.from_page
|
|
785
|
+
WHERE l.kind = 'embed' AND l.to_slug = ?`)
|
|
786
|
+
.all(base);
|
|
787
|
+
for (const candidate of embedded) {
|
|
788
|
+
const candidateDir = path.posix.dirname(candidate.rel_path.replace(/\\/g, '/'));
|
|
789
|
+
if (candidateDir === dir)
|
|
790
|
+
return candidate.id;
|
|
791
|
+
}
|
|
792
|
+
return null;
|
|
793
|
+
}
|
|
794
|
+
// ─── Embedding ────────────────────────────────────────────────────────────
|
|
795
|
+
/** Page ids for the given relative paths, for scoping a targeted pass. */
|
|
796
|
+
#pageIdsFor(relPaths) {
|
|
797
|
+
const out = new Set();
|
|
798
|
+
if (relPaths.length === 0)
|
|
799
|
+
return out;
|
|
800
|
+
const find = this.#store.db.prepare('SELECT id FROM pages WHERE rel_path = ?');
|
|
801
|
+
for (const relPath of relPaths) {
|
|
802
|
+
const row = find.get(relPath);
|
|
803
|
+
if (row)
|
|
804
|
+
out.add(row.id);
|
|
805
|
+
}
|
|
806
|
+
return out;
|
|
807
|
+
}
|
|
808
|
+
/**
|
|
809
|
+
* **Extraction happens on arrival, always** — and this is what makes that true for
|
|
810
|
+
* attachments Akno did not place itself: a PDF someone dropped into `documents/` by
|
|
811
|
+
* hand, or one that predates Akno entirely. Their text is read, chunked, and indexed
|
|
812
|
+
* against the document, so the file is searchable by its own content.
|
|
813
|
+
*
|
|
814
|
+
* The invalidation rule is the *file* hash, which is why `extracted_sha` exists:
|
|
815
|
+
* re-extract when the bytes change, and never otherwise. Extraction is local — PDFKit,
|
|
816
|
+
* Vision, `textutil` — so a backlog costs seconds, not model calls.
|
|
817
|
+
*
|
|
818
|
+
* A document with no page of its own is chunked with a NULL page id and returned as a
|
|
819
|
+
* first-class document card. Organization improves the result, but is not a visibility gate.
|
|
820
|
+
*/
|
|
821
|
+
async extractPending(report, progress, only) {
|
|
822
|
+
const scopeClause = only ? ` AND d.rel_path IN (${only.map(() => '?').join(',')})` : '';
|
|
823
|
+
const stale = this.#store.db
|
|
824
|
+
.prepare(`SELECT DISTINCT d.group_key FROM documents d
|
|
825
|
+
WHERE d.renders IS NULL
|
|
826
|
+
AND d.availability = 'available'
|
|
827
|
+
AND (
|
|
828
|
+
d.extracted_sha IS NULL
|
|
829
|
+
OR d.extracted_sha != d.sha256
|
|
830
|
+
-- Self-healing: a page removed and restored takes its document's chunks with
|
|
831
|
+
-- it, and the file hash never moved to signal that they are missing.
|
|
832
|
+
OR NOT EXISTS (SELECT 1 FROM chunks WHERE chunks.document_id = d.id)
|
|
833
|
+
)
|
|
834
|
+
${scopeClause}`)
|
|
835
|
+
.all(...(only ?? []));
|
|
836
|
+
if (stale.length === 0)
|
|
837
|
+
return;
|
|
838
|
+
// Whole groups, in part order. One stale part shifts the page offsets of every part
|
|
839
|
+
// after it, so a group is extracted together or not at all — otherwise a citation reads
|
|
840
|
+
// "page 5" against a document that has since become six pages longer at the front.
|
|
841
|
+
const partsOf = this.#store.db.prepare(`SELECT id, rel_path, sha256, page_id, part, page_count, availability FROM documents
|
|
842
|
+
WHERE group_key = ? AND renders IS NULL ORDER BY part`);
|
|
843
|
+
const groups = stale.map((row) => partsOf.all(row.group_key));
|
|
844
|
+
const total = groups.reduce((sum, parts) => sum + parts.length, 0);
|
|
845
|
+
progress({ phase: 'extract', done: 0, total });
|
|
846
|
+
let done = 0;
|
|
847
|
+
for (const parts of groups) {
|
|
848
|
+
let pageOffset = 0;
|
|
849
|
+
for (const row of parts) {
|
|
850
|
+
if (row.availability === 'missing') {
|
|
851
|
+
// Preserve the established page numbering when another part changes while this
|
|
852
|
+
// one is away. Re-extracting the later part at offset zero would make every stored
|
|
853
|
+
// citation after the gap point at the wrong page.
|
|
854
|
+
pageOffset += row.page_count ?? 0;
|
|
855
|
+
progress({ phase: 'extract', done: ++done, total, detail: row.rel_path });
|
|
856
|
+
continue;
|
|
857
|
+
}
|
|
858
|
+
const absPath = path.join(this.#config.aknoPath, row.rel_path);
|
|
859
|
+
try {
|
|
860
|
+
const extraction = await extract({
|
|
861
|
+
absPath,
|
|
862
|
+
maxOcrPages: this.#config.ingest.maxOcrPages,
|
|
863
|
+
maxBytes: this.#config.ingest.maxFileBytes,
|
|
864
|
+
});
|
|
865
|
+
const offset = pageOffset;
|
|
866
|
+
this.#store.transaction(() => {
|
|
867
|
+
this.#store.db
|
|
868
|
+
.prepare(`UPDATE documents
|
|
869
|
+
SET text = ?, page_count = ?, ocr = ?, extracted_sha = ?, page_offset = ?,
|
|
870
|
+
extract_via = ?, confidence = ?,
|
|
871
|
+
-- New text means the file beside it is stale. Cleared in the same
|
|
872
|
+
-- statement that changes the text, so the two cannot drift.
|
|
873
|
+
rendition_sha = NULL
|
|
874
|
+
WHERE id = ?`)
|
|
875
|
+
.run(extraction.text.length > 0 ? extraction.text : null, extraction.pageCount, extraction.ocr ? 1 : 0,
|
|
876
|
+
// Recorded even when nothing could be read, so an unreadable file is not
|
|
877
|
+
// re-OCR'd on every pass. A changed file gets another go.
|
|
878
|
+
row.sha256, offset, extraction.via, extraction.confidence, row.id);
|
|
879
|
+
this.replaceDocumentChunks(row.id, row.page_id, chunkDocument(extraction, {
|
|
880
|
+
targetChars: this.#config.index.chunkTargetChars,
|
|
881
|
+
maxChars: this.#config.index.chunkMaxChars,
|
|
882
|
+
}), offset);
|
|
883
|
+
});
|
|
884
|
+
pageOffset += extraction.pageCount ?? 0;
|
|
885
|
+
if (extraction.text.length > 0)
|
|
886
|
+
report.documentsExtracted++;
|
|
887
|
+
else if (extraction.note)
|
|
888
|
+
report.warnings.push(`${row.rel_path}: ${extraction.note}`);
|
|
889
|
+
}
|
|
890
|
+
catch (err) {
|
|
891
|
+
report.warnings.push(`could not extract ${row.rel_path}: ${errorMessage(err)}`);
|
|
892
|
+
}
|
|
893
|
+
progress({ phase: 'extract', done: ++done, total, detail: row.rel_path });
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
/**
|
|
898
|
+
* The extracted text, written beside the file it came from.
|
|
899
|
+
*
|
|
900
|
+
* Invalidated by `rendition_sha`, never by whether the file happens to be there: a steady
|
|
901
|
+
* state costs one indexed comparison per pass rather than a `stat` per document, and a
|
|
902
|
+
* rendition the user deleted stays deleted, because the folder is theirs and deleting a
|
|
903
|
+
* file in it is an instruction rather than damage to repair.
|
|
904
|
+
*
|
|
905
|
+
* A decline is recorded too — the same reason `extracted_sha` is written when nothing
|
|
906
|
+
* could be read. Without that, every photograph in the knowledge base is reconsidered on
|
|
907
|
+
* every pass forever to reach the same answer.
|
|
908
|
+
*/
|
|
909
|
+
async writeRenditions(report, progress, only) {
|
|
910
|
+
if (!this.#config.ingest.textRendition)
|
|
911
|
+
return;
|
|
912
|
+
// Qualified: this query joins `pages`, which has a `rel_path` of its own, and an
|
|
913
|
+
// unqualified one is ambiguous. Only the watcher passes a scope, so an unqualified name
|
|
914
|
+
// here fails on exactly the path a full index never takes.
|
|
915
|
+
const scopeClause = only ? ` AND d.rel_path IN (${only.map(() => '?').join(',')})` : '';
|
|
916
|
+
const pending = this.#store.db
|
|
917
|
+
.prepare(`SELECT d.id, d.rel_path, d.sha256, d.text, d.page_count, d.ocr, d.extract_via, d.confidence,
|
|
918
|
+
p.slug
|
|
919
|
+
FROM documents d LEFT JOIN pages p ON p.id = d.page_id
|
|
920
|
+
WHERE d.renders IS NULL
|
|
921
|
+
AND d.availability = 'available'
|
|
922
|
+
AND (d.rendition_sha IS NULL OR d.rendition_sha != d.sha256)
|
|
923
|
+
${scopeClause}`)
|
|
924
|
+
.all(...(only ?? []));
|
|
925
|
+
if (pending.length === 0)
|
|
926
|
+
return;
|
|
927
|
+
const decided = this.#store.db.prepare('UPDATE documents SET rendition_sha = ? WHERE id = ?');
|
|
928
|
+
progress({ phase: 'renditions', done: 0, total: pending.length });
|
|
929
|
+
let done = 0;
|
|
930
|
+
for (const row of pending) {
|
|
931
|
+
const source = {
|
|
932
|
+
relPath: row.rel_path,
|
|
933
|
+
text: row.text ?? '',
|
|
934
|
+
pageCount: row.page_count,
|
|
935
|
+
ocr: row.ocr === 1,
|
|
936
|
+
// Null only where the text is null too — the two are cleared together whenever a
|
|
937
|
+
// hash changes or a file turns out to be a rendition. So 'none' is not a fallback
|
|
938
|
+
// for an unknown provenance; it is the provenance of a row with nothing extracted.
|
|
939
|
+
via: row.extract_via ?? 'none',
|
|
940
|
+
confidence: row.confidence,
|
|
941
|
+
};
|
|
942
|
+
const gate = renditionWanted(source, {
|
|
943
|
+
minChars: this.#config.ingest.textRenditionMinChars,
|
|
944
|
+
ingestRule: effectiveRule(row.slug ?? row.rel_path, this.#config.rules).ingest,
|
|
945
|
+
});
|
|
946
|
+
if (gate.write) {
|
|
947
|
+
const relPath = renditionPathFor(row.rel_path);
|
|
948
|
+
try {
|
|
949
|
+
// The name a rendition gets has to be one that reads back as *this* document's.
|
|
950
|
+
// `scan.jpg` and `scan.pdf` in one folder both want `scan.txt`, and a file two
|
|
951
|
+
// documents claim belongs to neither.
|
|
952
|
+
const claims = documentRendition(relPath, { entries: (dir) => this.entriesOf(dir) });
|
|
953
|
+
if (claims?.source !== row.rel_path) {
|
|
954
|
+
report.warnings.push(`${relPath} would not read back as the text of ${row.rel_path}` +
|
|
955
|
+
`${claims ? ` — that name belongs to ${claims.source}` : ', so it was not written'}`);
|
|
956
|
+
// Not recorded as decided. "This photo does not earn one" is an answer worth
|
|
957
|
+
// keeping; "I could not work out a name for this" is a failure, and the folder it
|
|
958
|
+
// failed against is one file away from making it answerable. Same reason a
|
|
959
|
+
// summary is retried while an unreadable file is not.
|
|
960
|
+
progress({ phase: 'renditions', done: ++done, total: pending.length, detail: row.rel_path });
|
|
961
|
+
continue;
|
|
962
|
+
}
|
|
963
|
+
if (await this.renditionIsOurs(relPath)) {
|
|
964
|
+
await writeFileAtomic(this.#config.aknoPath, relPath, renditionBody(source));
|
|
965
|
+
report.renditionsWritten++;
|
|
966
|
+
}
|
|
967
|
+
// A `.txt` somebody wrote themselves already holds this document's text under this
|
|
968
|
+
// document's name. That is the file, not a conflict — and overwriting the hand
|
|
969
|
+
// corrections in it is the one thing not to do.
|
|
970
|
+
}
|
|
971
|
+
catch (err) {
|
|
972
|
+
report.warnings.push(`could not write ${relPath}: ${errorMessage(err)}`);
|
|
973
|
+
progress({ phase: 'renditions', done: ++done, total: pending.length, detail: row.rel_path });
|
|
974
|
+
continue;
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
decided.run(row.sha256, row.id);
|
|
978
|
+
progress({ phase: 'renditions', done: ++done, total: pending.length, detail: row.rel_path });
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
/**
|
|
982
|
+
* Whether Akno may write this path: only if it is free, or holds something Akno
|
|
983
|
+
* itself put there — which the file says in its own first line.
|
|
984
|
+
*
|
|
985
|
+
* Existing and being *recognised* as a rendition is not the same question. The scanner
|
|
986
|
+
* recognises `contract.pdf.txt` by its name, and a person's own file can have that name.
|
|
987
|
+
* Authorship has to be read from the contents or the check answers yes to everything.
|
|
988
|
+
*/
|
|
989
|
+
async renditionIsOurs(relPath) {
|
|
990
|
+
const absPath = path.join(this.#config.aknoPath, relPath);
|
|
991
|
+
const head = await fsp.readFile(absPath, 'utf8').catch(() => null);
|
|
992
|
+
if (head === null)
|
|
993
|
+
return true;
|
|
994
|
+
return looksLikeRendition(head.slice(0, 200));
|
|
995
|
+
}
|
|
996
|
+
/**
|
|
997
|
+
* Whether each document still is — or has become — a rendition of another.
|
|
998
|
+
*
|
|
999
|
+
* `registerAttachment` answers that question for files the pass looked at, and the stat
|
|
1000
|
+
* fast path means an unchanged file is never looked at again. So the answer would be
|
|
1001
|
+
* frozen at whatever was true when the file first arrived: a `contract.txt` indexed before
|
|
1002
|
+
* the rule could recognise it keeps its own chunks and goes on returning the contract's
|
|
1003
|
+
* every phrase a second time, and one whose PDF has since been deleted stays a rendition
|
|
1004
|
+
* of nothing. Neither file changed; what changed is what is around it.
|
|
1005
|
+
*
|
|
1006
|
+
* A query over `documents` rather than over the tree, so it costs one pass over a table
|
|
1007
|
+
* with one row per attachment and nothing per note.
|
|
1008
|
+
*/
|
|
1009
|
+
reconcileRenditionClaims(report) {
|
|
1010
|
+
const rows = this.#store.db
|
|
1011
|
+
.prepare('SELECT id, rel_path, renders, availability FROM documents')
|
|
1012
|
+
.all();
|
|
1013
|
+
let changed = 0;
|
|
1014
|
+
for (const row of rows) {
|
|
1015
|
+
// Filesystem classification cannot rediscover a file that is not there. Keep its last
|
|
1016
|
+
// durable rendition relationship until the file returns or is explicitly forgotten.
|
|
1017
|
+
if (row.availability === 'missing')
|
|
1018
|
+
continue;
|
|
1019
|
+
if (row.renders) {
|
|
1020
|
+
const source = this.#store.db
|
|
1021
|
+
.prepare('SELECT availability FROM documents WHERE rel_path = ?')
|
|
1022
|
+
.get(row.renders);
|
|
1023
|
+
if (source?.availability === 'missing')
|
|
1024
|
+
continue;
|
|
1025
|
+
}
|
|
1026
|
+
const verdict = documentRendition(row.rel_path, { entries: (dir) => this.entriesOf(dir) });
|
|
1027
|
+
const renders = verdict?.source ?? null;
|
|
1028
|
+
if (renders === row.renders)
|
|
1029
|
+
continue;
|
|
1030
|
+
this.#store.transaction(() => {
|
|
1031
|
+
this.#store.db.prepare('UPDATE documents SET renders = ? WHERE id = ?').run(renders, row.id);
|
|
1032
|
+
if (renders) {
|
|
1033
|
+
// Its words belong to the file it renders and are indexed there. Two copies is the
|
|
1034
|
+
// duplicate this whole distinction exists to remove.
|
|
1035
|
+
this.deleteChunkRows(CHUNKS_FOR_DOCUMENT, row.id);
|
|
1036
|
+
this.#store.db
|
|
1037
|
+
.prepare(`UPDATE documents SET text = NULL, summary = NULL, page_count = NULL, ocr = 0,
|
|
1038
|
+
extracted_sha = NULL, extract_via = NULL, confidence = NULL
|
|
1039
|
+
WHERE id = ?`)
|
|
1040
|
+
.run(row.id);
|
|
1041
|
+
}
|
|
1042
|
+
else {
|
|
1043
|
+
// No longer renders anything — the file it copied is gone. It is a document again,
|
|
1044
|
+
// and `extractPending` reads it on the next pass because its hash is unrecorded.
|
|
1045
|
+
this.#store.db.prepare('UPDATE documents SET extracted_sha = NULL WHERE id = ?').run(row.id);
|
|
1046
|
+
}
|
|
1047
|
+
});
|
|
1048
|
+
changed++;
|
|
1049
|
+
}
|
|
1050
|
+
if (changed > 0) {
|
|
1051
|
+
report.warnings.push(`${changed} document(s) changed between being a document and being the text of one`);
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
/**
|
|
1055
|
+
* A rendition policy that moved has to ask again about every document it declined.
|
|
1056
|
+
*
|
|
1057
|
+
* Same problem `reclassify` solves for rules, and for the same reason: declines are
|
|
1058
|
+
* recorded so they are not recomputed, which means lowering the threshold would otherwise
|
|
1059
|
+
* change nothing until the files themselves changed.
|
|
1060
|
+
*/
|
|
1061
|
+
reconsiderRenditions(report) {
|
|
1062
|
+
const fingerprint = sha256(JSON.stringify({
|
|
1063
|
+
enabled: this.#config.ingest.textRendition,
|
|
1064
|
+
minChars: this.#config.ingest.textRenditionMinChars,
|
|
1065
|
+
// Where the file goes is part of the policy, not an implementation detail: a
|
|
1066
|
+
// rendition already written under a different name is not the one that would be
|
|
1067
|
+
// written now. Derived from the naming itself so it cannot go stale.
|
|
1068
|
+
scheme: renditionPathFor('scheme.pdf'),
|
|
1069
|
+
}));
|
|
1070
|
+
if (this.#store.meta(RENDITION_FINGERPRINT) === fingerprint)
|
|
1071
|
+
return;
|
|
1072
|
+
const first = this.#store.meta(RENDITION_FINGERPRINT) === null;
|
|
1073
|
+
const cleared = this.#store.db
|
|
1074
|
+
.prepare('UPDATE documents SET rendition_sha = NULL WHERE renders IS NULL AND rendition_sha IS NOT NULL')
|
|
1075
|
+
.run();
|
|
1076
|
+
this.#store.setMeta(RENDITION_FINGERPRINT, fingerprint);
|
|
1077
|
+
if (!first && cleared.changes > 0) {
|
|
1078
|
+
report.warnings.push(`the text rendition policy changed since the last pass: ${cleared.changes} document(s) were reconsidered`);
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
/**
|
|
1082
|
+
* A stored document has extracted text, a summary and embeddings of its own — one
|
|
1083
|
+
* summary per *document*, so a passport split into two files does not get two
|
|
1084
|
+
* half-summaries describing halves of one thing.
|
|
1085
|
+
*
|
|
1086
|
+
* Kept separate from extraction because the two are invalidated by different things:
|
|
1087
|
+
* extraction by the file's hash, a summary by not having one. A model that was down,
|
|
1088
|
+
* or that failed to answer in JSON, is retried on the next pass instead of waiting for the
|
|
1089
|
+
* bytes on disk to change.
|
|
1090
|
+
*/
|
|
1091
|
+
async summarizeDocuments(report, progress) {
|
|
1092
|
+
if (!this.#models.derive.available)
|
|
1093
|
+
return;
|
|
1094
|
+
const groups = this.#store.db
|
|
1095
|
+
.prepare(`SELECT group_key FROM documents
|
|
1096
|
+
WHERE renders IS NULL
|
|
1097
|
+
GROUP BY group_key
|
|
1098
|
+
HAVING sum(CASE WHEN availability = 'missing' THEN 1 ELSE 0 END) = 0
|
|
1099
|
+
AND sum(CASE WHEN text IS NOT NULL AND summary IS NULL THEN 1 ELSE 0 END) > 0`)
|
|
1100
|
+
.all();
|
|
1101
|
+
if (groups.length === 0)
|
|
1102
|
+
return;
|
|
1103
|
+
// Renditions excluded: one summary per document means the file holding a copy of the
|
|
1104
|
+
// text is not a second thing to describe, and joining its text in would summarize the
|
|
1105
|
+
// document twice over.
|
|
1106
|
+
const partsOf = this.#store.db.prepare(`SELECT id, text FROM documents
|
|
1107
|
+
WHERE group_key = ? AND renders IS NULL AND availability = 'available'
|
|
1108
|
+
ORDER BY part`);
|
|
1109
|
+
const write = this.#store.db.prepare('UPDATE documents SET summary = ? WHERE id = ?');
|
|
1110
|
+
progress({ phase: 'summarize', done: 0, total: groups.length });
|
|
1111
|
+
let done = 0;
|
|
1112
|
+
for (const group of groups) {
|
|
1113
|
+
const parts = partsOf.all(group.group_key);
|
|
1114
|
+
const text = parts
|
|
1115
|
+
.map((part) => part.text)
|
|
1116
|
+
.filter((value) => value !== null)
|
|
1117
|
+
.join('\n\n');
|
|
1118
|
+
if (text.length === 0)
|
|
1119
|
+
continue;
|
|
1120
|
+
const summarized = await summarizeDocument(text, this.#models.derive);
|
|
1121
|
+
if (summarized.summary) {
|
|
1122
|
+
this.#store.transaction(() => {
|
|
1123
|
+
for (const part of parts)
|
|
1124
|
+
write.run(summarized.summary, part.id);
|
|
1125
|
+
});
|
|
1126
|
+
report.documentsSummarized++;
|
|
1127
|
+
}
|
|
1128
|
+
else if (summarized.error) {
|
|
1129
|
+
report.warnings.push(`could not summarize ${group.group_key}: ${summarized.error}`);
|
|
1130
|
+
}
|
|
1131
|
+
progress({ phase: 'summarize', done: ++done, total: groups.length, detail: group.group_key });
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
/**
|
|
1135
|
+
* A document's chunks are `source`: evidence, quoted in a capped
|
|
1136
|
+
* window, never mined for facts. A contract is not the household asserting its terms.
|
|
1137
|
+
*/
|
|
1138
|
+
replaceDocumentChunks(documentId, pageId, chunks, pageOffset) {
|
|
1139
|
+
this.deleteChunkRows(CHUNKS_FOR_DOCUMENT, documentId);
|
|
1140
|
+
const identity = this.#store.db
|
|
1141
|
+
.prepare('SELECT rel_path, label FROM documents WHERE id = ?')
|
|
1142
|
+
.get(documentId);
|
|
1143
|
+
const heading = identity ? [identity.label, identity.rel_path].filter(Boolean).join(' · ') : '';
|
|
1144
|
+
const insert = this.#store.db.prepare(`INSERT INTO chunks(page_id, document_id, doc_page, ord, kind, heading_path, text,
|
|
1145
|
+
line_start, line_end, embedded)
|
|
1146
|
+
VALUES(?, ?, ?, ?, 'source', ?, ?, 0, 0, 0)`);
|
|
1147
|
+
const insertFts = this.#store.db.prepare('INSERT INTO chunks_fts(rowid, text, heading_path) VALUES(?, ?, ?)');
|
|
1148
|
+
// An unreadable attachment still has a stable identity. A zero-text chunk makes an
|
|
1149
|
+
// exact filename lookup find that identity through `heading_path` without inventing
|
|
1150
|
+
// document content or allowing an empty body to match topical queries.
|
|
1151
|
+
const searchable = chunks.length > 0 ? chunks : [{ ord: 0, text: '', docPage: null }];
|
|
1152
|
+
for (const chunk of searchable) {
|
|
1153
|
+
// The page number within the *whole* document, not within this file: page 2 of
|
|
1154
|
+
// `passport-2.pdf` is page 5 of the passport, and that is the one a reader can find.
|
|
1155
|
+
const docPage = chunk.docPage === null ? null : chunk.docPage + pageOffset;
|
|
1156
|
+
const result = insert.run(pageId, documentId, docPage, chunk.ord, heading, chunk.text);
|
|
1157
|
+
insertFts.run(Number(result.lastInsertRowid), chunk.text, heading);
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
async embedPending(report, progress, scope) {
|
|
1161
|
+
if (!this.#models.embedding.available)
|
|
1162
|
+
return;
|
|
1163
|
+
if (scope && scope.size === 0)
|
|
1164
|
+
return;
|
|
1165
|
+
const pending = (scope
|
|
1166
|
+
? this.#store.db
|
|
1167
|
+
.prepare(`SELECT id, text, heading_path FROM chunks
|
|
1168
|
+
WHERE embedded = 0 AND page_id IN (${[...scope].map(() => '?').join(',')}) ORDER BY id`)
|
|
1169
|
+
.all(...scope)
|
|
1170
|
+
: this.#store.db
|
|
1171
|
+
.prepare('SELECT id, text, heading_path FROM chunks WHERE embedded = 0 ORDER BY id')
|
|
1172
|
+
.all());
|
|
1173
|
+
if (pending.length === 0)
|
|
1174
|
+
return;
|
|
1175
|
+
const batchSize = this.#config.models.embedding.batch ?? 32;
|
|
1176
|
+
progress({ phase: 'embed', done: 0, total: pending.length });
|
|
1177
|
+
const markEmbedded = this.#store.db.prepare('UPDATE chunks SET embedded = 1 WHERE id = ?');
|
|
1178
|
+
let done = 0;
|
|
1179
|
+
for (let i = 0; i < pending.length; i += batchSize) {
|
|
1180
|
+
const batch = pending.slice(i, i + batchSize);
|
|
1181
|
+
const texts = batch.map((chunk) => embeddingText({
|
|
1182
|
+
ord: 0,
|
|
1183
|
+
kind: 'knowledge',
|
|
1184
|
+
headingPath: chunk.heading_path,
|
|
1185
|
+
text: chunk.text,
|
|
1186
|
+
lineStart: 0,
|
|
1187
|
+
lineEnd: 0,
|
|
1188
|
+
}));
|
|
1189
|
+
const result = await this.#models.embedding.embed(texts);
|
|
1190
|
+
if (!result.ok || !result.value) {
|
|
1191
|
+
// A partial embed is honest and recoverable: `embedded = 0` rows are
|
|
1192
|
+
// picked up next pass, and recall reports `degraded` in the meantime.
|
|
1193
|
+
report.warnings.push(`embedding stopped after ${done} chunks: ${result.error ?? 'unknown error'}`);
|
|
1194
|
+
break;
|
|
1195
|
+
}
|
|
1196
|
+
this.#store.transaction(() => {
|
|
1197
|
+
for (let j = 0; j < batch.length; j++) {
|
|
1198
|
+
const vector = result.value[j];
|
|
1199
|
+
if (!vector)
|
|
1200
|
+
continue;
|
|
1201
|
+
this.#store.vectors.upsert(batch[j].id, vector);
|
|
1202
|
+
markEmbedded.run(batch[j].id);
|
|
1203
|
+
}
|
|
1204
|
+
});
|
|
1205
|
+
done += batch.length;
|
|
1206
|
+
report.chunksEmbedded += batch.length;
|
|
1207
|
+
progress({ phase: 'embed', done, total: pending.length });
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
// ─── Derivation ───────────────────────────────────────────────────────────
|
|
1211
|
+
async derivePending(report, progress, force, scope) {
|
|
1212
|
+
const wantSummaries = this.#config.index.summaries;
|
|
1213
|
+
const wantFacts = this.#config.index.facts;
|
|
1214
|
+
if (!wantSummaries && !wantFacts)
|
|
1215
|
+
return;
|
|
1216
|
+
if (!this.#models.derive.available)
|
|
1217
|
+
return;
|
|
1218
|
+
if (scope && scope.size === 0)
|
|
1219
|
+
return;
|
|
1220
|
+
// Every indexed page may be summarized, but only canonical knowledge pages
|
|
1221
|
+
// assert facts. Source and inference pages remain retrievable evidence.
|
|
1222
|
+
const scopeClause = scope ? ` AND id IN (${[...scope].map(() => '?').join(',')})` : '';
|
|
1223
|
+
const pending = this.#store.db
|
|
1224
|
+
.prepare(`SELECT id, slug, rel_path, body_hash, role FROM pages
|
|
1225
|
+
WHERE role != 'ignored' AND (? = 1 OR derived_hash IS NULL OR derived_hash != body_hash)
|
|
1226
|
+
${scopeClause}
|
|
1227
|
+
ORDER BY updated_at DESC`)
|
|
1228
|
+
.all(force ? 1 : 0, ...(scope ?? []));
|
|
1229
|
+
if (pending.length === 0)
|
|
1230
|
+
return;
|
|
1231
|
+
progress({ phase: 'derive', done: 0, total: pending.length });
|
|
1232
|
+
const concurrency = Math.max(1, this.#config.models.derive.concurrency ?? 2);
|
|
1233
|
+
let done = 0;
|
|
1234
|
+
await mapWithConcurrency(pending, concurrency, async (row) => {
|
|
1235
|
+
try {
|
|
1236
|
+
const content = await fsp.readFile(path.join(this.#config.aknoPath, row.rel_path), 'utf8');
|
|
1237
|
+
const page = parsePage(row.rel_path, content);
|
|
1238
|
+
const wantedFacts = wantFacts && row.role === 'knowledge';
|
|
1239
|
+
const derived = await derivePage(page, this.#models.derive, {
|
|
1240
|
+
summaries: wantSummaries,
|
|
1241
|
+
facts: wantedFacts,
|
|
1242
|
+
});
|
|
1243
|
+
if (derived.error) {
|
|
1244
|
+
report.warnings.push(`derivation for ${row.slug}: ${derived.error}`);
|
|
1245
|
+
}
|
|
1246
|
+
else if (derived.partial && wantedFacts) {
|
|
1247
|
+
// **The facts half failed. Keep the summary, and touch nothing else.**
|
|
1248
|
+
//
|
|
1249
|
+
// This used to fall through to the branch below, which meant a transient failure —
|
|
1250
|
+
// one 400, one unparseable answer — did two permanent things. `replaceFacts` with an
|
|
1251
|
+
// empty list *deleted* every fact on the page, because a fact whose source line is
|
|
1252
|
+
// still present and which the derivation did not repeat is a rephrasing, not a
|
|
1253
|
+
// supersession, and rephrasings are deleted. Then `derived_hash` was stamped, so the
|
|
1254
|
+
// page read as derived and was never offered to another pass. One flaky call, and a
|
|
1255
|
+
// page's facts were gone with no superseded rows to show they had ever existed.
|
|
1256
|
+
//
|
|
1257
|
+
// Observed here on 2026-08-17: `people/ada-marlow`, `timeline` and
|
|
1258
|
+
// `shopping/zephyr-qx-100` lost every fact to a token-parameter race that a
|
|
1259
|
+
// retry would have fixed, and nothing would ever have retried them.
|
|
1260
|
+
//
|
|
1261
|
+
// So the hash stays unstamped and the page comes back next pass, exactly as an outright
|
|
1262
|
+
// `error` already does. The cost is honest and bounded: a page whose full derivation
|
|
1263
|
+
// genuinely cannot succeed is re-attempted once per index pass, and its summary is
|
|
1264
|
+
// rewritten each time. That is the same bill the `error` branch above already accepts,
|
|
1265
|
+
// and it buys the difference between a stale fact and a deleted one.
|
|
1266
|
+
report.warnings.push(`derivation for ${row.slug}: ${derived.partial}`);
|
|
1267
|
+
this.#store.db
|
|
1268
|
+
.prepare('UPDATE pages SET summary = ?, keywords = ? WHERE id = ?')
|
|
1269
|
+
.run(derived.summary, JSON.stringify(derived.keywords), row.id);
|
|
1270
|
+
}
|
|
1271
|
+
else {
|
|
1272
|
+
// A `partial` still reaches here when facts were never wanted — a `source` page asks for
|
|
1273
|
+
// a summary alone, so there is nothing held back and nothing to protect.
|
|
1274
|
+
if (derived.partial)
|
|
1275
|
+
report.warnings.push(`derivation for ${row.slug}: ${derived.partial}`);
|
|
1276
|
+
this.#store.transaction(() => {
|
|
1277
|
+
this.#store.db
|
|
1278
|
+
.prepare('UPDATE pages SET summary = ?, keywords = ?, derived_hash = ? WHERE id = ?')
|
|
1279
|
+
.run(derived.summary, JSON.stringify(derived.keywords), row.body_hash, row.id);
|
|
1280
|
+
this.replaceFacts(row.id, derived.facts, bodyLineHashes(page), bodyItemIds(page));
|
|
1281
|
+
});
|
|
1282
|
+
report.pagesDerived++;
|
|
1283
|
+
report.factsDerived += derived.facts.length;
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
catch (err) {
|
|
1287
|
+
report.warnings.push(`derivation for ${row.slug} failed: ${errorMessage(err)}`);
|
|
1288
|
+
}
|
|
1289
|
+
progress({ phase: 'derive', done: ++done, total: pending.length, detail: row.slug });
|
|
1290
|
+
});
|
|
1291
|
+
}
|
|
1292
|
+
/**
|
|
1293
|
+
* Supersession is structural: a fact whose source line is gone or changed
|
|
1294
|
+
* gets `valid_to` set rather than being deleted, so recall can return it *as
|
|
1295
|
+
* superseded* — "was €28 until June" — instead of as a second competing current
|
|
1296
|
+
* answer.
|
|
1297
|
+
*
|
|
1298
|
+
* The distinction that matters is **why** a fact disappeared:
|
|
1299
|
+
*
|
|
1300
|
+
* - Its source line changed or went away → a real supersession. Retire it.
|
|
1301
|
+
* - Its source line is byte-identical and the deriver merely phrased the claim
|
|
1302
|
+
* differently → not a supersession at all. Delete it.
|
|
1303
|
+
*
|
|
1304
|
+
* Conflating the two invents history. A fresh derivation
|
|
1305
|
+
* may phrase a claim differently, so retiring on id alone would make every
|
|
1306
|
+
* `--rederive` flood recall with "was X until today" for values that never
|
|
1307
|
+
* changed — and an invented historical claim is worse than none, because a
|
|
1308
|
+
* reader has no way to tell it apart from a real one.
|
|
1309
|
+
*
|
|
1310
|
+
* `presentLines` therefore comes from **the page**, not from the incoming facts. Read
|
|
1311
|
+
* from the facts, an empty derivation looks like every source line vanishing at once, so
|
|
1312
|
+
* a page that merely became `source` — or one where a small model returned no facts
|
|
1313
|
+
* this time — retired its whole history as superseded on lines nobody had touched.
|
|
1314
|
+
*/
|
|
1315
|
+
replaceFacts(pageId, facts, presentLines, presentItems) {
|
|
1316
|
+
const now = nowIso();
|
|
1317
|
+
const today = now.slice(0, 10);
|
|
1318
|
+
const existing = this.#store.db
|
|
1319
|
+
.prepare('SELECT id, source_line_hash, item_id FROM facts WHERE page_id = ?')
|
|
1320
|
+
.all(pageId);
|
|
1321
|
+
const idFor = (fact) => fact.itemId ? managedFactId(fact.itemId) : factId(pageId, fact.sourceLineHash, fact.claim);
|
|
1322
|
+
const incomingIds = new Set(facts.map(idFor));
|
|
1323
|
+
const insert = this.#store.db.prepare(`INSERT INTO facts(id, page_id, claim, subject, attribute, value, line_start, line_end,
|
|
1324
|
+
source_line_hash, item_id, confidence, valid_from, valid_to, first_seen, last_seen)
|
|
1325
|
+
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?)
|
|
1326
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
1327
|
+
page_id = excluded.page_id, claim = excluded.claim, subject = excluded.subject,
|
|
1328
|
+
attribute = excluded.attribute, value = excluded.value,
|
|
1329
|
+
line_start = excluded.line_start, line_end = excluded.line_end,
|
|
1330
|
+
source_line_hash = excluded.source_line_hash, item_id = excluded.item_id,
|
|
1331
|
+
confidence = excluded.confidence, last_seen = excluded.last_seen, valid_to = NULL`);
|
|
1332
|
+
for (const fact of facts) {
|
|
1333
|
+
insert.run(idFor(fact), pageId, fact.claim, fact.subject, fact.attribute, fact.value, fact.line, fact.line, fact.sourceLineHash, fact.itemId, fact.confidence, today, now, now);
|
|
1334
|
+
}
|
|
1335
|
+
const retire = this.#store.db.prepare('UPDATE facts SET valid_to = ? WHERE id = ? AND valid_to IS NULL');
|
|
1336
|
+
const drop = this.#store.db.prepare('DELETE FROM facts WHERE id = ?');
|
|
1337
|
+
for (const row of existing) {
|
|
1338
|
+
if (incomingIds.has(row.id))
|
|
1339
|
+
continue;
|
|
1340
|
+
if ((row.item_id && presentItems.has(row.item_id)) || presentLines.has(row.source_line_hash))
|
|
1341
|
+
drop.run(row.id);
|
|
1342
|
+
else
|
|
1343
|
+
retire.run(today, row.id);
|
|
1344
|
+
}
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
1348
|
+
function isLedger(slug, config) {
|
|
1349
|
+
return slug === ledgerSlug(config);
|
|
1350
|
+
}
|
|
1351
|
+
function nowIso() {
|
|
1352
|
+
return new Date().toISOString();
|
|
1353
|
+
}
|
|
1354
|
+
function mtimeIso(mtimeNs) {
|
|
1355
|
+
const ms = Number(BigInt(mtimeNs) / 1000000n);
|
|
1356
|
+
return new Date(ms).toISOString();
|
|
1357
|
+
}
|
|
1358
|
+
function filesystemIso(nanoseconds) {
|
|
1359
|
+
const value = BigInt(nanoseconds);
|
|
1360
|
+
if (value <= 0n)
|
|
1361
|
+
return null;
|
|
1362
|
+
return mtimeIso(nanoseconds);
|
|
1363
|
+
}
|
|
1364
|
+
function fileExists(absPath) {
|
|
1365
|
+
return fs.existsSync(absPath);
|
|
1366
|
+
}
|
|
1367
|
+
function guessMime(relPath) {
|
|
1368
|
+
const extension = path.extname(relPath).toLowerCase();
|
|
1369
|
+
const table = {
|
|
1370
|
+
'.pdf': 'application/pdf',
|
|
1371
|
+
'.png': 'image/png',
|
|
1372
|
+
'.jpg': 'image/jpeg',
|
|
1373
|
+
'.jpeg': 'image/jpeg',
|
|
1374
|
+
'.heic': 'image/heic',
|
|
1375
|
+
'.webp': 'image/webp',
|
|
1376
|
+
'.gif': 'image/gif',
|
|
1377
|
+
'.txt': 'text/plain',
|
|
1378
|
+
'.csv': 'text/csv',
|
|
1379
|
+
'.json': 'application/json',
|
|
1380
|
+
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
1381
|
+
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
1382
|
+
'.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
|
1383
|
+
'.eml': 'message/rfc822',
|
|
1384
|
+
'.html': 'text/html',
|
|
1385
|
+
};
|
|
1386
|
+
return table[extension] ?? null;
|
|
1387
|
+
}
|
|
1388
|
+
function errorMessage(err) {
|
|
1389
|
+
return err instanceof Error ? err.message : String(err);
|
|
1390
|
+
}
|
|
1391
|
+
/** `meta` key holding the fingerprint of the rules the index was last built under. */
|
|
1392
|
+
const RULES_FINGERPRINT = 'rules_fingerprint';
|
|
1393
|
+
/** The same, for the settings that decide which documents get a `.txt` beside them. */
|
|
1394
|
+
const RENDITION_FINGERPRINT = 'rendition_fingerprint';
|
|
1395
|
+
const ALL_CHUNKS_FOR_PAGE = 'SELECT id FROM chunks WHERE page_id = ?';
|
|
1396
|
+
const BODY_CHUNKS_FOR_PAGE = 'SELECT id FROM chunks WHERE page_id = ? AND document_id IS NULL';
|
|
1397
|
+
const CHUNKS_FOR_DOCUMENT = 'SELECT id FROM chunks WHERE document_id = ?';
|
|
1398
|
+
//# sourceMappingURL=indexer.js.map
|