@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.
- package/dist/api/env-writer.d.ts +1 -0
- package/dist/api/env-writer.js +9 -2
- package/dist/api/routes/agent-schedule.js +5 -1
- package/dist/api/routes/apple-calendar.js +4 -1
- package/dist/api/routes/calendar.js +12 -2
- package/dist/api/routes/context/path-resolve.js +6 -1
- package/dist/api/routes/context/permissions.js +9 -0
- package/dist/api/routes/dashboard/config.js +10 -0
- package/dist/api/routes/dashboard/oauth-google.js +5 -3
- package/dist/api/routes/feedback.d.ts +3 -0
- package/dist/api/routes/feedback.js +349 -0
- package/dist/api/routes/git.js +10 -3
- package/dist/api/routes/github.js +5 -1
- package/dist/api/routes/mcp.js +65 -13
- package/dist/api/server.js +3 -0
- package/dist/bootstrap/event-pipeline.js +1 -1
- package/dist/config.js +6 -0
- package/dist/core/backends/gemini-cli-core.js +13 -0
- package/dist/core/backends/plan-presets.js +8 -3
- package/dist/core/context-builder.js +149 -3
- package/dist/core/context-paths.d.ts +10 -0
- package/dist/core/context-paths.js +16 -0
- package/dist/core/daemon-api-cli.js +1 -1
- package/dist/core/dispatcher-message-handler.js +7 -0
- package/dist/core/dispatcher-scheduled-tasks.d.ts +41 -0
- package/dist/core/dispatcher-scheduled-tasks.js +267 -2
- package/dist/core/dispatcher.js +13 -1
- package/dist/core/feedback/consolidation-prep.d.ts +94 -0
- package/dist/core/feedback/consolidation-prep.js +242 -0
- package/dist/core/feedback/eviction-scorer.d.ts +81 -0
- package/dist/core/feedback/eviction-scorer.js +132 -0
- package/dist/core/feedback/lesson-format.d.ts +79 -0
- package/dist/core/feedback/lesson-format.js +194 -0
- package/dist/core/feedback/lesson-injection.d.ts +98 -0
- package/dist/core/feedback/lesson-injection.js +159 -0
- package/dist/core/feedback/lesson-merge.d.ts +51 -0
- package/dist/core/feedback/lesson-merge.js +88 -0
- package/dist/core/feedback/lesson-store-overview.d.ts +42 -0
- package/dist/core/feedback/lesson-store-overview.js +38 -0
- package/dist/core/feedback/promotion-gate.d.ts +69 -0
- package/dist/core/feedback/promotion-gate.js +117 -0
- package/dist/core/feedback/regeneralization-prep.d.ts +87 -0
- package/dist/core/feedback/regeneralization-prep.js +139 -0
- package/dist/core/feedback/scope-parser.d.ts +86 -0
- package/dist/core/feedback/scope-parser.js +141 -0
- package/dist/core/injection-policy.d.ts +82 -0
- package/dist/core/injection-policy.js +58 -0
- package/dist/core/signal-detector.d.ts +39 -1
- package/dist/core/signal-detector.js +277 -24
- package/dist/core/today-direct-writer.d.ts +59 -13
- package/dist/core/today-direct-writer.js +90 -13
- package/dist/core/wiki/wiki-fts.js +13 -6
- package/dist/db/feedback-signals-store.d.ts +77 -0
- package/dist/db/feedback-signals-store.js +144 -0
- package/dist/db/migrations.js +50 -0
- package/dist/db/schema.js +43 -6
- package/dist/safety/always-disallowed.d.ts +1 -1
- package/dist/safety/always-disallowed.js +39 -0
- package/dist/safety/risk-classifier.js +22 -7
- package/dist/services/browser-history/automation/egress-denylist.js +18 -2
- package/dist/services/browser-history/lifecycle/platform.js +44 -2
- package/dist/services/mcp/probe.js +30 -8
- package/dist/settings/runtime-settings.d.ts +8 -2
- package/dist/settings/runtime-settings.js +12 -0
- 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, "&")
|
|
50
|
+
.replace(/</g, "<")
|
|
51
|
+
.replace(/>/g, ">")
|
|
52
|
+
.replace(/"/g, """);
|
|
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="true" 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
|
+
}
|