@aitne/daemon 0.1.9 → 0.1.10

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 (65) hide show
  1. package/dist/api/env-writer.d.ts +1 -0
  2. package/dist/api/env-writer.js +9 -2
  3. package/dist/api/routes/agent-schedule.js +5 -1
  4. package/dist/api/routes/apple-calendar.js +4 -1
  5. package/dist/api/routes/calendar.js +12 -2
  6. package/dist/api/routes/context/path-resolve.js +6 -1
  7. package/dist/api/routes/context/permissions.js +9 -0
  8. package/dist/api/routes/dashboard/config.js +10 -0
  9. package/dist/api/routes/dashboard/oauth-google.js +5 -3
  10. package/dist/api/routes/feedback.d.ts +3 -0
  11. package/dist/api/routes/feedback.js +349 -0
  12. package/dist/api/routes/git.js +10 -3
  13. package/dist/api/routes/github.js +5 -1
  14. package/dist/api/routes/mcp.js +65 -13
  15. package/dist/api/server.js +3 -0
  16. package/dist/bootstrap/event-pipeline.js +1 -1
  17. package/dist/config.js +6 -0
  18. package/dist/core/backends/gemini-cli-core.js +13 -0
  19. package/dist/core/backends/plan-presets.js +8 -3
  20. package/dist/core/context-builder.js +149 -3
  21. package/dist/core/context-paths.d.ts +10 -0
  22. package/dist/core/context-paths.js +16 -0
  23. package/dist/core/daemon-api-cli.js +1 -1
  24. package/dist/core/dispatcher-message-handler.js +7 -0
  25. package/dist/core/dispatcher-scheduled-tasks.d.ts +41 -0
  26. package/dist/core/dispatcher-scheduled-tasks.js +267 -2
  27. package/dist/core/dispatcher.js +13 -1
  28. package/dist/core/feedback/consolidation-prep.d.ts +94 -0
  29. package/dist/core/feedback/consolidation-prep.js +242 -0
  30. package/dist/core/feedback/eviction-scorer.d.ts +81 -0
  31. package/dist/core/feedback/eviction-scorer.js +132 -0
  32. package/dist/core/feedback/lesson-format.d.ts +79 -0
  33. package/dist/core/feedback/lesson-format.js +194 -0
  34. package/dist/core/feedback/lesson-injection.d.ts +98 -0
  35. package/dist/core/feedback/lesson-injection.js +159 -0
  36. package/dist/core/feedback/lesson-merge.d.ts +51 -0
  37. package/dist/core/feedback/lesson-merge.js +88 -0
  38. package/dist/core/feedback/lesson-store-overview.d.ts +42 -0
  39. package/dist/core/feedback/lesson-store-overview.js +38 -0
  40. package/dist/core/feedback/promotion-gate.d.ts +69 -0
  41. package/dist/core/feedback/promotion-gate.js +117 -0
  42. package/dist/core/feedback/regeneralization-prep.d.ts +87 -0
  43. package/dist/core/feedback/regeneralization-prep.js +139 -0
  44. package/dist/core/feedback/scope-parser.d.ts +86 -0
  45. package/dist/core/feedback/scope-parser.js +141 -0
  46. package/dist/core/injection-policy.d.ts +82 -0
  47. package/dist/core/injection-policy.js +58 -0
  48. package/dist/core/signal-detector.d.ts +39 -1
  49. package/dist/core/signal-detector.js +277 -24
  50. package/dist/core/today-direct-writer.d.ts +59 -13
  51. package/dist/core/today-direct-writer.js +90 -13
  52. package/dist/core/wiki/wiki-fts.js +13 -6
  53. package/dist/db/feedback-signals-store.d.ts +77 -0
  54. package/dist/db/feedback-signals-store.js +144 -0
  55. package/dist/db/migrations.js +50 -0
  56. package/dist/db/schema.js +43 -6
  57. package/dist/safety/always-disallowed.d.ts +1 -1
  58. package/dist/safety/always-disallowed.js +39 -0
  59. package/dist/safety/risk-classifier.js +22 -7
  60. package/dist/services/browser-history/automation/egress-denylist.js +18 -2
  61. package/dist/services/browser-history/lifecycle/platform.js +44 -2
  62. package/dist/services/mcp/probe.js +30 -8
  63. package/dist/settings/runtime-settings.d.ts +8 -2
  64. package/dist/settings/runtime-settings.js +12 -0
  65. package/package.json +2 -2
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Feedback Learning Loop — lesson MD format (FEEDBACK_LEARNING_LOOP_DESIGN.md §3.4).
3
+ *
4
+ * A *lesson* is a generalized, injectable directive rendered as a markdown
5
+ * bullet that extends the existing Learned-Context convention with a
6
+ * machine-readable trailer:
7
+ *
8
+ * - [2026-06-07] Keep the weekly report's budget section even when spend is
9
+ * flat — owner flagged it missing twice.
10
+ * <!-- ev=2 kind=correction src=explicit conf=high last=2026-06-07 -->
11
+ *
12
+ * Optional `<!-- provisional -->` marks a lesson stored but excluded from
13
+ * injection until it promotes (§4 step 4).
14
+ *
15
+ * This module is pure (no I/O): parse a `## Lessons` section body into typed
16
+ * {@link Lesson}s and serialize them back byte-stably. It is the shared
17
+ * vocabulary for the promotion gate, eviction scorer, merge/dedup, and the
18
+ * consolidation worksheet — the §4 division-of-labour mechanical layer. The
19
+ * LLM authors *prose*; this code owns the *structure*.
20
+ */
21
+ export const LESSON_KINDS = new Set([
22
+ "preference",
23
+ "correction",
24
+ "do-more",
25
+ "do-less",
26
+ "constraint",
27
+ ]);
28
+ export const LESSON_SOURCES = new Set([
29
+ "explicit",
30
+ "behavioral",
31
+ "self_critique",
32
+ ]);
33
+ export const LESSON_CONFIDENCES = new Set(["high", "medium", "low"]);
34
+ const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
35
+ /** Bullet entry start: `- [YYYY-MM-DD] …` (rest of the line is prose). */
36
+ const ENTRY_START_RE = /^-\s+\[(\d{4}-\d{2}-\d{2})\]\s?(.*)$/;
37
+ /**
38
+ * Eviction marker `- [...N … omitted …]` — emitted by the scorer, never a
39
+ * lesson (its bracket holds `...`, not a date). Skipped on parse so re-reading
40
+ * a previously-evicted section does not fold the marker text into the
41
+ * preceding lesson's prose.
42
+ */
43
+ const OMITTED_MARKER_RE = /^\s*-\s*\[\.\.\./;
44
+ /** Any HTML comment — trailer attrs + the `provisional` marker both ride these. */
45
+ const COMMENT_RE = /<!--([\s\S]*?)-->/g;
46
+ const PROVISIONAL_RE = /<!--\s*provisional\s*-->/i;
47
+ function isDate(value) {
48
+ return DATE_RE.test(value);
49
+ }
50
+ function coerceKind(value) {
51
+ return value && LESSON_KINDS.has(value) ? value : "preference";
52
+ }
53
+ function coerceSource(value) {
54
+ return value && LESSON_SOURCES.has(value)
55
+ ? value
56
+ : "behavioral";
57
+ }
58
+ function coerceConf(value) {
59
+ return value && LESSON_CONFIDENCES.has(value)
60
+ ? value
61
+ : "low";
62
+ }
63
+ /** Parse `ev=2 kind=correction src=explicit conf=high last=2026-06-07`. */
64
+ function parseTrailerAttrs(raw) {
65
+ const out = {};
66
+ for (const token of raw.trim().split(/\s+/)) {
67
+ const eq = token.indexOf("=");
68
+ if (eq <= 0)
69
+ continue;
70
+ const key = token.slice(0, eq);
71
+ const value = token.slice(eq + 1);
72
+ if (key.length > 0 && value.length > 0)
73
+ out[key] = value;
74
+ }
75
+ return out;
76
+ }
77
+ /**
78
+ * Extract a single markdown `## <header>` section body from a file, returning
79
+ * the lines between the header and the next `## `/`# ` heading (exclusive),
80
+ * or `null` when the header is absent. CRLF-tolerant.
81
+ */
82
+ export function extractMarkdownSection(md, header) {
83
+ const lines = md.split(/\r?\n/);
84
+ const wanted = `## ${header}`;
85
+ const start = lines.findIndex((line) => line.trim() === wanted);
86
+ if (start < 0)
87
+ return null;
88
+ const body = [];
89
+ for (let i = start + 1; i < lines.length; i++) {
90
+ if (/^#{1,2}\s/.test(lines[i]))
91
+ break;
92
+ body.push(lines[i]);
93
+ }
94
+ return body.join("\n").trim();
95
+ }
96
+ /**
97
+ * Parse a `## Lessons` section body into typed lessons. Non-lesson lines
98
+ * (blank lines, the `<!-- scope: … -->` header comment, the `[...N omitted]`
99
+ * eviction marker, stray prose) are ignored. Continuation lines (indented)
100
+ * fold into the preceding lesson's prose. Malformed entries degrade to
101
+ * defaults rather than throwing, so a hand-edited file never crashes the
102
+ * nightly pass.
103
+ */
104
+ export function parseLessonsSection(sectionBody) {
105
+ if (!sectionBody)
106
+ return [];
107
+ const lines = sectionBody.split(/\r?\n/);
108
+ const lessons = [];
109
+ let current = null;
110
+ const flush = () => {
111
+ if (!current)
112
+ return;
113
+ const joined = current.buffer.join("\n");
114
+ const provisional = PROVISIONAL_RE.test(joined);
115
+ // Merge attrs from every comment in the entry — `provisional` and any
116
+ // valueless tokens fall out in `parseTrailerAttrs` rather than breaking
117
+ // the whole trailer match (a hand-edited file never crashes the pass).
118
+ const attrs = {};
119
+ for (const match of joined.matchAll(COMMENT_RE)) {
120
+ Object.assign(attrs, parseTrailerAttrs(match[1]));
121
+ }
122
+ // Strip every HTML comment (trailer + provisional marker) from prose.
123
+ const text = joined
124
+ .replace(/<!--[\s\S]*?-->/g, " ")
125
+ .replace(/\s+/g, " ")
126
+ .trim();
127
+ const evNum = Number(attrs.ev);
128
+ const ev = Number.isFinite(evNum) && evNum > 0 ? evNum : 1;
129
+ const last = attrs.last && isDate(attrs.last) ? attrs.last : current.date;
130
+ lessons.push({
131
+ date: current.date,
132
+ text,
133
+ ev,
134
+ kind: coerceKind(attrs.kind),
135
+ src: coerceSource(attrs.src),
136
+ conf: coerceConf(attrs.conf),
137
+ last,
138
+ provisional,
139
+ });
140
+ current = null;
141
+ };
142
+ for (const line of lines) {
143
+ const startMatch = ENTRY_START_RE.exec(line);
144
+ if (startMatch) {
145
+ flush();
146
+ current = { date: startMatch[1], buffer: [startMatch[2]] };
147
+ continue;
148
+ }
149
+ if (OMITTED_MARKER_RE.test(line)) {
150
+ // The eviction marker terminates the current lesson and is not itself a
151
+ // lesson — neither a continuation nor a new entry.
152
+ flush();
153
+ continue;
154
+ }
155
+ if (current && (line.startsWith(" ") || line.trim().length > 0)) {
156
+ // Continuation of the current lesson (indented, or a trailer comment
157
+ // the author placed on its own non-indented line).
158
+ current.buffer.push(line.trim());
159
+ }
160
+ else if (current) {
161
+ // Blank line ends the current lesson.
162
+ flush();
163
+ }
164
+ }
165
+ flush();
166
+ return lessons;
167
+ }
168
+ /**
169
+ * Render one lesson as a markdown bullet with its trailer. Prose stays on the
170
+ * first line; the trailer follows on a 2-space-indented continuation line so
171
+ * it survives `trimBulletEntries` / `clearEntriesBefore` continuation rules.
172
+ */
173
+ export function formatLesson(lesson) {
174
+ const trailer = `<!-- ev=${lesson.ev} kind=${lesson.kind} src=${lesson.src} ` +
175
+ `conf=${lesson.conf} last=${lesson.last} -->`;
176
+ const provisional = lesson.provisional ? " <!-- provisional -->" : "";
177
+ return `- [${lesson.date}] ${lesson.text}\n ${trailer}${provisional}`;
178
+ }
179
+ /**
180
+ * Render a full `## Lessons` section: the `<!-- scope: … -->` header comment
181
+ * (carrying the cap for at-a-glance review), the lesson bullets, and an
182
+ * optional eviction marker. `scopeLabel` is the {@link formatScope} string.
183
+ */
184
+ export function formatLessonsSection(lessons, opts) {
185
+ const header = `<!-- scope: ${opts.scopeLabel} · cap: ${opts.capBytes}B · ${opts.maxEntries} entries -->`;
186
+ const parts = [header, ...lessons.map((lesson) => formatLesson(lesson))];
187
+ if (opts.omittedMarker)
188
+ parts.push(opts.omittedMarker);
189
+ return parts.join("\n");
190
+ }
191
+ /** UTF-8 byte length of a serialized lessons section — the cap unit (§6). */
192
+ export function lessonsSectionByteLength(lessons, opts) {
193
+ return Buffer.byteLength(formatLessonsSection(lessons, opts), "utf-8");
194
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Feedback Learning Loop — Stage 3 inject renderer (FEEDBACK_LEARNING_LOOP_DESIGN.md §5/§6).
3
+ *
4
+ * Renders the `<agent_lessons>` block `ContextBuilder` pushes onto DM /
5
+ * notify-deciding turns. Pure logic, no I/O — the FS read (the lessons file)
6
+ * lives in the coverage-excluded builder; this module owns the *structure* and
7
+ * the *cap*, and is in the 100%-covered `core/feedback/*` subset (§8).
8
+ *
9
+ * Three variants, matching the §5/§6 split — all pack the highest-signal lessons
10
+ * under their cap via one shared {@link packByScore} core, differing only in the
11
+ * wrapper tag, preamble, cap unit, entry cap, and whether an over-cap is an
12
+ * operability signal:
13
+ *
14
+ * - **global** (DM + review cadences + defined-agent runs) — emit every
15
+ * *active* (non-provisional) lesson from `policies/agent-lessons.md` when the
16
+ * body fits the cap. Over cap the block **degrades to the top-N lessons by
17
+ * score** (v1.5 §11.6) and the builder warns: the cap stays a hard,
18
+ * non-bypassable guarantee (the emitted body never exceeds it), but the agent
19
+ * keeps the highest-signal lessons instead of losing all of them. A degrade
20
+ * only happens if consolidation failed to pre-cap the file, so it is an
21
+ * operability signal (`overflow`), not a routine path.
22
+ * - **self** (Phase 4 — any run bound to an Agent slug) — identical render +
23
+ * degrade discipline to global, but the source is the per-agent
24
+ * `policies/agents/<slug>/lessons.md` and the wrapper is
25
+ * `<agent_lessons scope="self">` with a self-facing preamble. Capped at
26
+ * `feedbackLessonMaxBytesPerAgent`; over-cap degrades + warns exactly like
27
+ * global. Selected via `opts.selfScope`.
28
+ * - **slim** (hourly notify turn) — top-N by eviction score, greedily packed
29
+ * so the *whole* block stays under the hard 2048-byte budget (§6). Tail
30
+ * dropping is routine here (tight hourly budget), so the slim path never
31
+ * reports `overflow`; it just drops the lowest-signal tail.
32
+ *
33
+ * Provisional lessons are excluded from injection (§4 step 4) — they are stored
34
+ * for corroboration but must not yet bind behaviour. The machine-readable
35
+ * trailers (`<!-- ev=… -->`) are dropped: the agent consumes the directive
36
+ * prose, not the consolidator's bookkeeping.
37
+ */
38
+ /**
39
+ * Hard inject-time byte cap for the slim hourly notify-discipline variant (§6
40
+ * table: "slim notify-discipline subset injected to hourly_check · hard 2048 at
41
+ * inject"). Exported so the builder and tests share one constant.
42
+ */
43
+ export declare const AGENT_LESSONS_SLIM_CAP_BYTES = 2048;
44
+ /**
45
+ * Belt-and-braces entry cap for the slim variant: the byte cap is the binding
46
+ * constraint, but a small entry cap keeps the hourly turn focused on the
47
+ * highest-signal lessons even if they are individually tiny.
48
+ */
49
+ export declare const AGENT_LESSONS_SLIM_MAX_ENTRIES = 12;
50
+ export interface AgentLessonsBlockResult {
51
+ /** The `<agent_lessons>`-wrapped block, or `null` when nothing is injected. */
52
+ block: string | null;
53
+ /**
54
+ * Set **only** on the global path when the full body exceeded `capBytes` and
55
+ * lessons had to be dropped to fit — the builder logs a warning.
56
+ * - `bytes` is the full (over-cap) body size that triggered the degrade;
57
+ * - `dropped` is how many lessons were left out (all of them when not even
58
+ * the single highest-scored lesson fits, in which case `block` is `null`).
59
+ *
60
+ * `null` on the slim path (tail-dropping there is routine, not a warning
61
+ * condition) and whenever everything fit. The cap is never breached either
62
+ * way: this signals a degrade, not a cap bypass.
63
+ */
64
+ overflow: {
65
+ bytes: number;
66
+ cap: number;
67
+ dropped: number;
68
+ } | null;
69
+ }
70
+ export interface RenderAgentLessonsOptions {
71
+ /** Defensive byte cap for the rendered body (global/self) / whole block (slim). */
72
+ capBytes: number;
73
+ /** Slim hourly variant: top-N by score, packed under the hard byte cap. */
74
+ slim: boolean;
75
+ /** ISO timestamp used to score lessons for ranking + degrade. */
76
+ nowIso: string;
77
+ /** Override the slim entry cap (defaults to {@link AGENT_LESSONS_SLIM_MAX_ENTRIES}). */
78
+ maxSlimEntries?: number;
79
+ /**
80
+ * Phase 4 — render the per-agent (`agent:<slug>`) block: wrap as
81
+ * `<agent_lessons scope="self">` with the self preamble, same body cap +
82
+ * degrade discipline as global. Ignored when {@link RenderAgentLessonsOptions.slim}
83
+ * is set (the slim variant is global-only by construction).
84
+ */
85
+ selfScope?: boolean;
86
+ }
87
+ /**
88
+ * Render the `<agent_lessons>` block for a surface, or `null` when there is
89
+ * nothing to inject (no file, no `## Lessons` section, no active lessons, or —
90
+ * global/self path only — not even the single highest-scored lesson fits the
91
+ * cap). When the global/self body is over cap but some lessons fit, the block
92
+ * degrades to the top-N by score and `overflow` is set so the caller can warn.
93
+ *
94
+ * Variant selection: `slim` → the hourly notify block; else `selfScope` → the
95
+ * per-agent `<agent_lessons scope="self">` block; else the global block. `slim`
96
+ * and `selfScope` are mutually exclusive by construction (slim wins).
97
+ */
98
+ export declare function renderAgentLessonsBlock(fileMd: string | null, opts: RenderAgentLessonsOptions): AgentLessonsBlockResult;
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Feedback Learning Loop — Stage 3 inject renderer (FEEDBACK_LEARNING_LOOP_DESIGN.md §5/§6).
3
+ *
4
+ * Renders the `<agent_lessons>` block `ContextBuilder` pushes onto DM /
5
+ * notify-deciding turns. Pure logic, no I/O — the FS read (the lessons file)
6
+ * lives in the coverage-excluded builder; this module owns the *structure* and
7
+ * the *cap*, and is in the 100%-covered `core/feedback/*` subset (§8).
8
+ *
9
+ * Three variants, matching the §5/§6 split — all pack the highest-signal lessons
10
+ * under their cap via one shared {@link packByScore} core, differing only in the
11
+ * wrapper tag, preamble, cap unit, entry cap, and whether an over-cap is an
12
+ * operability signal:
13
+ *
14
+ * - **global** (DM + review cadences + defined-agent runs) — emit every
15
+ * *active* (non-provisional) lesson from `policies/agent-lessons.md` when the
16
+ * body fits the cap. Over cap the block **degrades to the top-N lessons by
17
+ * score** (v1.5 §11.6) and the builder warns: the cap stays a hard,
18
+ * non-bypassable guarantee (the emitted body never exceeds it), but the agent
19
+ * keeps the highest-signal lessons instead of losing all of them. A degrade
20
+ * only happens if consolidation failed to pre-cap the file, so it is an
21
+ * operability signal (`overflow`), not a routine path.
22
+ * - **self** (Phase 4 — any run bound to an Agent slug) — identical render +
23
+ * degrade discipline to global, but the source is the per-agent
24
+ * `policies/agents/<slug>/lessons.md` and the wrapper is
25
+ * `<agent_lessons scope="self">` with a self-facing preamble. Capped at
26
+ * `feedbackLessonMaxBytesPerAgent`; over-cap degrades + warns exactly like
27
+ * global. Selected via `opts.selfScope`.
28
+ * - **slim** (hourly notify turn) — top-N by eviction score, greedily packed
29
+ * so the *whole* block stays under the hard 2048-byte budget (§6). Tail
30
+ * dropping is routine here (tight hourly budget), so the slim path never
31
+ * reports `overflow`; it just drops the lowest-signal tail.
32
+ *
33
+ * Provisional lessons are excluded from injection (§4 step 4) — they are stored
34
+ * for corroboration but must not yet bind behaviour. The machine-readable
35
+ * trailers (`<!-- ev=… -->`) are dropped: the agent consumes the directive
36
+ * prose, not the consolidator's bookkeeping.
37
+ */
38
+ import { extractMarkdownSection, parseLessonsSection, } from "./lesson-format.js";
39
+ import { scoreLesson } from "./eviction-scorer.js";
40
+ /**
41
+ * Hard inject-time byte cap for the slim hourly notify-discipline variant (§6
42
+ * table: "slim notify-discipline subset injected to hourly_check · hard 2048 at
43
+ * inject"). Exported so the builder and tests share one constant.
44
+ */
45
+ export const AGENT_LESSONS_SLIM_CAP_BYTES = 2048;
46
+ /**
47
+ * Belt-and-braces entry cap for the slim variant: the byte cap is the binding
48
+ * constraint, but a small entry cap keeps the hourly turn focused on the
49
+ * highest-signal lessons even if they are individually tiny.
50
+ */
51
+ export const AGENT_LESSONS_SLIM_MAX_ENTRIES = 12;
52
+ const GLOBAL_STYLE = {
53
+ openTag: "<agent_lessons>",
54
+ preamble: "Lessons calibrated from past owner feedback and your own reviews. Treat each " +
55
+ "as a standing directive and prefer it over your defaults when they conflict.",
56
+ };
57
+ const SELF_STYLE = {
58
+ openTag: '<agent_lessons scope="self">',
59
+ preamble: "Lessons calibrated specifically from feedback on THIS agent's own past " +
60
+ "output. Treat each as a standing directive for your work and prefer it over " +
61
+ "your defaults when they conflict.",
62
+ };
63
+ const SLIM_PREAMBLE = "Your highest-signal operating lessons, calibrated from past feedback. Weigh " +
64
+ "these before deciding whether to notify the owner.";
65
+ /** Parse the `## Lessons` section and keep only injectable (active) lessons. */
66
+ function activeLessons(fileMd) {
67
+ const section = extractMarkdownSection(fileMd, "Lessons");
68
+ if (!section)
69
+ return [];
70
+ return parseLessonsSection(section).filter((lesson) => !lesson.provisional && lesson.text.length > 0);
71
+ }
72
+ /** One lesson as an agent-facing bullet (trailer + date stripped). */
73
+ function bulletFor(lesson) {
74
+ return `- ${lesson.text}`;
75
+ }
76
+ function wrap(style, bullets) {
77
+ return [style.openTag, style.preamble, ...bullets, "</agent_lessons>"].join("\n");
78
+ }
79
+ /**
80
+ * Greedily keep the highest-scored lessons whose serialized form — produced by
81
+ * `render`, the cap-measured unit — stays within `capBytes`, up to `maxEntries`.
82
+ * Returns the kept bullets (highest-score-first) and how many lessons dropped.
83
+ *
84
+ * Strict score-prefix: stops at the first lesson that would overflow — a
85
+ * lower-scored shorter tail is never swapped in, since that would violate
86
+ * "top-N by score". Scoring is the same `scoreLesson` consolidation eviction
87
+ * uses, so inject-time ranking matches on-disk ranking. Measuring and keeping
88
+ * the same `bullet` value guarantees what was size-checked is exactly what
89
+ * lands in the block.
90
+ */
91
+ function packByScore(lessons, nowIso, capBytes, maxEntries, render) {
92
+ const ranked = [...lessons].sort((a, b) => scoreLesson(b, nowIso) - scoreLesson(a, nowIso));
93
+ const kept = [];
94
+ for (const lesson of ranked) {
95
+ if (kept.length >= maxEntries)
96
+ break;
97
+ const bullet = bulletFor(lesson);
98
+ if (Buffer.byteLength(render([...kept, bullet]), "utf-8") > capBytes)
99
+ break;
100
+ kept.push(bullet);
101
+ }
102
+ return { kept, dropped: ranked.length - kept.length };
103
+ }
104
+ function renderBody(lessons, capBytes, nowIso, style) {
105
+ // Cap on the rendered body — matches the `<management_rules>` precedent
106
+ // (check the content bytes, then wrap unconditionally). The wrapper tag /
107
+ // preamble are the only per-variant difference between global and self.
108
+ const bodyBytes = Buffer.byteLength(lessons.map(bulletFor).join("\n"), "utf-8");
109
+ if (bodyBytes <= capBytes) {
110
+ return { block: wrap(style, lessons.map(bulletFor)), overflow: null };
111
+ }
112
+ // Over cap → graceful degradation (v1.5 §11.6): keep the highest-scored
113
+ // lessons whose body still fits and warn, rather than dropping all of them.
114
+ // The cap stays a hard, non-bypassable guarantee (the emitted body never
115
+ // exceeds it); consolidation should have pre-capped the file, so a degrade
116
+ // here is an operability signal the builder logs.
117
+ const { kept, dropped } = packByScore(lessons, nowIso, capBytes, Number.POSITIVE_INFINITY, (bullets) => bullets.join("\n"));
118
+ const overflow = { bytes: bodyBytes, cap: capBytes, dropped };
119
+ // Even the single highest-scored bullet can exceed the cap → empty kept set.
120
+ if (kept.length === 0)
121
+ return { block: null, overflow };
122
+ return { block: wrap(style, kept), overflow };
123
+ }
124
+ function renderSlim(lessons, opts) {
125
+ const maxEntries = opts.maxSlimEntries ?? AGENT_LESSONS_SLIM_MAX_ENTRIES;
126
+ // Measure the *whole* block so the hard 2048 budget covers preamble + tags,
127
+ // not just the bullets. Tail-dropping is routine on the tight hourly turn, so
128
+ // the slim path never reports `overflow`. Slim is global-only — always the
129
+ // plain `<agent_lessons>` wrapper with the notify-discipline preamble.
130
+ const slimStyle = {
131
+ openTag: GLOBAL_STYLE.openTag,
132
+ preamble: SLIM_PREAMBLE,
133
+ };
134
+ const { kept } = packByScore(lessons, opts.nowIso, opts.capBytes, maxEntries, (bullets) => wrap(slimStyle, bullets));
135
+ if (kept.length === 0)
136
+ return { block: null, overflow: null };
137
+ return { block: wrap(slimStyle, kept), overflow: null };
138
+ }
139
+ /**
140
+ * Render the `<agent_lessons>` block for a surface, or `null` when there is
141
+ * nothing to inject (no file, no `## Lessons` section, no active lessons, or —
142
+ * global/self path only — not even the single highest-scored lesson fits the
143
+ * cap). When the global/self body is over cap but some lessons fit, the block
144
+ * degrades to the top-N by score and `overflow` is set so the caller can warn.
145
+ *
146
+ * Variant selection: `slim` → the hourly notify block; else `selfScope` → the
147
+ * per-agent `<agent_lessons scope="self">` block; else the global block. `slim`
148
+ * and `selfScope` are mutually exclusive by construction (slim wins).
149
+ */
150
+ export function renderAgentLessonsBlock(fileMd, opts) {
151
+ if (!fileMd)
152
+ return { block: null, overflow: null };
153
+ const lessons = activeLessons(fileMd);
154
+ if (lessons.length === 0)
155
+ return { block: null, overflow: null };
156
+ if (opts.slim)
157
+ return renderSlim(lessons, opts);
158
+ return renderBody(lessons, opts.capBytes, opts.nowIso, opts.selfScope ? SELF_STYLE : GLOBAL_STYLE);
159
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Feedback Learning Loop — mechanical merge / dedup (FEEDBACK_LEARNING_LOOP_DESIGN.md §4 step 3, §6).
3
+ *
4
+ * The *mechanical* half of "merge, don't append": group incoming signals by a
5
+ * normalised summary so identical reports collapse into one candidate, and
6
+ * collapse near-duplicate existing lessons (summing their `ev`) before the
7
+ * eviction scorer runs (§6: "Near-duplicates are merged … before eviction is
8
+ * even considered").
9
+ *
10
+ * The *semantic* half — judging whether a candidate matches an existing
11
+ * lesson's *intent* and phrasing the generalization — is the LLM's job (§4
12
+ * division of labour). This module never paraphrases; it only collapses exact
13
+ * normalised-text matches, which is safe to do deterministically.
14
+ */
15
+ import type { Lesson } from "./lesson-format.js";
16
+ /**
17
+ * Normalise a summary for mechanical equality: lowercase, strip punctuation,
18
+ * collapse whitespace. Two signals/lessons with the same normalised form are
19
+ * treated as the same candidate. Conservative — only *identical* phrasings
20
+ * collapse; anything semantic is left to the LLM.
21
+ */
22
+ export declare function normalizeSummary(summary: string): string;
23
+ export interface SignalLike {
24
+ id: number;
25
+ summary: string;
26
+ }
27
+ export interface SignalGroup<T extends SignalLike> {
28
+ /** Normalised-summary key shared by every member. */
29
+ key: string;
30
+ /** Representative (first-seen) raw summary, for display. */
31
+ summary: string;
32
+ members: T[];
33
+ }
34
+ /**
35
+ * Group signals by normalised summary, preserving first-seen order for both
36
+ * the groups and their members. Empty / whitespace-only summaries are kept
37
+ * under a stable empty key rather than dropped, so no signal id is lost from
38
+ * the consume set.
39
+ */
40
+ export declare function groupSignalsBySummary<T extends SignalLike>(signals: ReadonlyArray<T>): SignalGroup<T>[];
41
+ /**
42
+ * Collapse near-duplicate lessons (identical normalised text) into one, summing
43
+ * `ev`, keeping the earliest `date`, the latest `last`, the max confidence, the
44
+ * strongest `kind` (constraint outranks a softer kind), and OR-ing
45
+ * `provisional` to `false` if any duplicate is active. Order is stable: the
46
+ * first occurrence's position is retained.
47
+ *
48
+ * Deterministic and lossless on `ev` — the summed evidence flows straight into
49
+ * the eviction score so a merged lesson is *harder* to evict, never easier.
50
+ */
51
+ export declare function dedupeLessons(lessons: ReadonlyArray<Lesson>): Lesson[];
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Feedback Learning Loop — mechanical merge / dedup (FEEDBACK_LEARNING_LOOP_DESIGN.md §4 step 3, §6).
3
+ *
4
+ * The *mechanical* half of "merge, don't append": group incoming signals by a
5
+ * normalised summary so identical reports collapse into one candidate, and
6
+ * collapse near-duplicate existing lessons (summing their `ev`) before the
7
+ * eviction scorer runs (§6: "Near-duplicates are merged … before eviction is
8
+ * even considered").
9
+ *
10
+ * The *semantic* half — judging whether a candidate matches an existing
11
+ * lesson's *intent* and phrasing the generalization — is the LLM's job (§4
12
+ * division of labour). This module never paraphrases; it only collapses exact
13
+ * normalised-text matches, which is safe to do deterministically.
14
+ */
15
+ /**
16
+ * Normalise a summary for mechanical equality: lowercase, strip punctuation,
17
+ * collapse whitespace. Two signals/lessons with the same normalised form are
18
+ * treated as the same candidate. Conservative — only *identical* phrasings
19
+ * collapse; anything semantic is left to the LLM.
20
+ */
21
+ export function normalizeSummary(summary) {
22
+ return summary
23
+ .toLowerCase()
24
+ .replace(/[^\p{L}\p{N}\s]/gu, " ")
25
+ .replace(/\s+/g, " ")
26
+ .trim();
27
+ }
28
+ /**
29
+ * Group signals by normalised summary, preserving first-seen order for both
30
+ * the groups and their members. Empty / whitespace-only summaries are kept
31
+ * under a stable empty key rather than dropped, so no signal id is lost from
32
+ * the consume set.
33
+ */
34
+ export function groupSignalsBySummary(signals) {
35
+ const order = [];
36
+ const byKey = new Map();
37
+ for (const signal of signals) {
38
+ const key = normalizeSummary(signal.summary);
39
+ let group = byKey.get(key);
40
+ if (!group) {
41
+ group = { key, summary: signal.summary, members: [] };
42
+ byKey.set(key, group);
43
+ order.push(key);
44
+ }
45
+ group.members.push(signal);
46
+ }
47
+ return order.map((key) => byKey.get(key));
48
+ }
49
+ const CONF_RANK = {
50
+ high: 3,
51
+ medium: 2,
52
+ low: 1,
53
+ };
54
+ /**
55
+ * Collapse near-duplicate lessons (identical normalised text) into one, summing
56
+ * `ev`, keeping the earliest `date`, the latest `last`, the max confidence, the
57
+ * strongest `kind` (constraint outranks a softer kind), and OR-ing
58
+ * `provisional` to `false` if any duplicate is active. Order is stable: the
59
+ * first occurrence's position is retained.
60
+ *
61
+ * Deterministic and lossless on `ev` — the summed evidence flows straight into
62
+ * the eviction score so a merged lesson is *harder* to evict, never easier.
63
+ */
64
+ export function dedupeLessons(lessons) {
65
+ const order = [];
66
+ const byKey = new Map();
67
+ for (const lesson of lessons) {
68
+ const key = normalizeSummary(lesson.text);
69
+ const existing = byKey.get(key);
70
+ if (!existing) {
71
+ byKey.set(key, { ...lesson });
72
+ order.push(key);
73
+ continue;
74
+ }
75
+ existing.ev += lesson.ev;
76
+ if (lesson.date < existing.date)
77
+ existing.date = lesson.date;
78
+ if (lesson.last > existing.last)
79
+ existing.last = lesson.last;
80
+ if (CONF_RANK[lesson.conf] > CONF_RANK[existing.conf]) {
81
+ existing.conf = lesson.conf;
82
+ }
83
+ if (lesson.kind === "constraint")
84
+ existing.kind = "constraint";
85
+ existing.provisional = existing.provisional && lesson.provisional;
86
+ }
87
+ return order.map((key) => byKey.get(key));
88
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Feedback Learning Loop — lesson-store overview (FEEDBACK_LEARNING_LOOP_DESIGN.md §9 Phase 5).
3
+ *
4
+ * The pure, deterministic half of the `GET /api/feedback/lessons` dashboard
5
+ * surface. The route (coverage-excluded — it does the FS enumeration) hands
6
+ * each lesson file's raw contents to {@link summarizeLessonStore}, which parses
7
+ * the `## Lessons` section and reports the cap-utilisation metrics the
8
+ * "view/edit lessons and tune caps/threshold" settings page renders:
9
+ * byte size vs. cap, entry count vs. cap, active vs. provisional split, and
10
+ * whether the store is currently over either cap.
11
+ *
12
+ * Mirrors the §4 division of labour: the byte/entry accounting is mechanical
13
+ * (here, 100% covered), while the route owns only the FS read + JSON assembly.
14
+ */
15
+ export interface LessonStoreSummary {
16
+ /** UTF-8 byte size of the whole file (the cap unit, §6). */
17
+ bytes: number;
18
+ /** Per-scope byte cap. */
19
+ capBytes: number;
20
+ /** Total parsed lessons (active + provisional). */
21
+ entries: number;
22
+ /** Per-scope entry cap. */
23
+ maxEntries: number;
24
+ /** Injectable (promoted) lessons — the ones that actually reach a prompt. */
25
+ active: number;
26
+ /** Stored-but-not-yet-injected lessons awaiting corroboration (§4 step 4). */
27
+ provisional: number;
28
+ /** True when the file exceeds its byte cap or its entry cap. */
29
+ overCap: boolean;
30
+ }
31
+ /**
32
+ * Summarise one lesson store from its raw file contents. A file with no
33
+ * `## Lessons` section (or an empty one) reports zero entries — never throws,
34
+ * so a hand-edited or partially-written file degrades to "empty store" rather
35
+ * than breaking the overview. `bytes` is measured against the *whole* file
36
+ * because that is what the consolidation cap and the eviction scorer both
37
+ * guard, matching what lands on disk.
38
+ */
39
+ export declare function summarizeLessonStore(fileMd: string, caps: {
40
+ capBytes: number;
41
+ maxEntries: number;
42
+ }): LessonStoreSummary;
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Feedback Learning Loop — lesson-store overview (FEEDBACK_LEARNING_LOOP_DESIGN.md §9 Phase 5).
3
+ *
4
+ * The pure, deterministic half of the `GET /api/feedback/lessons` dashboard
5
+ * surface. The route (coverage-excluded — it does the FS enumeration) hands
6
+ * each lesson file's raw contents to {@link summarizeLessonStore}, which parses
7
+ * the `## Lessons` section and reports the cap-utilisation metrics the
8
+ * "view/edit lessons and tune caps/threshold" settings page renders:
9
+ * byte size vs. cap, entry count vs. cap, active vs. provisional split, and
10
+ * whether the store is currently over either cap.
11
+ *
12
+ * Mirrors the §4 division of labour: the byte/entry accounting is mechanical
13
+ * (here, 100% covered), while the route owns only the FS read + JSON assembly.
14
+ */
15
+ import { extractMarkdownSection, parseLessonsSection, } from "./lesson-format.js";
16
+ /**
17
+ * Summarise one lesson store from its raw file contents. A file with no
18
+ * `## Lessons` section (or an empty one) reports zero entries — never throws,
19
+ * so a hand-edited or partially-written file degrades to "empty store" rather
20
+ * than breaking the overview. `bytes` is measured against the *whole* file
21
+ * because that is what the consolidation cap and the eviction scorer both
22
+ * guard, matching what lands on disk.
23
+ */
24
+ export function summarizeLessonStore(fileMd, caps) {
25
+ const sectionBody = extractMarkdownSection(fileMd, "Lessons");
26
+ const lessons = sectionBody ? parseLessonsSection(sectionBody) : [];
27
+ const provisional = lessons.filter((lesson) => lesson.provisional).length;
28
+ const bytes = Buffer.byteLength(fileMd, "utf-8");
29
+ return {
30
+ bytes,
31
+ capBytes: caps.capBytes,
32
+ entries: lessons.length,
33
+ maxEntries: caps.maxEntries,
34
+ active: lessons.length - provisional,
35
+ provisional,
36
+ overCap: bytes > caps.capBytes || lessons.length > caps.maxEntries,
37
+ };
38
+ }