@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,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Feedback Learning Loop — consolidation pre-step (FEEDBACK_LEARNING_LOOP_DESIGN.md §4).
|
|
3
|
+
*
|
|
4
|
+
* The daemon-side, deterministic half of Stage 2. On the evening-review tick it
|
|
5
|
+
* reads unconsumed `feedback_signals`, groups them by `(scope_type, scope_ref)`,
|
|
6
|
+
* pre-computes each candidate's weighted evidence + promotion verdict and each
|
|
7
|
+
* lessons file's eviction ranking + headroom, and emits a `<feedback_worksheet>`
|
|
8
|
+
* block — exactly as `<journal_skeleton>` / `harvestForGate` blocks are
|
|
9
|
+
* daemon-prepared today. The LLM step then does only the *semantic* work
|
|
10
|
+
* (intent-match merge, contradiction detection, phrasing) and writes via
|
|
11
|
+
* `PATCH /api/context/policies/agent-lessons`, then consumes the worksheet's ids.
|
|
12
|
+
*
|
|
13
|
+
* Two layers, mirroring `journal-skeleton-builder.ts`:
|
|
14
|
+
* - `gatherFeedbackWorksheetScopes(db, …)` — the single DB read (side-effect
|
|
15
|
+
* free); groups pending signals by scope. Cost scales with feedback volume,
|
|
16
|
+
* not agent count (the `idx_feedback_unconsumed` partial index).
|
|
17
|
+
* - `buildFeedbackWorksheet(scopes, …)` — pure markdown/XML composer. Every
|
|
18
|
+
* output byte is a deterministic function of its inputs; the caller supplies
|
|
19
|
+
* each lessons file's current contents so this stays I/O-free and 100%
|
|
20
|
+
* coverable.
|
|
21
|
+
*
|
|
22
|
+
* Phase 2 stored `user` + `agent`; Phase 4 added `agent:<slug>` (the evening-review
|
|
23
|
+
* pre-step now requests it). This module already rendered any lessons scope
|
|
24
|
+
* generically, so Phase 4 was wiring (`scopeTypes` + task-flow), not new logic here.
|
|
25
|
+
*/
|
|
26
|
+
import { getPendingFeedbackSignals, } from "../../db/feedback-signals-store.js";
|
|
27
|
+
import { extractMarkdownSection, LESSON_KINDS, parseLessonsSection, } from "./lesson-format.js";
|
|
28
|
+
import { evaluatePromotion } from "./promotion-gate.js";
|
|
29
|
+
import { enforceCaps, scoreLesson, isLessonStale, DEFAULT_RECENCY_HALFLIFE_DAYS, } from "./eviction-scorer.js";
|
|
30
|
+
import { groupSignalsBySummary } from "./lesson-merge.js";
|
|
31
|
+
import { formatScope, parseScope, scopeKey, scopeSectionSlug, scopeStoreFile, } from "./scope-parser.js";
|
|
32
|
+
/** Fixed entry caps (§6 table) — config carries only the byte caps. */
|
|
33
|
+
export const GLOBAL_LESSON_ENTRY_CAP = 40;
|
|
34
|
+
export const PER_AGENT_LESSON_ENTRY_CAP = 20;
|
|
35
|
+
/** Default ceiling on signals pulled per pass (store caps the query at 500). */
|
|
36
|
+
const DEFAULT_SIGNAL_LIMIT = 400;
|
|
37
|
+
/**
|
|
38
|
+
* Read unconsumed signals for the requested scope types and group them by
|
|
39
|
+
* canonical scope. Each scope type is queried independently (oldest-first
|
|
40
|
+
* within the type) so the per-pass row budget applies *per type*. A single
|
|
41
|
+
* global `LIMIT` over `created_at ASC` would let a backlog of unconsumed
|
|
42
|
+
* `agent_slug` rows occupy the oldest-N window and silently starve the
|
|
43
|
+
* `user`/`agent` scopes — the per-type fetch caps each scope type
|
|
44
|
+
* independently so a busy agent can't crowd out the others. Groups come back in
|
|
45
|
+
* `scopeTypes` order; rows whose `(scope_type, scope_ref)` can't be parsed
|
|
46
|
+
* (defensive — the route + behavioral sink only write valid pairs) are skipped
|
|
47
|
+
* so a bad row never breaks the pass.
|
|
48
|
+
*/
|
|
49
|
+
export function gatherFeedbackWorksheetScopes(db, opts) {
|
|
50
|
+
const limit = opts.limit ?? DEFAULT_SIGNAL_LIMIT;
|
|
51
|
+
const order = [];
|
|
52
|
+
const byKey = new Map();
|
|
53
|
+
for (const scopeType of opts.scopeTypes) {
|
|
54
|
+
const rows = getPendingFeedbackSignals(db, { scopeType, limit });
|
|
55
|
+
for (const row of rows) {
|
|
56
|
+
const scope = parseScope(row.scope_type, row.scope_ref);
|
|
57
|
+
if (!scope)
|
|
58
|
+
continue;
|
|
59
|
+
const key = scopeKey(scope);
|
|
60
|
+
let group = byKey.get(key);
|
|
61
|
+
if (!group) {
|
|
62
|
+
group = { scope, signals: [] };
|
|
63
|
+
byKey.set(key, group);
|
|
64
|
+
order.push(key);
|
|
65
|
+
}
|
|
66
|
+
group.signals.push(row);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return order.map((key) => byKey.get(key));
|
|
70
|
+
}
|
|
71
|
+
/** Resolve per-scope byte/entry caps; `null` for raw (user) + unstored scopes. */
|
|
72
|
+
export function lessonCapsForScope(scope, byteCaps) {
|
|
73
|
+
if (scope.kind === "agent") {
|
|
74
|
+
return { capBytes: byteCaps.global, maxEntries: GLOBAL_LESSON_ENTRY_CAP };
|
|
75
|
+
}
|
|
76
|
+
if (scope.kind === "agent_slug") {
|
|
77
|
+
return {
|
|
78
|
+
capBytes: byteCaps.perAgent,
|
|
79
|
+
maxEntries: PER_AGENT_LESSON_ENTRY_CAP,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
function xmlEscape(value) {
|
|
85
|
+
return value
|
|
86
|
+
.replace(/&/g, "&")
|
|
87
|
+
.replace(/</g, "<")
|
|
88
|
+
.replace(/>/g, ">")
|
|
89
|
+
.replace(/"/g, """);
|
|
90
|
+
}
|
|
91
|
+
function round2(value) {
|
|
92
|
+
return (Math.round(value * 100) / 100).toFixed(2);
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* `store=` attribute for a scope. Stored scopes (user/agent/agent_slug) resolve
|
|
96
|
+
* to a path; v2 scopes surfaced raw (channel/task/integration, not yet stored)
|
|
97
|
+
* render an empty string so the LLM treats them as advisory-only.
|
|
98
|
+
*/
|
|
99
|
+
function storeFileAttr(scope) {
|
|
100
|
+
return scopeStoreFile(scope) ?? "";
|
|
101
|
+
}
|
|
102
|
+
/** Collapse a one-line excerpt of a signal/lesson for an XML text node. */
|
|
103
|
+
function inline(text, max = 300) {
|
|
104
|
+
const flat = text.replace(/\s+/g, " ").trim();
|
|
105
|
+
const clipped = flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
|
|
106
|
+
return xmlEscape(clipped);
|
|
107
|
+
}
|
|
108
|
+
/** Authority ranking for picking a candidate's representative `src=` trailer. */
|
|
109
|
+
const SOURCE_AUTHORITY = {
|
|
110
|
+
explicit: 3,
|
|
111
|
+
self_critique: 2,
|
|
112
|
+
behavioral: 1,
|
|
113
|
+
};
|
|
114
|
+
/** The strongest source across a candidate's contributing signals. */
|
|
115
|
+
function dominantSource(rows) {
|
|
116
|
+
return rows.reduce((best, row) => SOURCE_AUTHORITY[row.source] > SOURCE_AUTHORITY[best] ? row.source : best, "behavioral");
|
|
117
|
+
}
|
|
118
|
+
/** Read a stated lesson `kind` out of a signal's `evidence_json` (the route
|
|
119
|
+
* stores an explicit/self_critique POST's `kind` there), tolerating malformed
|
|
120
|
+
* JSON. */
|
|
121
|
+
function evidenceKind(json) {
|
|
122
|
+
if (!json)
|
|
123
|
+
return null;
|
|
124
|
+
try {
|
|
125
|
+
const parsed = JSON.parse(json);
|
|
126
|
+
return typeof parsed?.kind === "string" && LESSON_KINDS.has(parsed.kind)
|
|
127
|
+
? parsed.kind
|
|
128
|
+
: null;
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Best-effort lesson `kind` for a candidate so the LLM doesn't have to guess
|
|
136
|
+
* the trailer it's told to copy "from the candidate" (task-flow Step 4a): an
|
|
137
|
+
* explicit/self_critique POST's stated `kind` wins, else a `correction`
|
|
138
|
+
* valence maps to `correction`, else `null` (the LLM infers from the prose).
|
|
139
|
+
*/
|
|
140
|
+
function candidateKind(rows) {
|
|
141
|
+
for (const row of rows) {
|
|
142
|
+
const kind = evidenceKind(row.evidence_json);
|
|
143
|
+
if (kind)
|
|
144
|
+
return kind;
|
|
145
|
+
}
|
|
146
|
+
return rows.some((row) => row.valence === "correction") ? "correction" : null;
|
|
147
|
+
}
|
|
148
|
+
function renderLessonsScope(input, opts, out) {
|
|
149
|
+
const label = formatScope(input.scope);
|
|
150
|
+
const storeFile = storeFileAttr(input.scope);
|
|
151
|
+
const section = scopeSectionSlug(input.scope);
|
|
152
|
+
const halfLife = opts.recencyHalfLifeDays ?? DEFAULT_RECENCY_HALFLIFE_DAYS;
|
|
153
|
+
const sectionBody = input.existingFileMd
|
|
154
|
+
? extractMarkdownSection(input.existingFileMd, "Lessons")
|
|
155
|
+
: null;
|
|
156
|
+
const existing = sectionBody ? parseLessonsSection(sectionBody) : [];
|
|
157
|
+
const currentBytes = input.existingFileMd
|
|
158
|
+
? Buffer.byteLength(input.existingFileMd, "utf-8")
|
|
159
|
+
: 0;
|
|
160
|
+
// Eviction ranking: ascending score → rank 1 = evict-first. The plan
|
|
161
|
+
// (post-dedupe) tells the LLM whether the store is already over cap.
|
|
162
|
+
const plan = enforceCaps(existing, { maxBytes: input.caps.capBytes, maxEntries: input.caps.maxEntries }, opts.nowIso, { scopeLabel: label });
|
|
163
|
+
const ranked = [...existing].sort((a, b) => scoreLesson(a, opts.nowIso, undefined, halfLife) -
|
|
164
|
+
scoreLesson(b, opts.nowIso, undefined, halfLife));
|
|
165
|
+
out.push(` <scope label="${xmlEscape(label)}" store="${xmlEscape(storeFile)}" ` +
|
|
166
|
+
`section="${xmlEscape(section)}" mode="lessons" ` +
|
|
167
|
+
`cap_bytes="${input.caps.capBytes}" max_entries="${input.caps.maxEntries}" ` +
|
|
168
|
+
`current_bytes="${currentBytes}" current_entries="${existing.length}" ` +
|
|
169
|
+
`over_cap="${plan.evicted.length > 0}">`);
|
|
170
|
+
if (ranked.length > 0) {
|
|
171
|
+
out.push(` <existing_lessons note="ranked by eviction score; drop any lesson marked stale="true" unless a fresh candidate re-reinforces it; if the section still exceeds the cap after your edits, remove from rank 1 upward then append: ${xmlEscape("- [...N lower-signal lessons omitted — full history in feedback_signals]")}">`);
|
|
172
|
+
ranked.forEach((lesson, idx) => {
|
|
173
|
+
out.push(` <lesson rank="${idx + 1}" score="${round2(scoreLesson(lesson, opts.nowIso, undefined, halfLife))}" ev="${lesson.ev}" kind="${lesson.kind}" last="${lesson.last}" ` +
|
|
174
|
+
`provisional="${lesson.provisional}" ` +
|
|
175
|
+
`stale="${isLessonStale(lesson, opts.nowIso, opts.staleDays)}">` +
|
|
176
|
+
`${inline(lesson.text)}</lesson>`);
|
|
177
|
+
});
|
|
178
|
+
out.push(" </existing_lessons>");
|
|
179
|
+
}
|
|
180
|
+
renderCandidates(input.signals, opts, out, true);
|
|
181
|
+
out.push(" </scope>");
|
|
182
|
+
}
|
|
183
|
+
function renderRawScope(input, opts, out) {
|
|
184
|
+
const label = formatScope(input.scope);
|
|
185
|
+
const storeFile = storeFileAttr(input.scope);
|
|
186
|
+
const section = scopeSectionSlug(input.scope);
|
|
187
|
+
out.push(` <scope label="${xmlEscape(label)}" store="${xmlEscape(storeFile)}" ` +
|
|
188
|
+
`section="${xmlEscape(section)}" mode="raw">`);
|
|
189
|
+
renderCandidates(input.signals, opts, out, false);
|
|
190
|
+
out.push(" </scope>");
|
|
191
|
+
}
|
|
192
|
+
function renderCandidates(signals, opts, out, withVerdict) {
|
|
193
|
+
const groups = groupSignalsBySummary(signals.map((row) => ({ id: row.id, summary: row.summary, row })));
|
|
194
|
+
out.push(" <candidates>");
|
|
195
|
+
for (const group of groups) {
|
|
196
|
+
const ids = group.members.map((member) => member.id).join(",");
|
|
197
|
+
if (withVerdict) {
|
|
198
|
+
const memberRows = group.members.map((member) => member.row);
|
|
199
|
+
const verdict = evaluatePromotion(memberRows.map((row) => ({
|
|
200
|
+
source: row.source,
|
|
201
|
+
valence: row.valence,
|
|
202
|
+
})), opts.promotionThreshold);
|
|
203
|
+
const src = dominantSource(memberRows);
|
|
204
|
+
const kind = candidateKind(memberRows);
|
|
205
|
+
out.push(` <candidate signals="${group.members.length}" ` +
|
|
206
|
+
`weighted_ev="${round2(verdict.weightedEv)}" ` +
|
|
207
|
+
`decision="${verdict.promotable ? "promote" : "hold-provisional"}" ` +
|
|
208
|
+
`conf="${verdict.conf}" src="${src}"` +
|
|
209
|
+
(kind ? ` kind="${kind}"` : "") +
|
|
210
|
+
` reason="${verdict.reason}" ids="${ids}">` +
|
|
211
|
+
`${inline(group.summary)}</candidate>`);
|
|
212
|
+
}
|
|
213
|
+
else {
|
|
214
|
+
out.push(` <candidate signals="${group.members.length}" ids="${ids}">` +
|
|
215
|
+
`${inline(group.summary)}</candidate>`);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
out.push(" </candidates>");
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Compose the `<feedback_worksheet>` block. Returns `null` when there are no
|
|
222
|
+
* signals at all (the caller then stamps nothing — no empty block in the prompt).
|
|
223
|
+
*/
|
|
224
|
+
export function buildFeedbackWorksheet(scopes, opts) {
|
|
225
|
+
const signalIds = scopes.flatMap((scope) => scope.signals.map((signal) => signal.id));
|
|
226
|
+
if (signalIds.length === 0)
|
|
227
|
+
return null;
|
|
228
|
+
const out = [];
|
|
229
|
+
out.push(`<feedback_worksheet generated_at="${xmlEscape(opts.nowIso)}" ` +
|
|
230
|
+
`promotion_threshold="${opts.promotionThreshold}" scopes="${scopes.length}">`);
|
|
231
|
+
for (const scope of scopes) {
|
|
232
|
+
if (scope.caps) {
|
|
233
|
+
renderLessonsScope({ ...scope, caps: scope.caps }, opts, out);
|
|
234
|
+
}
|
|
235
|
+
else {
|
|
236
|
+
renderRawScope(scope, opts, out);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
out.push(` <consume ids="${signalIds.join(",")}" />`);
|
|
240
|
+
out.push("</feedback_worksheet>");
|
|
241
|
+
return { block: out.join("\n"), signalIds, scopeCount: scopes.length };
|
|
242
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Feedback Learning Loop — eviction scorer + cap enforcer (FEEDBACK_LEARNING_LOOP_DESIGN.md §6).
|
|
3
|
+
*
|
|
4
|
+
* A **new** pure-logic module (not `trimBulletEntries`, which is recency
|
|
5
|
+
* top-N with no notion of `ev`/`kind`; not `clearEntriesBefore`, which keys on
|
|
6
|
+
* the *leading* `[date]` not the trailer `last=`). It scores lessons and, when
|
|
7
|
+
* a file is over its per-scope byte/entry cap, evicts the lowest-scored first
|
|
8
|
+
* — provisional + stale go first — emitting an `[...N omitted]` marker.
|
|
9
|
+
*
|
|
10
|
+
* score = w_ev·log(ev+1) + w_recency·decay(last) + w_kind·importance(kind)
|
|
11
|
+
*
|
|
12
|
+
* where importance is `constraint > correction > do-more/do-less > preference`.
|
|
13
|
+
* Provisional lessons carry a fixed penalty so they sort below active peers of
|
|
14
|
+
* equal evidence. Near-duplicates are merged (their `ev` summed) *before*
|
|
15
|
+
* eviction is considered, so a merged lesson is harder to evict, never easier.
|
|
16
|
+
*/
|
|
17
|
+
import { type Lesson, type LessonKind } from "./lesson-format.js";
|
|
18
|
+
export interface EvictionWeights {
|
|
19
|
+
ev: number;
|
|
20
|
+
recency: number;
|
|
21
|
+
kind: number;
|
|
22
|
+
/** Subtracted from a provisional lesson's score so it evicts first. */
|
|
23
|
+
provisionalPenalty: number;
|
|
24
|
+
}
|
|
25
|
+
export declare const DEFAULT_EVICTION_WEIGHTS: EvictionWeights;
|
|
26
|
+
/** Half-life (days) of the recency decay term. */
|
|
27
|
+
export declare const DEFAULT_RECENCY_HALFLIFE_DAYS = 45;
|
|
28
|
+
/** `constraint` > `correction` > `do-more`/`do-less` > `preference`. */
|
|
29
|
+
export declare function kindImportance(kind: LessonKind): number;
|
|
30
|
+
/**
|
|
31
|
+
* Exponential recency decay in `[0, 1]`: `1` for a lesson reinforced today,
|
|
32
|
+
* `0.5` at one half-life, approaching `0` for ancient lessons. A future or
|
|
33
|
+
* unparseable `last` clamps to `1` (treated as fresh — never penalised for a
|
|
34
|
+
* clock/format quirk).
|
|
35
|
+
*/
|
|
36
|
+
export declare function recencyDecay(last: string, nowIso: string, halfLifeDays?: number): number;
|
|
37
|
+
/** Composite eviction score — higher means keep, lower means evict first. */
|
|
38
|
+
export declare function scoreLesson(lesson: Lesson, nowIso: string, weights?: EvictionWeights, halfLifeDays?: number): number;
|
|
39
|
+
/**
|
|
40
|
+
* §4 step 7 staleness test — a lesson is prunable for staleness when its `last`
|
|
41
|
+
* reinforcement predates `now − staleDays` and it is not a durable
|
|
42
|
+
* `constraint`. Shared single source of truth for both worksheet builders
|
|
43
|
+
* (the nightly `consolidation-prep` and the monthly `regeneralization-prep`)
|
|
44
|
+
* so the `stale="…"` flag they stamp can never drift apart.
|
|
45
|
+
*
|
|
46
|
+
* Semantics (kept byte-stable across the two prior local copies):
|
|
47
|
+
* - no horizon configured (`staleDays === undefined`) ⇒ never stale;
|
|
48
|
+
* - `kind=constraint` ⇒ never stale (durable);
|
|
49
|
+
* - an unparseable `last` (or `nowIso`) yields a `NaN` comparison, which is
|
|
50
|
+
* `false` — i.e. never prune on a clock/format quirk. Reuses {@link dateToMs}
|
|
51
|
+
* for the same `YYYY-MM-DD → epoch ms` parse the recency decay uses.
|
|
52
|
+
*/
|
|
53
|
+
export declare function isLessonStale(lesson: Lesson, nowIso: string, staleDays: number | undefined): boolean;
|
|
54
|
+
export interface CapConfig {
|
|
55
|
+
maxBytes: number;
|
|
56
|
+
maxEntries: number;
|
|
57
|
+
}
|
|
58
|
+
export interface EvictionPlan {
|
|
59
|
+
/** Lessons that survive, in eviction-score order (highest first). */
|
|
60
|
+
keep: Lesson[];
|
|
61
|
+
/** Lessons removed to satisfy the cap, lowest-scored first. */
|
|
62
|
+
evicted: Lesson[];
|
|
63
|
+
/** `[...N … omitted]` marker when anything was evicted, else `null`. */
|
|
64
|
+
omittedMarker: string | null;
|
|
65
|
+
/** Serialized byte length of the kept section incl. header + marker. */
|
|
66
|
+
bytes: number;
|
|
67
|
+
}
|
|
68
|
+
export declare function omittedMarker(count: number): string;
|
|
69
|
+
/**
|
|
70
|
+
* Dedupe, score, sort (highest first), then evict the lowest-scored lessons
|
|
71
|
+
* until the section fits both `maxEntries` and `maxBytes`. The byte cap is
|
|
72
|
+
* checked against the *serialized* section (header + bullets + marker) so the
|
|
73
|
+
* measured size matches what lands on disk.
|
|
74
|
+
*
|
|
75
|
+
* Always makes progress when over the byte cap with ≥1 lesson — even a single
|
|
76
|
+
* lesson longer than the cap is reduced to an empty kept set with the marker —
|
|
77
|
+
* so the loop terminates.
|
|
78
|
+
*/
|
|
79
|
+
export declare function enforceCaps(lessons: ReadonlyArray<Lesson>, cap: CapConfig, nowIso: string, opts: {
|
|
80
|
+
scopeLabel: string;
|
|
81
|
+
}, weights?: EvictionWeights, halfLifeDays?: number): EvictionPlan;
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Feedback Learning Loop — eviction scorer + cap enforcer (FEEDBACK_LEARNING_LOOP_DESIGN.md §6).
|
|
3
|
+
*
|
|
4
|
+
* A **new** pure-logic module (not `trimBulletEntries`, which is recency
|
|
5
|
+
* top-N with no notion of `ev`/`kind`; not `clearEntriesBefore`, which keys on
|
|
6
|
+
* the *leading* `[date]` not the trailer `last=`). It scores lessons and, when
|
|
7
|
+
* a file is over its per-scope byte/entry cap, evicts the lowest-scored first
|
|
8
|
+
* — provisional + stale go first — emitting an `[...N omitted]` marker.
|
|
9
|
+
*
|
|
10
|
+
* score = w_ev·log(ev+1) + w_recency·decay(last) + w_kind·importance(kind)
|
|
11
|
+
*
|
|
12
|
+
* where importance is `constraint > correction > do-more/do-less > preference`.
|
|
13
|
+
* Provisional lessons carry a fixed penalty so they sort below active peers of
|
|
14
|
+
* equal evidence. Near-duplicates are merged (their `ev` summed) *before*
|
|
15
|
+
* eviction is considered, so a merged lesson is harder to evict, never easier.
|
|
16
|
+
*/
|
|
17
|
+
import { formatLessonsSection, } from "./lesson-format.js";
|
|
18
|
+
import { dedupeLessons } from "./lesson-merge.js";
|
|
19
|
+
export const DEFAULT_EVICTION_WEIGHTS = {
|
|
20
|
+
ev: 1.0,
|
|
21
|
+
recency: 1.0,
|
|
22
|
+
kind: 0.75,
|
|
23
|
+
provisionalPenalty: 1.0,
|
|
24
|
+
};
|
|
25
|
+
/** Half-life (days) of the recency decay term. */
|
|
26
|
+
export const DEFAULT_RECENCY_HALFLIFE_DAYS = 45;
|
|
27
|
+
const KIND_IMPORTANCE = {
|
|
28
|
+
constraint: 4,
|
|
29
|
+
correction: 3,
|
|
30
|
+
"do-more": 2,
|
|
31
|
+
"do-less": 2,
|
|
32
|
+
preference: 1,
|
|
33
|
+
};
|
|
34
|
+
/** `constraint` > `correction` > `do-more`/`do-less` > `preference`. */
|
|
35
|
+
export function kindImportance(kind) {
|
|
36
|
+
return KIND_IMPORTANCE[kind];
|
|
37
|
+
}
|
|
38
|
+
function dateToMs(date) {
|
|
39
|
+
const ms = Date.parse(`${date.slice(0, 10)}T00:00:00Z`);
|
|
40
|
+
return Number.isFinite(ms) ? ms : NaN;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Exponential recency decay in `[0, 1]`: `1` for a lesson reinforced today,
|
|
44
|
+
* `0.5` at one half-life, approaching `0` for ancient lessons. A future or
|
|
45
|
+
* unparseable `last` clamps to `1` (treated as fresh — never penalised for a
|
|
46
|
+
* clock/format quirk).
|
|
47
|
+
*/
|
|
48
|
+
export function recencyDecay(last, nowIso, halfLifeDays = DEFAULT_RECENCY_HALFLIFE_DAYS) {
|
|
49
|
+
const lastMs = dateToMs(last);
|
|
50
|
+
const nowMs = Date.parse(nowIso);
|
|
51
|
+
if (!Number.isFinite(lastMs) || !Number.isFinite(nowMs))
|
|
52
|
+
return 1;
|
|
53
|
+
const ageDays = (nowMs - lastMs) / 86_400_000;
|
|
54
|
+
if (ageDays <= 0)
|
|
55
|
+
return 1;
|
|
56
|
+
return Math.pow(0.5, ageDays / halfLifeDays);
|
|
57
|
+
}
|
|
58
|
+
/** Composite eviction score — higher means keep, lower means evict first. */
|
|
59
|
+
export function scoreLesson(lesson, nowIso, weights = DEFAULT_EVICTION_WEIGHTS, halfLifeDays = DEFAULT_RECENCY_HALFLIFE_DAYS) {
|
|
60
|
+
const evTerm = weights.ev * Math.log(Math.max(lesson.ev, 0) + 1);
|
|
61
|
+
const recencyTerm = weights.recency * recencyDecay(lesson.last, nowIso, halfLifeDays);
|
|
62
|
+
const kindTerm = weights.kind * kindImportance(lesson.kind);
|
|
63
|
+
const penalty = lesson.provisional ? weights.provisionalPenalty : 0;
|
|
64
|
+
return evTerm + recencyTerm + kindTerm - penalty;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* §4 step 7 staleness test — a lesson is prunable for staleness when its `last`
|
|
68
|
+
* reinforcement predates `now − staleDays` and it is not a durable
|
|
69
|
+
* `constraint`. Shared single source of truth for both worksheet builders
|
|
70
|
+
* (the nightly `consolidation-prep` and the monthly `regeneralization-prep`)
|
|
71
|
+
* so the `stale="…"` flag they stamp can never drift apart.
|
|
72
|
+
*
|
|
73
|
+
* Semantics (kept byte-stable across the two prior local copies):
|
|
74
|
+
* - no horizon configured (`staleDays === undefined`) ⇒ never stale;
|
|
75
|
+
* - `kind=constraint` ⇒ never stale (durable);
|
|
76
|
+
* - an unparseable `last` (or `nowIso`) yields a `NaN` comparison, which is
|
|
77
|
+
* `false` — i.e. never prune on a clock/format quirk. Reuses {@link dateToMs}
|
|
78
|
+
* for the same `YYYY-MM-DD → epoch ms` parse the recency decay uses.
|
|
79
|
+
*/
|
|
80
|
+
export function isLessonStale(lesson, nowIso, staleDays) {
|
|
81
|
+
if (staleDays === undefined || lesson.kind === "constraint")
|
|
82
|
+
return false;
|
|
83
|
+
const lastMs = dateToMs(lesson.last);
|
|
84
|
+
const nowMs = Date.parse(nowIso);
|
|
85
|
+
return (nowMs - lastMs) / 86_400_000 > staleDays;
|
|
86
|
+
}
|
|
87
|
+
export function omittedMarker(count) {
|
|
88
|
+
return `- [...${count} lower-signal lessons omitted — full history in feedback_signals]`;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Dedupe, score, sort (highest first), then evict the lowest-scored lessons
|
|
92
|
+
* until the section fits both `maxEntries` and `maxBytes`. The byte cap is
|
|
93
|
+
* checked against the *serialized* section (header + bullets + marker) so the
|
|
94
|
+
* measured size matches what lands on disk.
|
|
95
|
+
*
|
|
96
|
+
* Always makes progress when over the byte cap with ≥1 lesson — even a single
|
|
97
|
+
* lesson longer than the cap is reduced to an empty kept set with the marker —
|
|
98
|
+
* so the loop terminates.
|
|
99
|
+
*/
|
|
100
|
+
export function enforceCaps(lessons, cap, nowIso, opts, weights = DEFAULT_EVICTION_WEIGHTS, halfLifeDays = DEFAULT_RECENCY_HALFLIFE_DAYS) {
|
|
101
|
+
const deduped = dedupeLessons(lessons);
|
|
102
|
+
const sorted = [...deduped].sort((a, b) => scoreLesson(b, nowIso, weights, halfLifeDays) -
|
|
103
|
+
scoreLesson(a, nowIso, weights, halfLifeDays));
|
|
104
|
+
const evicted = [];
|
|
105
|
+
let keep = sorted;
|
|
106
|
+
// Entry cap first — cheap, and shrinks the byte-cap work.
|
|
107
|
+
if (keep.length > cap.maxEntries) {
|
|
108
|
+
evicted.push(...keep.slice(cap.maxEntries));
|
|
109
|
+
keep = keep.slice(0, cap.maxEntries);
|
|
110
|
+
}
|
|
111
|
+
const sectionOpts = {
|
|
112
|
+
scopeLabel: opts.scopeLabel,
|
|
113
|
+
capBytes: cap.maxBytes,
|
|
114
|
+
maxEntries: cap.maxEntries,
|
|
115
|
+
};
|
|
116
|
+
const measure = (lessonsToMeasure) => Buffer.byteLength(formatLessonsSection(lessonsToMeasure, {
|
|
117
|
+
...sectionOpts,
|
|
118
|
+
omittedMarker: evicted.length > 0 ? omittedMarker(evicted.length) : null,
|
|
119
|
+
}), "utf-8");
|
|
120
|
+
// Byte cap — drop lowest-scored (tail of the sorted array) until it fits.
|
|
121
|
+
while (keep.length > 0 && measure(keep) > cap.maxBytes) {
|
|
122
|
+
evicted.push(keep[keep.length - 1]);
|
|
123
|
+
keep = keep.slice(0, -1);
|
|
124
|
+
}
|
|
125
|
+
const marker = evicted.length > 0 ? omittedMarker(evicted.length) : null;
|
|
126
|
+
return {
|
|
127
|
+
keep,
|
|
128
|
+
evicted,
|
|
129
|
+
omittedMarker: marker,
|
|
130
|
+
bytes: Buffer.byteLength(formatLessonsSection(keep, { ...sectionOpts, omittedMarker: marker }), "utf-8"),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
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 type LessonKind = "preference" | "correction" | "do-more" | "do-less" | "constraint";
|
|
22
|
+
export type LessonSource = "explicit" | "behavioral" | "self_critique";
|
|
23
|
+
export type LessonConfidence = "high" | "medium" | "low";
|
|
24
|
+
export interface Lesson {
|
|
25
|
+
/** Leading `[YYYY-MM-DD]` — creation date, drives age-based pruning. */
|
|
26
|
+
date: string;
|
|
27
|
+
/** Human-readable directive prose, newlines collapsed to single spaces. */
|
|
28
|
+
text: string;
|
|
29
|
+
/** Evidence count (weighted sum, §4 step 4) — drives promotion + eviction. */
|
|
30
|
+
ev: number;
|
|
31
|
+
kind: LessonKind;
|
|
32
|
+
src: LessonSource;
|
|
33
|
+
conf: LessonConfidence;
|
|
34
|
+
/** Last reinforced `YYYY-MM-DD` — staleness pruning keys on this, NOT date. */
|
|
35
|
+
last: string;
|
|
36
|
+
/** Stored but excluded from injection until promoted. */
|
|
37
|
+
provisional: boolean;
|
|
38
|
+
}
|
|
39
|
+
export declare const LESSON_KINDS: ReadonlySet<string>;
|
|
40
|
+
export declare const LESSON_SOURCES: ReadonlySet<string>;
|
|
41
|
+
export declare const LESSON_CONFIDENCES: ReadonlySet<string>;
|
|
42
|
+
/**
|
|
43
|
+
* Extract a single markdown `## <header>` section body from a file, returning
|
|
44
|
+
* the lines between the header and the next `## `/`# ` heading (exclusive),
|
|
45
|
+
* or `null` when the header is absent. CRLF-tolerant.
|
|
46
|
+
*/
|
|
47
|
+
export declare function extractMarkdownSection(md: string, header: string): string | null;
|
|
48
|
+
/**
|
|
49
|
+
* Parse a `## Lessons` section body into typed lessons. Non-lesson lines
|
|
50
|
+
* (blank lines, the `<!-- scope: … -->` header comment, the `[...N omitted]`
|
|
51
|
+
* eviction marker, stray prose) are ignored. Continuation lines (indented)
|
|
52
|
+
* fold into the preceding lesson's prose. Malformed entries degrade to
|
|
53
|
+
* defaults rather than throwing, so a hand-edited file never crashes the
|
|
54
|
+
* nightly pass.
|
|
55
|
+
*/
|
|
56
|
+
export declare function parseLessonsSection(sectionBody: string): Lesson[];
|
|
57
|
+
/**
|
|
58
|
+
* Render one lesson as a markdown bullet with its trailer. Prose stays on the
|
|
59
|
+
* first line; the trailer follows on a 2-space-indented continuation line so
|
|
60
|
+
* it survives `trimBulletEntries` / `clearEntriesBefore` continuation rules.
|
|
61
|
+
*/
|
|
62
|
+
export declare function formatLesson(lesson: Lesson): string;
|
|
63
|
+
/**
|
|
64
|
+
* Render a full `## Lessons` section: the `<!-- scope: … -->` header comment
|
|
65
|
+
* (carrying the cap for at-a-glance review), the lesson bullets, and an
|
|
66
|
+
* optional eviction marker. `scopeLabel` is the {@link formatScope} string.
|
|
67
|
+
*/
|
|
68
|
+
export declare function formatLessonsSection(lessons: ReadonlyArray<Lesson>, opts: {
|
|
69
|
+
scopeLabel: string;
|
|
70
|
+
capBytes: number;
|
|
71
|
+
maxEntries: number;
|
|
72
|
+
omittedMarker?: string | null;
|
|
73
|
+
}): string;
|
|
74
|
+
/** UTF-8 byte length of a serialized lessons section — the cap unit (§6). */
|
|
75
|
+
export declare function lessonsSectionByteLength(lessons: ReadonlyArray<Lesson>, opts: {
|
|
76
|
+
scopeLabel: string;
|
|
77
|
+
capBytes: number;
|
|
78
|
+
maxEntries: number;
|
|
79
|
+
}): number;
|