akm-cli 0.10.261009-beta → 0.10.261011-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 (69) hide show
  1. package/CHANGELOG.md +115 -0
  2. package/STABILITY.md +15 -4
  3. package/dist/assets/prompts/memory-infer-system.md +1 -1
  4. package/dist/assets/prompts/memory-infer-user.md +6 -3
  5. package/dist/assets/ui/app.js +59 -2
  6. package/dist/assets/ui/asset.js +71 -6
  7. package/dist/assets/ui/bundles.js +80 -3
  8. package/dist/assets/ui/diff-view.js +1 -1
  9. package/dist/assets/ui/editor.js +103 -0
  10. package/dist/assets/ui/index.html +2 -0
  11. package/dist/assets/ui/lib.js +51 -1
  12. package/dist/assets/ui/markdown.js +86 -19
  13. package/dist/assets/ui/search.js +25 -5
  14. package/dist/assets/ui/styles.css +70 -0
  15. package/dist/commands/health/checks.js +10 -6
  16. package/dist/commands/health/improve-metrics.js +11 -1
  17. package/dist/commands/health/md-report.js +3 -3
  18. package/dist/commands/improve/distill-promotion-policy.js +5 -4
  19. package/dist/commands/improve/extract.js +92 -21
  20. package/dist/commands/improve/improve.js +16 -2
  21. package/dist/commands/improve/loop-stages.js +4 -4
  22. package/dist/commands/improve/session-asset.js +2 -2
  23. package/dist/commands/read/curate.js +1 -1
  24. package/dist/commands/read/knowledge.js +109 -77
  25. package/dist/commands/read/remember-cli.js +1 -1
  26. package/dist/commands/read/show.js +4 -1
  27. package/dist/commands/remember.js +12 -7
  28. package/dist/commands/sources/bundle-cli.js +4 -6
  29. package/dist/commands/sources/installed-stashes.js +10 -19
  30. package/dist/commands/ui/api.js +95 -5
  31. package/dist/commands/ui/document-source.js +51 -0
  32. package/dist/commands/ui/git-read.js +18 -2
  33. package/dist/commands/ui/proposal-actions.js +5 -3
  34. package/dist/commands/ui/server.js +25 -13
  35. package/dist/commands/ui/ui-cli.js +3 -3
  36. package/dist/commands/ui/workspace-actions.js +264 -0
  37. package/dist/commands/workflow-cli.js +3 -1
  38. package/dist/core/adapter/adapters/akm-metadata.js +5 -14
  39. package/dist/core/asset/akm-markdown.js +19 -3
  40. package/dist/core/asset/frontmatter.js +10 -1
  41. package/dist/core/asset/markdown.js +14 -6
  42. package/dist/core/config/config.js +6 -4
  43. package/dist/core/non-task-input.js +67 -3
  44. package/dist/core/write-source.js +2 -2
  45. package/dist/indexer/index-writer-lock.js +5 -1
  46. package/dist/indexer/passes/memory-inference.js +217 -20
  47. package/dist/indexer/search/db-search.js +2 -0
  48. package/dist/indexer/search/search-attribution.js +8 -3
  49. package/dist/indexer/search/search-hit-enrichers.js +16 -14
  50. package/dist/llm/memory-infer.js +26 -15
  51. package/dist/output/renderers.js +4 -4
  52. package/dist/output/text/show-format.js +1 -1
  53. package/dist/scripts/akm-migrate-node.js +25 -20
  54. package/dist/scripts/akm-migrate.js +25 -20
  55. package/dist/workflows/exec/environment.js +4 -8
  56. package/dist/workflows/parser.js +13 -4
  57. package/docs/architecture/0.10-process-rules.md +15 -9
  58. package/docs/architecture/improvement.md +23 -0
  59. package/docs/architecture/internals/storage-locations.md +5 -3
  60. package/docs/architecture/persisted-data-compat.md +1 -0
  61. package/docs/guides/author-workflows.md +78 -28
  62. package/docs/guides/bundles.md +16 -2
  63. package/docs/reference/cli.md +40 -17
  64. package/docs/reference/data-and-telemetry.md +2 -1
  65. package/docs/reference/env-and-secrets.md +11 -0
  66. package/docs/reference/memory.md +30 -5
  67. package/docs/reference/ui-api.md +84 -8
  68. package/docs/reference/workflow-schema.md +63 -32
  69. package/package.json +1 -1
@@ -102,7 +102,7 @@ export function api(path, query = {}) {
102
102
  return request(url, { headers: { "X-Akm-Ui-Token": token ?? "" }, cache: "no-store" });
103
103
  }
104
104
 
105
- /** POST `body` as JSON to `path`. Only the proposal accept/reject routes take one; the token goes in the header, never the URL. */
105
+ /** POST `body` as JSON to an explicit action. The token goes in the header, never the URL. */
106
106
  export function post(path, body = {}) {
107
107
  return request(path, {
108
108
  method: "POST",
@@ -236,3 +236,53 @@ export function pretty(value) {
236
236
  export function percent(part, total) {
237
237
  return total > 0 ? Math.round((part / total) * 1000) / 10 : 0;
238
238
  }
239
+
240
+ // ---- unsaved drafts and one-shot status ------------------------------------
241
+
242
+ let unsaved = false;
243
+ /** The editor marks a draft that leaving the page would lose; the router asks before discarding it. */
244
+ export function setUnsaved(value) {
245
+ unsaved = value;
246
+ }
247
+ export function hasUnsaved() {
248
+ return unsaved;
249
+ }
250
+
251
+ let flash = null;
252
+ /** Show the status a form left before its page re-rendered, once. Called by the router after each render. */
253
+ export function showFlash(root) {
254
+ const pending = flash;
255
+ flash = null;
256
+ const status = pending && root.querySelectorAll(pending.selector)[pending.index]?.querySelector("[data-status]");
257
+ if (status) mount(status, html`<p role="status" class="text-ok">${pending.message}</p>`);
258
+ }
259
+
260
+ /** Bind an explicit form action; errors stay beside the user's inputs. */
261
+ export function actionForm(root, selector, action, { after } = {}) {
262
+ root.querySelectorAll(selector).forEach((form, index) => {
263
+ form.addEventListener("submit", async (event) => {
264
+ event.preventDefault();
265
+ const status = form.querySelector("[data-status]");
266
+ const button = event.submitter;
267
+ let locked = false;
268
+ if (button) button.disabled = true;
269
+ if (status) mount(status, html`<p role="status">Working…</p>`);
270
+ try {
271
+ const result = await action(form, new FormData(form));
272
+ const message = result?.saved === false ? "No changes." : "Saved.";
273
+ if (status) mount(status, html`<p role="status" class="text-ok">${message}</p>`);
274
+ if (after) after(result);
275
+ else {
276
+ flash = { selector, index, message };
277
+ window.dispatchEvent(new HashChangeEvent("hashchange"));
278
+ }
279
+ } catch (error) {
280
+ // The action may already have happened; the server asks not to repeat it without a reload.
281
+ locked = error?.code === "INVALID_COMMAND_RESPONSE";
282
+ if (status) mount(status, html`<p role="alert" class="text-fail">${error.message}</p>`);
283
+ } finally {
284
+ if (button && !locked) button.disabled = false;
285
+ }
286
+ });
287
+ });
288
+ }
@@ -1,12 +1,35 @@
1
- // A deliberately tiny markdown renderer: headings, lists, fenced code, inline code, bold, links and [[wikilinks]].
1
+ // Safe document rendering: headings, lists, tables, task items, fenced code, inline code, bold, links and
2
+ // [[wikilinks]]. Link targets and heading selectors come from AKM's indexed reads.
2
3
  // It never touches raw text except through `html`, which escapes every interpolated value.
3
-
4
4
  import { assetHref, html } from "./lib.js";
5
5
 
6
6
  const SAFE_URL = /^(https?:\/\/|mailto:|#)/i;
7
7
  const INLINE = /`([^`]+)`|\[\[([^\]]+)\]\]|\[([^\]]+)\]\(([^)\s]+)\)|\*\*([^*]+)\*\*/g;
8
+ // The same fence rule as the server's parseMarkdownToc (CommonMark), so both agree on which headings exist:
9
+ // a fence opens with 3+ backticks or tildes indented at most 3 spaces (a backtick fence's info string has
10
+ // no backtick) and closes only on a line of the same character, at least as long, then only whitespace.
11
+ const FENCE = /^ {0,3}(`{3,}|~{3,})(.*)$/;
12
+ const opensFence = (line) => {
13
+ const m = FENCE.exec(line);
14
+ return m && !(m[1][0] === "`" && m[2].includes("`")) ? m[1] : null;
15
+ };
16
+ const closesFence = (line, open) => {
17
+ const m = FENCE.exec(line);
18
+ return m !== null && m[1][0] === open[0] && m[1].length >= open.length && !m[2].trim();
19
+ };
20
+ const TABLE_DELIMITER = /^\s*\|?\s*:?-{3,}:?\s*(?:\|\s*:?-{3,}:?\s*)*\|?\s*$/;
21
+
22
+ /** DOM id of a heading: prefixed so a slug like "app" never collides with the page's own ids. */
23
+ export const headingId = (slug) => `h-${slug}`;
8
24
 
9
- function link(text, url) {
25
+ function link(text, url, context) {
26
+ if (context.links && Object.hasOwn(context.links, url))
27
+ return html`<a href="${assetHref(context.links[url])}">${text}</a>`;
28
+ if (url.startsWith("#")) {
29
+ // A bare "#x" would be read by the hash router as a page; without an asset to anchor to, show text.
30
+ if (!context.ref) return html`${text}`;
31
+ return html`<a href="${assetHref(context.ref)}&section=${encodeURIComponent(url.slice(1))}">${text}</a>`;
32
+ }
10
33
  if (!SAFE_URL.test(url)) return html`${text}`;
11
34
  const external = /^(https?:|mailto:)/i.test(url);
12
35
  return external
@@ -14,16 +37,18 @@ function link(text, url) {
14
37
  : html`<a href="${url}">${text}</a>`;
15
38
  }
16
39
 
17
- /** Inline spans of one line of text. */
18
- export function inline(text) {
40
+ /** Inline spans of one line of text. `context` is the same as for {@link markdown}. */
41
+ export function inline(text, context = {}) {
19
42
  const out = [];
20
43
  let last = 0;
21
44
  for (const m of text.matchAll(INLINE)) {
22
45
  out.push(text.slice(last, m.index));
23
46
  if (m[1] !== undefined) out.push(html`<code>${m[1]}</code>`);
24
- else if (m[2] !== undefined)
25
- out.push(html`<a href="${assetHref(m[2].split("|")[0].trim())}"><code>${m[2]}</code></a>`);
26
- else if (m[3] !== undefined) out.push(link(m[3], m[4]));
47
+ else if (m[2] !== undefined) {
48
+ const [ref, label] = m[2].split("|");
49
+ const target = context.bundle && !ref.includes("//") ? `${context.bundle}//${ref.trim()}` : ref.trim();
50
+ out.push(html`<a href="${assetHref(target)}"><code>${label ?? ref}</code></a>`);
51
+ } else if (m[3] !== undefined) out.push(link(m[3], m[4], context));
27
52
  else out.push(html`<strong>${m[5]}</strong>`);
28
53
  last = m.index + m[0].length;
29
54
  }
@@ -31,16 +56,31 @@ export function inline(text) {
31
56
  return html`${out}`;
32
57
  }
33
58
 
34
- /** Render markdown text to safe HTML. */
35
- export function markdown(source) {
59
+ const cells = (line) =>
60
+ line
61
+ .trim()
62
+ .replace(/^\|/, "")
63
+ .replace(/\|$/, "")
64
+ .split("|")
65
+ .map((cell) => cell.trim());
66
+
67
+ /**
68
+ * Render markdown text to safe HTML. `context` is optional: `ref` and `bundle` anchor `#section` links and
69
+ * bare [[wikilinks]] to the asset, `links` maps raw link targets to indexed refs, and `headings` is the
70
+ * server's heading list ({line, slug}), matched by `line` against `startLine`, the source line of the
71
+ * text's first line (after frontmatter).
72
+ */
73
+ export function markdown(source, context = {}) {
36
74
  const lines = String(source ?? "")
37
75
  .replace(/\r\n?/g, "\n")
38
76
  .split("\n");
39
77
  const blocks = [];
40
78
  let paragraph = [];
41
79
  let list = null;
80
+ const slugByLine = new Map((context.headings ?? []).map((h) => [h.line, h.slug]));
81
+ const firstLine = context.startLine ?? 1;
42
82
  const flushParagraph = () => {
43
- if (paragraph.length > 0) blocks.push(html`<p>${inline(paragraph.join(" "))}</p>`);
83
+ if (paragraph.length > 0) blocks.push(html`<p>${inline(paragraph.join(" "), context)}</p>`);
44
84
  paragraph = [];
45
85
  };
46
86
  const flushList = () => {
@@ -49,21 +89,40 @@ export function markdown(source) {
49
89
  };
50
90
  for (let i = 0; i < lines.length; i++) {
51
91
  const line = lines[i];
52
- const fence = /^```\s*(\S*)/.exec(line);
92
+ const fence = opensFence(line);
53
93
  if (fence) {
54
94
  flushParagraph();
55
95
  flushList();
56
96
  const code = [];
57
- for (i++; i < lines.length && !/^```/.test(lines[i]); i++) code.push(lines[i]);
97
+ for (i++; i < lines.length && !closesFence(lines[i], fence); i++) code.push(lines[i]);
58
98
  blocks.push(html`<pre class="code"><code>${code.join("\n")}</code></pre>`);
59
99
  continue;
60
100
  }
61
- const heading = /^(#{1,6})\s+(.*)$/.exec(line);
101
+ const heading = /^(#{1,6})\s+(.+)$/.exec(line);
62
102
  if (heading) {
63
103
  flushParagraph();
64
104
  flushList();
65
105
  const level = Math.min(heading[1].length + 1, 6);
66
- blocks.push(html`<h${level}>${inline(heading[2])}</h${level}>`);
106
+ const slug = slugByLine.get(firstLine + i);
107
+ blocks.push(
108
+ html`<h${level}${slug ? html` id="${headingId(slug)}"` : ""}>${inline(heading[2].replace(/\s+#+\s*$/, ""), context)}</h${level}>`,
109
+ );
110
+ continue;
111
+ }
112
+ if (line.includes("|") && lines[i + 1]?.includes("|") && TABLE_DELIMITER.test(lines[i + 1])) {
113
+ flushParagraph();
114
+ flushList();
115
+ const headers = cells(line);
116
+ i += 2;
117
+ const rows = [];
118
+ while (i < lines.length && lines[i].includes("|") && lines[i].trim()) {
119
+ rows.push(html`<tr>${cells(lines[i]).map((cell) => html`<td>${inline(cell, context)}</td>`)}</tr>`);
120
+ i++;
121
+ }
122
+ i--;
123
+ blocks.push(
124
+ html`<div class="table-wrap"><table><thead><tr>${headers.map((cell) => html`<th>${inline(cell, context)}</th>`)}</tr></thead><tbody>${rows}</tbody></table></div>`,
125
+ );
67
126
  continue;
68
127
  }
69
128
  const item = /^\s*(?:([-*+])|(\d+)[.)])\s+(.*)$/.exec(line);
@@ -72,7 +131,12 @@ export function markdown(source) {
72
131
  const ordered = item[2] !== undefined;
73
132
  if (list && list.ordered !== ordered) flushList();
74
133
  list ??= { ordered, items: [] };
75
- list.items.push(html`<li>${inline(item[3])}</li>`);
134
+ const task = /^\[([ xX])\]\s+(.*)$/.exec(item[3]);
135
+ list.items.push(
136
+ task
137
+ ? html`<li class="task-item"><input type="checkbox" disabled ${task[1].toLowerCase() === "x" ? html`checked` : ""} aria-label="${task[2]}">${inline(task[2], context)}</li>`
138
+ : html`<li>${inline(item[3], context)}</li>`,
139
+ );
76
140
  continue;
77
141
  }
78
142
  if (line.trim() === "") {
@@ -88,16 +152,19 @@ export function markdown(source) {
88
152
  return html`<div class="md">${blocks}</div>`;
89
153
  }
90
154
 
91
- /** Split leading `---` frontmatter from `content`: { fields: [[key, value]], body }. */
155
+ /**
156
+ * Split leading `---` frontmatter from `content`: { fields: [[key, value]], body, startLine }, where
157
+ * `startLine` is the 1-based source line the body starts on (pass it to {@link markdown}).
158
+ */
92
159
  export function splitFrontmatter(content) {
93
160
  const text = String(content ?? "").replace(/\r\n?/g, "\n");
94
161
  const m = /^---\n([\s\S]*?)\n---[ \t]*(?:\n|$)/.exec(text);
95
- if (!m) return { fields: [], body: text };
162
+ if (!m) return { fields: [], body: text, startLine: 1 };
96
163
  const fields = [];
97
164
  for (const line of m[1].split("\n")) {
98
165
  const kv = /^([A-Za-z0-9_.-]+):\s*(.*)$/.exec(line);
99
166
  if (kv) fields.push([kv[1], kv[2]]);
100
167
  else if (fields.length > 0 && line.trim() !== "") fields[fields.length - 1][1] += `\n${line}`;
101
168
  }
102
- return { fields, body: text.slice(m[0].length) };
169
+ return { fields, body: text.slice(m[0].length), startLine: m[0].split("\n").length };
103
170
  }
@@ -4,8 +4,13 @@ import { api, assetHref, badge, emptyState, html, pageHeader } from "./lib.js";
4
4
 
5
5
  const RESULT_LIMIT = 50;
6
6
 
7
+ /** Exactly `values` as options, with `selected` chosen. */
8
+ function choices(values, selected) {
9
+ return values.map((v) => html`<option value="${v}" ${v === selected ? html`selected` : ""}>${v}</option>`);
10
+ }
11
+
7
12
  function options(values, selected, allLabel) {
8
- return html`<option value="">${allLabel}</option>${values.map((v) => html`<option value="${v}" ${v === selected ? html`selected` : ""}>${v}</option>`)}`;
13
+ return html`<option value="">${allLabel}</option>${choices(values, selected)}`;
9
14
  }
10
15
 
11
16
  function hit(h) {
@@ -21,8 +26,13 @@ export async function render(ctx) {
21
26
  const q = ctx.query.get("q") ?? "";
22
27
  const type = ctx.query.get("type") ?? "";
23
28
  const bundle = ctx.query.get("bundle") ?? "";
29
+ const belief = ctx.query.get("belief") || "current";
30
+ const limit = ctx.query.get("limit") || String(RESULT_LIMIT);
31
+ const scope = Object.fromEntries(
32
+ ["user", "agent", "run", "channel"].map((key) => [`scope_${key}`, ctx.query.get(`scope_${key}`) ?? ""]),
33
+ );
24
34
  const [result, bundles] = await Promise.all([
25
- api("/api/search", { q, type, bundle, limit: RESULT_LIMIT }),
35
+ api("/api/search", { q, type, bundle, belief, ...scope, limit }),
26
36
  api("/api/bundles").catch(() => null),
27
37
  ]);
28
38
  const types = new Set(Object.keys(bundles?.index?.byType ?? {}));
@@ -46,17 +56,27 @@ export async function render(ctx) {
46
56
  for (const select of form.querySelectorAll("select")) select.addEventListener("change", go);
47
57
  });
48
58
 
49
- return html`${pageHeader("Search", "Search your bundles. Browsing here is not counted as usage.")}
59
+ const groups = new Map();
60
+ for (const h of result.hits) {
61
+ const group = h.ref.slice(0, h.ref.lastIndexOf("/")) || h.type;
62
+ if (!groups.has(group)) groups.set(group, []);
63
+ groups.get(group).push(h);
64
+ }
65
+ return html`${pageHeader("Search", "Search or browse your indexed assets. Browsing is not counted as usage.")}
66
+ <p class="action-links"><a href="#/new">New knowledge</a> <span class="muted small">· Ctrl/Cmd+K opens search</span></p>
50
67
  <form class="search-form" role="search">
51
- <input type="search" name="q" value="${q}" placeholder="Search assets (empty lists everything)" aria-label="Query" autofocus>
68
+ <input type="search" name="q" value="${q}" placeholder="Search assets (empty browses the index)" aria-label="Query" autofocus>
52
69
  <select name="type" aria-label="Type">${options([...types].sort(), type, "All types")}</select>
53
70
  <select name="bundle" aria-label="Bundle">${options(bundleNames, bundle, "All bundles")}</select>
71
+ <select name="belief" aria-label="Belief state">${choices(["current", "all", "historical"], belief)}</select>
72
+ <select name="limit" aria-label="Result limit">${choices(["50", "100", "200"], limit)}</select>
73
+ <details class="scope-controls"><summary>Scope filters</summary><div class="form-grid">${Object.entries(scope).map(([key, value]) => html`<label>${key.slice(6)}<input name="${key}" value="${value}" placeholder="Any"></label>`)}</div></details>
54
74
  <button type="submit">Search</button>
55
75
  </form>
56
76
  ${(result.warnings ?? []).map((w) => html`<div class="notice notice-warn">${w}</div>`)}
57
77
  ${
58
78
  result.hits.length === 0
59
79
  ? emptyState(q ? `No assets match "${q}".` : "No assets found.", result.tip ?? "")
60
- : html`<p class="muted">${result.hits.length} ${result.hits.length === 1 ? "result" : "results"}${result.hits.length === RESULT_LIMIT ? ` (the first ${RESULT_LIMIT})` : ""}, ${result.searchMode ?? "search"} mode${result.timing ? `, ${result.timing.totalMs} ms` : ""}.</p><ul class="hits">${result.hits.map(hit)}</ul>`
80
+ : html`<p class="muted">${result.hits.length} ${result.hits.length === 1 ? "result" : "results"}${result.hits.length === Number(limit) ? ` (first ${limit}; narrow the query or filters for more specific results)` : ""}, ${result.searchMode ?? "search"} mode${result.timing ? `, ${result.timing.totalMs} ms` : ""}.</p>${q ? html`<ul class="hits">${result.hits.map(hit)}</ul>` : html`<div class="asset-inventory">${[...groups].sort(([a], [b]) => a.localeCompare(b)).map(([name, hits]) => html`<details open><summary>${name} <span class="muted">(${hits.length})</span></summary><ul class="hits">${hits.sort((a, b) => a.ref.localeCompare(b.ref)).map(hit)}</ul></details>`)}</div>`}`
61
81
  }`;
62
82
  }
@@ -372,6 +372,21 @@ p {
372
372
  padding-left: 20px;
373
373
  }
374
374
 
375
+ /* Unsaved-draft guard: floats over the page so the editor underneath stays mounted. */
376
+ #unsaved {
377
+ position: fixed;
378
+ top: 16px;
379
+ right: 16px;
380
+ z-index: 10;
381
+ max-width: min(420px, calc(100vw - 32px));
382
+ margin: 0;
383
+ box-shadow: 0 4px 16px rgb(0 0 0 / 0.2);
384
+ }
385
+
386
+ #unsaved p {
387
+ margin: 0 0 8px;
388
+ }
389
+
375
390
  /* ---- badges ---- */
376
391
  .badge {
377
392
  display: inline-block;
@@ -1017,6 +1032,8 @@ pre.diff {
1017
1032
  }
1018
1033
 
1019
1034
  input[type="search"],
1035
+ input:not([type]),
1036
+ textarea,
1020
1037
  select,
1021
1038
  button {
1022
1039
  font: inherit;
@@ -1027,6 +1044,59 @@ button {
1027
1044
  padding: 8px 12px;
1028
1045
  }
1029
1046
 
1047
+ .form-grid {
1048
+ display: grid;
1049
+ gap: 12px;
1050
+ margin: 16px 0;
1051
+ }
1052
+
1053
+ .form-grid label:not(.check) {
1054
+ display: grid;
1055
+ gap: 4px;
1056
+ }
1057
+
1058
+ .form-grid input:not([type="checkbox"]),
1059
+ .form-grid textarea {
1060
+ width: 100%;
1061
+ min-width: 0;
1062
+ }
1063
+
1064
+ .form-grid textarea {
1065
+ resize: vertical;
1066
+ font-family: var(--mono);
1067
+ }
1068
+
1069
+ .form-grid button {
1070
+ justify-self: start;
1071
+ }
1072
+
1073
+ .check {
1074
+ display: flex;
1075
+ gap: 8px;
1076
+ align-items: baseline;
1077
+ flex-wrap: wrap;
1078
+ }
1079
+
1080
+ .scope-controls {
1081
+ flex-basis: 100%;
1082
+ }
1083
+
1084
+ .asset-inventory > details {
1085
+ margin-top: 16px;
1086
+ }
1087
+
1088
+ .md [id] {
1089
+ scroll-margin-top: 20px;
1090
+ }
1091
+
1092
+ .task-item {
1093
+ list-style: none;
1094
+ }
1095
+
1096
+ .task-item input {
1097
+ margin-right: 8px;
1098
+ }
1099
+
1030
1100
  input[type="search"] {
1031
1101
  flex: 1 1 260px;
1032
1102
  min-width: 0;
@@ -1123,19 +1123,23 @@ export const HEALTH_CHECKS = [
1123
1123
  return { ...base, status: "unknown", message: `No extraction recorded in the last ${days} days.` };
1124
1124
  }
1125
1125
  // `read_failed` / `exception` land in the ledger as `failed`, not `skipped`.
1126
- const nothingHarvested = rows.every((row) => row.outcome === "skipped" || row.outcome === "failed");
1127
- const knownSkipReasons = [...new Set(rows.flatMap((row) => (row.skipReason ? [row.skipReason] : [])))].sort();
1128
- const allKnownReasonsAreInfra = knownSkipReasons.every((reason) => EXTRACT_INFRASTRUCTURE_SKIP_REASONS.includes(reason));
1126
+ const nothingHarvested = rows.every((row) => ["skipped", "failed", "proposal_write_failed"].includes(row.outcome));
1127
+ const reasonFor = (row) => row.outcome === "proposal_write_failed" ? row.outcome : row.skipReason;
1128
+ const knownSkipReasons = [...new Set(rows.map(reasonFor).filter((reason) => reason !== null))].sort();
1129
+ const allKnownReasonsAreInfra = knownSkipReasons.every((reason) => reason === "proposal_write_failed" ||
1130
+ EXTRACT_INFRASTRUCTURE_SKIP_REASONS.includes(reason));
1129
1131
  if (nothingHarvested && allKnownReasonsAreInfra) {
1130
1132
  const reasonClauses = knownSkipReasons.map((reason) => {
1131
1133
  const engines = [
1132
- ...new Set(rows.flatMap((row) => (row.skipReason === reason && row.engine ? [row.engine] : []))),
1134
+ ...new Set(rows.flatMap((row) => (reasonFor(row) === reason && row.engine ? [row.engine] : []))),
1133
1135
  ].sort();
1134
1136
  return engines.length > 0 ? `${reason} (engine ${engines.map((e) => `"${e}"`).join(", ")})` : reason;
1135
1137
  });
1136
- if (rows.some((row) => row.skipReason === null))
1138
+ if (rows.some((row) => reasonFor(row) === null))
1137
1139
  reasonClauses.push("unknown reason");
1138
- const verb = rows.some((row) => row.outcome === "failed") ? "skipped or failed" : "skipped";
1140
+ const verb = rows.some((row) => row.outcome === "failed" || row.outcome === "proposal_write_failed")
1141
+ ? "skipped or failed"
1142
+ : "skipped";
1139
1143
  return {
1140
1144
  ...base,
1141
1145
  status: "warn",
@@ -108,7 +108,15 @@ export function emptyImproveMetrics() {
108
108
  totalChunks: 0,
109
109
  durationMs: 0,
110
110
  },
111
- memoryInference: { considered: 0, freshAttempts: 0, written: 0, skippedNoFacts: 0, yieldRate: 0, durationMs: 0 },
111
+ memoryInference: {
112
+ considered: 0,
113
+ freshAttempts: 0,
114
+ written: 0,
115
+ noChange: 0,
116
+ skippedNoFacts: 0,
117
+ yieldRate: 0,
118
+ durationMs: 0,
119
+ },
112
120
  wallTime: { medianMs: 0, p95Ms: 0 },
113
121
  coverage: { acceptedProposals: 0, distinctRefs: 0 },
114
122
  };
@@ -220,6 +228,7 @@ function projectRunMetrics(result) {
220
228
  // them keeps the yield rate a statement about real inference attempts.
221
229
  mi.freshAttempts += Math.max(0, considered - toFiniteNumber(memoryInference.cacheHits) - toFiniteNumber(memoryInference.skippedAborted));
222
230
  mi.written += toFiniteNumber(memoryInference.writtenFacts);
231
+ mi.noChange += toFiniteNumber(memoryInference.noChange);
223
232
  mi.skippedNoFacts += toFiniteNumber(memoryInference.skippedNoFacts);
224
233
  }
225
234
  metrics.memoryInference.durationMs += toFiniteNumber(result.memoryInferenceDurationMs);
@@ -267,6 +276,7 @@ function mergeImproveMetrics(dst, src) {
267
276
  dst.memoryInference.considered += src.memoryInference.considered;
268
277
  dst.memoryInference.freshAttempts += src.memoryInference.freshAttempts;
269
278
  dst.memoryInference.written += src.memoryInference.written;
279
+ dst.memoryInference.noChange += src.memoryInference.noChange;
270
280
  dst.memoryInference.skippedNoFacts += src.memoryInference.skippedNoFacts;
271
281
  dst.memoryInference.durationMs += src.memoryInference.durationMs;
272
282
  }
@@ -20,7 +20,7 @@ function renderTable(headers, rows) {
20
20
  *
21
21
  * Columns: ts | ok | actions | refl_ok/fail/cd/skip |
22
22
  * distill_q/llm-fail/qrej/cfg/skip | cons_proc/promo/merge/del |
23
- * mem_cons/written/skip | orphans | lint_f/fl
23
+ * mem_cons/written/nochange/skip | orphans | lint_f/fl
24
24
  */
25
25
  export function renderRunsDetailMd(runs) {
26
26
  const headers = [
@@ -31,7 +31,7 @@ export function renderRunsDetailMd(runs) {
31
31
  "refl_ok/fail/cd/skip",
32
32
  "distill_q/llm-fail/judge/validator/cfg/skip",
33
33
  "cons_proc/promo/merge/del",
34
- "mem_cons/written/skip",
34
+ "mem_cons/written/nochange/skip",
35
35
  "orphans",
36
36
  "lint_f/fl",
37
37
  "result_status",
@@ -58,7 +58,7 @@ export function renderRunsDetailMd(runs) {
58
58
  `${r.actions.reflect.ok}/${r.actions.reflect.failed}/${r.actions.reflect.cooldown}/${r.actions.reflect.skipped}`,
59
59
  `${r.actions.distill.queued}/${r.actions.distill.llmFailed}/${r.actions.distill.judgeRejected}/${r.actions.distill.validatorRejected}/${r.actions.distill.configDisabled}/${r.actions.distill.skipped}`,
60
60
  `${r.consolidation.processed}/${r.consolidation.promoted}/${r.consolidation.merged}/${r.consolidation.deleted}`,
61
- `${r.memoryInference.considered}/${r.memoryInference.written}/${r.memoryInference.skippedNoFacts}`,
61
+ `${r.memoryInference.considered}/${r.memoryInference.written}/${r.memoryInference.noChange}/${r.memoryInference.skippedNoFacts}`,
62
62
  String(r.orphansPurged),
63
63
  `${r.lintFixed}/${r.lintFlagged}`,
64
64
  r.resultStatus ?? "valid",
@@ -2,7 +2,7 @@
2
2
  // License, v. 2.0. If a copy of the MPL was not distributed with this
3
3
  // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
4
  import { assembleAsset } from "../../core/asset/asset-serialize.js";
5
- import { parseFrontmatter } from "../../core/asset/frontmatter.js";
5
+ import { parseFrontmatter, readFrontmatterDate } from "../../core/asset/frontmatter.js";
6
6
  import { parseRefInput } from "../../core/asset/resolve-ref.js";
7
7
  function hasNonEmptyList(value) {
8
8
  return Array.isArray(value) && value.some((item) => typeof item === "string" && item.trim().length > 0);
@@ -97,7 +97,7 @@ function collectPromotionFeatures(input) {
97
97
  const description = typeof fm.description === "string" ? fm.description.trim() : undefined;
98
98
  const tags = Array.isArray(fm.tags) ? fm.tags : undefined;
99
99
  const source = typeof fm.source === "string" && fm.source.trim() ? fm.source.trim() : undefined;
100
- const observedAt = typeof fm.observed_at === "string" && fm.observed_at.trim() ? fm.observed_at.trim() : undefined;
100
+ const observedAt = readFrontmatterDate(fm, "observed");
101
101
  const features = {
102
102
  positiveFeedback,
103
103
  negativeFeedback,
@@ -118,13 +118,14 @@ function buildKnowledgeContent(input) {
118
118
  const body = parsedContent.content.trim();
119
119
  const description = typeof fm.description === "string" ? fm.description.trim() : undefined;
120
120
  const normalizedDescription = deriveDescription(body, description);
121
+ const observed = readFrontmatterDate(fm, "observed");
121
122
  const xrefs = [input.inputRef];
122
123
  if (typeof fm.source === "string" && fm.source.trim())
123
124
  xrefs.push(fm.source.trim());
124
125
  const knowledgeFrontmatter = {
125
126
  ...(normalizedDescription ? { description: normalizedDescription } : {}),
126
127
  ...(Array.isArray(fm.tags) ? { tags: fm.tags } : {}),
127
- ...(typeof fm.observed_at === "string" && fm.observed_at.trim() ? { observed_at: fm.observed_at.trim() } : {}),
128
+ ...(observed ? { observed } : {}),
128
129
  xrefs,
129
130
  };
130
131
  return assembleAsset(knowledgeFrontmatter, body);
@@ -184,7 +185,7 @@ function assessWithWeightedModel(input, model, threshold) {
184
185
  }
185
186
  if (features.hasObservedAt) {
186
187
  score += model.observedAtWeight;
187
- positiveSignals.push("observed_at present");
188
+ positiveSignals.push("observed present");
188
189
  }
189
190
  if (features.hasDescription) {
190
191
  score += model.descriptionWeight;