akm-cli 0.9.0-rc.1 → 0.9.0-rc.2
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/CHANGELOG.md +223 -9
- package/SECURITY.md +23 -24
- package/dist/assets/help/help-improve.md +10 -10
- package/dist/assets/hints/cli-hints-full.md +44 -10
- package/dist/assets/hints/cli-hints-short.md +6 -2
- package/dist/assets/{profiles → improve-strategies}/default.json +1 -0
- package/dist/assets/{profiles → improve-strategies}/graph-refresh.json +1 -1
- package/dist/assets/{profiles → improve-strategies}/proactive-maintenance.json +2 -3
- package/dist/assets/{profiles → improve-strategies}/reflect-distill.json +3 -4
- package/dist/assets/stash-skeleton/README.md +28 -0
- package/dist/assets/stash-skeleton/facts/conventions/assets/agent.md +6 -0
- package/dist/assets/stash-skeleton/facts/conventions/assets/command.md +6 -0
- package/dist/assets/stash-skeleton/facts/conventions/assets/fact.md +12 -1
- package/dist/assets/stash-skeleton/facts/conventions/assets/knowledge.md +11 -1
- package/dist/assets/stash-skeleton/facts/conventions/assets/lesson.md +6 -0
- package/dist/assets/stash-skeleton/facts/conventions/assets/memory.md +9 -0
- package/dist/assets/stash-skeleton/facts/conventions/assets/script.md +7 -0
- package/dist/assets/stash-skeleton/facts/conventions/assets/skill.md +7 -0
- package/dist/assets/stash-skeleton/facts/conventions/assets/workflow.md +6 -0
- package/dist/assets/stash-skeleton/facts/conventions/backlinks.md +98 -0
- package/dist/assets/stash-skeleton/facts/conventions/domains.md +63 -0
- package/dist/assets/stash-skeleton/facts/conventions/organization.md +127 -0
- package/dist/assets/tasks/core/backup.yml +1 -0
- package/dist/assets/tasks/core/extract.yml +1 -0
- package/dist/assets/tasks/core/improve.yml +1 -0
- package/dist/assets/tasks/core/index-refresh.yml +1 -0
- package/dist/assets/tasks/core/sync.yml +1 -0
- package/dist/assets/tasks/core/version-check.yml +1 -0
- package/dist/assets/tasks/graph-refresh-weekly.yml +4 -4
- package/dist/assets/templates/html/health.html +5 -1
- package/dist/cli/config-migrate.js +31 -138
- package/dist/cli/config-validate.js +10 -8
- package/dist/cli.js +48 -14
- package/dist/commands/agent/agent-dispatch.js +17 -16
- package/dist/commands/agent/agent-support.js +0 -24
- package/dist/commands/agent/contribute-cli.js +5 -15
- package/dist/commands/backup-cli.js +54 -0
- package/dist/commands/config-cli.js +45 -159
- package/dist/commands/env/secret.js +8 -5
- package/dist/commands/health/checks.js +130 -83
- package/dist/commands/health/html-report.js +4 -0
- package/dist/commands/health/improve-metrics.js +30 -32
- package/dist/commands/health/llm-usage.js +19 -19
- package/dist/commands/health/md-report.js +4 -0
- package/dist/commands/health/metrics.js +2 -1
- package/dist/commands/health/surfaces.js +5 -4
- package/dist/commands/health.js +1 -1
- package/dist/commands/improve/consolidate/chunking.js +2 -2
- package/dist/commands/improve/consolidate.js +28 -25
- package/dist/commands/improve/distill/promote-memory.js +5 -12
- package/dist/commands/improve/distill/quality-gate.js +5 -7
- package/dist/commands/improve/distill.js +16 -5
- package/dist/commands/improve/eligibility.js +22 -12
- package/dist/commands/improve/extract-cli.js +47 -19
- package/dist/commands/improve/extract.js +110 -62
- package/dist/commands/improve/improve-cli.js +38 -16
- package/dist/commands/improve/improve-result-file.js +30 -24
- package/dist/commands/improve/improve-strategies.js +137 -0
- package/dist/commands/improve/improve.js +60 -30
- package/dist/commands/improve/locks.js +66 -45
- package/dist/commands/improve/loop-stages.js +75 -33
- package/dist/commands/improve/memory/memory-belief.js +79 -7
- package/dist/commands/improve/memory/memory-contradiction-detect.js +12 -4
- package/dist/commands/improve/preparation.js +71 -73
- package/dist/commands/improve/procedural.js +3 -2
- package/dist/commands/improve/recombine.js +2 -1
- package/dist/commands/improve/reflect.js +119 -214
- package/dist/commands/improve/shared.js +11 -5
- package/dist/commands/lint/base-linter.js +152 -42
- package/dist/commands/mv-cli.js +809 -0
- package/dist/commands/proposal/proposal-cli.js +18 -8
- package/dist/commands/proposal/propose.js +64 -69
- package/dist/commands/read/knowledge.js +436 -4
- package/dist/commands/read/remember-cli.js +39 -2
- package/dist/commands/read/search-cli.js +6 -1
- package/dist/commands/registry-cli.js +29 -14
- package/dist/commands/remember.js +2 -0
- package/dist/commands/sources/init.js +13 -14
- package/dist/commands/sources/schema-repair.js +2 -4
- package/dist/commands/sources/source-add.js +62 -73
- package/dist/commands/sources/source-manage.js +50 -46
- package/dist/commands/sources/stash-cli.js +41 -4
- package/dist/commands/tasks/default-tasks.js +12 -12
- package/dist/commands/tasks/tasks-cli.js +7 -3
- package/dist/commands/tasks/tasks.js +113 -18
- package/dist/commands/wiki-cli.js +9 -10
- package/dist/core/asset/frontmatter.js +12 -2
- package/dist/core/common.js +5 -3
- package/dist/core/config/config-io.js +28 -17
- package/dist/core/config/config-schema.js +358 -66
- package/dist/core/config/config-types.js +3 -3
- package/dist/core/config/config-version.js +29 -0
- package/dist/core/config/config-walker.js +98 -27
- package/dist/core/config/config.js +132 -266
- package/dist/core/config/deep-merge.js +41 -0
- package/dist/core/config/engine-semantics.js +32 -0
- package/dist/core/errors.js +2 -2
- package/dist/core/extra-params.js +61 -0
- package/dist/core/file-lock.js +201 -56
- package/dist/core/improve-result.js +178 -0
- package/dist/core/maintenance-barrier.js +119 -0
- package/dist/core/migration-backup.js +416 -0
- package/dist/core/paths.js +3 -0
- package/dist/core/redaction.js +358 -0
- package/dist/core/state/migrations.js +17 -2
- package/dist/core/state-db.js +44 -1
- package/dist/indexer/db/db.js +116 -1
- package/dist/indexer/graph/graph-extraction.js +28 -16
- package/dist/indexer/index-writer-lock.js +31 -24
- package/dist/indexer/index-written-assets.js +15 -6
- package/dist/indexer/indexer.js +47 -2
- package/dist/indexer/passes/memory-inference.js +10 -6
- package/dist/indexer/passes/metadata.js +250 -0
- package/dist/indexer/search/db-search.js +111 -44
- package/dist/indexer/search/fts-query.js +41 -0
- package/dist/indexer/search/ranking-contributors.js +48 -0
- package/dist/indexer/search/ranking.js +36 -23
- package/dist/indexer/search/search-fields.js +11 -1
- package/dist/integrations/agent/builder-shared.js +7 -0
- package/dist/integrations/agent/builders.js +5 -52
- package/dist/integrations/agent/config.js +3 -143
- package/dist/integrations/agent/detect.js +17 -2
- package/dist/integrations/agent/engine-resolution.js +202 -0
- package/dist/integrations/agent/index.js +1 -2
- package/dist/integrations/agent/model-aliases.js +7 -2
- package/dist/integrations/agent/profiles.js +6 -99
- package/dist/integrations/agent/runner-dispatch.js +76 -13
- package/dist/integrations/agent/runner.js +76 -207
- package/dist/integrations/agent/spawn.js +4 -6
- package/dist/integrations/harnesses/aider/agent-builder.js +2 -3
- package/dist/integrations/harnesses/aider/index.js +0 -1
- package/dist/integrations/harnesses/amazonq/agent-builder.js +2 -3
- package/dist/integrations/harnesses/amazonq/index.js +0 -1
- package/dist/integrations/harnesses/claude/agent-builder.js +2 -3
- package/dist/integrations/harnesses/claude/index.js +0 -2
- package/dist/integrations/harnesses/codex/agent-builder.js +2 -3
- package/dist/integrations/harnesses/codex/index.js +0 -1
- package/dist/integrations/harnesses/copilot/agent-builder.js +2 -3
- package/dist/integrations/harnesses/copilot/index.js +0 -1
- package/dist/integrations/harnesses/gemini/agent-builder.js +2 -3
- package/dist/integrations/harnesses/gemini/index.js +0 -1
- package/dist/integrations/harnesses/index.js +1 -24
- package/dist/integrations/harnesses/opencode/agent-builder.js +2 -3
- package/dist/integrations/harnesses/opencode/index.js +0 -6
- package/dist/integrations/harnesses/opencode-sdk/harness.js +1 -6
- package/dist/integrations/harnesses/opencode-sdk/sdk-runner.js +274 -191
- package/dist/integrations/harnesses/openhands/agent-builder.js +2 -3
- package/dist/integrations/harnesses/openhands/index.js +0 -1
- package/dist/integrations/harnesses/pi/agent-builder.js +2 -3
- package/dist/integrations/harnesses/pi/index.js +0 -1
- package/dist/integrations/harnesses/types.js +1 -32
- package/dist/integrations/lockfile.js +32 -21
- package/dist/llm/client.js +48 -14
- package/dist/llm/feature-gate.js +15 -47
- package/dist/llm/graph-extract.js +1 -1
- package/dist/llm/index-passes.js +8 -42
- package/dist/llm/memory-infer-impl.js +1 -1
- package/dist/llm/usage-persist.js +4 -0
- package/dist/llm/usage-telemetry.js +35 -5
- package/dist/output/shapes/helpers.js +2 -1
- package/dist/output/shapes/passthrough.js +2 -0
- package/dist/output/text/helpers.js +3 -1
- package/dist/schemas/akm-config.json +11013 -8600
- package/dist/schemas/akm-task.json +87 -0
- package/dist/schemas/akm-workflow.json +57 -13
- package/dist/scripts/migrate-storage.js +8591 -509
- package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +8623 -484
- package/dist/setup/detected-engines.js +142 -0
- package/dist/setup/engine-config.js +89 -0
- package/dist/setup/setup.js +236 -132
- package/dist/setup/steps/connection.js +61 -32
- package/dist/setup/steps/platforms.js +4 -4
- package/dist/setup/steps.js +3 -2
- package/dist/storage/database.js +13 -1
- package/dist/storage/engines/sqlite-migrations.js +1 -0
- package/dist/storage/repositories/improve-runs-repository.js +5 -5
- package/dist/storage/repositories/task-history-repository.js +78 -0
- package/dist/storage/repositories/workflow-runs-repository.js +9 -8
- package/dist/tasks/parser.js +138 -52
- package/dist/tasks/runner.js +71 -75
- package/dist/tasks/schema.js +1 -1
- package/dist/tasks/validator.js +11 -6
- package/dist/wiki/wiki.js +9 -8
- package/dist/workflows/authoring/workflow-program-template.yaml +3 -3
- package/dist/workflows/concurrency-policy.js +15 -0
- package/dist/workflows/db.js +65 -13
- package/dist/workflows/exec/brief.js +18 -24
- package/dist/workflows/exec/frozen-judge.js +47 -0
- package/dist/workflows/exec/native-executor.js +124 -65
- package/dist/workflows/exec/report.js +93 -33
- package/dist/workflows/exec/run-workflow.js +38 -25
- package/dist/workflows/exec/scheduler.js +12 -41
- package/dist/workflows/exec/step-work.js +91 -35
- package/dist/workflows/ir/compile.js +13 -26
- package/dist/workflows/ir/freeze.js +243 -0
- package/dist/workflows/ir/plan-hash.js +40 -5
- package/dist/workflows/ir/schema.js +542 -1
- package/dist/workflows/parser.js +7 -0
- package/dist/workflows/program/parser.js +132 -23
- package/dist/workflows/program/project.js +3 -4
- package/dist/workflows/program/schema.js +2 -2
- package/dist/workflows/resource-limits.js +20 -0
- package/dist/workflows/runtime/plan-classifier.js +187 -0
- package/dist/workflows/runtime/runs.js +54 -47
- package/dist/workflows/runtime/workflow-asset-loader.js +0 -22
- package/dist/workflows/validator.js +25 -0
- package/docs/data-and-telemetry.md +2 -2
- package/docs/migration/release-notes/0.6.0.md +1 -1
- package/docs/migration/release-notes/0.7.0.md +5 -4
- package/docs/migration/v0.8-to-v0.9.md +401 -0
- package/package.json +4 -2
- package/schemas/akm-config.json +16638 -0
- package/schemas/akm-task.json +87 -0
- package/schemas/akm-workflow.json +372 -0
- package/dist/commands/improve/improve-profiles.js +0 -168
- package/dist/core/config/config-migration.js +0 -602
- package/dist/core/deep-merge.js +0 -38
- package/dist/llm/call-ai.js +0 -62
- package/dist/setup/legacy-config.js +0 -106
- package/docs/README.md +0 -104
- /package/dist/assets/{profiles → improve-strategies}/catchup.json +0 -0
- /package/dist/assets/{profiles → improve-strategies}/consolidate.json +0 -0
- /package/dist/assets/{profiles → improve-strategies}/frequent.json +0 -0
- /package/dist/assets/{profiles → improve-strategies}/memory-focus.json +0 -0
- /package/dist/assets/{profiles → improve-strategies}/quick.json +0 -0
- /package/dist/assets/{profiles → improve-strategies}/recombine-only.json +0 -0
- /package/dist/assets/{profiles → improve-strategies}/synthesize.json +0 -0
- /package/dist/assets/{profiles → improve-strategies}/thorough.json +0 -0
|
@@ -6,6 +6,7 @@ import path from "node:path";
|
|
|
6
6
|
import { deriveCanonicalAssetName, deriveCanonicalAssetNameFromStashRoot, isRelevantAssetFile, } from "../../core/asset/asset-spec.js";
|
|
7
7
|
import { parseFrontmatter } from "../../core/asset/frontmatter.js";
|
|
8
8
|
import { asNonEmptyString, isAssetType, writeFileAtomic } from "../../core/common.js";
|
|
9
|
+
import { loadUserConfig } from "../../core/config/config.js";
|
|
9
10
|
import { isVerbose, warn } from "../../core/warn.js";
|
|
10
11
|
import { buildFileContext, buildRenderContext, getRenderer, runMatchers } from "../walk/file-context.js";
|
|
11
12
|
import { applyMetadataContributors } from "./metadata-contributors.js";
|
|
@@ -182,6 +183,12 @@ export function validateStashEntry(entry) {
|
|
|
182
183
|
const sources = normalizeNonEmptyStringList(e.sources);
|
|
183
184
|
if (sources)
|
|
184
185
|
result.sources = sources;
|
|
186
|
+
// SPEC-6: `category` must survive the whitelist or .stash.json-declared
|
|
187
|
+
// facts lose their convention/meta marker on the round-trip. Non-string
|
|
188
|
+
// values are dropped, not coerced.
|
|
189
|
+
if (typeof e.category === "string" && e.category.trim().length > 0) {
|
|
190
|
+
result.category = e.category.trim();
|
|
191
|
+
}
|
|
185
192
|
if (typeof e.beliefState === "string" && e.beliefState.trim().length > 0) {
|
|
186
193
|
result.beliefState = e.beliefState.trim();
|
|
187
194
|
}
|
|
@@ -218,6 +225,12 @@ export function validateStashEntry(entry) {
|
|
|
218
225
|
if (typeof e.derivedFrom === "string" && e.derivedFrom.trim().length > 0) {
|
|
219
226
|
result.derivedFrom = e.derivedFrom.trim();
|
|
220
227
|
}
|
|
228
|
+
// SPEC-8: `bodyOpening` must survive the whitelist so entries round-tripped
|
|
229
|
+
// through `.stash.json` keep their captured opening. Preserved verbatim —
|
|
230
|
+
// the extractor already trimmed and capped it at capture time.
|
|
231
|
+
if (typeof e.bodyOpening === "string" && e.bodyOpening.trim().length > 0) {
|
|
232
|
+
result.bodyOpening = e.bodyOpening;
|
|
233
|
+
}
|
|
221
234
|
if (typeof e.scope === "object" && e.scope !== null && !Array.isArray(e.scope)) {
|
|
222
235
|
const scope = normalizeScopeObject(e.scope);
|
|
223
236
|
if (scope)
|
|
@@ -342,6 +355,12 @@ export function applyCuratedFrontmatter(entry, fmData) {
|
|
|
342
355
|
const quality = asNonEmptyString(fmData.quality);
|
|
343
356
|
if (quality)
|
|
344
357
|
entry.quality = normalizeQuality(quality);
|
|
358
|
+
// SPEC-6 capture step: the `category:` frontmatter key (e.g. `convention`,
|
|
359
|
+
// `meta` on facts) must land on the indexed entry so category-keyed
|
|
360
|
+
// policies can see it. Trimmed; blank/non-string values are ignored.
|
|
361
|
+
const category = asNonEmptyString(fmData.category);
|
|
362
|
+
if (category)
|
|
363
|
+
entry.category = category;
|
|
345
364
|
const beliefState = asNonEmptyString(fmData.beliefState);
|
|
346
365
|
if (beliefState)
|
|
347
366
|
entry.beliefState = beliefState;
|
|
@@ -805,6 +824,192 @@ export function isEnrichmentComplete(entry) {
|
|
|
805
824
|
const hasSearchHints = Array.isArray(entry.searchHints) && entry.searchHints.length > 0;
|
|
806
825
|
return hasDescription && hasTags && hasSearchHints;
|
|
807
826
|
}
|
|
827
|
+
// ── Body-opening extraction (stash-conventions SPEC-8) ──────────────────────
|
|
828
|
+
/**
|
|
829
|
+
* Maximum length of a captured self-situating body opening. Bounds index-size
|
|
830
|
+
* growth and keeps a single verbose opening from dominating the low-weight
|
|
831
|
+
* `content` FTS column.
|
|
832
|
+
*/
|
|
833
|
+
export const BODY_OPENING_MAX_CHARS = 280;
|
|
834
|
+
/**
|
|
835
|
+
* Minimum characters retained when the cap truncates at a word boundary. A
|
|
836
|
+
* boundary cut that would retain less than this falls back to a hard cut, so
|
|
837
|
+
* one pathological long token cannot gut the capture.
|
|
838
|
+
*/
|
|
839
|
+
const BODY_OPENING_MIN_RETAINED_CHARS = 250;
|
|
840
|
+
/**
|
|
841
|
+
* True when `index.indexBodyOpening` is enabled in the user config.
|
|
842
|
+
*
|
|
843
|
+
* The gate is the GLOBAL user config, read directly by the metadata pass so
|
|
844
|
+
* every indexing entry point (stash walk, flat walk, write-path indexing)
|
|
845
|
+
* honors the flag without parameter plumbing. Fail-open: an unreadable or
|
|
846
|
+
* invalid config must never break indexing (CLI entry points surface config
|
|
847
|
+
* errors loudly on their own), so any load failure reads as "off".
|
|
848
|
+
*/
|
|
849
|
+
function isBodyOpeningIndexingEnabled() {
|
|
850
|
+
try {
|
|
851
|
+
return loadUserConfig().index?.indexBodyOpening === true;
|
|
852
|
+
}
|
|
853
|
+
catch {
|
|
854
|
+
return false;
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
/**
|
|
858
|
+
* Locate a leading nested frontmatter block in a body: up to three blank
|
|
859
|
+
* lines, then a `---` line, closed by a later `---` line. Mirrors the
|
|
860
|
+
* base-linter's `parseInnerFrontmatterBlock` recognition — when `akm
|
|
861
|
+
* remember` wraps a session-capture hook's file in its own frontmatter, the
|
|
862
|
+
* hook's `---\nakm_memory_kind: …\n---` block survives at the top of the
|
|
863
|
+
* body. Returns the open/close line indexes, or `null` when no block opens.
|
|
864
|
+
* Location only — callers apply their own interior checks (marker scan in
|
|
865
|
+
* {@link hasSessionMemoryMarker}, shape test in {@link isFrontmatterShaped}).
|
|
866
|
+
*/
|
|
867
|
+
function findInnerFrontmatterBlock(lines) {
|
|
868
|
+
let i = 0;
|
|
869
|
+
while (i < lines.length && i < 3 && lines[i].trim() === "")
|
|
870
|
+
i += 1;
|
|
871
|
+
if (lines[i] !== "---")
|
|
872
|
+
return null;
|
|
873
|
+
for (let j = i + 1; j < lines.length; j += 1) {
|
|
874
|
+
if (lines[j] === "---")
|
|
875
|
+
return { open: i, close: j };
|
|
876
|
+
}
|
|
877
|
+
return null;
|
|
878
|
+
}
|
|
879
|
+
/**
|
|
880
|
+
* True when the document carries the session-capture `akm_memory_kind`
|
|
881
|
+
* marker — in the outer frontmatter data OR in a nested inner block at the
|
|
882
|
+
* top of the body (both producer layouts exist; see base-linter's
|
|
883
|
+
* `extractFrontmatterRefs`). Session bodies are raw transcripts, never a
|
|
884
|
+
* self-situating opening.
|
|
885
|
+
*/
|
|
886
|
+
function hasSessionMemoryMarker(fmData, body) {
|
|
887
|
+
if (typeof fmData.akm_memory_kind === "string")
|
|
888
|
+
return true;
|
|
889
|
+
const lines = body.split(/\r?\n/);
|
|
890
|
+
const block = findInnerFrontmatterBlock(lines);
|
|
891
|
+
if (!block)
|
|
892
|
+
return false;
|
|
893
|
+
for (let i = block.open + 1; i < block.close; i += 1) {
|
|
894
|
+
if (/^akm_memory_kind:\s*\S/.test(lines[i]))
|
|
895
|
+
return true;
|
|
896
|
+
}
|
|
897
|
+
return false;
|
|
898
|
+
}
|
|
899
|
+
/**
|
|
900
|
+
* True when the interior of a candidate inner block (located by
|
|
901
|
+
* {@link findInnerFrontmatterBlock}) actually reads as YAML frontmatter:
|
|
902
|
+
* every line is blank, indented (a continuation or nested value), or shaped
|
|
903
|
+
* like a top-level `key:` mapping entry. Ordinary prose bracketed by two
|
|
904
|
+
* thematic-break `---` lines fails this test, so a decorative opening
|
|
905
|
+
* callout is treated as the paragraph it is instead of being discarded
|
|
906
|
+
* (review finding on SPEC-8 — the block finder alone accepts ANY content up
|
|
907
|
+
* to an arbitrarily distant closing `---`).
|
|
908
|
+
*/
|
|
909
|
+
function isFrontmatterShaped(lines, block) {
|
|
910
|
+
for (let i = block.open + 1; i < block.close; i += 1) {
|
|
911
|
+
const line = lines[i];
|
|
912
|
+
if (line.trim() === "")
|
|
913
|
+
continue;
|
|
914
|
+
if (/^\s/.test(line))
|
|
915
|
+
continue; // indented continuation / nested value
|
|
916
|
+
if (/^[A-Za-z0-9_.-]+:(\s|$)/.test(line))
|
|
917
|
+
continue; // top-level key
|
|
918
|
+
return false;
|
|
919
|
+
}
|
|
920
|
+
return true;
|
|
921
|
+
}
|
|
922
|
+
/**
|
|
923
|
+
* Extract the first prose paragraph of a markdown body (frontmatter already
|
|
924
|
+
* stripped by the caller): skip blank lines, ATX headings, setext `=`
|
|
925
|
+
* underlines (discarding the heading text above them), thematic breaks,
|
|
926
|
+
* fenced code blocks (``` or ~~~, including their contents), and a leading
|
|
927
|
+
* nested frontmatter block — skipped only when its interior is actually
|
|
928
|
+
* frontmatter-shaped, so prose wrapped in decorative `---` lines is still
|
|
929
|
+
* captured; then collect consecutive non-blank lines until the paragraph
|
|
930
|
+
* ends. Deliberate asymmetry: a `---` row after captured prose ENDS the
|
|
931
|
+
* paragraph and keeps it (favoring the callout/thematic-break reading over
|
|
932
|
+
* CommonMark's setext-H2), while a `=+` row can only be a setext underline
|
|
933
|
+
* and so discards the pending lines as heading text. The result is capped at
|
|
934
|
+
* {@link BODY_OPENING_MAX_CHARS} chars — truncated at the last word boundary
|
|
935
|
+
* that still retains a substantial prefix, with a trailing ellipsis. Returns
|
|
936
|
+
* `undefined` when the body has no prose (frontmatter-only files,
|
|
937
|
+
* headings/fences-only bodies).
|
|
938
|
+
*/
|
|
939
|
+
export function extractBodyOpening(body) {
|
|
940
|
+
const lines = body.split(/\r?\n/);
|
|
941
|
+
const innerBlock = findInnerFrontmatterBlock(lines);
|
|
942
|
+
const start = innerBlock && isFrontmatterShaped(lines, innerBlock) ? innerBlock.close + 1 : 0;
|
|
943
|
+
const paragraph = [];
|
|
944
|
+
let inFence = false;
|
|
945
|
+
let fenceChar = "";
|
|
946
|
+
let inHtmlComment = false;
|
|
947
|
+
for (let i = start; i < lines.length; i += 1) {
|
|
948
|
+
const trimmed = lines[i].trim();
|
|
949
|
+
const fenceMatch = trimmed.match(/^(`{3,}|~{3,})/);
|
|
950
|
+
if (inFence) {
|
|
951
|
+
// Fence interiors are never prose (and may be secrets-adjacent command
|
|
952
|
+
// text); skip until the matching closing marker.
|
|
953
|
+
if (fenceMatch && fenceMatch[1][0] === fenceChar)
|
|
954
|
+
inFence = false;
|
|
955
|
+
continue;
|
|
956
|
+
}
|
|
957
|
+
if (inHtmlComment) {
|
|
958
|
+
// Comment interiors are machinery (the skeleton convention facts open
|
|
959
|
+
// with a <!-- SOFT guidance --> block), never orientation prose.
|
|
960
|
+
if (trimmed.includes("-->"))
|
|
961
|
+
inHtmlComment = false;
|
|
962
|
+
continue;
|
|
963
|
+
}
|
|
964
|
+
if (fenceMatch) {
|
|
965
|
+
if (paragraph.length > 0)
|
|
966
|
+
break; // a fence ends an open paragraph
|
|
967
|
+
inFence = true;
|
|
968
|
+
fenceChar = fenceMatch[1][0];
|
|
969
|
+
continue;
|
|
970
|
+
}
|
|
971
|
+
if (trimmed.startsWith("<!--")) {
|
|
972
|
+
if (paragraph.length > 0)
|
|
973
|
+
break; // a comment ends an open paragraph
|
|
974
|
+
if (!trimmed.includes("-->"))
|
|
975
|
+
inHtmlComment = true;
|
|
976
|
+
continue;
|
|
977
|
+
}
|
|
978
|
+
if (/^=+$/.test(trimmed)) {
|
|
979
|
+
// A row of `=` is a setext H1 underline: the pending lines are heading
|
|
980
|
+
// text, not prose — discard them and keep searching. (A bare `=` row
|
|
981
|
+
// with nothing pending is skipped like any other non-prose divider.)
|
|
982
|
+
paragraph.length = 0;
|
|
983
|
+
continue;
|
|
984
|
+
}
|
|
985
|
+
const isBlank = trimmed === "";
|
|
986
|
+
const isHeading = /^#{1,6}(\s|$)/.test(trimmed);
|
|
987
|
+
const isThematicBreak = /^(-{3,}|\*{3,}|_{3,})$/.test(trimmed);
|
|
988
|
+
if (isBlank || isHeading || isThematicBreak) {
|
|
989
|
+
if (paragraph.length > 0)
|
|
990
|
+
break; // paragraph complete
|
|
991
|
+
continue; // still searching for the first prose line
|
|
992
|
+
}
|
|
993
|
+
paragraph.push(trimmed);
|
|
994
|
+
}
|
|
995
|
+
const text = paragraph.join("\n");
|
|
996
|
+
if (!text)
|
|
997
|
+
return undefined;
|
|
998
|
+
if (text.length <= BODY_OPENING_MAX_CHARS)
|
|
999
|
+
return text;
|
|
1000
|
+
// Cap: prefer a word-boundary cut (never mid-token), but only when it keeps
|
|
1001
|
+
// a substantial prefix; append a one-char ellipsis inside the budget.
|
|
1002
|
+
const slice = text.slice(0, BODY_OPENING_MAX_CHARS - 1);
|
|
1003
|
+
const lastBoundary = Math.max(slice.lastIndexOf(" "), slice.lastIndexOf("\n"), slice.lastIndexOf("\t"));
|
|
1004
|
+
let cut = lastBoundary >= BODY_OPENING_MIN_RETAINED_CHARS ? slice.slice(0, lastBoundary) : slice;
|
|
1005
|
+
// slice() counts UTF-16 code units, so the no-boundary fallback can end on
|
|
1006
|
+
// the high half of a surrogate pair — a lone surrogate that corrupts the
|
|
1007
|
+
// FTS/embedding text. Drop it rather than emit invalid UTF-16.
|
|
1008
|
+
const lastCode = cut.charCodeAt(cut.length - 1);
|
|
1009
|
+
if (lastCode >= 0xd800 && lastCode <= 0xdbff)
|
|
1010
|
+
cut = cut.slice(0, -1);
|
|
1011
|
+
return `${cut.trimEnd()}…`;
|
|
1012
|
+
}
|
|
808
1013
|
// ── Metadata Generation ─────────────────────────────────────────────────────
|
|
809
1014
|
/**
|
|
810
1015
|
* Shared pipeline (steps 2-6) for building a single StashEntry from a file.
|
|
@@ -861,6 +1066,17 @@ async function buildEntryFromFile(file, assetType, canonicalName, dirPath, pkgMe
|
|
|
861
1066
|
entry.parameters = fmParams;
|
|
862
1067
|
// Pass wiki-pattern frontmatter through onto the entry
|
|
863
1068
|
applyWikiFrontmatter(entry, parsed.data);
|
|
1069
|
+
// Stash-organization conventions (SPEC-8): config-gated capture of the
|
|
1070
|
+
// self-situating body opening. Default off — enabling it changes indexed
|
|
1071
|
+
// text (collapse-detector canary baselines shift, and embeddings for
|
|
1072
|
+
// already-embedded entries are NOT regenerated; see docs/configuration.md).
|
|
1073
|
+
// Session-kind memories are raw transcripts and are never captured;
|
|
1074
|
+
// secrets never reach this branch (guard above) and env files are not .md.
|
|
1075
|
+
if (isBodyOpeningIndexingEnabled() && !hasSessionMemoryMarker(parsed.data, parsed.content)) {
|
|
1076
|
+
const bodyOpening = extractBodyOpening(parsed.content);
|
|
1077
|
+
if (bodyOpening)
|
|
1078
|
+
entry.bodyOpening = bodyOpening;
|
|
1079
|
+
}
|
|
864
1080
|
// Extract parameters from template placeholders ($1, $ARGUMENTS, {{named}})
|
|
865
1081
|
if (entry.type === "command") {
|
|
866
1082
|
const cmdParams = extractCommandParameters(parsed.content);
|
|
@@ -911,6 +1127,17 @@ async function buildEntryFromFile(file, assetType, canonicalName, dirPath, pkgMe
|
|
|
911
1127
|
if (!entry.tags || entry.tags.length === 0) {
|
|
912
1128
|
entry.tags = extractTagsFromPath(file, dirPath);
|
|
913
1129
|
}
|
|
1130
|
+
// Stash-organization conventions (SPEC-2): directory (scope/domain) tokens
|
|
1131
|
+
// always reach the tags column, even when the author set explicit tags, so
|
|
1132
|
+
// nested assets keep the exact-tag ranking boost for their scope token.
|
|
1133
|
+
// Derived from canonicalName (the ref subpath) rather than the filesystem
|
|
1134
|
+
// path so the stash-walk and flat-walk indexing paths agree (the flat walk
|
|
1135
|
+
// passes `path.dirname(file)` as dirPath, which strips directory segments
|
|
1136
|
+
// from the fallback above). Filename tokens are deliberately NOT merged when
|
|
1137
|
+
// explicit tags exist — they already live in the FTS name column and in
|
|
1138
|
+
// aliases, and merging them would inflate exact-tag matches for every
|
|
1139
|
+
// filename word. `normalizeTerms` below dedupes author-restated tokens.
|
|
1140
|
+
entry.tags = [...(entry.tags ?? []), ...extractDirTagsFromName(canonicalName)];
|
|
914
1141
|
entry.tags = normalizeTerms(entry.tags ?? []);
|
|
915
1142
|
entry.aliases = mergeAliases(entry.aliases, buildAliases(canonicalName, entry.tags));
|
|
916
1143
|
// Search hints are only generated when LLM is configured (via enhanceStashWithLlm)
|
|
@@ -1127,3 +1354,26 @@ export function extractTagsFromPath(filePath, rootDir) {
|
|
|
1127
1354
|
}
|
|
1128
1355
|
return Array.from(tags);
|
|
1129
1356
|
}
|
|
1357
|
+
/**
|
|
1358
|
+
* Extract scope/domain tags from the DIRECTORY segments of a canonical asset
|
|
1359
|
+
* name (the ref subpath — e.g. `"projectA/auth-tip"` → `["projecta"]`).
|
|
1360
|
+
*
|
|
1361
|
+
* Unlike {@link extractTagsFromPath} this never tokenizes the filename
|
|
1362
|
+
* segment: it exists so a nested asset's directory (scope/domain) tokens can
|
|
1363
|
+
* be merged into explicit author tags without dragging every filename word
|
|
1364
|
+
* into exact-tag matching (SPEC-2, docs/design/stash-conventions-code-spec.md).
|
|
1365
|
+
* Tokenization mirrors `extractTagsFromPath`: each segment splits on `-`/`_`/
|
|
1366
|
+
* `.`, lowercased, single-character tokens dropped. A name with no directory
|
|
1367
|
+
* segments yields no tags.
|
|
1368
|
+
*/
|
|
1369
|
+
export function extractDirTagsFromName(name) {
|
|
1370
|
+
const tags = new Set();
|
|
1371
|
+
for (const segment of name.split("/").slice(0, -1)) {
|
|
1372
|
+
for (const token of segment.split(/[-_.]+/)) {
|
|
1373
|
+
const clean = token.toLowerCase().trim();
|
|
1374
|
+
if (clean && clean.length > 1)
|
|
1375
|
+
tags.add(clean);
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1378
|
+
return Array.from(tags);
|
|
1379
|
+
}
|
|
@@ -17,6 +17,7 @@ import fs from "node:fs";
|
|
|
17
17
|
import { buildActionFromContributors, defaultActionContributors } from "../../core/action-contributors.js";
|
|
18
18
|
import { makeAssetRef } from "../../core/asset/asset-ref.js";
|
|
19
19
|
import { defaultRendererRegistry } from "../../core/asset/asset-registry.js";
|
|
20
|
+
import { getAssetTypes } from "../../core/asset/asset-spec.js";
|
|
20
21
|
import { getDbPath } from "../../core/paths.js";
|
|
21
22
|
import { warn } from "../../core/warn.js";
|
|
22
23
|
import { getCurrentWorkflowScopeKey } from "../../workflows/authoring/scope-key.js";
|
|
@@ -25,6 +26,7 @@ import { ensureIndex } from "../ensure-index.js";
|
|
|
25
26
|
import { collectGraphRelatedHit, computeGraphBoost, loadGraphBoostContext, } from "../graph/graph-boost.js";
|
|
26
27
|
import { isProposedQuality } from "../passes/metadata.js";
|
|
27
28
|
import { resolveProjectContext } from "../walk/project-context.js";
|
|
29
|
+
import { parseRefPrefixQuery } from "./fts-query.js";
|
|
28
30
|
import { applyRankingRules, combineSearchScores, normalizeFtsScores } from "./ranking.js";
|
|
29
31
|
import { enrichSearchHit } from "./search-hit-enrichers.js";
|
|
30
32
|
import { buildEditHint, findSourceForPath, isEditable } from "./search-source.js";
|
|
@@ -169,58 +171,53 @@ async function searchDatabase(db, query, searchType, limit, stashDir, allSourceD
|
|
|
169
171
|
// `includeExcludedTypes`. When the config key is ABSENT a built-in default of
|
|
170
172
|
// ['session'] is applied; an explicit empty list disables exclusion.
|
|
171
173
|
const defaultExcludes = searchType === "any" && !includeExcludedTypes ? (config.search?.defaultExcludeTypes ?? ["session"]) : [];
|
|
174
|
+
// SPEC-4 — ref-prefix queries (`<type>:` / `<type>:<prefix>/`) translate to
|
|
175
|
+
// a typed enumeration narrowed by name prefix, instead of degenerating into
|
|
176
|
+
// the AND-token FTS query their sanitized form would produce ("memory
|
|
177
|
+
// projecta" — noise). The branch fires only on the untyped path: an explicit
|
|
178
|
+
// `--type` flag expresses stronger intent and wins. The PARSED type is
|
|
179
|
+
// itself explicit intent, so `defaultExcludeTypes` does not apply — a bare
|
|
180
|
+
// `session:` enumerates sessions exactly like `--type session` does.
|
|
181
|
+
const refPrefix = searchType === "any" ? parseRefPrefixQuery(query, getAssetTypes()) : null;
|
|
182
|
+
if (refPrefix) {
|
|
183
|
+
return enumerateEntries({
|
|
184
|
+
db,
|
|
185
|
+
query,
|
|
186
|
+
typeFilter: refPrefix.type,
|
|
187
|
+
excludeTypes: [],
|
|
188
|
+
namePrefix: refPrefix.namePrefix,
|
|
189
|
+
limit,
|
|
190
|
+
stashDir,
|
|
191
|
+
allSourceDirs,
|
|
192
|
+
sources,
|
|
193
|
+
config,
|
|
194
|
+
rendererRegistry,
|
|
195
|
+
filters,
|
|
196
|
+
includeProposed,
|
|
197
|
+
beliefFilter,
|
|
198
|
+
restrictToSources,
|
|
199
|
+
});
|
|
200
|
+
}
|
|
172
201
|
// Empty queries — including ones that sanitize down to no searchable FTS
|
|
173
202
|
// tokens such as "." — should enumerate matching entries instead of
|
|
174
203
|
// returning an empty result set from FTS.
|
|
175
204
|
if (!hasSearchableTokens) {
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
// Deduplicate by file path — multiple entries can share the same file
|
|
179
|
-
const seenFilePaths = new Set();
|
|
180
|
-
const uniqueEntries = allEntries.filter((ie) => {
|
|
181
|
-
if (seenFilePaths.has(ie.filePath))
|
|
182
|
-
return false;
|
|
183
|
-
seenFilePaths.add(ie.filePath);
|
|
184
|
-
return true;
|
|
185
|
-
});
|
|
186
|
-
// Source filter: when the caller narrowed `sources` via `--source <name>`,
|
|
187
|
-
// drop entries whose filePath does not live under any of the requested
|
|
188
|
-
// sources. The FTS index spans every configured source, so without this
|
|
189
|
-
// filter a narrowed --source request would still leak results.
|
|
190
|
-
const sourceFiltered = restrictToSources
|
|
191
|
-
? uniqueEntries.filter((ie) => findSourceForPath(ie.filePath, sources) !== undefined)
|
|
192
|
-
: uniqueEntries;
|
|
193
|
-
// Scope filter: drop entries whose stored scope does not satisfy every
|
|
194
|
-
// supplied scope key. Filtering happens BEFORE the limit slice so a
|
|
195
|
-
// restrictive filter still returns up to `limit` results.
|
|
196
|
-
const scopeFiltered = filters
|
|
197
|
-
? sourceFiltered.filter((ie) => entryMatchesScope(ie.entry.scope, filters))
|
|
198
|
-
: sourceFiltered;
|
|
199
|
-
// Proposed-quality filter (v1 spec §4.2): exclude entries with
|
|
200
|
-
// `quality: "proposed"` unless the caller explicitly opts in.
|
|
201
|
-
const qualityFiltered = includeProposed
|
|
202
|
-
? scopeFiltered
|
|
203
|
-
: scopeFiltered.filter((ie) => !isProposedQuality(ie.entry.quality));
|
|
204
|
-
// 03-R3: derived twins inherit their base's demoting belief state here too,
|
|
205
|
-
// so the belief FILTER (and the reported hit state) stays consistent on the
|
|
206
|
-
// enumerate/browse path — not only on the FTS-scored path below.
|
|
207
|
-
inheritDerivedTwinBeliefStates(db, qualityFiltered);
|
|
208
|
-
const beliefFiltered = qualityFiltered.filter((ie) => matchBeliefFilter(ie.entry.beliefState, beliefFilter));
|
|
209
|
-
const selected = beliefFiltered.slice(0, limit);
|
|
210
|
-
const hits = await Promise.all(selected.map((ie) => buildDbHit({
|
|
211
|
-
entry: ie.entry,
|
|
212
|
-
path: ie.filePath,
|
|
213
|
-
score: 1,
|
|
205
|
+
return enumerateEntries({
|
|
206
|
+
db,
|
|
214
207
|
query,
|
|
215
|
-
|
|
216
|
-
|
|
208
|
+
typeFilter: searchType === "any" ? undefined : searchType,
|
|
209
|
+
excludeTypes: defaultExcludes,
|
|
210
|
+
limit,
|
|
211
|
+
stashDir,
|
|
217
212
|
allSourceDirs,
|
|
218
213
|
sources,
|
|
219
214
|
config,
|
|
220
215
|
rendererRegistry,
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
216
|
+
filters,
|
|
217
|
+
includeProposed,
|
|
218
|
+
beliefFilter,
|
|
219
|
+
restrictToSources,
|
|
220
|
+
});
|
|
224
221
|
}
|
|
225
222
|
// Start the async embedding request without awaiting, then run FTS
|
|
226
223
|
// synchronously while the HTTP/local embedding request is in-flight.
|
|
@@ -323,8 +320,14 @@ async function searchDatabase(db, query, searchType, limit, stashDir, allSourceD
|
|
|
323
320
|
// Drop semantic-only hits (cosine-only, no FTS match) whose score falls
|
|
324
321
|
// below the configured floor. FTS hits and hybrid hits are always kept.
|
|
325
322
|
// Default floor: 0.2. Set search.minScore = 0 in config to disable.
|
|
323
|
+
// Judged on the PRE-ceiling score when a demoting belief state clamped the
|
|
324
|
+
// item (`preCeilingScore`): the belief ceilings can sit below this floor
|
|
325
|
+
// (archived 0.15 < 0.2), and a demotion must rank the hit last, not
|
|
326
|
+
// silently remove a result that would otherwise have listed.
|
|
326
327
|
const minScore = config.search?.minScore ?? 0.2;
|
|
327
|
-
const preFilter = minScore > 0
|
|
328
|
+
const preFilter = minScore > 0
|
|
329
|
+
? scored.filter((item) => item.rankingMode !== "semantic" || (item.preCeilingScore ?? item.score) >= minScore)
|
|
330
|
+
: scored;
|
|
328
331
|
// Deterministic tiebreaker on equal scores.
|
|
329
332
|
//
|
|
330
333
|
// CRITICAL: sort on the SAME clamped+rounded value the user sees (see the
|
|
@@ -393,6 +396,70 @@ async function searchDatabase(db, query, searchType, limit, stashDir, allSourceD
|
|
|
393
396
|
}));
|
|
394
397
|
return { embedMs, rankMs, hits };
|
|
395
398
|
}
|
|
399
|
+
// ── Enumeration (browse) path ────────────────────────────────────────────────
|
|
400
|
+
/**
|
|
401
|
+
* Enumerate index entries without FTS scoring — the browse path shared by
|
|
402
|
+
* empty/unsearchable queries and SPEC-4 ref-prefix queries (`<type>:` /
|
|
403
|
+
* `<type>:<prefix>/`). Applies the same post-ranking filters as the scored
|
|
404
|
+
* path (source narrowing, scope, proposed-quality, belief) before the limit
|
|
405
|
+
* slice. Hits carry the fixed browse score 1 in insertion order — this is a
|
|
406
|
+
* deterministic listing, not a relevance ranking.
|
|
407
|
+
*/
|
|
408
|
+
async function enumerateEntries(opts) {
|
|
409
|
+
const { db, query, sources, config, rendererRegistry, filters, beliefFilter } = opts;
|
|
410
|
+
const allEntries = getAllEntries(db, opts.typeFilter, opts.excludeTypes);
|
|
411
|
+
// SPEC-4: narrow to the requested subtree. `startsWith` on the full
|
|
412
|
+
// slash-retaining prefix is exact — "projecta/" cannot match a sibling
|
|
413
|
+
// "projectalpha/…" scope.
|
|
414
|
+
const namePrefix = opts.namePrefix?.toLowerCase() ?? "";
|
|
415
|
+
const prefixFiltered = namePrefix.length > 0 ? allEntries.filter((ie) => ie.entry.name.toLowerCase().startsWith(namePrefix)) : allEntries;
|
|
416
|
+
// Deduplicate by file path — multiple entries can share the same file
|
|
417
|
+
const seenFilePaths = new Set();
|
|
418
|
+
const uniqueEntries = prefixFiltered.filter((ie) => {
|
|
419
|
+
if (seenFilePaths.has(ie.filePath))
|
|
420
|
+
return false;
|
|
421
|
+
seenFilePaths.add(ie.filePath);
|
|
422
|
+
return true;
|
|
423
|
+
});
|
|
424
|
+
// Source filter: when the caller narrowed `sources` via `--source <name>`,
|
|
425
|
+
// drop entries whose filePath does not live under any of the requested
|
|
426
|
+
// sources. The FTS index spans every configured source, so without this
|
|
427
|
+
// filter a narrowed --source request would still leak results.
|
|
428
|
+
const sourceFiltered = opts.restrictToSources
|
|
429
|
+
? uniqueEntries.filter((ie) => findSourceForPath(ie.filePath, sources) !== undefined)
|
|
430
|
+
: uniqueEntries;
|
|
431
|
+
// Scope filter: drop entries whose stored scope does not satisfy every
|
|
432
|
+
// supplied scope key. Filtering happens BEFORE the limit slice so a
|
|
433
|
+
// restrictive filter still returns up to `limit` results.
|
|
434
|
+
const scopeFiltered = filters
|
|
435
|
+
? sourceFiltered.filter((ie) => entryMatchesScope(ie.entry.scope, filters))
|
|
436
|
+
: sourceFiltered;
|
|
437
|
+
// Proposed-quality filter (v1 spec §4.2): exclude entries with
|
|
438
|
+
// `quality: "proposed"` unless the caller explicitly opts in.
|
|
439
|
+
const qualityFiltered = opts.includeProposed
|
|
440
|
+
? scopeFiltered
|
|
441
|
+
: scopeFiltered.filter((ie) => !isProposedQuality(ie.entry.quality));
|
|
442
|
+
// 03-R3: derived twins inherit their base's demoting belief state here too,
|
|
443
|
+
// so the belief FILTER (and the reported hit state) stays consistent on the
|
|
444
|
+
// enumerate/browse path — not only on the FTS-scored path.
|
|
445
|
+
inheritDerivedTwinBeliefStates(db, qualityFiltered);
|
|
446
|
+
const beliefFiltered = qualityFiltered.filter((ie) => matchBeliefFilter(ie.entry.beliefState, beliefFilter));
|
|
447
|
+
const selected = beliefFiltered.slice(0, opts.limit);
|
|
448
|
+
const hits = await Promise.all(selected.map((ie) => buildDbHit({
|
|
449
|
+
entry: ie.entry,
|
|
450
|
+
path: ie.filePath,
|
|
451
|
+
score: 1,
|
|
452
|
+
query,
|
|
453
|
+
rankingMode: "fts",
|
|
454
|
+
defaultStashDir: opts.stashDir,
|
|
455
|
+
allSourceDirs: opts.allSourceDirs,
|
|
456
|
+
sources,
|
|
457
|
+
config,
|
|
458
|
+
rendererRegistry,
|
|
459
|
+
db,
|
|
460
|
+
})));
|
|
461
|
+
return { hits };
|
|
462
|
+
}
|
|
396
463
|
/**
|
|
397
464
|
* 03-R3: let each `.derived` twin inherit its base memory's demoting belief
|
|
398
465
|
* state for this ranking pass, so a stale flag-free twin is demoted like its
|
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
*
|
|
7
7
|
* These transform a raw user query into an FTS5-safe MATCH expression. They
|
|
8
8
|
* touch no database state, so they are unit-testable with zero DB setup.
|
|
9
|
+
* `parseRefPrefixQuery` is the one non-FTS helper: it decides whether a raw
|
|
10
|
+
* query should bypass FTS entirely (SPEC-4 ref-prefix enumeration).
|
|
9
11
|
*/
|
|
10
12
|
/**
|
|
11
13
|
* Sanitize a raw user query into an FTS5-safe implicit-AND expression.
|
|
@@ -49,3 +51,42 @@ export function buildPrefixQuery(ftsQuery) {
|
|
|
49
51
|
return null;
|
|
50
52
|
return prefixTokens.join(" ");
|
|
51
53
|
}
|
|
54
|
+
/**
|
|
55
|
+
* SPEC-4 — parse a ref-prefix query (`akm search "<type>:<prefix>/"`).
|
|
56
|
+
*
|
|
57
|
+
* Decides whether a raw query is a typed subtree-enumeration request rather
|
|
58
|
+
* than an ordinary keyword search. Matching is deliberately conservative: the
|
|
59
|
+
* trimmed query must be EXACTLY
|
|
60
|
+
*
|
|
61
|
+
* - `<known-type>:` → enumerate the whole type (namePrefix `""`), or
|
|
62
|
+
* - `<known-type>:<prefix>/` → enumerate names under `<prefix>/`.
|
|
63
|
+
*
|
|
64
|
+
* The trailing slash is REQUIRED for a non-empty prefix — and is RETAINED in
|
|
65
|
+
* the returned `namePrefix` — so that a plain `entry.name.startsWith(namePrefix)`
|
|
66
|
+
* check gives exact `/`-boundary subtree semantics (`"projecta/"` cannot match
|
|
67
|
+
* a sibling `projectalpha/…` scope). Bare refs like `memory:a/b` therefore
|
|
68
|
+
* stay ordinary searches (resolving one ref is `akm show` territory), and any
|
|
69
|
+
* interior whitespace disqualifies (prose mentioning a ref is still prose).
|
|
70
|
+
*
|
|
71
|
+
* `knownTypes` is passed in by the caller (e.g. `getAssetTypes()`) to keep
|
|
72
|
+
* this module dependency-free.
|
|
73
|
+
*
|
|
74
|
+
* Returns `null` when the query is not a ref-prefix request.
|
|
75
|
+
*/
|
|
76
|
+
export function parseRefPrefixQuery(query, knownTypes) {
|
|
77
|
+
const trimmed = query.trim();
|
|
78
|
+
if (trimmed.length === 0 || /\s/.test(trimmed))
|
|
79
|
+
return null;
|
|
80
|
+
const colon = trimmed.indexOf(":");
|
|
81
|
+
if (colon <= 0)
|
|
82
|
+
return null;
|
|
83
|
+
const type = trimmed.slice(0, colon);
|
|
84
|
+
if (!knownTypes.includes(type))
|
|
85
|
+
return null;
|
|
86
|
+
const rest = trimmed.slice(colon + 1);
|
|
87
|
+
if (rest === "")
|
|
88
|
+
return { type, namePrefix: "" };
|
|
89
|
+
if (rest.endsWith("/"))
|
|
90
|
+
return { type, namePrefix: rest };
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
@@ -61,6 +61,54 @@ function beliefStateBoost(item) {
|
|
|
61
61
|
return 0.06;
|
|
62
62
|
return 0;
|
|
63
63
|
}
|
|
64
|
+
/**
|
|
65
|
+
* Post-boost score ceilings for the demoting belief states (SPEC-5,
|
|
66
|
+
* stash-conventions-code-spec.md — corrections demotion).
|
|
67
|
+
*
|
|
68
|
+
* Why the additive {@link beliefStateBoost} penalties alone are not enough:
|
|
69
|
+
* keyword base scores are min-max normalized into [0.3, 1.0]
|
|
70
|
+
* (`normalizeFtsScores`), so the spread between the best FTS hit and its
|
|
71
|
+
* runner-up can be as large as 0.7 — and the boost sum then MULTIPLIES the
|
|
72
|
+
* base (`score *= 1 + boostSum`, {@link applyScoreContributors}). A
|
|
73
|
+
* superseded incumbent that is the best keyword match for a query therefore
|
|
74
|
+
* stays clamp-pinned at 1.0 above its own correction no matter what additive
|
|
75
|
+
* penalty it receives — defeating the corrections pattern's point ("so the
|
|
76
|
+
* ranker demotes the stale version instead of letting it outrank your fix").
|
|
77
|
+
*
|
|
78
|
+
* The ceilings guarantee the demotion while keeping flagged entries VISIBLE:
|
|
79
|
+
* un-demoted keyword hits floor at a 0.3 base, so any un-demoted hit outranks
|
|
80
|
+
* a ceilinged one; demoted entries still list (belief FILTERING stays a
|
|
81
|
+
* separate opt-in axis, `--belief`), and scores already below a ceiling keep
|
|
82
|
+
* their relative ordering. Ceiling order mirrors the additive-penalty
|
|
83
|
+
* severity order pinned in tests/belief-state-phase1a.test.ts:
|
|
84
|
+
* deprecated (mildest) > superseded > contradicted > archived.
|
|
85
|
+
*/
|
|
86
|
+
const BELIEF_STATE_SCORE_CEILINGS = {
|
|
87
|
+
deprecated: 0.28,
|
|
88
|
+
superseded: 0.25,
|
|
89
|
+
contradicted: 0.2,
|
|
90
|
+
archived: 0.15,
|
|
91
|
+
};
|
|
92
|
+
/**
|
|
93
|
+
* Clamp a ranked entry's FINAL score (after every additive and utility boost)
|
|
94
|
+
* to its demoting belief state's ceiling. No-op for `asserted`/`active`/unset
|
|
95
|
+
* entries. Applied once per item at the end of `applyRankingRules` so sort
|
|
96
|
+
* order and displayed scores stay consistent (single scoring pipeline).
|
|
97
|
+
*
|
|
98
|
+
* When the ceiling clamps, the pre-clamp score is recorded as
|
|
99
|
+
* `preCeilingScore` so db-search's semantic-only `minScore` floor can judge
|
|
100
|
+
* the hit by what it would have scored WITHOUT the demotion — a ceiling below
|
|
101
|
+
* the floor (archived 0.15 < default minScore 0.2) must demote a hit to last
|
|
102
|
+
* place, never silently drop it from the results.
|
|
103
|
+
*/
|
|
104
|
+
export function applyBeliefStateScoreCeiling(item) {
|
|
105
|
+
const state = item.entry.beliefState;
|
|
106
|
+
const ceiling = state !== undefined ? BELIEF_STATE_SCORE_CEILINGS[state] : undefined;
|
|
107
|
+
if (ceiling !== undefined && item.score > ceiling) {
|
|
108
|
+
item.preCeilingScore = item.score;
|
|
109
|
+
item.score = ceiling;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
64
112
|
const exactNameRankingContributor = {
|
|
65
113
|
name: "exact-name-ranking",
|
|
66
114
|
appliesTo: () => true,
|