@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,69 @@
1
+ /**
2
+ * Feedback Learning Loop — promotion gate (FEEDBACK_LEARNING_LOOP_DESIGN.md §4 step 4).
3
+ *
4
+ * Decides whether a candidate lesson (one or more corroborating signals) is
5
+ * *active / injectable* or stays *provisional*. This is the deterministic
6
+ * "globally optimized, not single-shot" gate (requirement #4): the LLM groups
7
+ * signals by intent (semantic), but the promote/hold decision is pure code so
8
+ * the model never decides the threshold.
9
+ *
10
+ * Two hard rules kill the §3.5.1 sign-inversion failure mode at the gate:
11
+ * 1. `ignored` carries `valence='neutral'` and weight 0.25 — silence is weak
12
+ * corroboration, never disapproval, and can never *flip a lesson negative*.
13
+ * 2. `ignored` is **non-initiating**: a candidate made *only* of `ignored`
14
+ * signals never promotes, regardless of weighted sum. An ignore can
15
+ * strengthen an explicit/corrected lesson; it can never start one.
16
+ */
17
+ import type { FeedbackSignalSource, FeedbackSignalValence } from "../../db/feedback-signals-store.js";
18
+ /** Minimal signal shape the gate scores — a projection of `feedback_signals`. */
19
+ export interface GateSignal {
20
+ source: FeedbackSignalSource;
21
+ valence: FeedbackSignalValence | null;
22
+ }
23
+ /**
24
+ * Per-signal evidence weight (§3.5.1 / §4 step 4):
25
+ * explicit | corrected = 1.0 · self_critique | replied | acted = 0.5 · ignored = 0.25
26
+ *
27
+ * Derived from `(source, valence)` only — `valence` already encodes the
28
+ * behavioral reaction (corrected→correction, ignored→neutral,
29
+ * replied/acted→positive), so the gate needs no `evidence_json` lookup.
30
+ * Checked in priority order: an authoritative directive (explicit source or a
31
+ * correction) outranks everything; only the *behavioral* `ignored` reaction
32
+ * (see {@link isIgnoredSignal}) drops to 0.25 — a neutral valence on an
33
+ * `explicit`/`self_critique` row is a deliberate signal, not silence, and keeps
34
+ * the 0.5 (or 1.0, for explicit) authoritative weight.
35
+ */
36
+ export declare function signalWeight(signal: GateSignal): number;
37
+ /**
38
+ * `ignored` is the §3.5.1 behavioral notification-elapsed reaction: a
39
+ * `behavioral` signal carrying `valence='neutral'`. It drives the
40
+ * non-initiating / never-negative rule. A *neutral* valence on an `explicit`
41
+ * or `self_critique` row is NOT an ignore — it is an authoritative/deliberate
42
+ * signal that merely lacks a positive/negative tilt — so it must not inherit
43
+ * the 0.25 weight or the non-initiating treatment. Scoping the check to
44
+ * `behavioral` keeps this consistent with {@link signalWeight}, which already
45
+ * treats an explicit-neutral row as authoritative (1.0).
46
+ */
47
+ export declare function isIgnoredSignal(signal: GateSignal): boolean;
48
+ /** An authoritative owner directive — promotes on first occurrence. */
49
+ export declare function isExplicitDirective(signal: GateSignal): boolean;
50
+ /** Weighted evidence sum across a candidate's contributing signals. */
51
+ export declare function computeWeightedEvidence(signals: ReadonlyArray<GateSignal>): number;
52
+ export type PromotionReason = "explicit-directive" | "evidence-threshold" | "below-threshold" | "ignored-non-initiating" | "no-signals";
53
+ export interface PromotionVerdict {
54
+ /** Active & injectable when true; provisional otherwise. */
55
+ promotable: boolean;
56
+ /** Stored-but-excluded-from-injection marker (`<!-- provisional -->`). */
57
+ provisional: boolean;
58
+ /** `high` if any explicit/corrected; `medium` at threshold; else `low`. */
59
+ conf: "high" | "medium" | "low";
60
+ weightedEv: number;
61
+ reason: PromotionReason;
62
+ }
63
+ /**
64
+ * Evaluate the promotion gate for a candidate's contributing signals.
65
+ *
66
+ * @param threshold weighted-evidence bar for behavioral/self_critique
67
+ * (`feedbackPromotionThreshold`, default 2).
68
+ */
69
+ export declare function evaluatePromotion(signals: ReadonlyArray<GateSignal>, threshold: number): PromotionVerdict;
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Feedback Learning Loop — promotion gate (FEEDBACK_LEARNING_LOOP_DESIGN.md §4 step 4).
3
+ *
4
+ * Decides whether a candidate lesson (one or more corroborating signals) is
5
+ * *active / injectable* or stays *provisional*. This is the deterministic
6
+ * "globally optimized, not single-shot" gate (requirement #4): the LLM groups
7
+ * signals by intent (semantic), but the promote/hold decision is pure code so
8
+ * the model never decides the threshold.
9
+ *
10
+ * Two hard rules kill the §3.5.1 sign-inversion failure mode at the gate:
11
+ * 1. `ignored` carries `valence='neutral'` and weight 0.25 — silence is weak
12
+ * corroboration, never disapproval, and can never *flip a lesson negative*.
13
+ * 2. `ignored` is **non-initiating**: a candidate made *only* of `ignored`
14
+ * signals never promotes, regardless of weighted sum. An ignore can
15
+ * strengthen an explicit/corrected lesson; it can never start one.
16
+ */
17
+ /**
18
+ * Per-signal evidence weight (§3.5.1 / §4 step 4):
19
+ * explicit | corrected = 1.0 · self_critique | replied | acted = 0.5 · ignored = 0.25
20
+ *
21
+ * Derived from `(source, valence)` only — `valence` already encodes the
22
+ * behavioral reaction (corrected→correction, ignored→neutral,
23
+ * replied/acted→positive), so the gate needs no `evidence_json` lookup.
24
+ * Checked in priority order: an authoritative directive (explicit source or a
25
+ * correction) outranks everything; only the *behavioral* `ignored` reaction
26
+ * (see {@link isIgnoredSignal}) drops to 0.25 — a neutral valence on an
27
+ * `explicit`/`self_critique` row is a deliberate signal, not silence, and keeps
28
+ * the 0.5 (or 1.0, for explicit) authoritative weight.
29
+ */
30
+ export function signalWeight(signal) {
31
+ if (signal.source === "explicit")
32
+ return 1.0;
33
+ if (signal.valence === "correction")
34
+ return 1.0;
35
+ if (isIgnoredSignal(signal))
36
+ return 0.25;
37
+ return 0.5;
38
+ }
39
+ /**
40
+ * `ignored` is the §3.5.1 behavioral notification-elapsed reaction: a
41
+ * `behavioral` signal carrying `valence='neutral'`. It drives the
42
+ * non-initiating / never-negative rule. A *neutral* valence on an `explicit`
43
+ * or `self_critique` row is NOT an ignore — it is an authoritative/deliberate
44
+ * signal that merely lacks a positive/negative tilt — so it must not inherit
45
+ * the 0.25 weight or the non-initiating treatment. Scoping the check to
46
+ * `behavioral` keeps this consistent with {@link signalWeight}, which already
47
+ * treats an explicit-neutral row as authoritative (1.0).
48
+ */
49
+ export function isIgnoredSignal(signal) {
50
+ return signal.source === "behavioral" && signal.valence === "neutral";
51
+ }
52
+ /** An authoritative owner directive — promotes on first occurrence. */
53
+ export function isExplicitDirective(signal) {
54
+ return signal.source === "explicit" || signal.valence === "correction";
55
+ }
56
+ /** Weighted evidence sum across a candidate's contributing signals. */
57
+ export function computeWeightedEvidence(signals) {
58
+ return signals.reduce((sum, signal) => sum + signalWeight(signal), 0);
59
+ }
60
+ /**
61
+ * Evaluate the promotion gate for a candidate's contributing signals.
62
+ *
63
+ * @param threshold weighted-evidence bar for behavioral/self_critique
64
+ * (`feedbackPromotionThreshold`, default 2).
65
+ */
66
+ export function evaluatePromotion(signals, threshold) {
67
+ const weightedEv = computeWeightedEvidence(signals);
68
+ if (signals.length === 0) {
69
+ return {
70
+ promotable: false,
71
+ provisional: true,
72
+ conf: "low",
73
+ weightedEv,
74
+ reason: "no-signals",
75
+ };
76
+ }
77
+ // Rule 2: an ignored-only candidate is non-initiating — never promotes,
78
+ // regardless of weighted sum (two coincidental busy-morning ignores cannot
79
+ // teach "stop notifying about X").
80
+ if (signals.every(isIgnoredSignal)) {
81
+ return {
82
+ promotable: false,
83
+ provisional: true,
84
+ conf: "low",
85
+ weightedEv,
86
+ reason: "ignored-non-initiating",
87
+ };
88
+ }
89
+ // An explicit owner directive (or any correction) is authoritative →
90
+ // promote on first occurrence with high confidence.
91
+ if (signals.some(isExplicitDirective)) {
92
+ return {
93
+ promotable: true,
94
+ provisional: false,
95
+ conf: "high",
96
+ weightedEv,
97
+ reason: "explicit-directive",
98
+ };
99
+ }
100
+ // Behavioral / self_critique corroboration: promote at the weighted bar.
101
+ if (weightedEv >= threshold) {
102
+ return {
103
+ promotable: true,
104
+ provisional: false,
105
+ conf: "medium",
106
+ weightedEv,
107
+ reason: "evidence-threshold",
108
+ };
109
+ }
110
+ return {
111
+ promotable: false,
112
+ provisional: true,
113
+ conf: "low",
114
+ weightedEv,
115
+ reason: "below-threshold",
116
+ };
117
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Feedback Learning Loop — monthly re-generalization pre-step
3
+ * (FEEDBACK_LEARNING_LOOP_DESIGN.md §4 "Monthly re-generalization", Phase 5).
4
+ *
5
+ * The deterministic, daemon-side half of the *monthly* collapse. Where the
6
+ * nightly evening-review pre-step (`consolidation-prep.ts`) folds unconsumed
7
+ * *signals* into lessons, the monthly pass re-reads the *already-consolidated*
8
+ * lesson stores and surfaces them so the LLM can collapse several specific
9
+ * lessons that share a theme into one higher-level principle — e.g. three
10
+ * "shorter mail summary" / "shorter standup" / "shorter report" lessons → one
11
+ * `agent`-scope lesson "Default to terse, bulleted output." This is the engine
12
+ * that turns accumulated specifics into a small set of meaningful generalizations.
13
+ *
14
+ * Two layers, mirroring `consolidation-prep.ts`:
15
+ * - The dispatcher (coverage-excluded, FS-heavy) enumerates the lesson files
16
+ * on disk — the global `policies/agent-lessons.md` plus every per-agent
17
+ * `policies/agents/<slug>/lessons.md` — and reads their contents.
18
+ * - `buildRegeneralizationWorksheet(scopes, …)` — this pure markdown/XML
19
+ * composer turns those contents into a `<feedback_regeneralization>` block.
20
+ * Every output byte is a deterministic function of its inputs, so it stays
21
+ * I/O-free and 100% coverable.
22
+ *
23
+ * Unlike the evening worksheet, this pass carries **no signals and no consume
24
+ * ids** — it neither promotes nor consumes; it only ranks the existing lessons
25
+ * (lowest-scored first, the same eviction order Step 4 already uses) and flags
26
+ * staleness / over-cap so the LLM's collapse honours the same caps. A scope is
27
+ * surfaced only when it holds at least {@link MIN_LESSONS_FOR_REGENERALIZATION}
28
+ * *active* lessons — you need two to collapse one — and the whole block is
29
+ * omitted when no scope qualifies, so a sparse vault adds nothing to the
30
+ * monthly prompt.
31
+ *
32
+ * **Promotion-neutral by construction.** Only *active* (non-provisional)
33
+ * lessons are surfaced for collapse. Provisional lessons are awaiting
34
+ * corroboration and are owned exclusively by the nightly evening pass — the
35
+ * single promotion authority (`promotion-gate.ts`). Offering them here would
36
+ * let the LLM merge two provisional lessons into one active lesson, summing
37
+ * their `ev` past the threshold and bypassing the gate's
38
+ * `ignored`-only-never-promotes guard (§3.5.1) — the exact sign-inversion the
39
+ * gate exists to kill. They stay in the file untouched; the task-flow tells the
40
+ * LLM to preserve any provisional lesson byte-for-byte.
41
+ */
42
+ import { type CanonicalScope } from "./scope-parser.js";
43
+ /** A scope needs at least this many *active* lessons before a collapse is possible. */
44
+ export declare const MIN_LESSONS_FOR_REGENERALIZATION = 2;
45
+ export interface RegeneralizationScopeInput {
46
+ /** `agent` (global) or `agent_slug` (per-agent) — the user scope is handled
47
+ * by the existing nightly user-profile consolidation, not re-generalised. */
48
+ scope: CanonicalScope;
49
+ /** Canonical store path (`policies/agent-lessons.md` / `policies/agents/<slug>/lessons.md`). */
50
+ storeFile: string;
51
+ /** Current lessons-store file contents. */
52
+ existingFileMd: string;
53
+ /** Byte/entry caps for the scope (§6). */
54
+ caps: {
55
+ capBytes: number;
56
+ maxEntries: number;
57
+ };
58
+ }
59
+ export interface BuildRegeneralizationOptions {
60
+ nowIso: string;
61
+ recencyHalfLifeDays?: number;
62
+ /**
63
+ * Staleness horizon in days (`feedbackLessonStaleDays`, §4 step 7). A lesson
64
+ * whose `last=` predates `now − staleDays` and is not a `constraint` is
65
+ * flagged `stale="true"` so the LLM can drop it while collapsing. Omitted ⇒
66
+ * nothing is flagged stale.
67
+ */
68
+ staleDays?: number;
69
+ }
70
+ export interface RegeneralizationResult {
71
+ /** `<feedback_regeneralization>…</…>` block for verbatim injection. */
72
+ block: string;
73
+ /** Number of scopes surfaced (each with ≥ MIN_LESSONS_FOR_REGENERALIZATION active lessons). */
74
+ scopeCount: number;
75
+ /** Total *active* lessons surfaced across all scopes (provisional excluded). */
76
+ lessonCount: number;
77
+ }
78
+ /**
79
+ * Compose the `<feedback_regeneralization>` block. Returns `null` when no scope
80
+ * holds at least {@link MIN_LESSONS_FOR_REGENERALIZATION} *active*
81
+ * (non-provisional) lessons — there is nothing to collapse, so the caller
82
+ * stamps nothing (no empty block in the prompt). Provisional lessons are
83
+ * excluded from the collapse set (see module header) but still counted in each
84
+ * scope's `current_entries` / `over_cap` so the cap status stays whole-file
85
+ * truthful. Scopes are emitted in input order.
86
+ */
87
+ export declare function buildRegeneralizationWorksheet(scopes: ReadonlyArray<RegeneralizationScopeInput>, opts: BuildRegeneralizationOptions): RegeneralizationResult | null;
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Feedback Learning Loop — monthly re-generalization pre-step
3
+ * (FEEDBACK_LEARNING_LOOP_DESIGN.md §4 "Monthly re-generalization", Phase 5).
4
+ *
5
+ * The deterministic, daemon-side half of the *monthly* collapse. Where the
6
+ * nightly evening-review pre-step (`consolidation-prep.ts`) folds unconsumed
7
+ * *signals* into lessons, the monthly pass re-reads the *already-consolidated*
8
+ * lesson stores and surfaces them so the LLM can collapse several specific
9
+ * lessons that share a theme into one higher-level principle — e.g. three
10
+ * "shorter mail summary" / "shorter standup" / "shorter report" lessons → one
11
+ * `agent`-scope lesson "Default to terse, bulleted output." This is the engine
12
+ * that turns accumulated specifics into a small set of meaningful generalizations.
13
+ *
14
+ * Two layers, mirroring `consolidation-prep.ts`:
15
+ * - The dispatcher (coverage-excluded, FS-heavy) enumerates the lesson files
16
+ * on disk — the global `policies/agent-lessons.md` plus every per-agent
17
+ * `policies/agents/<slug>/lessons.md` — and reads their contents.
18
+ * - `buildRegeneralizationWorksheet(scopes, …)` — this pure markdown/XML
19
+ * composer turns those contents into a `<feedback_regeneralization>` block.
20
+ * Every output byte is a deterministic function of its inputs, so it stays
21
+ * I/O-free and 100% coverable.
22
+ *
23
+ * Unlike the evening worksheet, this pass carries **no signals and no consume
24
+ * ids** — it neither promotes nor consumes; it only ranks the existing lessons
25
+ * (lowest-scored first, the same eviction order Step 4 already uses) and flags
26
+ * staleness / over-cap so the LLM's collapse honours the same caps. A scope is
27
+ * surfaced only when it holds at least {@link MIN_LESSONS_FOR_REGENERALIZATION}
28
+ * *active* lessons — you need two to collapse one — and the whole block is
29
+ * omitted when no scope qualifies, so a sparse vault adds nothing to the
30
+ * monthly prompt.
31
+ *
32
+ * **Promotion-neutral by construction.** Only *active* (non-provisional)
33
+ * lessons are surfaced for collapse. Provisional lessons are awaiting
34
+ * corroboration and are owned exclusively by the nightly evening pass — the
35
+ * single promotion authority (`promotion-gate.ts`). Offering them here would
36
+ * let the LLM merge two provisional lessons into one active lesson, summing
37
+ * their `ev` past the threshold and bypassing the gate's
38
+ * `ignored`-only-never-promotes guard (§3.5.1) — the exact sign-inversion the
39
+ * gate exists to kill. They stay in the file untouched; the task-flow tells the
40
+ * LLM to preserve any provisional lesson byte-for-byte.
41
+ */
42
+ import { extractMarkdownSection, parseLessonsSection, } from "./lesson-format.js";
43
+ import { scoreLesson, isLessonStale, DEFAULT_RECENCY_HALFLIFE_DAYS, } from "./eviction-scorer.js";
44
+ import { formatScope, scopeSectionSlug } from "./scope-parser.js";
45
+ /** A scope needs at least this many *active* lessons before a collapse is possible. */
46
+ export const MIN_LESSONS_FOR_REGENERALIZATION = 2;
47
+ function xmlEscape(value) {
48
+ return value
49
+ .replace(/&/g, "&amp;")
50
+ .replace(/</g, "&lt;")
51
+ .replace(/>/g, "&gt;")
52
+ .replace(/"/g, "&quot;");
53
+ }
54
+ function round2(value) {
55
+ return (Math.round(value * 100) / 100).toFixed(2);
56
+ }
57
+ /** Collapse a one-line excerpt of a lesson for an XML text node. */
58
+ function inline(text, max = 300) {
59
+ const flat = text.replace(/\s+/g, " ").trim();
60
+ const clipped = flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
61
+ return xmlEscape(clipped);
62
+ }
63
+ function parseScopeLessons(existingFileMd) {
64
+ const sectionBody = extractMarkdownSection(existingFileMd, "Lessons");
65
+ return sectionBody ? parseLessonsSection(sectionBody) : [];
66
+ }
67
+ function renderScope(input, activeLessons, totalEntries, opts, out) {
68
+ const label = formatScope(input.scope);
69
+ const section = scopeSectionSlug(input.scope);
70
+ const halfLife = opts.recencyHalfLifeDays ?? DEFAULT_RECENCY_HALFLIFE_DAYS;
71
+ const currentBytes = Buffer.byteLength(input.existingFileMd, "utf-8");
72
+ // `current_bytes` / `current_entries` describe the WHOLE on-disk file
73
+ // (active + provisional), because that is exactly what the byte/entry caps
74
+ // guard (§6). `over_cap` therefore reflects the real file state, not the
75
+ // collapsible subset — the LLM's Step-12 eviction targets the disk cap.
76
+ const overCap = currentBytes > input.caps.capBytes || totalEntries > input.caps.maxEntries;
77
+ const provisionalHeld = totalEntries - activeLessons.length;
78
+ // Ascending score → rank 1 = lowest score = drop-first, the same convention
79
+ // the evening worksheet uses so the LLM reads both with one mental model.
80
+ const ranked = [...activeLessons].sort((a, b) => scoreLesson(a, opts.nowIso, undefined, halfLife) -
81
+ scoreLesson(b, opts.nowIso, undefined, halfLife));
82
+ out.push(` <scope label="${xmlEscape(label)}" store="${xmlEscape(input.storeFile)}" ` +
83
+ `section="${xmlEscape(section)}" ` +
84
+ `cap_bytes="${input.caps.capBytes}" max_entries="${input.caps.maxEntries}" ` +
85
+ `current_bytes="${currentBytes}" current_entries="${totalEntries}" ` +
86
+ `provisional_held="${provisionalHeld}" over_cap="${overCap}">`);
87
+ out.push(` <lessons note="active (non-provisional) lessons only, ranked by eviction ` +
88
+ `score (rank 1 = lowest, drop-first); cluster lessons that share a theme ` +
89
+ `and collapse each cluster into ONE higher-level principle; drop any lesson ` +
90
+ `marked stale=&quot;true&quot; unless it joins a cluster; never collapse ` +
91
+ `across a contradiction; preserve any provisional lesson in the file ` +
92
+ `byte-for-byte — they await corroboration and are not yours to collapse or promote">`);
93
+ ranked.forEach((lesson, idx) => {
94
+ out.push(` <lesson rank="${idx + 1}" score="${round2(scoreLesson(lesson, opts.nowIso, undefined, halfLife))}" ev="${lesson.ev}" kind="${lesson.kind}" last="${lesson.last}" ` +
95
+ `provisional="${lesson.provisional}" ` +
96
+ `stale="${isLessonStale(lesson, opts.nowIso, opts.staleDays)}">` +
97
+ `${inline(lesson.text)}</lesson>`);
98
+ });
99
+ out.push(" </lessons>");
100
+ out.push(" </scope>");
101
+ }
102
+ /**
103
+ * Compose the `<feedback_regeneralization>` block. Returns `null` when no scope
104
+ * holds at least {@link MIN_LESSONS_FOR_REGENERALIZATION} *active*
105
+ * (non-provisional) lessons — there is nothing to collapse, so the caller
106
+ * stamps nothing (no empty block in the prompt). Provisional lessons are
107
+ * excluded from the collapse set (see module header) but still counted in each
108
+ * scope's `current_entries` / `over_cap` so the cap status stays whole-file
109
+ * truthful. Scopes are emitted in input order.
110
+ */
111
+ export function buildRegeneralizationWorksheet(scopes, opts) {
112
+ const eligible = [];
113
+ for (const input of scopes) {
114
+ const allLessons = parseScopeLessons(input.existingFileMd);
115
+ // Collapse the ACTIVE set only — provisional lessons are owned by the
116
+ // evening promotion gate (see module header); merging them here would
117
+ // bypass the `ignored`-only-never-promotes guard.
118
+ const active = allLessons.filter((lesson) => !lesson.provisional);
119
+ if (active.length >= MIN_LESSONS_FOR_REGENERALIZATION) {
120
+ eligible.push({ input, active, totalEntries: allLessons.length });
121
+ }
122
+ }
123
+ if (eligible.length === 0)
124
+ return null;
125
+ const out = [];
126
+ out.push(`<feedback_regeneralization generated_at="${xmlEscape(opts.nowIso)}" ` +
127
+ `scopes="${eligible.length}">`);
128
+ let lessonCount = 0;
129
+ for (const { input, active, totalEntries } of eligible) {
130
+ renderScope(input, active, totalEntries, opts, out);
131
+ lessonCount += active.length;
132
+ }
133
+ out.push("</feedback_regeneralization>");
134
+ return {
135
+ block: out.join("\n"),
136
+ scopeCount: eligible.length,
137
+ lessonCount,
138
+ };
139
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Feedback Learning Loop — scope grammar (FEEDBACK_LEARNING_LOOP_DESIGN.md §3.3).
3
+ *
4
+ * A feedback signal / lesson carries a **scope** that decides *who sees it*.
5
+ * This module is the single source of truth that maps the DB row's
6
+ * `(scope_type, scope_ref)` pair onto:
7
+ * - a canonical, human-readable scope string (`user`, `agent`,
8
+ * `agent:report-writer`, `channel:slack`, …) used in worksheet XML and
9
+ * lesson-file headers, and
10
+ * - the writable vault file that stores that scope's lessons.
11
+ *
12
+ * Pure logic, no I/O — the §4 division-of-labour "scope parser" covered module.
13
+ * Phase 2 only *stores* `user` + `agent`; the parser still recognises the full
14
+ * v2 grammar (`agent_slug`, `channel`, `task`, `integration`) so forward-compat
15
+ * rows round-trip rather than throwing, but `scopeStoreFile` returns `null` for
16
+ * the not-yet-stored classes — the caller treats that as "surface but do not
17
+ * persist a lesson file yet".
18
+ */
19
+ import type { FeedbackScopeType } from "../../db/feedback-signals-store.js";
20
+ /** Parsed, normalised scope. `agent_slug` collapses to `kind: "agent_slug"`. */
21
+ export type CanonicalScope = {
22
+ kind: "user";
23
+ } | {
24
+ kind: "agent";
25
+ } | {
26
+ kind: "agent_slug";
27
+ ref: string;
28
+ } | {
29
+ kind: "channel";
30
+ ref: string;
31
+ } | {
32
+ kind: "task";
33
+ ref: string;
34
+ } | {
35
+ kind: "integration";
36
+ ref: string;
37
+ };
38
+ /**
39
+ * Parse a `(scope_type, scope_ref)` pair into a {@link CanonicalScope}.
40
+ * Returns `null` when the type is unknown or a ref-required class is missing
41
+ * its ref — the caller drops such rows from the worksheet rather than guessing.
42
+ */
43
+ export declare function parseScope(scopeType: string, scopeRef: string | null | undefined): CanonicalScope | null;
44
+ /**
45
+ * Canonical human-readable scope label used in worksheet XML attributes and
46
+ * the `<!-- scope: … -->` lesson-file header. `agent:<slug>` is the literal
47
+ * answer to requirement #3 ("feedback on a generated agent's output").
48
+ */
49
+ export declare function formatScope(scope: CanonicalScope): string;
50
+ /**
51
+ * Stable grouping key for a scope — used by the consolidation pre-step to
52
+ * bucket unconsumed signals by `(scope_type, scope_ref)` (§4 step 1). Equal
53
+ * for two signals that target the same lesson destination.
54
+ */
55
+ export declare function scopeKey(scope: CanonicalScope): string;
56
+ /**
57
+ * Resolve the writable-vault relative path that stores a scope's lessons.
58
+ * - `user` → `identity/profile.md` (`## Learned Context` section)
59
+ * - `agent` → `policies/agent-lessons.md`
60
+ * - `agent:slug` → `policies/agents/<slug>/lessons.md`
61
+ * - everything else (v2 channel/task/integration) → `null` (not yet stored)
62
+ *
63
+ * The `agent:<slug>` ref is path-validated with {@link isSafeAgentSlug} before
64
+ * it is composed into a vault path — the same guard the inject side applies to
65
+ * `event.data.agentId` (`context-builder.ts`), so both consumers of a slug
66
+ * reject the same unsafe shapes (`agentLessonsPath` itself trusts a
67
+ * pre-validated slug). A path-unsafe ref yields `null` ("surface but do not
68
+ * persist"), so the consolidation worksheet never emits an unsafe `store=` for
69
+ * the LLM to PATCH. In practice the `/api/feedback` route + the behavioral
70
+ * sink only ever write a real `agents.id` ref, so this is pure defence-in-depth
71
+ * against a malformed / forged row.
72
+ */
73
+ export declare function scopeStoreFile(scope: CanonicalScope): string | null;
74
+ export declare function isSafeAgentSlug(value: string): boolean;
75
+ /**
76
+ * The markdown section a scope's lessons live under, for PATCH `section=`
77
+ * targeting. `user` folds into the existing `## Learned Context`; lesson
78
+ * stores use `## Lessons`.
79
+ */
80
+ export declare function scopeSectionSlug(scope: CanonicalScope): string;
81
+ /**
82
+ * True when a scope type needs a ref to be valid. Exposed for the route /
83
+ * worksheet validators that mirror the §3.5.2 "scope_ref present iff
84
+ * scope_type=agent_slug" rule across the extended grammar.
85
+ */
86
+ export declare function scopeNeedsRef(scopeType: FeedbackScopeType): boolean;
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Feedback Learning Loop — scope grammar (FEEDBACK_LEARNING_LOOP_DESIGN.md §3.3).
3
+ *
4
+ * A feedback signal / lesson carries a **scope** that decides *who sees it*.
5
+ * This module is the single source of truth that maps the DB row's
6
+ * `(scope_type, scope_ref)` pair onto:
7
+ * - a canonical, human-readable scope string (`user`, `agent`,
8
+ * `agent:report-writer`, `channel:slack`, …) used in worksheet XML and
9
+ * lesson-file headers, and
10
+ * - the writable vault file that stores that scope's lessons.
11
+ *
12
+ * Pure logic, no I/O — the §4 division-of-labour "scope parser" covered module.
13
+ * Phase 2 only *stores* `user` + `agent`; the parser still recognises the full
14
+ * v2 grammar (`agent_slug`, `channel`, `task`, `integration`) so forward-compat
15
+ * rows round-trip rather than throwing, but `scopeStoreFile` returns `null` for
16
+ * the not-yet-stored classes — the caller treats that as "surface but do not
17
+ * persist a lesson file yet".
18
+ */
19
+ import { agentLessonsPath, CONTEXT_RELATIVE_PATHS } from "../context-paths.js";
20
+ /** Scope classes that require a `scope_ref` to be meaningful. */
21
+ const REF_REQUIRED = new Set([
22
+ "agent_slug",
23
+ "channel",
24
+ "task",
25
+ "integration",
26
+ ]);
27
+ /**
28
+ * Parse a `(scope_type, scope_ref)` pair into a {@link CanonicalScope}.
29
+ * Returns `null` when the type is unknown or a ref-required class is missing
30
+ * its ref — the caller drops such rows from the worksheet rather than guessing.
31
+ */
32
+ export function parseScope(scopeType, scopeRef) {
33
+ const ref = typeof scopeRef === "string" ? scopeRef.trim() : "";
34
+ switch (scopeType) {
35
+ case "user":
36
+ return { kind: "user" };
37
+ case "agent":
38
+ return { kind: "agent" };
39
+ case "agent_slug":
40
+ return ref.length > 0 ? { kind: "agent_slug", ref } : null;
41
+ case "channel":
42
+ return ref.length > 0 ? { kind: "channel", ref } : null;
43
+ case "task":
44
+ return ref.length > 0 ? { kind: "task", ref } : null;
45
+ case "integration":
46
+ return ref.length > 0 ? { kind: "integration", ref } : null;
47
+ default:
48
+ return null;
49
+ }
50
+ }
51
+ /**
52
+ * Canonical human-readable scope label used in worksheet XML attributes and
53
+ * the `<!-- scope: … -->` lesson-file header. `agent:<slug>` is the literal
54
+ * answer to requirement #3 ("feedback on a generated agent's output").
55
+ */
56
+ export function formatScope(scope) {
57
+ switch (scope.kind) {
58
+ case "user":
59
+ return "user";
60
+ case "agent":
61
+ return "agent";
62
+ case "agent_slug":
63
+ return `agent:${scope.ref}`;
64
+ case "channel":
65
+ return `channel:${scope.ref}`;
66
+ case "task":
67
+ return `task:${scope.ref}`;
68
+ case "integration":
69
+ return `integration:${scope.ref}`;
70
+ }
71
+ }
72
+ /**
73
+ * Stable grouping key for a scope — used by the consolidation pre-step to
74
+ * bucket unconsumed signals by `(scope_type, scope_ref)` (§4 step 1). Equal
75
+ * for two signals that target the same lesson destination.
76
+ */
77
+ export function scopeKey(scope) {
78
+ return formatScope(scope);
79
+ }
80
+ /**
81
+ * Resolve the writable-vault relative path that stores a scope's lessons.
82
+ * - `user` → `identity/profile.md` (`## Learned Context` section)
83
+ * - `agent` → `policies/agent-lessons.md`
84
+ * - `agent:slug` → `policies/agents/<slug>/lessons.md`
85
+ * - everything else (v2 channel/task/integration) → `null` (not yet stored)
86
+ *
87
+ * The `agent:<slug>` ref is path-validated with {@link isSafeAgentSlug} before
88
+ * it is composed into a vault path — the same guard the inject side applies to
89
+ * `event.data.agentId` (`context-builder.ts`), so both consumers of a slug
90
+ * reject the same unsafe shapes (`agentLessonsPath` itself trusts a
91
+ * pre-validated slug). A path-unsafe ref yields `null` ("surface but do not
92
+ * persist"), so the consolidation worksheet never emits an unsafe `store=` for
93
+ * the LLM to PATCH. In practice the `/api/feedback` route + the behavioral
94
+ * sink only ever write a real `agents.id` ref, so this is pure defence-in-depth
95
+ * against a malformed / forged row.
96
+ */
97
+ export function scopeStoreFile(scope) {
98
+ switch (scope.kind) {
99
+ case "user":
100
+ return CONTEXT_RELATIVE_PATHS.user.profile;
101
+ case "agent":
102
+ return CONTEXT_RELATIVE_PATHS.agentLessons;
103
+ case "agent_slug":
104
+ return isSafeAgentSlug(scope.ref) ? agentLessonsPath(scope.ref) : null;
105
+ default:
106
+ return null;
107
+ }
108
+ }
109
+ /**
110
+ * Path-safety guard for an `agent:<slug>` ref before it is interpolated into a
111
+ * vault file path (`agentLessonsPath`). The Phase-4 inject path reads
112
+ * `event.data.agentId` — a `Record<string, unknown>` value set by the dispatch
113
+ * site from `resolveAgentId()` — and joins it under `policies/agents/`. Although
114
+ * `resolveAgentId` only ever returns DB-verified / built-in-registry slugs,
115
+ * defence-in-depth requires the consuming side validate the shape rather than
116
+ * trust the carrier: the slug must be a single safe segment (kebab-case-ish,
117
+ * the same `[a-z0-9._-]` alphabet `deriveSlug` and the context-write whitelist
118
+ * use), start alphanumeric (so `.`/`..` are rejected outright), and contain no
119
+ * `..` traversal or `/` separator. A failing value yields no self block rather
120
+ * than a path escape.
121
+ */
122
+ const SAFE_AGENT_SLUG_RE = /^[a-z0-9][a-z0-9._-]*$/;
123
+ export function isSafeAgentSlug(value) {
124
+ return SAFE_AGENT_SLUG_RE.test(value) && !value.includes("..");
125
+ }
126
+ /**
127
+ * The markdown section a scope's lessons live under, for PATCH `section=`
128
+ * targeting. `user` folds into the existing `## Learned Context`; lesson
129
+ * stores use `## Lessons`.
130
+ */
131
+ export function scopeSectionSlug(scope) {
132
+ return scope.kind === "user" ? "learned_context" : "lessons";
133
+ }
134
+ /**
135
+ * True when a scope type needs a ref to be valid. Exposed for the route /
136
+ * worksheet validators that mirror the §3.5.2 "scope_ref present iff
137
+ * scope_type=agent_slug" rule across the extended grammar.
138
+ */
139
+ export function scopeNeedsRef(scopeType) {
140
+ return REF_REQUIRED.has(scopeType);
141
+ }