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.
- package/CHANGELOG.md +15 -28
- package/dist/assets/stash-skeleton/facts/conventions/backlinks.md +3 -4
- package/dist/assets/stash-skeleton/facts/conventions/organization.md +1 -3
- package/dist/commands/improve/collapse-detector.js +3 -4
- package/dist/commands/improve/extract-prompt.js +64 -22
- package/dist/commands/improve/extract.js +122 -53
- package/dist/commands/read/curate.js +43 -22
- package/dist/commands/sources/bundle-cli.js +1 -1
- package/dist/commands/sources/installed-stashes.js +67 -26
- package/dist/core/adapter/adapters/akm-adapter.js +2 -1
- package/dist/core/config/config.js +2 -6
- package/dist/core/config/schema/index-config.js +0 -27
- package/dist/indexer/index-written-assets.js +17 -9
- package/dist/indexer/indexer.js +32 -214
- package/dist/indexer/materialize-embeddings.js +155 -0
- package/dist/indexer/passes/metadata.js +263 -118
- package/dist/indexer/scan/doc-to-entry.js +0 -1
- package/dist/indexer/search/db-search.js +58 -28
- package/dist/indexer/search/fts-query.js +40 -40
- package/dist/indexer/search/ranking.js +36 -1
- package/dist/indexer/search/search-attribution.js +3 -1
- package/dist/indexer/search/search-fields.js +23 -14
- package/dist/output/text/command-format.js +3 -1
- package/dist/scripts/akm-migrate-node.js +12892 -12731
- package/dist/scripts/akm-migrate.js +12892 -12731
- package/dist/storage/repositories/index-entries-repository.js +40 -26
- package/dist/storage/repositories/index-entry-schema.js +1 -1
- package/dist/storage/repositories/index-fts-repository.js +56 -63
- package/dist/storage/repositories/index-schema.js +4 -9
- package/dist/storage/repositories/index-vec-repository.js +55 -6
- package/docs/reference/cli.md +4 -4
- package/docs/reference/configuration.md +6 -9
- package/package.json +1 -1
- 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
|
-
|
|
184
|
-
|
|
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
|
-
// ──
|
|
835
|
+
// ── Native Markdown search projection ──────────────────────────────────────
|
|
839
836
|
/**
|
|
840
|
-
* Maximum
|
|
841
|
-
*
|
|
842
|
-
*
|
|
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
|
|
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
|
-
*
|
|
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
|
-
*
|
|
935
|
-
*
|
|
936
|
-
*
|
|
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
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
let
|
|
956
|
-
let
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
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 (
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
if (
|
|
972
|
-
|
|
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 (
|
|
976
|
-
if (
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
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 (
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
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
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
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
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1064
|
-
//
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
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);
|