akm-cli 0.10.261007-beta → 0.10.261009-beta

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 (43) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/STABILITY.md +10 -4
  3. package/dist/api.d.ts +6 -0
  4. package/dist/api.js +12 -1
  5. package/dist/assets/prompts/consolidate-pair-check.md +2 -0
  6. package/dist/assets/prompts/consolidate-pair-same-age.md +1 -0
  7. package/dist/assets/prompts/consolidate-pair-terms.md +6 -0
  8. package/dist/assets/prompts/consolidate-pair.md +4 -12
  9. package/dist/commands/health.js +1 -2
  10. package/dist/commands/improve/consolidate/pair-pass.js +59 -31
  11. package/dist/commands/improve/extract.js +18 -12
  12. package/dist/commands/improve/memory/memory-belief.js +2 -2
  13. package/dist/commands/proposal/repository.js +44 -13
  14. package/dist/commands/read/curate.js +77 -18
  15. package/dist/commands/read/search-cli.js +25 -11
  16. package/dist/commands/read/search.js +3 -3
  17. package/dist/commands/read/show.js +47 -1
  18. package/dist/core/adapter/adapters/akm-adapter.js +1 -0
  19. package/dist/core/adapter/adapters/llm-wiki-adapter.js +3 -5
  20. package/dist/core/adapter/adapters/okf-adapter.js +3 -16
  21. package/dist/core/asset/markdown-link-targets.js +43 -0
  22. package/dist/core/common.js +17 -2
  23. package/dist/core/config/config-io.js +14 -4
  24. package/dist/indexer/passes/metadata.js +17 -0
  25. package/dist/indexer/scan/doc-to-entry.js +1 -0
  26. package/dist/indexer/search/db-search.js +16 -7
  27. package/dist/indexer/search/search-hit-enrichers.js +3 -3
  28. package/dist/output/shapes/curate.js +3 -1
  29. package/dist/output/shapes/helpers.js +6 -0
  30. package/dist/output/text/show-format.js +7 -0
  31. package/dist/scripts/akm-migrate-node.js +1824 -324
  32. package/dist/scripts/akm-migrate.js +1824 -324
  33. package/dist/storage/repositories/index-entries-repository.js +16 -0
  34. package/dist/storage/repositories/index-fts-repository.js +28 -6
  35. package/dist/storage/repositories/index-vec-repository.js +19 -3
  36. package/docs/architecture/0.10-process-rules.md +4 -4
  37. package/docs/architecture/architecture.md +4 -0
  38. package/docs/architecture/persisted-data-compat.md +1 -1
  39. package/docs/reference/api.md +19 -1
  40. package/docs/reference/cli.md +56 -4
  41. package/docs/reference/data-and-telemetry.md +1 -1
  42. package/docs/reference/memory.md +7 -6
  43. package/package.json +2 -1
@@ -24,7 +24,7 @@ import { appendEvent } from "../../core/events.js";
24
24
  import { nonTaskInput } from "../../core/non-task-input.js";
25
25
  import { redactCredentialPatterns } from "../../core/redaction.js";
26
26
  import { withStateDbTelemetry } from "../../core/state-db.js";
27
- import { searchHitContent } from "../../indexer/search/db-search.js";
27
+ import { matchBeliefFilter, searchHitContent } from "../../indexer/search/db-search.js";
28
28
  import { copySearchHitAttribution, getSearchHitAttribution, usageEventAttributionMetadata, } from "../../indexer/search/search-attribution.js";
29
29
  import { insertUsageEvent } from "../../indexer/usage/usage-events.js";
30
30
  import { estimateTokenCount } from "../../llm/embedders/remote.js";
@@ -32,9 +32,11 @@ import { isLlmFeatureEnabled, tryLlmFeature } from "../../llm/feature-gate.js";
32
32
  import { rerankDocuments } from "../../llm/rerank-client.js";
33
33
  import { truncateDescription } from "../../output/shapes/helpers.js";
34
34
  import { TELEMETRY_BUSY_TIMEOUT_MS, withIndexDb } from "../../storage/repositories/index-db.js";
35
- import { findEntryIdByRef, getItemRefById } from "../../storage/repositories/index-entries-repository.js";
36
- import { akmSearch, parseSearchSource } from "./search.js";
35
+ import { findEntryIdByRef, getEntryIdByFilePath, getItemRefById, } from "../../storage/repositories/index-entries-repository.js";
36
+ import { akmSearch, normalizeScopeFilters, parseBeliefFilterMode, parseSearchSource } from "./search.js";
37
37
  import { akmShowUnified } from "./show.js";
38
+ // Keep the effective policy with the result through packing without changing JSON.
39
+ const recallPolicies = new WeakMap();
38
40
  const DEFAULT_CURATE_LIMIT = 4;
39
41
  const MAX_CURATE_SUPPORT_REFS = 2;
40
42
  /** Fused candidates the reranker reorders when `search.curateRerank.topN` is unset. */
@@ -103,6 +105,12 @@ export async function akmCurate(options) {
103
105
  if (!trimmedQuery) {
104
106
  throw new UsageError('A curation query is required. Usage: akm curate "<task or prompt>" [--type <type>] [--limit <n>]', "MISSING_REQUIRED_ARGUMENT");
105
107
  }
108
+ const recall = {
109
+ type: options.type,
110
+ source: options.source ?? "local",
111
+ filters: normalizeScopeFilters(options.filters),
112
+ belief: parseBeliefFilterMode(options.belief ?? "current"),
113
+ };
106
114
  const nonTask = nonTaskInput(trimmedQuery);
107
115
  if (nonTask) {
108
116
  const abstained = {
@@ -125,15 +133,18 @@ export async function akmCurate(options) {
125
133
  // An enabled reranker reorders the top fused candidates, not just the final `limit`.
126
134
  limit: Math.max(limit, rerankTopN(loadConfig())),
127
135
  source,
136
+ filters: recall.filters,
137
+ belief: recall.belief,
128
138
  skipLogging: true,
129
139
  }));
130
- const result = await curateSearchResults(options.query, searchResponse, limit, options.type, options.eventSource);
140
+ const result = await curateSearchResults(options.query, searchResponse, limit, options.type, options.eventSource, options.searchResponse ? undefined : recall);
141
+ recallPolicies.set(result, recall);
131
142
  if (!options.skipLogging) {
132
143
  logCurateEvent(options.query, result, options.eventSource, options.attributionProjection);
133
144
  }
134
145
  return result;
135
146
  }
136
- export async function curateSearchResults(query, result, limit, selectedType, eventSource) {
147
+ export async function curateSearchResults(query, result, limit, selectedType, eventSource, recall) {
137
148
  const allStashHits = result.hits.filter((hit) => hit.type !== "registry");
138
149
  const registryHits = result.registryHits ?? [];
139
150
  // F3/R-018: `--type` NARROWS the candidate pool. The caller's search
@@ -145,9 +156,18 @@ export async function curateSearchResults(query, result, limit, selectedType, ev
145
156
  // F4/R-019: respect `--limit` for registry fill instead of hard-capping it
146
157
  // at a bare literal 2 — the remaining slots after stash hits ARE the cap.
147
158
  const selectedRegistryHits = selectedStashHits.length >= limit ? [] : registryHits.slice(0, limit - selectedStashHits.length);
148
- const selectedRefs = new Set(selectedStashHits.map((hit) => hit.ref));
159
+ const selectedRefs = new Set(selectedStashHits.flatMap((hit) => {
160
+ if (!recall)
161
+ return [hit.ref];
162
+ try {
163
+ return [hit.ref, evidenceRef(hit)];
164
+ }
165
+ catch {
166
+ return [hit.ref]; // no longer indexed: enrichCuratedStashHit drops it
167
+ }
168
+ }));
149
169
  const items = [
150
- ...(await Promise.all(selectedStashHits.map((hit) => enrichCuratedStashHit(query, hit, selectedRefs, eventSource)))),
170
+ ...(await Promise.all(selectedStashHits.map((hit) => enrichCuratedStashHit(query, hit, selectedRefs, eventSource, recall)))).filter((item) => item !== undefined),
151
171
  ...selectedRegistryHits.map((hit) => buildCuratedRegistryItem(query, hit)),
152
172
  ].slice(0, limit);
153
173
  return {
@@ -181,15 +201,24 @@ export async function packCuratedHits(result, budgetTokens) {
181
201
  for (const item of stashItems) {
182
202
  let shown;
183
203
  try {
184
- shown = await akmShowUnified({ ref: item.ref, skipLogging: true });
204
+ shown = await akmShowUnified({
205
+ ref: evidenceRef(item),
206
+ skipLogging: true,
207
+ recall: recallPolicies.get(result) ?? { belief: "current" },
208
+ });
185
209
  }
186
210
  catch {
187
211
  continue;
188
212
  }
213
+ const historical = matchBeliefFilter(shown.beliefState, "historical");
214
+ const context = {
215
+ ...(shown.beliefState ? { beliefState: shown.beliefState } : {}),
216
+ ...(historical ? { reason: "Historical context; not current supporting authority." } : {}),
217
+ };
189
218
  const content = shown.content ?? shown.template ?? shown.prompt ?? "";
190
219
  const tokens = estimateTokenCount(content);
191
220
  if (used + tokens <= budgetTokens) {
192
- packed.push({ ref: item.ref, tokens, content });
221
+ packed.push({ ref: item.ref, tokens, content, ...context });
193
222
  used += tokens;
194
223
  continue;
195
224
  }
@@ -197,7 +226,7 @@ export async function packCuratedHits(result, budgetTokens) {
197
226
  const remaining = budgetTokens - used;
198
227
  if (remaining > 0) {
199
228
  const truncated = content.slice(0, remaining * 4);
200
- packed.push({ ref: item.ref, tokens: estimateTokenCount(truncated), content: truncated });
229
+ packed.push({ ref: item.ref, tokens: estimateTokenCount(truncated), content: truncated, ...context });
201
230
  used += estimateTokenCount(truncated);
202
231
  }
203
232
  }
@@ -205,22 +234,38 @@ export async function packCuratedHits(result, budgetTokens) {
205
234
  }
206
235
  return { query: result.query, budget: budgetTokens, tokens: used, items: packed };
207
236
  }
208
- async function enrichCuratedStashHit(query, hit, selectedRefs, eventSource) {
237
+ function evidenceRef(item) {
238
+ return withIndexDb((db) => {
239
+ const id = getEntryIdByFilePath(db, item.path);
240
+ const ref = id === undefined ? undefined : getItemRefById(db, id);
241
+ if (!ref) {
242
+ if (item.path)
243
+ throw new UsageError(`Indexed asset no longer available: ${item.ref}`);
244
+ return item.ref;
245
+ }
246
+ const fragment = parseBundleRef(item.ref).fragment;
247
+ return fragment ? `${ref}#${fragment}` : ref;
248
+ });
249
+ }
250
+ async function enrichCuratedStashHit(query, hit, selectedRefs, eventSource, recall) {
209
251
  let shown;
210
252
  try {
211
- shown = await akmShowUnified({ ref: hit.ref, eventSource, skipLogging: true });
253
+ shown = await akmShowUnified({ ref: recall ? evidenceRef(hit) : hit.ref, eventSource, skipLogging: true, recall });
212
254
  }
213
255
  catch {
256
+ if (recall)
257
+ return undefined;
214
258
  shown = undefined;
215
259
  }
260
+ const ref = shown?.selectedRef ?? shown?.ref ?? hit.ref;
216
261
  const description = shown?.description ?? hit.description;
217
262
  const preview = buildCuratedPreview(shown, hit);
218
- const supportRefs = buildCurateSupportRefs(shown?.links, selectedRefs, hit.ref);
263
+ const supportRefs = await buildCurateSupportRefs(shown?.links, selectedRefs, ref, recall);
219
264
  const item = {
220
265
  source: "local",
221
266
  type: shown?.type ?? hit.type,
222
267
  name: shown?.name ?? hit.name,
223
- ref: hit.ref,
268
+ ref,
224
269
  path: shown?.path ?? hit.path,
225
270
  editable: shown?.editable ?? hit.editable ?? false,
226
271
  ...((shown?.editable ?? hit.editable ?? false) === false
@@ -232,8 +277,9 @@ async function enrichCuratedStashHit(query, hit, selectedRefs, eventSource) {
232
277
  ...(shown?.parameters?.length ? { parameters: shown.parameters } : {}),
233
278
  ...(shown?.run ? { run: shown.run } : {}),
234
279
  ...(supportRefs.length > 0 ? { supportRefs } : {}),
235
- followUp: `akm show ${hit.ref}`,
236
- reason: buildCuratedReason(query, shown?.type ?? hit.type),
280
+ followUp: `akm show ${ref}`,
281
+ ...(shown?.beliefState ? { beliefState: shown.beliefState } : {}),
282
+ reason: `${matchBeliefFilter(shown?.beliefState, "historical") ? "Historical context. " : ""}${buildCuratedReason(query, shown?.type ?? hit.type)}`,
237
283
  ...(hit.score !== undefined ? { score: hit.score } : {}),
238
284
  };
239
285
  copySearchHitAttribution(hit, item, item.description);
@@ -333,7 +379,7 @@ function rerankDocumentTexts(hits) {
333
379
  * it, in the order `akm show` lists them. The LLM entity graph's `related`
334
380
  * list no longer feeds them.
335
381
  */
336
- function buildCurateSupportRefs(links, selectedRefs, ownerRef) {
382
+ async function buildCurateSupportRefs(links, selectedRefs, ownerRef, recall) {
337
383
  const supportRefs = [];
338
384
  for (const [part, direction] of [
339
385
  ["outgoing", "from"],
@@ -343,11 +389,24 @@ function buildCurateSupportRefs(links, selectedRefs, ownerRef) {
343
389
  for (const ref of group.refs) {
344
390
  if (ref === ownerRef || selectedRefs.has(ref) || supportRefs.some((existing) => existing.ref === ref))
345
391
  continue;
392
+ let shown;
393
+ if (recall) {
394
+ try {
395
+ // A support ref is a link, not a hit: `--type` narrows the hits only.
396
+ const { type: _type, ...linkRecall } = recall;
397
+ shown = await akmShowUnified({ ref, recall: linkRecall, skipLogging: true });
398
+ }
399
+ catch {
400
+ continue;
401
+ }
402
+ }
403
+ const historical = matchBeliefFilter(shown?.beliefState, "historical");
346
404
  const type = supportRefType(ref);
347
405
  supportRefs.push({
348
406
  ref,
349
407
  ...(type ? { type } : {}),
350
- reason: `Declared link (${kind}) ${direction} this asset.`,
408
+ ...(shown?.beliefState ? { beliefState: shown.beliefState } : {}),
409
+ reason: `${historical ? "Historical context; " : ""}Declared link (${kind}) ${direction} this asset.`,
351
410
  });
352
411
  if (supportRefs.length >= MAX_CURATE_SUPPORT_REFS)
353
412
  return supportRefs;
@@ -24,6 +24,14 @@ import { deliverRendered } from "../../output/html-render.js";
24
24
  import { akmCurate, packCuratedHits } from "./curate.js";
25
25
  import { akmSearch, parseBeliefFilterMode, parseScopeFilterFlags, parseSearchSource } from "./search.js";
26
26
  import { akmShowUnified } from "./show.js";
27
+ const scopeFilterArg = {
28
+ type: "string",
29
+ description: "Scope filter (repeatable): --filter user=<id> --filter agent=<id> --filter run=<id> --filter channel=<name>. Narrows results without changing ranking.",
30
+ };
31
+ const beliefArg = {
32
+ type: "string",
33
+ description: "Belief filter on any asset: all|current|historical. current keeps active/asserted and unflagged assets; historical keeps deprecated/contradicted/superseded/archived assets. Labels do not establish factual truth.",
34
+ };
27
35
  export const searchCommand = defineJsonCommand({
28
36
  meta: { name: "search", description: "Search the bundle" },
29
37
  args: {
@@ -44,20 +52,13 @@ export const searchCommand = defineJsonCommand({
44
52
  description: "Include asset-level search results (only meaningful with --from registry|all)",
45
53
  default: false,
46
54
  },
47
- filter: {
48
- type: "string",
49
- description: "Scope filter (repeatable): --filter user=<id> --filter agent=<id> --filter run=<id> --filter channel=<name>. Narrows results without changing ranking.",
50
- },
55
+ filter: scopeFilterArg,
51
56
  "include-proposed": {
52
57
  type: "boolean",
53
58
  description: 'Include entries with quality:"proposed" in the result set. Excluded by default.',
54
59
  default: false,
55
60
  },
56
- belief: {
57
- type: "string",
58
- description: "Memory belief filter: all|current|historical. current keeps active memory beliefs; historical keeps contradicted/superseded/archived memory beliefs.",
59
- default: "all",
60
- },
61
+ belief: { ...beliefArg, default: "current" },
61
62
  "include-sessions": {
62
63
  type: "boolean",
63
64
  description: "Include session assets (excluded from default search results via config.search.defaultExcludeTypes).",
@@ -110,6 +111,8 @@ export const curateCommand = defineJsonCommand({
110
111
  },
111
112
  limit: { type: "string", description: "Maximum number of curated results", default: "4" },
112
113
  from: { type: "string", description: "Search source (local|registry|all)", default: "local" },
114
+ filter: scopeFilterArg,
115
+ belief: { ...beliefArg, default: "current" },
113
116
  pack: {
114
117
  type: "string",
115
118
  description: "Pack the ranked stash hits' full content into a single token-budgeted blob instead of returning refs " +
@@ -123,7 +126,14 @@ export const curateCommand = defineJsonCommand({
123
126
  async run({ args }) {
124
127
  const packBudget = parsePositiveIntFlag(args.pack ?? undefined, "--pack");
125
128
  const outputMode = getOutputMode();
126
- const curated = await runCurate({ query: args.query, type: args.type, limit: args.limit, from: args.from }, outputMode);
129
+ const curated = await runCurate({
130
+ query: args.query,
131
+ type: args.type,
132
+ limit: args.limit,
133
+ from: args.from,
134
+ filters: parseAllFlagValues("--filter"),
135
+ belief: args.belief,
136
+ }, outputMode);
127
137
  if (packBudget !== undefined) {
128
138
  const packed = await packCuratedHits(curated, packBudget);
129
139
  deliverRendered(outputMode.format === "text" ? formatCuratePackText(packed) : JSON.stringify(packed.items, null, 2), outputMode.outputPath);
@@ -149,6 +159,8 @@ export async function runCurate(args, outputMode) {
149
159
  type: args.type,
150
160
  limit: limitParsed && limitParsed > 0 ? limitParsed : 4,
151
161
  source: parseSearchSource(args.from ?? "local"),
162
+ filters: parseScopeFilterFlags(args.filters ?? [], "--filter"),
163
+ belief: parseBeliefFilterMode(args.belief ?? "current"),
152
164
  eventSource: resolveUsageEventSource(),
153
165
  attributionProjection: outputMode.shape === "agent" ? "agent" : outputMode.detail,
154
166
  });
@@ -158,7 +170,9 @@ function formatCuratePackText(packed) {
158
170
  if (packed.items.length === 0) {
159
171
  return `No packed content for "${packed.query}" (budget ${packed.budget} tokens).`;
160
172
  }
161
- return packed.items.map((item) => `## ${item.ref}\n\n${item.content}`).join("\n\n");
173
+ return packed.items
174
+ .map((item) => `## ${item.ref}\n\n${item.reason ? `${item.reason}\n\n` : ""}${item.content}`)
175
+ .join("\n\n");
162
176
  }
163
177
  /**
164
178
  * Reject any positional after the ref: `akm show` takes one ref, and a stray
@@ -103,7 +103,7 @@ export async function akmSearch(input) {
103
103
  const sources = allSources;
104
104
  const filters = normalizeScopeFilters(input.filters);
105
105
  const includeProposed = input.includeProposed === true;
106
- const belief = input.belief ?? "all";
106
+ const belief = parseBeliefFilterMode(input.belief ?? "current");
107
107
  const localResult = source === "registry"
108
108
  ? undefined
109
109
  : await searchLocal({
@@ -118,7 +118,7 @@ export async function akmSearch(input) {
118
118
  beliefFilter: belief,
119
119
  // When `--from <name>` narrowed the source list above, propagate
120
120
  // that intent down to the database layer so FTS/vector hits from
121
- // sources outside the narrowed set are filtered out post-ranking.
121
+ // sources outside the narrowed set cannot consume candidate budgets.
122
122
  // Without this, the index (which spans every configured source)
123
123
  // would leak hits from sources the caller did not request.
124
124
  restrictToSources: namedSourceName !== undefined,
@@ -384,7 +384,7 @@ export function parseBeliefFilterMode(value) {
384
384
  * `undefined` when nothing meaningful remains, so callers don't pay for an
385
385
  * empty-filter post-walk in `searchLocal`.
386
386
  */
387
- function normalizeScopeFilters(raw) {
387
+ export function normalizeScopeFilters(raw) {
388
388
  if (!raw)
389
389
  return undefined;
390
390
  const out = {};
@@ -34,13 +34,15 @@ import { SCRIPT_EXTENSIONS } from "../../core/recognition-util.js";
34
34
  import { presentationFor } from "../../core/type-presentation.js";
35
35
  import { warn, warnOnce } from "../../core/warn.js";
36
36
  import { lookupBundleRef, lookupBundleRefWithResolution, lookupBundleRefWithResolutionReadonly, } from "../../indexer/indexer.js";
37
- import { projectMarkdownFragmentContent } from "../../indexer/passes/metadata.js";
37
+ import { applyCuratedFrontmatter, applyProvenanceFrontmatter, projectMarkdownFragmentContent, } from "../../indexer/passes/metadata.js";
38
38
  import { ensurePrimaryIndexForRead, resolveReadSources } from "../../indexer/read-preflight.js";
39
+ import { applyEntryFilters } from "../../indexer/search/db-search.js";
39
40
  import { buildEditHint, findSourceForPath, isEditable, resolveSourceEntries, } from "../../indexer/search/search-source.js";
40
41
  import { recentShowCount, recordShowUsage } from "../../indexer/usage/show-usage.js";
41
42
  import { buildFileContext, buildRenderContext, getRenderer, } from "../../indexer/walk/file-context.js";
42
43
  import { resolveSourcesForOrigin } from "../../registry/origin-resolve.js";
43
44
  import { withIndexDb } from "../../storage/repositories/index-db.js";
45
+ import { getEntryById, getEntryIdByFilePath } from "../../storage/repositories/index-entries-repository.js";
44
46
  import { getIndexedMarkdownFragment } from "../../storage/repositories/index-fts-repository.js";
45
47
  import { readEntryLinks } from "../../storage/repositories/index-links-repository.js";
46
48
  import { getCurrentWorkflowScopeKey } from "../../workflows/authoring/scope-key.js";
@@ -258,6 +260,34 @@ export async function showLocal(input) {
258
260
  "Run `akm bundle create` to create the stash directory, or check `akm bundle list` for configured paths.");
259
261
  }
260
262
  const fileCtx = buildFileContext(sourceStashDir, assetPath);
263
+ let beliefState;
264
+ if (input.recall) {
265
+ const policy = input.recall;
266
+ const sources = policy.source && !["local", "all"].includes(policy.source)
267
+ ? resolveSourcesForOrigin(policy.source, allSources)
268
+ : allSources;
269
+ const eligible = withIndexDb((db) => {
270
+ const id = getEntryIdByFilePath(db, assetPath);
271
+ const row = id === undefined ? undefined : getEntryById(db, id);
272
+ if (!row || id === undefined)
273
+ return false;
274
+ // Source-live show must not restore a belief/scope changed after indexing.
275
+ const entry = { ...row.entry, scope: undefined };
276
+ applyCuratedFrontmatter(entry, parseFrontmatter(fileCtx.content()).data);
277
+ const allowed = applyEntryFilters([{ ...row, id, entry }], {
278
+ db,
279
+ sources,
280
+ restrictToSources: true,
281
+ filters: policy.filters,
282
+ includeProposed: false,
283
+ beliefFilter: policy.belief ?? "current",
284
+ });
285
+ beliefState = entry.beliefState;
286
+ return allowed.length > 0 && (!policy.type || policy.type === "any" || entry.type === policy.type);
287
+ }, { readOnly: input.readOnly === true });
288
+ if (!eligible)
289
+ throw new NotFoundError(`Asset not found for recall filters: ${input.ref}`);
290
+ }
261
291
  const presentedName = indexedEntry.name;
262
292
  const opaqueFragmentSelector = parsed.fragment?.startsWith("akm-fragment-") === true;
263
293
  // Friendly heading selectors retain the source-live exact response contract.
@@ -302,6 +332,8 @@ export async function showLocal(input) {
302
332
  catch (error) {
303
333
  throwIndexedPathNotFound(error, input.ref);
304
334
  }
335
+ if (beliefState)
336
+ response.beliefState = beliefState;
305
337
  response.type = indexedEntry.type;
306
338
  response.name = indexedEntry.name;
307
339
  const isPrimaryStash = source?.isDefault === true;
@@ -364,8 +396,22 @@ export async function showLocal(input) {
364
396
  delete response.toolPolicy;
365
397
  }
366
398
  const editable = isEditable(assetPath, config, allSources);
399
+ // Evidence must describe the same revision as the rendered claim: ordinary
400
+ // Markdown show is source-live, while indexed fragment show is a snapshot.
401
+ let evidenceMetadata;
402
+ if (["memory", "lesson", "knowledge"].includes(response.type)) {
403
+ evidenceMetadata = indexedFragment ? indexedEntry.document : { name: presentedName, type: response.type };
404
+ if (!indexedFragment && evidenceMetadata) {
405
+ const frontmatter = parseFrontmatter(fileCtx.content()).data;
406
+ applyCuratedFrontmatter(evidenceMetadata, frontmatter);
407
+ applyProvenanceFrontmatter(evidenceMetadata, frontmatter);
408
+ }
409
+ }
367
410
  const fullResponse = {
368
411
  ...response,
412
+ ...(evidenceMetadata?.evidence ? { evidence: evidenceMetadata.evidence } : {}),
413
+ ...(evidenceMetadata?.evidenceSources ? { evidenceSources: evidenceMetadata.evidenceSources } : {}),
414
+ ...(evidenceMetadata?.evidence && evidenceMetadata.provenance ? { provenance: evidenceMetadata.provenance } : {}),
369
415
  ref: canonicalRef,
370
416
  origin: source?.registryId ?? null,
371
417
  editable,
@@ -192,6 +192,7 @@ const DOCUMENT_JSON_CARRIED_FIELDS = [
192
192
  "wikiRole",
193
193
  "sources",
194
194
  "evidenceSources",
195
+ "evidence",
195
196
  // #935: a workflow's step targets and a task's target, read as declared links.
196
197
  "uses",
197
198
  // D2 (#730): the OKF v0.2 provenance `promoteProposal` stamps onto AKM-native
@@ -73,6 +73,7 @@ import fs from "node:fs";
73
73
  import path from "node:path";
74
74
  import { parse as parseYaml } from "yaml";
75
75
  import { parseFrontmatter, parseFrontmatterBlock } from "../../asset/frontmatter.js";
76
+ import { markdownLinkTargets } from "../../asset/markdown-link-targets.js";
76
77
  import { toPosix } from "../../common.js";
77
78
  import { hashContent, nonEmptyString } from "./shared.js";
78
79
  /** A wiki is a single-component bundle; its one component is conventionally `main` (recognition golden). */
@@ -169,13 +170,10 @@ function resolveXref(xref, bundleId) {
169
170
  */
170
171
  function resolveBodyLinks(body, fileRelPath) {
171
172
  const dir = path.posix.dirname(toPosix(fileRelPath));
172
- const linkRe = /\[[^\]]*\]\(([^)]+)\)/g;
173
173
  const out = [];
174
174
  const seen = new Set();
175
- let match;
176
- // biome-ignore lint/suspicious/noAssignInExpressions: idiomatic regex loop
177
- while ((match = linkRe.exec(body)) !== null) {
178
- let target = match[1].trim();
175
+ for (const link of markdownLinkTargets(body, false)) {
176
+ let target = link.trim();
179
177
  const wsIdx = target.search(/\s/);
180
178
  if (wsIdx >= 0)
181
179
  target = target.slice(0, wsIdx);
@@ -31,6 +31,7 @@
31
31
  import fs from "node:fs";
32
32
  import path from "node:path";
33
33
  import { parseFrontmatter } from "../../asset/frontmatter.js";
34
+ import { markdownLinkTargets } from "../../asset/markdown-link-targets.js";
34
35
  import { isRecord, toPosix } from "../../common.js";
35
36
  import { hashContent, nonEmptyString, RESERVED_FILES, readTags, runBaseValidateChecks } from "./shared.js";
36
37
  /** v0.2 frontmatter keys consumed into first-class fields below (§0.1) — excluded from the generic `documentJson` extras fold alongside the v0.1 five, so nothing is duplicated between a first-class field and the opaque extras bag. */
@@ -142,24 +143,10 @@ function isReservedFileName(name) {
142
143
  */
143
144
  export function resolveOkfLinks(body, fileRelPath) {
144
145
  const dir = path.posix.dirname(toPosix(fileRelPath));
145
- const definitions = new Map();
146
- for (const match of body.matchAll(/^\s*\[([^\]]+)\]:\s*(\S+)/gm)) {
147
- definitions.set(match[1].trim().toLowerCase(), match[2]);
148
- }
149
- const candidates = [];
150
- for (const match of body.matchAll(/\[[^\]]*\]\(([^)]+)\)/g)) {
151
- candidates.push({ index: match.index, target: match[1] });
152
- }
153
- for (const match of body.matchAll(/(?<!!)\[[^\]]*\]\[([^\]]+)\]/g)) {
154
- const target = definitions.get(match[1].trim().toLowerCase());
155
- if (target)
156
- candidates.push({ index: match.index, target });
157
- }
158
- candidates.sort((a, b) => a.index - b.index);
159
146
  const out = [];
160
147
  const seen = new Set();
161
- for (const candidate of candidates) {
162
- let target = candidate.target.trim();
148
+ for (const link of markdownLinkTargets(body, true)) {
149
+ let target = link.trim();
163
150
  // Drop an optional markdown link title: `[x](/a.md "Title")`.
164
151
  const wsIdx = target.search(/\s/);
165
152
  if (wsIdx >= 0)
@@ -0,0 +1,43 @@
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 { Marked } from "marked";
5
+ // A private instance keeps unrelated consumers' global Marked options out of indexing.
6
+ const markdown = new Marked({ gfm: true });
7
+ /**
8
+ * Actual document links, in authored order. The tokenizer owns code regions,
9
+ * escapes and nested containers; the adapter still owns its supported syntax
10
+ * and path resolution. In particular, shortcut/collapsed references and wiki
11
+ * links are not new indexed syntax. Inline images retain their existing treatment.
12
+ */
13
+ export function markdownLinkTargets(body, references) {
14
+ const tokens = markdown.lexer(body);
15
+ const definitions = new Map();
16
+ if (references) {
17
+ markdown.walkTokens(tokens, (token) => {
18
+ if (token.type !== "def")
19
+ return;
20
+ const definition = /^\s*\[([^\]]+)\]:\s*(\S+)/.exec(token.raw);
21
+ if (definition)
22
+ definitions.set(definition[1].trim().toLowerCase(), definition[2]);
23
+ });
24
+ }
25
+ const targets = [];
26
+ markdown.walkTokens(tokens, (token) => {
27
+ if (token.type !== "link" && token.type !== "image")
28
+ return;
29
+ const inline = /^!?\[[\s\S]*\]\(/.test(token.raw);
30
+ if (inline) {
31
+ // Angle-wrapped destinations were outside the adapters' existing subset.
32
+ const angled = /\]\(\s*<[^>]*>(?:\s+["'(][\s\S]*["')])?\s*\)$/.test(token.raw);
33
+ if (!angled)
34
+ targets.push(token.href);
35
+ return;
36
+ }
37
+ const reference = references && token.type === "link" ? /^\[[\s\S]*\]\[([^\]]+)\]$/.exec(token.raw) : null;
38
+ const target = reference ? definitions.get(reference[1].trim().toLowerCase()) : undefined;
39
+ if (target)
40
+ targets.push(target);
41
+ });
42
+ return targets;
43
+ }
@@ -165,8 +165,15 @@ export function writeFileAtomic(target, content, mode) {
165
165
  try {
166
166
  fd = fs.openSync(tmp, "wx", fileMode);
167
167
  tempOwned = true;
168
- if (process.platform !== "win32")
169
- fs.fchmodSync(fd, fileMode);
168
+ if (process.platform !== "win32") {
169
+ try {
170
+ fs.fchmodSync(fd, fileMode);
171
+ }
172
+ catch (error) {
173
+ if (!isChmodUnsupported(error))
174
+ throw error;
175
+ }
176
+ }
170
177
  let offset = 0;
171
178
  while (offset < data.byteLength) {
172
179
  const written = fs.writeSync(fd, data, offset, data.byteLength - offset);
@@ -361,6 +368,14 @@ export function hasErrnoCode(error, code) {
361
368
  return false;
362
369
  return error.code === code;
363
370
  }
371
+ /**
372
+ * True when a chmod failed because the filesystem has no settable POSIX modes
373
+ * (CIFS/SMB such as an Azure Files share, some FUSE mounts). The mount options
374
+ * decide the mode there, so callers keep going with whatever mode the file got.
375
+ */
376
+ export function isChmodUnsupported(error) {
377
+ return hasErrnoCode(error, "EPERM") || hasErrnoCode(error, "ENOTSUP") || hasErrnoCode(error, "EINVAL");
378
+ }
364
379
  /**
365
380
  * True when `value` is a RELATIVE path that cannot leave its base directory:
366
381
  * no absolute form (POSIX `/`, Windows `\` or a `C:` drive prefix), no `~`
@@ -15,7 +15,7 @@
15
15
  import fs from "node:fs";
16
16
  import path from "node:path";
17
17
  import { sleepSync } from "../../runtime.js";
18
- import { readTextFile, stripJsonComments, writeFileAtomic } from "../common.js";
18
+ import { isChmodUnsupported, readTextFile, stripJsonComments, writeFileAtomic } from "../common.js";
19
19
  import { ConfigError } from "../errors.js";
20
20
  import { createLockPayload, probeLock, reclaimStaleLock, releaseLock, tryAcquireLockSync } from "../file-lock.js";
21
21
  import { getCacheDir, getConfigDir } from "../paths.js";
@@ -88,7 +88,7 @@ export function backupExistingConfig(configPath, now = new Date()) {
88
88
  // env.ts/secret.ts convention — so no other local user can traverse in during
89
89
  // the copy→chmod window. chmod again to tighten a dir from an older version.
90
90
  fs.mkdirSync(backupDir, { recursive: true, mode: 0o700 });
91
- fs.chmodSync(backupDir, 0o700);
91
+ chmodIfSupported(backupDir, 0o700);
92
92
  const timestamp = now.toISOString().replace(/[.:]/g, "-");
93
93
  let sequence = 0;
94
94
  let timestamped;
@@ -109,11 +109,21 @@ export function backupExistingConfig(configPath, now = new Date()) {
109
109
  // 08-F4: a config backup carries the same sensitive fields as the live config
110
110
  // (endpoints, tokens). `copyFileSync` inherits the source's (often 0644) mode,
111
111
  // so tighten the backups to owner-only — mirrors the env-cli 0600 write floor.
112
- fs.chmodSync(timestamped, 0o600);
113
- fs.chmodSync(latest, 0o600);
112
+ chmodIfSupported(timestamped, 0o600);
113
+ chmodIfSupported(latest, 0o600);
114
114
  pruneOldBackups(backupDir);
115
115
  return { timestamped, latest };
116
116
  }
117
+ /** chmod, skipped on a filesystem without POSIX modes (CIFS), where the mount sets them. */
118
+ function chmodIfSupported(target, mode) {
119
+ try {
120
+ fs.chmodSync(target, mode);
121
+ }
122
+ catch (error) {
123
+ if (!isChmodUnsupported(error))
124
+ throw error;
125
+ }
126
+ }
117
127
  function pruneOldBackups(backupDir) {
118
128
  pruneToNewest(backupDir, MAX_CONFIG_BACKUPS, (entry) => entry.isFile() && isTimestampedConfigBackup(entry.name));
119
129
  }
@@ -165,6 +165,8 @@ export function validateStashEntry(entry) {
165
165
  const evidenceSources = normalizeNonEmptyStringList(e.evidenceSources);
166
166
  if (evidenceSources)
167
167
  result.evidenceSources = evidenceSources;
168
+ if (typeof e.evidence === "string" && e.evidence.trim().length > 0)
169
+ result.evidence = e.evidence;
168
170
  if (typeof e.derivedFrom === "string" && e.derivedFrom.trim().length > 0) {
169
171
  result.derivedFrom = e.derivedFrom.trim();
170
172
  }
@@ -242,6 +244,19 @@ export function applyScopeFrontmatter(entry, fmData) {
242
244
  if (scope)
243
245
  entry.scope = scope;
244
246
  }
247
+ /** The same normalized tuple the index projects, including legacy nested scope. */
248
+ export function scopeFromFrontmatter(fm) {
249
+ const entry = { type: "memory", name: "scope" };
250
+ if (typeof fm.scope === "object" && fm.scope !== null && !Array.isArray(fm.scope)) {
251
+ entry.scope = normalizeScopeObject(fm.scope);
252
+ }
253
+ applyScopeFrontmatter(entry, fm);
254
+ return entry.scope ?? {};
255
+ }
256
+ /** Exact maintenance equality: absence is a value, never a wildcard. */
257
+ export function sameScope(a, b) {
258
+ return SCOPE_KEYS.every((key) => a[key] === b[key]);
259
+ }
245
260
  function normalizeIntent(value) {
246
261
  if (typeof value !== "object" || value === null || Array.isArray(value))
247
262
  return undefined;
@@ -367,6 +382,8 @@ export function applyCuratedFrontmatter(entry, fmData) {
367
382
  const evidenceSources = normalizeNonEmptyStringList(fmData.evidenceSources);
368
383
  if (evidenceSources)
369
384
  entry.evidenceSources = evidenceSources;
385
+ if (typeof fmData.evidence === "string" && fmData.evidence.trim().length > 0)
386
+ entry.evidence = fmData.evidence;
370
387
  // Phase 5A / Advantage D5: capture parent ref for derived memories.
371
388
  // Memory-inference writes `source: "memories/<parent>"` and `inferred: true`
372
389
  // (and a derived child name suffix `.derived`). We mirror that source ref into
@@ -138,6 +138,7 @@ export function indexDocumentToStashEntry(doc) {
138
138
  entry.wikiRole = dj.wikiRole;
139
139
  assignStringList(entry, "sources", dj.sources);
140
140
  assignStringList(entry, "evidenceSources", dj.evidenceSources);
141
+ assignString(entry, "evidence", dj.evidence);
141
142
  assignStringList(entry, "uses", dj.uses);
142
143
  // D2 (#730): OKF v0.2 provenance promoteProposal stamps onto AKM-native
143
144
  // writes, carried via DOCUMENT_JSON_CARRIED_FIELDS (akm-adapter.ts) —