akm-cli 0.9.2-alpha.1 → 0.9.2-alpha.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.
Files changed (34) hide show
  1. package/CHANGELOG.md +15 -28
  2. package/dist/assets/stash-skeleton/facts/conventions/backlinks.md +3 -4
  3. package/dist/assets/stash-skeleton/facts/conventions/organization.md +1 -3
  4. package/dist/commands/improve/collapse-detector.js +3 -4
  5. package/dist/commands/improve/extract-prompt.js +64 -22
  6. package/dist/commands/improve/extract.js +122 -53
  7. package/dist/commands/read/curate.js +43 -22
  8. package/dist/commands/sources/bundle-cli.js +1 -1
  9. package/dist/commands/sources/installed-stashes.js +67 -26
  10. package/dist/core/adapter/adapters/akm-adapter.js +2 -1
  11. package/dist/core/config/config.js +2 -6
  12. package/dist/core/config/schema/index-config.js +0 -27
  13. package/dist/indexer/index-written-assets.js +17 -9
  14. package/dist/indexer/indexer.js +32 -214
  15. package/dist/indexer/materialize-embeddings.js +155 -0
  16. package/dist/indexer/passes/metadata.js +263 -118
  17. package/dist/indexer/scan/doc-to-entry.js +0 -1
  18. package/dist/indexer/search/db-search.js +58 -28
  19. package/dist/indexer/search/fts-query.js +40 -40
  20. package/dist/indexer/search/ranking.js +36 -1
  21. package/dist/indexer/search/search-attribution.js +3 -1
  22. package/dist/indexer/search/search-fields.js +23 -14
  23. package/dist/output/text/command-format.js +3 -1
  24. package/dist/scripts/akm-migrate-node.js +12892 -12731
  25. package/dist/scripts/akm-migrate.js +12892 -12731
  26. package/dist/storage/repositories/index-entries-repository.js +40 -26
  27. package/dist/storage/repositories/index-entry-schema.js +1 -1
  28. package/dist/storage/repositories/index-fts-repository.js +56 -63
  29. package/dist/storage/repositories/index-schema.js +4 -9
  30. package/dist/storage/repositories/index-vec-repository.js +55 -6
  31. package/docs/reference/cli.md +4 -4
  32. package/docs/reference/configuration.md +6 -9
  33. package/package.json +1 -1
  34. package/schemas/akm-config.json +0 -8
@@ -0,0 +1,155 @@
1
+ // This Source Code Form is subject to the terms of the Mozilla Public
2
+ // License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
+ import { isVerbose, warn, warnVerbose } from "../core/warn.js";
5
+ import { embedBatch } from "../llm/embedder.js";
6
+ import { estimateTokenCount } from "../llm/embedders/remote.js";
7
+ import { getEmbeddableEntryCount } from "../storage/repositories/index-entries-repository.js";
8
+ import { deleteMeta, getMeta, setMeta } from "../storage/repositories/index-meta-repository.js";
9
+ import { getAllEntriesForEmbedding, getEmbeddingCount, isVecAvailable, isVecFastPathComplete, isVecFastPathReady, purgeEmbeddings, setVecFastPathReady, upsertEmbedding, } from "../storage/repositories/index-vec-repository.js";
10
+ import { classifySemanticFailure, clearSemanticStatus, deriveSemanticProviderFingerprint, writeSemanticStatus, } from "./search/semantic-status.js";
11
+ function throwIfAborted(signal) {
12
+ if (signal?.aborted) {
13
+ throw signal.reason instanceof Error ? signal.reason : new Error("index interrupted");
14
+ }
15
+ }
16
+ export async function generateEmbeddingsForDb(db, config, onProgress, signal, entryIds) {
17
+ throwIfAborted(signal);
18
+ if (config.semanticSearchMode === "off") {
19
+ onProgress({ phase: "embeddings", message: "Semantic search disabled; skipping embeddings." });
20
+ return { success: false, reason: "index-missing", message: "Semantic search is disabled." };
21
+ }
22
+ // A targeted call starts from an already-published generation. Preserve its
23
+ // trust decision in O(1): successful writes for the changed IDs keep a
24
+ // healthy fast path healthy, but can never promote a generation already
25
+ // marked degraded. Global runs can afford to verify the entire derived set.
26
+ const vecFastPathWasReady = isVecFastPathReady(db);
27
+ const currentFingerprint = deriveSemanticProviderFingerprint(config.embedding);
28
+ const storedFingerprint = getMeta(db, "embeddingFingerprint");
29
+ let targetEntryIds = entryIds;
30
+ if (storedFingerprint && storedFingerprint !== currentFingerprint) {
31
+ purgeEmbeddings(db, { dropVecTable: true });
32
+ deleteMeta(db, "embeddingDim");
33
+ // A provider/model change invalidates the entire vector generation, even
34
+ // when a targeted write happened to discover it first.
35
+ targetEntryIds = undefined;
36
+ }
37
+ try {
38
+ throwIfAborted(signal);
39
+ const allEntries = getAllEntriesForEmbedding(db, targetEntryIds);
40
+ if (allEntries.length === 0) {
41
+ onProgress({ phase: "embeddings", message: "Embeddings already up to date." });
42
+ setMeta(db, "embeddingFingerprint", currentFingerprint);
43
+ return { success: true };
44
+ }
45
+ onProgress({
46
+ phase: "embeddings",
47
+ message: `Generating embeddings for ${allEntries.length} entr${allEntries.length === 1 ? "y" : "ies"}.`,
48
+ });
49
+ const texts = allEntries.map((entry) => entry.searchText);
50
+ if (isVerbose()) {
51
+ const EMBED_BATCH_SIZE = 100;
52
+ const totalBatches = Math.ceil(texts.length / EMBED_BATCH_SIZE);
53
+ for (const [i, entry] of allEntries.entries()) {
54
+ const batchNum = Math.floor(i / EMBED_BATCH_SIZE) + 1;
55
+ const chars = entry.searchText.length;
56
+ const tokens = estimateTokenCount(entry.searchText);
57
+ const ref = entry.itemRef;
58
+ warnVerbose(`[embed] ${ref} (${chars} chars, est. ${tokens} tokens) → batch ${batchNum}/${totalBatches}`);
59
+ }
60
+ }
61
+ let heartbeatTimer;
62
+ try {
63
+ heartbeatTimer = setInterval(() => {
64
+ onProgress({
65
+ phase: "embeddings",
66
+ message: `Still generating embeddings for ${allEntries.length} entr${allEntries.length === 1 ? "y" : "ies"}; waiting on embedding provider.`,
67
+ });
68
+ }, 15000);
69
+ const embeddings = await embedBatch(texts, config.embedding, signal);
70
+ throwIfAborted(signal);
71
+ let storedCount = 0;
72
+ let skippedCount = 0;
73
+ let vecFailedCount = 0;
74
+ let vecUnavailableCount = 0;
75
+ db.transaction(() => {
76
+ for (const [i, entry] of allEntries.entries()) {
77
+ const embedding = embeddings[i];
78
+ if (!embedding)
79
+ throw new Error(`Embedding provider returned no vector for ${entry.itemRef}.`);
80
+ const result = upsertEmbedding(db, entry.id, embedding);
81
+ if (result.stored)
82
+ storedCount++;
83
+ else
84
+ skippedCount++;
85
+ if (result.vec === "failed")
86
+ vecFailedCount++;
87
+ if (result.vec === "unavailable")
88
+ vecUnavailableCount++;
89
+ }
90
+ })();
91
+ if (skippedCount > 0) {
92
+ warn(`[embed] ${skippedCount} embedding${skippedCount === 1 ? "" : "s"} skipped (entry deleted between queue and write)`);
93
+ }
94
+ const vecGenerationComplete = targetEntryIds === undefined ? isVecFastPathComplete(db) : vecFastPathWasReady;
95
+ setVecFastPathReady(db, vecFailedCount === 0 && vecUnavailableCount === 0 && vecGenerationComplete);
96
+ if (vecFailedCount > 0) {
97
+ warn(`[embed] ${vecFailedCount} sqlite-vec fast-path insert${vecFailedCount === 1 ? "" : "s"} failed — ` +
98
+ "semantic search will use the slower JS-cosine fallback over stored embeddings. " +
99
+ "Rebuild with 'akm index --full' after resolving the vec table (often a vector-dimension mismatch).");
100
+ }
101
+ onProgress({
102
+ phase: "embeddings",
103
+ message: `Stored ${storedCount} embedding${storedCount === 1 ? "" : "s"}.`,
104
+ });
105
+ setMeta(db, "embeddingFingerprint", currentFingerprint);
106
+ return { success: true, vecInsertFailures: vecFailedCount };
107
+ }
108
+ finally {
109
+ if (heartbeatTimer)
110
+ clearInterval(heartbeatTimer);
111
+ }
112
+ }
113
+ catch (error) {
114
+ const message = error instanceof Error ? error.message : String(error);
115
+ warn("Embedding generation failed, continuing without:", message);
116
+ onProgress({ phase: "embeddings", message: `Embedding generation failed: ${message}` });
117
+ return {
118
+ success: false,
119
+ reason: classifySemanticFailure(message),
120
+ message: `Semantic search verification failed: ${message}`,
121
+ };
122
+ }
123
+ }
124
+ /** Publish the canonical semantic health snapshot after a targeted mutation. */
125
+ export function publishTargetedSemanticStatus(db, config, result) {
126
+ if (config.semanticSearchMode === "off") {
127
+ clearSemanticStatus();
128
+ setMeta(db, "hasEmbeddings", "0");
129
+ return;
130
+ }
131
+ const entryCount = getEmbeddableEntryCount(db);
132
+ const embeddingCount = getEmbeddingCount(db);
133
+ let status;
134
+ if (entryCount === 0)
135
+ status = "pending";
136
+ else if (embeddingCount >= entryCount)
137
+ status = isVecAvailable(db) && isVecFastPathReady(db) ? "ready-vec" : "ready-js";
138
+ else
139
+ status = "blocked";
140
+ setMeta(db, "hasEmbeddings", status === "ready-js" || status === "ready-vec" ? "1" : "0");
141
+ writeSemanticStatus({
142
+ status,
143
+ ...(status === "blocked" ? { reason: result.reason ?? "index-failed" } : {}),
144
+ ...(status === "blocked"
145
+ ? {
146
+ message: result.message ??
147
+ `Semantic search verification failed (${embeddingCount}/${entryCount} embeddings available).`,
148
+ }
149
+ : {}),
150
+ providerFingerprint: deriveSemanticProviderFingerprint(config.embedding),
151
+ lastCheckedAt: new Date().toISOString(),
152
+ entryCount,
153
+ embeddingCount,
154
+ });
155
+ }
@@ -6,7 +6,6 @@ import path from "node:path";
6
6
  import { parseBundleRef } from "../../core/asset/asset-ref.js";
7
7
  import { parseFrontmatter } from "../../core/asset/frontmatter.js";
8
8
  import { asNonEmptyString } from "../../core/common.js";
9
- import { loadUserConfig } from "../../core/config/config.js";
10
9
  import { isVerbose, warn } from "../../core/warn.js";
11
10
  export const SCOPE_KEYS = ["user", "agent", "run", "channel"];
12
11
  // ── Quality semantics (v1 spec §4.2) ────────────────────────────────────────
@@ -180,10 +179,8 @@ export function validateStashEntry(entry) {
180
179
  if (typeof e.derivedFrom === "string" && e.derivedFrom.trim().length > 0) {
181
180
  result.derivedFrom = e.derivedFrom.trim();
182
181
  }
183
- // SPEC-8: `bodyOpening` must survive the projection. The extractor already
184
- // trimmed and capped it at capture time.
185
- if (typeof e.bodyOpening === "string" && e.bodyOpening.trim().length > 0) {
186
- result.bodyOpening = e.bodyOpening;
182
+ if (typeof e.content === "string" && e.content.trim().length > 0) {
183
+ result.content = e.content;
187
184
  }
188
185
  if (typeof e.scope === "object" && e.scope !== null && !Array.isArray(e.scope)) {
189
186
  const scope = normalizeScopeObject(e.scope);
@@ -835,36 +832,13 @@ export function isEnrichmentComplete(entry) {
835
832
  const hasSearchHints = Array.isArray(entry.searchHints) && entry.searchHints.length > 0;
836
833
  return hasDescription && hasTags && hasSearchHints;
837
834
  }
838
- // ── Body-opening extraction (stash-conventions SPEC-8) ──────────────────────
835
+ // ── Native Markdown search projection ──────────────────────────────────────
839
836
  /**
840
- * Maximum length of a captured self-situating body opening. Bounds index-size
841
- * growth and keeps a single verbose opening from dominating the low-weight
842
- * `content` FTS column.
837
+ * Maximum native Markdown prose carried by the low-weight `content` field.
838
+ * Structured fields remain separate and higher-weighted; this bound prevents
839
+ * large documents from dominating index size or embedding inputs.
843
840
  */
844
- export const BODY_OPENING_MAX_CHARS = 280;
845
- /**
846
- * Minimum characters retained when the cap truncates at a word boundary. A
847
- * boundary cut that would retain less than this falls back to a hard cut, so
848
- * one pathological long token cannot gut the capture.
849
- */
850
- const BODY_OPENING_MIN_RETAINED_CHARS = 250;
851
- /**
852
- * True when `index.indexBodyOpening` is enabled in the user config.
853
- *
854
- * The gate is the GLOBAL user config, read directly by the metadata pass so
855
- * every indexing entry point (stash walk, flat walk, write-path indexing)
856
- * honors the flag without parameter plumbing. Fail-open: an unreadable or
857
- * invalid config must never break indexing (CLI entry points surface config
858
- * errors loudly on their own), so any load failure reads as "off".
859
- */
860
- function isBodyOpeningIndexingEnabled() {
861
- try {
862
- return loadUserConfig().index?.indexBodyOpening === true;
863
- }
864
- catch {
865
- return false;
866
- }
867
- }
841
+ export const MARKDOWN_CONTENT_MAX_CHARS = 16_384;
868
842
  /**
869
843
  * Locate a leading nested frontmatter block in a body: up to three blank
870
844
  * lines, then a `---` line, closed by a later `---` line. Mirrors the
@@ -892,7 +866,7 @@ function findInnerFrontmatterBlock(lines) {
892
866
  * marker — in the outer frontmatter data OR in a nested inner block at the
893
867
  * top of the body (both producer layouts exist; see base-linter's
894
868
  * `extractFrontmatterRefs`). Session bodies are raw transcripts, never a
895
- * self-situating opening.
869
+ * searchable content projection.
896
870
  */
897
871
  function hasSessionMemoryMarker(fmData, body) {
898
872
  if (typeof fmData.akm_memory_kind === "string")
@@ -930,96 +904,271 @@ function isFrontmatterShaped(lines, block) {
930
904
  }
931
905
  return true;
932
906
  }
907
+ function truncateUnicodeSafe(text, maxChars) {
908
+ if (text.length <= maxChars)
909
+ return text;
910
+ let cut = text.slice(0, maxChars);
911
+ const lastCode = cut.charCodeAt(cut.length - 1);
912
+ if (lastCode >= 0xd800 && lastCode <= 0xdbff)
913
+ cut = cut.slice(0, -1);
914
+ const boundary = cut.lastIndexOf(" ");
915
+ if (boundary >= Math.floor(maxChars * 0.9))
916
+ cut = cut.slice(0, boundary);
917
+ return cut.trimEnd();
918
+ }
919
+ function parseMarkdownFenceOpening(line) {
920
+ const match = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(line);
921
+ const run = match?.[1];
922
+ if (!run)
923
+ return undefined;
924
+ const marker = run[0];
925
+ // CommonMark forbids backticks in the info string of a backtick fence.
926
+ if (marker === "`" && match?.[2]?.includes("`"))
927
+ return undefined;
928
+ return { marker, length: run.length };
929
+ }
930
+ function isMarkdownFenceClosing(line, fence) {
931
+ let cursor = 0;
932
+ while (cursor < line.length && cursor < 3 && line[cursor] === " ")
933
+ cursor += 1;
934
+ const runStart = cursor;
935
+ while (cursor < line.length && line[cursor] === fence.marker)
936
+ cursor += 1;
937
+ if (cursor - runStart < fence.length)
938
+ return false;
939
+ return /^[\t ]*$/.test(line.slice(cursor));
940
+ }
941
+ function stripMarkdownHtmlComments(line, state) {
942
+ let cursor = 0;
943
+ let visible = "";
944
+ while (cursor < line.length) {
945
+ if (state.inComment) {
946
+ const close = line.indexOf("-->", cursor);
947
+ if (close < 0)
948
+ return visible;
949
+ state.inComment = false;
950
+ cursor = close + 3;
951
+ continue;
952
+ }
953
+ const open = line.indexOf("<!--", cursor);
954
+ if (open < 0)
955
+ return `${visible}${line.slice(cursor)}`;
956
+ visible += line.slice(cursor, open);
957
+ state.inComment = true;
958
+ cursor = open + 4;
959
+ }
960
+ return visible;
961
+ }
962
+ function findBalancedMarkdownClose(text, openAt, open, close) {
963
+ let depth = 1;
964
+ for (let cursor = openAt + 1; cursor < text.length; cursor += 1) {
965
+ const char = text[cursor];
966
+ if (char === "\\") {
967
+ cursor += 1;
968
+ continue;
969
+ }
970
+ if (char === open)
971
+ depth += 1;
972
+ if (char !== close)
973
+ continue;
974
+ depth -= 1;
975
+ if (depth === 0)
976
+ return cursor;
977
+ }
978
+ return undefined;
979
+ }
933
980
  /**
934
- * Extract the first prose paragraph of a markdown body (frontmatter already
935
- * stripped by the caller): skip blank lines, ATX headings, setext `=`
936
- * underlines (discarding the heading text above them), thematic breaks,
937
- * fenced code blocks (``` or ~~~, including their contents), and a leading
938
- * nested frontmatter block — skipped only when its interior is actually
939
- * frontmatter-shaped, so prose wrapped in decorative `---` lines is still
940
- * captured; then collect consecutive non-blank lines until the paragraph
941
- * ends. Deliberate asymmetry: a `---` row after captured prose ENDS the
942
- * paragraph and keeps it (favoring the callout/thematic-break reading over
943
- * CommonMark's setext-H2), while a `=+` row can only be a setext underline
944
- * and so discards the pending lines as heading text. The result is capped at
945
- * {@link BODY_OPENING_MAX_CHARS} chars — truncated at the last word boundary
946
- * that still retains a substantial prefix, with a trailing ellipsis. Returns
947
- * `undefined` when the body has no prose (frontmatter-only files,
948
- * headings/fences-only bodies).
981
+ * Find the closing parenthesis of an inline link destination. Parentheses in
982
+ * angle-delimited destinations and quoted titles are data, while unquoted
983
+ * parentheses remain balanced. Backslash escapes suppress delimiter meaning.
949
984
  */
950
- export function extractBodyOpening(body) {
951
- const lines = body.split(/\r?\n/);
952
- const innerBlock = findInnerFrontmatterBlock(lines);
953
- const start = innerBlock && isFrontmatterShaped(lines, innerBlock) ? innerBlock.close + 1 : 0;
954
- const paragraph = [];
955
- let inFence = false;
956
- let fenceChar = "";
957
- let inHtmlComment = false;
958
- for (let i = start; i < lines.length; i += 1) {
959
- const trimmed = lines[i].trim();
960
- const fenceMatch = trimmed.match(/^(`{3,}|~{3,})/);
961
- if (inFence) {
962
- // Fence interiors are never prose (and may be secrets-adjacent command
963
- // text); skip until the matching closing marker.
964
- if (fenceMatch && fenceMatch[1].charAt(0) === fenceChar)
965
- inFence = false;
985
+ function findMarkdownDestinationClose(text, openAt) {
986
+ let depth = 1;
987
+ let phase = "before-destination";
988
+ let quote;
989
+ let parenthesizedTitle = false;
990
+ let titleSeparator = false;
991
+ for (let cursor = openAt + 1; cursor < text.length; cursor += 1) {
992
+ const char = text[cursor];
993
+ if (char === "\\") {
994
+ cursor += 1;
966
995
  continue;
967
996
  }
968
- if (inHtmlComment) {
969
- // Comment interiors are machinery (the skeleton convention facts open
970
- // with a <!-- SOFT guidance --> block), never orientation prose.
971
- if (trimmed.includes("-->"))
972
- inHtmlComment = false;
997
+ if (phase === "before-destination") {
998
+ if (/\s/u.test(char ?? ""))
999
+ continue;
1000
+ if (char === "<") {
1001
+ phase = "angle-destination";
1002
+ continue;
1003
+ }
1004
+ if (char === ")")
1005
+ return cursor;
1006
+ phase = "bare-destination";
1007
+ }
1008
+ if (phase === "angle-destination") {
1009
+ if (char === ">")
1010
+ phase = "after-destination";
973
1011
  continue;
974
1012
  }
975
- if (fenceMatch) {
976
- if (paragraph.length > 0)
977
- break; // a fence ends an open paragraph
978
- inFence = true;
979
- fenceChar = fenceMatch[1].charAt(0);
1013
+ if (phase === "title") {
1014
+ if (quote) {
1015
+ if (char === quote) {
1016
+ quote = undefined;
1017
+ phase = "after-destination";
1018
+ titleSeparator = false;
1019
+ }
1020
+ continue;
1021
+ }
1022
+ if (parenthesizedTitle) {
1023
+ if (char === "(")
1024
+ depth += 1;
1025
+ if (char !== ")")
1026
+ continue;
1027
+ depth -= 1;
1028
+ if (depth === 1) {
1029
+ parenthesizedTitle = false;
1030
+ phase = "after-destination";
1031
+ titleSeparator = false;
1032
+ }
1033
+ }
1034
+ continue;
1035
+ }
1036
+ if (phase === "after-destination") {
1037
+ if (/\s/u.test(char ?? "")) {
1038
+ titleSeparator = true;
1039
+ continue;
1040
+ }
1041
+ if (char === ")")
1042
+ return cursor;
1043
+ if (titleSeparator && (char === '"' || char === "'")) {
1044
+ quote = char;
1045
+ phase = "title";
1046
+ continue;
1047
+ }
1048
+ if (titleSeparator && char === "(") {
1049
+ depth += 1;
1050
+ parenthesizedTitle = true;
1051
+ phase = "title";
1052
+ continue;
1053
+ }
1054
+ // Invalid trailing bytes are still consumed conservatively until the
1055
+ // balanced outer close. Quotes here are ordinary bytes, never titles.
1056
+ phase = "bare-destination";
1057
+ }
1058
+ if (phase !== "bare-destination")
1059
+ continue;
1060
+ if (char === "(") {
1061
+ depth += 1;
1062
+ continue;
1063
+ }
1064
+ if (char === ")") {
1065
+ depth -= 1;
1066
+ if (depth === 0)
1067
+ return cursor;
980
1068
  continue;
981
1069
  }
982
- if (trimmed.startsWith("<!--")) {
983
- if (paragraph.length > 0)
984
- break; // a comment ends an open paragraph
985
- if (!trimmed.includes("-->"))
986
- inHtmlComment = true;
1070
+ if (/\s/u.test(char ?? "") && depth === 1) {
1071
+ phase = "after-destination";
1072
+ titleSeparator = true;
1073
+ }
1074
+ }
1075
+ return undefined;
1076
+ }
1077
+ const MAX_MARKDOWN_LINK_NESTING = 32;
1078
+ /** Retain recursively projected link labels while dropping complete destinations. */
1079
+ function stripMarkdownLinkDestinations(text, nesting = 0) {
1080
+ let visible = "";
1081
+ let cursor = 0;
1082
+ while (cursor < text.length) {
1083
+ const isImage = text[cursor] === "!" && text[cursor + 1] === "[";
1084
+ const labelOpen = isImage ? cursor + 1 : cursor;
1085
+ if (text[labelOpen] !== "[") {
1086
+ visible += text[cursor];
1087
+ cursor += 1;
987
1088
  continue;
988
1089
  }
989
- if (/^=+$/.test(trimmed)) {
990
- // A row of `=` is a setext H1 underline: the pending lines are heading
991
- // text, not prose discard them and keep searching. (A bare `=` row
992
- // with nothing pending is skipped like any other non-prose divider.)
993
- paragraph.length = 0;
1090
+ const labelClose = findBalancedMarkdownClose(text, labelOpen, "[", "]");
1091
+ const destinationOpen = labelClose === undefined ? undefined : labelClose + 1;
1092
+ if (destinationOpen === undefined || text[destinationOpen] !== "(") {
1093
+ visible += text[cursor];
1094
+ cursor += 1;
994
1095
  continue;
995
1096
  }
996
- const isBlank = trimmed === "";
997
- const isHeading = /^#{1,6}(\s|$)/.test(trimmed);
998
- const isThematicBreak = /^(-{3,}|\*{3,}|_{3,})$/.test(trimmed);
999
- if (isBlank || isHeading || isThematicBreak) {
1000
- if (paragraph.length > 0)
1001
- break; // paragraph complete
1002
- continue; // still searching for the first prose line
1097
+ const destinationClose = findMarkdownDestinationClose(text, destinationOpen);
1098
+ if (destinationClose === undefined) {
1099
+ visible += text[cursor];
1100
+ cursor += 1;
1101
+ continue;
1003
1102
  }
1004
- paragraph.push(trimmed);
1103
+ // Labels may themselves contain images/links. Recursively project them so
1104
+ // an inner destination cannot become visible when its outer link is
1105
+ // removed. At an adversarial nesting depth, omit the label rather than
1106
+ // leaking its unparsed bytes into the search projection.
1107
+ if (nesting < MAX_MARKDOWN_LINK_NESTING) {
1108
+ visible += stripMarkdownLinkDestinations(text.slice(labelOpen + 1, labelClose), nesting + 1);
1109
+ }
1110
+ cursor = destinationClose + 1;
1005
1111
  }
1006
- const text = paragraph.join("\n");
1112
+ return visible;
1113
+ }
1114
+ /**
1115
+ * Derive the one low-weight search projection for an AKM-native Markdown body.
1116
+ * Frontmatter is removed by the caller. This projection drops comments,
1117
+ * fenced code, raw link targets, and structural punctuation while retaining
1118
+ * prose, headings, link labels, and inline identifiers. It never receives
1119
+ * secret/env/session bytes; that policy is enforced at the adapter metadata
1120
+ * boundary below.
1121
+ */
1122
+ export function projectMarkdownContent(body) {
1123
+ const lines = body.split(/\r?\n/);
1124
+ const innerBlock = findInnerFrontmatterBlock(lines);
1125
+ const start = innerBlock && isFrontmatterShaped(lines, innerBlock) ? innerBlock.close + 1 : 0;
1126
+ const projected = [];
1127
+ let fence;
1128
+ const htmlComment = { inComment: false };
1129
+ for (let i = start; i < lines.length; i += 1) {
1130
+ const rawLine = lines[i];
1131
+ if (fence) {
1132
+ if (isMarkdownFenceClosing(rawLine, fence))
1133
+ fence = undefined;
1134
+ continue;
1135
+ }
1136
+ // A leading fence owns the whole line, including any info string that
1137
+ // resembles HTML. Comment state therefore cannot begin inside a fence.
1138
+ if (!htmlComment.inComment) {
1139
+ const openingFence = parseMarkdownFenceOpening(rawLine);
1140
+ if (openingFence) {
1141
+ fence = openingFence;
1142
+ continue;
1143
+ }
1144
+ }
1145
+ let trimmed = stripMarkdownHtmlComments(rawLine, htmlComment).trim();
1146
+ const openingFence = parseMarkdownFenceOpening(trimmed);
1147
+ if (openingFence) {
1148
+ fence = openingFence;
1149
+ continue;
1150
+ }
1151
+ if (!trimmed || /^(-{3,}|\*{3,}|_{3,}|=+)$/.test(trimmed))
1152
+ continue;
1153
+ if (/^\s*\[[^\]]+\]:\s*\S+/.test(trimmed))
1154
+ continue;
1155
+ if (/^<[^>]+>$/.test(trimmed))
1156
+ continue;
1157
+ // Preserve human-facing labels and inline identifiers, never destinations.
1158
+ trimmed = stripMarkdownLinkDestinations(trimmed);
1159
+ trimmed = trimmed.replace(/`{1,2}([^`]+)`{1,2}/g, "$1");
1160
+ trimmed = trimmed.replace(/^#{1,6}\s+/, "");
1161
+ trimmed = trimmed.replace(/^(?:>|[-+*]|\d+[.)])\s+/, "");
1162
+ trimmed = trimmed.replace(/<[^>]+>/g, " ");
1163
+ trimmed = trimmed.replace(/[|]+/g, " ");
1164
+ trimmed = trimmed.replace(/\s+/g, " ").trim();
1165
+ if (trimmed)
1166
+ projected.push(trimmed);
1167
+ }
1168
+ const text = projected.join(" ").replace(/\s+/g, " ").trim();
1007
1169
  if (!text)
1008
1170
  return undefined;
1009
- if (text.length <= BODY_OPENING_MAX_CHARS)
1010
- return text;
1011
- // Cap: prefer a word-boundary cut (never mid-token), but only when it keeps
1012
- // a substantial prefix; append a one-char ellipsis inside the budget.
1013
- const slice = text.slice(0, BODY_OPENING_MAX_CHARS - 1);
1014
- const lastBoundary = Math.max(slice.lastIndexOf(" "), slice.lastIndexOf("\n"), slice.lastIndexOf("\t"));
1015
- let cut = lastBoundary >= BODY_OPENING_MIN_RETAINED_CHARS ? slice.slice(0, lastBoundary) : slice;
1016
- // slice() counts UTF-16 code units, so the no-boundary fallback can end on
1017
- // the high half of a surrogate pair — a lone surrogate that corrupts the
1018
- // FTS/embedding text. Drop it rather than emit invalid UTF-16.
1019
- const lastCode = cut.charCodeAt(cut.length - 1);
1020
- if (lastCode >= 0xd800 && lastCode <= 0xdbff)
1021
- cut = cut.slice(0, -1);
1022
- return `${cut.trimEnd()}…`;
1171
+ return truncateUnicodeSafe(text, MARKDOWN_CONTENT_MAX_CHARS);
1023
1172
  }
1024
1173
  // ── Metadata Generation ─────────────────────────────────────────────────────
1025
1174
  /**
@@ -1060,16 +1209,12 @@ export function applyPreContributorFields(entry, file, ctx, pkgMeta) {
1060
1209
  applyWikiFrontmatter(entry, parsed.data);
1061
1210
  // D2 (#730): reread the namespaced `provenance:` block promoteProposal stamps.
1062
1211
  applyProvenanceFrontmatter(entry, parsed.data);
1063
- // Stash-organization conventions (SPEC-8): config-gated capture of the
1064
- // self-situating body opening. Default off enabling it changes indexed
1065
- // text (collapse-detector canary baselines shift, and embeddings for
1066
- // already-embedded entries are NOT regenerated; see docs/reference/configuration.md).
1067
- // Session-kind memories are raw transcripts and are never captured;
1068
- // secrets never reach this branch (guard above) and env files are not .md.
1069
- if (isBodyOpeningIndexingEnabled() && !hasSessionMemoryMarker(parsed.data, parsed.content)) {
1070
- const bodyOpening = extractBodyOpening(parsed.content);
1071
- if (bodyOpening)
1072
- entry.bodyOpening = bodyOpening;
1212
+ // Native Markdown has one bounded low-weight body projection. Sensitive
1213
+ // types and raw session/checkpoint material never cross this boundary.
1214
+ if (entry.type !== "env" && entry.type !== "session" && !hasSessionMemoryMarker(parsed.data, parsed.content)) {
1215
+ const contentProjection = projectMarkdownContent(parsed.content);
1216
+ if (contentProjection)
1217
+ entry.content = contentProjection;
1073
1218
  }
1074
1219
  // Extract parameters from template placeholders ($1, $ARGUMENTS, {{named}})
1075
1220
  if (entry.type === "command") {
@@ -121,7 +121,6 @@ export function indexDocumentToStashEntry(doc) {
121
121
  entry.toc = dj.toc;
122
122
  if (dj.parameters !== undefined)
123
123
  entry.parameters = dj.parameters;
124
- assignString(entry, "bodyOpening", dj.bodyOpening);
125
124
  if (dj.source !== undefined)
126
125
  entry.source = dj.source;
127
126
  assignString(entry, "category", dj.category);