opencode-swarm 7.105.0 → 7.107.0

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 (50) hide show
  1. package/.opencode/skills/critic-gate/SKILL.md +19 -5
  2. package/.opencode/skills/plan/SKILL.md +18 -3
  3. package/.opencode/skills/resume/SKILL.md +4 -1
  4. package/.opencode/skills/writing-tests/SKILL.md +31 -0
  5. package/dist/agents/critic.d.ts +1 -1
  6. package/dist/cli/{config-doctor-4dhkq5qe.js → config-doctor-cntnpyvj.js} +2 -2
  7. package/dist/cli/{evidence-summary-service-v9kmv783.js → evidence-summary-service-zhw4rkjb.js} +1 -1
  8. package/dist/cli/{guardrail-explain-9br76zh5.js → guardrail-explain-6xt0pb56.js} +6 -6
  9. package/dist/cli/{guardrail-log-peftzya3.js → guardrail-log-fsc85hwa.js} +3 -3
  10. package/dist/cli/{index-5qzchq7p.js → index-2560cctg.js} +27 -0
  11. package/dist/cli/{index-k2rmw9sz.js → index-25pev4e7.js} +165 -31
  12. package/dist/cli/{index-hxnnp4qj.js → index-esg1554h.js} +1 -1
  13. package/dist/cli/{index-qnapygnz.js → index-fhw0jm5c.js} +21468 -21240
  14. package/dist/cli/{index-50qqqbrb.js → index-fxtrk7y8.js} +1 -1
  15. package/dist/cli/{index-8mj10cds.js → index-v90bnn0f.js} +7 -7
  16. package/dist/cli/{index-25bc7j87.js → index-w8dzxnx4.js} +1 -1
  17. package/dist/cli/{index-wg8ars51.js → index-wa6at603.js} +2 -2
  18. package/dist/cli/index.js +5 -5
  19. package/dist/cli/{schema-3a02876y.js → schema-vw2ffhe9.js} +1 -1
  20. package/dist/commands/close.d.ts +2 -0
  21. package/dist/commands/reset-session.d.ts +10 -0
  22. package/dist/config/schema.d.ts +28 -0
  23. package/dist/hooks/delegation-gate/worktree-isolation.d.ts +3 -1
  24. package/dist/hooks/guardrails/index.d.ts +3 -1
  25. package/dist/hooks/system-enhancer.d.ts +4 -0
  26. package/dist/index.js +587 -523
  27. package/dist/memory/config.d.ts +48 -0
  28. package/dist/memory/finalize-reward-sweep.d.ts +132 -0
  29. package/dist/memory/gateway.d.ts +0 -1
  30. package/dist/memory/index.d.ts +1 -2
  31. package/dist/memory/injector.d.ts +11 -0
  32. package/dist/memory/local-jsonl-provider.d.ts +3 -1
  33. package/dist/memory/maintenance.d.ts +16 -4
  34. package/dist/memory/provider.d.ts +42 -47
  35. package/dist/memory/q-learning.d.ts +62 -0
  36. package/dist/memory/reward-capture.d.ts +119 -0
  37. package/dist/memory/schema.d.ts +9 -9
  38. package/dist/memory/scoring.d.ts +60 -5
  39. package/dist/memory/sqlite-provider.d.ts +5 -53
  40. package/dist/memory/types.d.ts +25 -5
  41. package/dist/plan/manager.d.ts +15 -1
  42. package/dist/state.d.ts +9 -2
  43. package/dist/tools/convene-council.d.ts +0 -2
  44. package/dist/tools/submit-phase-council-verdicts.d.ts +0 -1
  45. package/dist/tools/swarm-memory-recall.d.ts +2 -0
  46. package/dist/tools/update-task-status.d.ts +1 -0
  47. package/dist/tools/write-final-council-evidence.d.ts +2 -2
  48. package/dist/utils/spec-hash.d.ts +36 -0
  49. package/package.json +1 -1
  50. package/dist/memory/reward.d.ts +0 -39
@@ -58,6 +58,8 @@ export interface MemoryConfig {
58
58
  };
59
59
  latencyBudgetMs: number;
60
60
  };
61
+ /** Q-learning-style utility tracking for memory recall/promotion/suppression. */
62
+ qLearning: QLearningConfig;
61
63
  }
62
64
  export interface ImportanceConfig {
63
65
  wRecency: number;
@@ -70,6 +72,45 @@ export interface ImportanceConfig {
70
72
  /** A memory is low-utility when importance < threshold. */
71
73
  threshold: number;
72
74
  }
75
+ export interface QLearningConfig {
76
+ /** EMA learning rate η for q ← (1-η)·q + η·reward. */
77
+ learningRate: number;
78
+ /** Weight of the q-value term added to recall scoring. */
79
+ qValueBoostWeight: number;
80
+ /** Memories with qValue below this are suppressed from default recall. */
81
+ suppressionThreshold: number;
82
+ /** Memories with qValue above this (and enough retrievals) are promotion candidates. */
83
+ promotionThreshold: number;
84
+ /** Minimum retrieval count before a high-q memory is a promotion candidate. */
85
+ promotionMinRetrievals: number;
86
+ /** Fraction of a reward propagated to closely-related memories. */
87
+ propagationFraction: number;
88
+ /** Max related memories a single reward may propagate to. */
89
+ propagationFanoutCap: number;
90
+ /** Only propagate to memories retrieved within this window (days). */
91
+ propagationWindowDays: number;
92
+ /**
93
+ * Jaccard token-overlap bar for treating two same-scope+kind memories as
94
+ * "related" for reward propagation (B.5). Deliberately HIGH — propagation
95
+ * mutates OTHER memories' learned utility, so wrong bounds = blast radius;
96
+ * it must reach only near-duplicate memories. Strictly higher than
97
+ * consolidation's clustering bar (`DEFAULT_CONSOLIDATION_CONFIG.jaccardThreshold`
98
+ * = 0.30, a more aggressive MERGE operation): incidental template-word
99
+ * overlap between distinct sibling-task memories (e.g. "Recalled by task
100
+ * A." vs "...task B." ≈ 0.6 Jaccard) must NOT count as related, so
101
+ * propagation never cross-pollinates distinct tasks' memories — which
102
+ * coheres with the B.2 unitId attribution the reward path already
103
+ * enforces. Documented judgment call (task B.5 report): deviates upward
104
+ * from the spec's "e.g. 0.5" example toward blast-radius safety.
105
+ */
106
+ propagationRelatednessThreshold: number;
107
+ /** Bounded rate at which suppressed memories are surfaced for exploration. */
108
+ explorationRate: number;
109
+ /** Max bytes of the retained council-synthesis payload (truncated with a marker beyond this). */
110
+ verdictPayloadCapBytes: number;
111
+ /** Neutral starting utility for a new memory. */
112
+ initialQValue: number;
113
+ }
73
114
  export interface ConsolidationConfig {
74
115
  /** Run episodic→semantic consolidation at phase_complete. Gated by `enabled` too. */
75
116
  enabled: boolean;
@@ -105,6 +146,13 @@ export interface MemoryLearningConfig {
105
146
  }
106
147
  export declare const DEFAULT_DECAY_HALF_LIFE_DAYS: Record<MemoryKind, number>;
107
148
  export declare const DEFAULT_IMPORTANCE_CONFIG: ImportanceConfig;
149
+ export declare const DEFAULT_QLEARNING_CONFIG: QLearningConfig;
150
+ /** Council verdict → EMA reward on the [0,1] utility scale (coherence fix; see spec FR-001). */
151
+ export declare const COUNCIL_VERDICT_REWARDS: {
152
+ readonly APPROVE: 1;
153
+ readonly CONCERNS: 0.5;
154
+ readonly REJECT: 0;
155
+ };
108
156
  export declare const DEFAULT_CONSOLIDATION_CONFIG: ConsolidationConfig;
109
157
  export declare const DEFAULT_MEMORY_LEARNING_CONFIG: MemoryLearningConfig;
110
158
  export declare const DEFAULT_EMBEDDINGS_CONFIG: {
@@ -0,0 +1,132 @@
1
+ /**
2
+ * B.6 — Deterministic negative-terminal reward sweep at session finalize.
3
+ *
4
+ * This is the RELIABLE negative learning signal (resolved design decision C-6),
5
+ * the deterministic counterpart to A.4's positive terminal reward
6
+ * (APPROVE→complete→1.0). At `/swarm close`, every task left non-complete is
7
+ * stamped `close_reason='session_terminated'` (see `guaranteeAllPlansComplete`
8
+ * in `src/commands/close.ts`). The memories recalled into those non-completed
9
+ * tasks earn a NEGATIVE terminal reward (0.0) so their learned utility (q-value)
10
+ * drifts DOWN via the shared EMA mechanism and, once it crosses
11
+ * `qLearning.suppressionThreshold`, they are suppressed from future default
12
+ * recall (FR-001 negative terminal; FR-006 makes suppression functional;
13
+ * SC-007 end-to-end).
14
+ *
15
+ * ── Attribution / why we DISCOVER the runId rather than assume the finalize
16
+ * session ──────────────────────────────────────────────────────────────
17
+ * `applyCouncilReward` lists recall bundles by `runId` at the provider level
18
+ * (`listRecallUsage({ runId })`) BEFORE narrowing them by `unitId` in memory.
19
+ * Recall usage is recorded with `runId = context.runId ?? context.sessionID` —
20
+ * i.e. the WORK session that performed the recall. Finalize, however, may run
21
+ * in a DIFFERENT session (plans persist across sessions via the ledger, and
22
+ * `/swarm close` can run in a separate process — the reason close has a
23
+ * cross-process `readEarliestSessionStart` fallback). So passing the finalize
24
+ * session id as `runId` would make the provider-level filter miss the task's
25
+ * bundles entirely and the sweep would be a silent no-op — unacceptable for a
26
+ * "reliable" signal. Passing an empty `runId` (which disables the provider
27
+ * filter) is also wrong: it would let UNTAGGED bundles from unrelated sessions
28
+ * be penalized once per swept task (blast-radius / disjointness violation).
29
+ *
30
+ * Therefore, for each closed task we FIRST discover the real run id(s) that
31
+ * recalled memories into it via `listRecallUsage({ unitId: taskId })` (the B.1
32
+ * bundle-identity join key), then invoke `applyCouncilReward` once per
33
+ * (task, runId) with `reward = 0.0` and `unitId = taskId`. Within each runId,
34
+ * `applyCouncilReward`'s unitId narrowing keeps this task's tagged bundles plus
35
+ * that run's untagged bundles (the documented B.2 run_id fallback) — exactly
36
+ * symmetric to how A.4 rewards those same bundles on completion.
37
+ *
38
+ * ── Correctness invariants ────────────────────────────────────────────────
39
+ * - Disjointness: only `closedTaskIds` (non-complete tasks) are swept, so a
40
+ * memory recalled into a COMPLETED task is never penalized. When a completed
41
+ * task and a closed task share one work session, the per-runId
42
+ * `applyCouncilReward` call still lists the whole session's bundles but its
43
+ * `unitId` narrowing excludes the completed task's tagged bundle (its
44
+ * `unitId` differs), so the completed task's memory is untouched. A memory
45
+ * recalled into BOTH a completed and a non-completed task legitimately gets
46
+ * +1.0 (A.4) and 0.0 (this sweep) for the two DIFFERENT tasks — not a
47
+ * double-penalty.
48
+ * - Cross-finalize idempotency: the sweep holds NO persistent per-task
49
+ * "rewarded" flag (unlike A.4's `session.taskCouncilApproved.rewarded`). It
50
+ * does not need one — a task closed on a prior finalize already has status
51
+ * `'closed'`, so `guaranteeAllPlansComplete` does not re-close it and it is
52
+ * NOT present in `closedTaskIds` on a subsequent finalize. (Finalize also
53
+ * removes `plan.json`/`plan-ledger.jsonl`, so a re-run short-circuits with an
54
+ * empty task set.) Within a single sweep we additionally dedupe `taskId`.
55
+ * Two intentional multiplicity sources are accepted, both a consequence of
56
+ * reusing the mandated shared reward path rather than a bug:
57
+ * 1. A memory recalled into the SAME closed task across MULTIPLE work
58
+ * sessions receives one 0.0 EMA step PER runId (`applyCouncilReward`
59
+ * dedupes within a runId, not across our per-runId calls). The
60
+ * dominant one-runId-per-task case is exactly A.4-symmetric; the
61
+ * multi-session case is marginally more aggressive.
62
+ * 2. An UNTAGGED (`unit_id == null`) bundle in a runId SHARED by N closed
63
+ * tasks is penalized once PER closed task that shares that runId: each
64
+ * per-task `applyCouncilReward` call re-keeps the same untagged bundle
65
+ * via the documented B.2 run_id fallback (an untagged bundle is kept
66
+ * regardless of which task's unitId is being narrowed for), so a
67
+ * memory recalled only in that untagged bundle earns N separate 0.0
68
+ * steps for N closed tasks in the same session, not one.
69
+ * - Persistence ordering: the caller invokes this sweep BEFORE the destructive
70
+ * git alignment stage, and the memory store lives at `.swarm/memory/` (not in
71
+ * finalize's clean allowlists and not touched by align's `dist`-scoped
72
+ * clean), so the reward writes survive finalize.
73
+ * - Non-blocking: the ENTIRE sweep is wrapped in try/catch that logs and
74
+ * continues. A sweep failure NEVER throws into finalize and never alters
75
+ * finalize's task/archival/align behavior — it only records rewards.
76
+ *
77
+ * NOTE: `applyCouncilReward`'s `verdictLabel` option lets this sweep label its
78
+ * reward events with the true reason instead of the misleading default
79
+ * `'APPROVE'`. Sweep events are persisted as
80
+ * `{ verdict: 'session_terminated', reward: 0.0 }` (see the `applyCouncilReward`
81
+ * call below).
82
+ */
83
+ import type { MemoryConfig } from '../config/schema';
84
+ import { createConfiguredMemoryProvider } from './gateway';
85
+ import { applyCouncilReward } from './reward-capture';
86
+ /**
87
+ * Negative terminal reward for memories recalled into non-completed
88
+ * (`session_terminated`) tasks. On the [0,1] utility scale, 0.0 is the minimum:
89
+ * the EMA step `q ← (1-η)·q + η·0` strictly decreases any positive q toward 0.
90
+ */
91
+ export declare const FINALIZE_NEGATIVE_TERMINAL_REWARD = 0;
92
+ export interface FinalizeRewardSweepArgs {
93
+ /** Project root; the memory store lives under `<directory>/.swarm/memory/`. */
94
+ directory: string;
95
+ /**
96
+ * Task ids stamped `close_reason='session_terminated'` this finalize
97
+ * (`ctx.guaranteeResult.closedTaskIds`). Deduped defensively inside.
98
+ */
99
+ closedTaskIds: readonly string[];
100
+ /**
101
+ * Resolved `config.memory` (already parsed via `PluginConfigSchema`). When
102
+ * absent or `enabled !== true` the sweep is a complete no-op.
103
+ */
104
+ memoryConfig: MemoryConfig | undefined;
105
+ /** ISO 8601 timestamp for the reward events; defaults to now. */
106
+ timestamp?: string;
107
+ }
108
+ export interface FinalizeRewardSweepResult {
109
+ /** True once the sweep loop actually ran (memory enabled + tasks present). */
110
+ swept: boolean;
111
+ /** Count of closed tasks that had at least one memory rewarded. */
112
+ tasksSwept: number;
113
+ /** Total EMA steps applied (summed across tasks and their run ids). */
114
+ memoriesRewarded: number;
115
+ /** Count of (task, runId) `applyCouncilReward` invocations performed. */
116
+ runIdsProcessed: number;
117
+ }
118
+ /**
119
+ * Apply the deterministic negative terminal reward (0.0) to memories recalled
120
+ * into the given non-completed tasks. See the module header for the full
121
+ * attribution model and correctness invariants. Never throws.
122
+ */
123
+ export declare function runFinalizeRewardSweep(args: FinalizeRewardSweepArgs): Promise<FinalizeRewardSweepResult>;
124
+ /**
125
+ * DI seam (AGENTS.md invariant 7 — DI over `mock.module`). Tests override these
126
+ * to inject a throwing provider (non-blocking assertion) or to count/observe
127
+ * calls. Restore in `afterEach`.
128
+ */
129
+ export declare const _internals: {
130
+ createConfiguredMemoryProvider: typeof createConfiguredMemoryProvider;
131
+ applyCouncilReward: typeof applyCouncilReward;
132
+ };
@@ -24,7 +24,6 @@ export interface RecallMemoryInput {
24
24
  maxItems?: number;
25
25
  tokenBudget?: number;
26
26
  minScore?: number;
27
- includeLowQ?: boolean;
28
27
  requireQuerySignal?: boolean;
29
28
  includeExpired?: boolean;
30
29
  }
@@ -9,10 +9,9 @@ export { backupLegacyJsonl, getLegacyJsonlFileStatus, type JsonlBackupResult, ty
9
9
  export { LocalJsonlMemoryProvider } from './local-jsonl-provider';
10
10
  export { buildMemoryMaintenanceReport, type MemoryMaintenanceReport, type MemoryMaintenanceReportOptions, type MemoryRecallUsageByMemory, type MemoryRecallUsageByRole, type MemorySupersededChain, shouldCompactMemory, } from './maintenance';
11
11
  export { buildRecallPromptBlock } from './prompt-block';
12
- export type { MemoryCompactOptions, MemoryCompactResult, MemoryProposalStore, MemoryProvider, MemoryRecallRewardInput, MemoryRecallRewardResult, MemoryRecallUsageEvent, MemoryRecallUsageFilter, MemoryTaskOutcome, MemoryValueLogEntry, MemoryValueLogFilter, } from './provider';
12
+ export type { MemoryCompactOptions, MemoryCompactResult, MemoryProposalStore, MemoryProvider, MemoryRecallUsageEvent, MemoryRecallUsageFilter, } from './provider';
13
13
  export { buildMemoryRecallPlan, type MemoryRecallPlan, type MemoryRecallPlannerInput, } from './recall-planner';
14
14
  export { findSecrets, redactSecrets } from './redaction';
15
- export { applyRecallRewardForCouncil, councilVerdictToMemoryOutcome, resolveRewardRunIds, } from './reward';
16
15
  export { MEMORY_RECALL_PROFILES, type MemoryRecallProfile, normalizeMemoryAgentRole, resolveMemoryRecallProfile, } from './role-profiles';
17
16
  export { appendMemoryRunLog, sanitizeRunId } from './run-log';
18
17
  export { computeMemoryContentHash, createBundleId, createMemoryId, createProposalId, isExpired, normalizeMemoryText, validateCuratorMemoryDecision, validateMemoryProposal, validateMemoryRecordRules, } from './schema';
@@ -6,12 +6,23 @@ export interface MemoryLifecycleHookOptions {
6
6
  directory: string;
7
7
  config?: Partial<MemoryConfig>;
8
8
  getActiveAgentName?: (sessionID: string | undefined) => string | undefined;
9
+ /**
10
+ * Resolve the task/phase unit-of-work id (e.g. plan task "1.1") for the
11
+ * session being processed. ADDITIVE join key threaded onto recorded recall
12
+ * bundles (B.1). Injectable seam (mirrors getActiveAgentName) so the injector
13
+ * stays swarmState-free; the production default reads the SAME session's
14
+ * `currentTaskId` in index.ts. MUST resolve only from the recall's OWN session
15
+ * — never a parent/orchestrator — because a wrong id corrupts B.2 attribution
16
+ * (a false join is worse than NULL). Returns undefined when unresolvable.
17
+ */
18
+ getActiveTaskId?: (sessionID: string | undefined) => string | undefined;
9
19
  createGateway?: (context: {
10
20
  directory: string;
11
21
  sessionID?: string;
12
22
  agentRole?: string;
13
23
  agentId?: string;
14
24
  runId?: string;
25
+ unitId?: string;
15
26
  }, options: {
16
27
  config?: Partial<MemoryConfig>;
17
28
  }) => Pick<MemoryGateway, 'isEnabled' | 'deriveAllowedScopes' | 'recall' | 'propose'> & Partial<Pick<MemoryGateway, 'applyCuratorDecision' | 'dispose'>>;
@@ -1,5 +1,5 @@
1
1
  import { type MemoryConfig } from './config';
2
- import type { MemoryCompactOptions, MemoryCompactResult, MemoryProposalStore, MemoryProvider, MemoryRecallUsageEvent, MemoryRecallUsageFilter } from './provider';
2
+ import type { MemoryCompactOptions, MemoryCompactResult, MemoryProposalStore, MemoryProvider, MemoryRecallUsageEvent, MemoryRecallUsageFilter, MemoryRewardEvent, MemoryRewardEventFilter } from './provider';
3
3
  import type { RecallScoringDiagnostics } from './scoring';
4
4
  import type { AppliedMemoryChange, MemoryListFilter, MemoryProposal, MemoryRecord, RecallRequest, RecallResultItem, ResolvedCuratorMemoryDecision } from './types';
5
5
  export declare class LocalJsonlMemoryProvider implements MemoryProvider, MemoryProposalStore {
@@ -22,6 +22,8 @@ export declare class LocalJsonlMemoryProvider implements MemoryProvider, MemoryP
22
22
  }>;
23
23
  recordRecallUsage(event: MemoryRecallUsageEvent): Promise<void>;
24
24
  listRecallUsage(filter?: MemoryRecallUsageFilter): Promise<MemoryRecallUsageEvent[]>;
25
+ appendRewardEvent(event: Omit<MemoryRewardEvent, 'id'>): Promise<void>;
26
+ listRewardEvents(filter?: MemoryRewardEventFilter): Promise<MemoryRewardEvent[]>;
25
27
  list(filter?: MemoryListFilter): Promise<MemoryRecord[]>;
26
28
  createProposal(proposal: MemoryProposal): Promise<MemoryProposal>;
27
29
  listProposals(filter?: {
@@ -1,4 +1,5 @@
1
- import type { MemoryProposalStore, MemoryProvider, MemoryValueLogEntry } from './provider';
1
+ import { type QLearningConfig } from './config';
2
+ import type { MemoryProposalStore, MemoryProvider } from './provider';
2
3
  import { type ImportanceWeights } from './scoring';
3
4
  import type { MemoryProposal, MemoryRecord } from './types';
4
5
  /**
@@ -40,8 +41,18 @@ export interface MemoryMaintenanceReport {
40
41
  supersededMemories: MemoryRecord[];
41
42
  supersededChains: MemorySupersededChain[];
42
43
  lowUtilityMemories: MemoryRecord[];
43
- lowQValueMemories: MemoryValueLogEntry[];
44
- promotionCandidates: MemoryValueLogEntry[];
44
+ /**
45
+ * Memories suppressed for low LEARNED utility (q-value < suppression
46
+ * threshold). Distinct from `lowUtilityMemories`, which is the orthogonal
47
+ * importance-based (recency/frequency/confidence) axis — see FR-006/A-1.
48
+ */
49
+ lowQValueMemories: MemoryRecord[];
50
+ /**
51
+ * Memories whose learned utility is above the promotion threshold AND that
52
+ * have been recalled more than the configured minimum — surfaced as
53
+ * candidates for promotion (not auto-promoted). FR-007.
54
+ */
55
+ promotionCandidates: MemoryRecord[];
45
56
  neverRecalledMemories: MemoryRecord[];
46
57
  mostRecalledMemories: MemoryRecallUsageByMemory[];
47
58
  recallByAgentRole: MemoryRecallUsageByRole[];
@@ -58,10 +69,11 @@ export interface MemoryMaintenanceReportOptions {
58
69
  importanceWeights?: ImportanceWeights;
59
70
  /** A memory is low-utility when importance < threshold. Defaults to DEFAULT_IMPORTANCE_THRESHOLD. */
60
71
  importanceThreshold?: number;
72
+ /** Learned-utility (q-value) thresholds for the low-Q / promotion surfaces. Defaults to DEFAULT_QLEARNING_CONFIG. */
73
+ qLearning?: QLearningConfig;
61
74
  }
62
75
  type ObservableProvider = MemoryProvider & Partial<MemoryProposalStore> & {
63
76
  listRecallUsage?: MemoryProvider['listRecallUsage'];
64
- listMemoryValueLog?: MemoryProvider['listMemoryValueLog'];
65
77
  };
66
78
  export declare function buildMemoryMaintenanceReport(provider: ObservableProvider, options?: MemoryMaintenanceReportOptions): Promise<MemoryMaintenanceReport>;
67
79
  export declare function shouldCompactMemory(memory: MemoryRecord, now?: Date): 'deleted' | 'superseded' | 'expired_scratch' | null;
@@ -14,61 +14,43 @@ export interface MemoryRecallUsageEvent {
14
14
  tokenEstimate: number;
15
15
  agentRole?: string;
16
16
  runId?: string;
17
- qValue?: number;
18
- lastReward?: number;
19
- taskOutcome?: MemoryTaskOutcome;
17
+ /**
18
+ * Task/phase unit-of-work identity (ADDITIVE — recorded alongside `runId`).
19
+ * Undefined when unresolvable at recording time (graceful degrade to
20
+ * session-scoped `runId`).
21
+ */
22
+ unitId?: string;
20
23
  timestamp: string;
21
24
  }
22
25
  export interface MemoryRecallUsageFilter {
23
26
  limit?: number;
24
- }
25
- export type MemoryTaskOutcome = 'approved' | 'rejected' | 'concerns' | 'unknown';
26
- export interface MemoryRecallRewardInput {
27
+ runId?: string;
27
28
  /**
28
- * Candidate session/run identifiers whose recall-usage bundle(s) should
29
- * receive this reward. Every id is matched independently (exact match
30
- * only, no unscoped time-window fallback); all matched bundles are
31
- * rewarded together. Callers should include every session id known to
32
- * have actually recalled memory for this task (e.g. dispatched council
33
- * member sessions), not just the submitting session, so sub-agent
34
- * recalls are not silently skipped.
29
+ * Restrict to rows whose `unit_id` matches. Combined with `runId` the two
30
+ * predicates AND. Attribution prefers this filter and falls back to `runId`.
35
31
  */
36
- runIds: string[];
37
- outcome: MemoryTaskOutcome;
38
- verdictPayload: unknown;
39
- timestamp?: string;
40
- }
41
- export interface MemoryRecallRewardResult {
42
- success: boolean;
43
- /** First matched bundle id, for back-compat display. See `bundleIds` for the full set. */
44
- bundleId?: string;
45
- /** Every recall-usage bundle (across all matched runIds) that received this reward. */
46
- bundleIds?: string[];
47
- outcome: MemoryTaskOutcome;
48
- memoryIds: string[];
49
- updatedMemoryIds: string[];
50
- propagatedMemoryIds: string[];
51
- reward: number;
52
- qValue?: number;
53
- reason?: string;
32
+ unitId?: string;
33
+ /**
34
+ * Restrict recall events to those with `timestamp >= since` (ISO 8601).
35
+ * Used by buildRetrievalRecency to bound iteration over recent events only.
36
+ */
37
+ since?: string;
54
38
  }
55
- export interface MemoryValueLogEntry {
39
+ export interface MemoryRewardEvent {
40
+ id: string;
56
41
  memoryId: string;
57
- kind: MemoryRecord['kind'];
58
- scopeKey: string;
59
- textPreview: string;
60
- qValue: number;
61
- lastReward?: number;
62
- taskOutcome?: MemoryTaskOutcome;
63
- recallCount: number;
64
- lastRecalledAt?: string;
65
- promotionCandidate: boolean;
66
- suppressionCandidate: boolean;
42
+ runId?: string;
43
+ unitId?: string;
44
+ verdict: string;
45
+ reward: number;
46
+ qBefore?: number;
47
+ qAfter?: number;
48
+ verdictSynthesisJson?: string;
49
+ timestamp: string;
67
50
  }
68
- export interface MemoryValueLogFilter {
51
+ export interface MemoryRewardEventFilter {
52
+ memoryId?: string;
69
53
  limit?: number;
70
- includePromotionCandidatesOnly?: boolean;
71
- includeSuppressionCandidatesOnly?: boolean;
72
54
  }
73
55
  export interface MemoryCompactOptions {
74
56
  dryRun?: boolean;
@@ -81,6 +63,11 @@ export interface MemoryCompactResult {
81
63
  removedExpiredScratch: number;
82
64
  remaining: number;
83
65
  }
66
+ /**
67
+ * Lightweight transaction marker. Concrete transaction semantics are
68
+ * backend-specific (SQLite serialised, local-jsonl no-op).
69
+ */
70
+ export type MemoryTransaction = object;
84
71
  export interface MemoryProvider {
85
72
  readonly name: string;
86
73
  initialize?(): Promise<void>;
@@ -92,10 +79,18 @@ export interface MemoryProvider {
92
79
  recallWithDiagnostics?(request: RecallRequest): Promise<MemoryRecallResult>;
93
80
  recordRecallUsage?(event: MemoryRecallUsageEvent): Promise<void>;
94
81
  listRecallUsage?(filter?: MemoryRecallUsageFilter): Promise<MemoryRecallUsageEvent[]>;
95
- applyRecallReward?(input: MemoryRecallRewardInput): Promise<MemoryRecallRewardResult>;
96
- listMemoryValueLog?(filter?: MemoryValueLogFilter): Promise<MemoryValueLogEntry[]>;
82
+ appendRewardEvent?(event: Omit<MemoryRewardEvent, 'id'>): Promise<void>;
83
+ listRewardEvents?(filter?: MemoryRewardEventFilter): Promise<MemoryRewardEvent[]>;
97
84
  compactMaintenance?(options?: MemoryCompactOptions): Promise<MemoryCompactResult>;
98
85
  list(filter: MemoryListFilter): Promise<MemoryRecord[]>;
86
+ /**
87
+ * Run `fn` atomically within a transaction. When the provider does not
88
+ * support transactions (e.g. local-jsonl), this is a no-op that calls
89
+ * `fn` directly. The applyCouncilReward loop uses this to avoid a
90
+ * read-then-update race between concurrent council verdicts on the same
91
+ * memory id.
92
+ */
93
+ withTransaction?<T>(fn: (tx: MemoryTransaction) => Promise<T> | T): Promise<T>;
99
94
  }
100
95
  export interface MemoryProposalStore {
101
96
  createProposal(proposal: MemoryProposal): Promise<MemoryProposal>;
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Pure q-learning-style utility math for memory reward propagation.
3
+ *
4
+ * No I/O, no provider imports — safe to import from any layer (hooks, scoring,
5
+ * tools) without pulling in sqlite/jsonl or council-verdict types. Callers
6
+ * (A.4 reward capture, A.5 scoring) own the wiring; this module only owns
7
+ * the math.
8
+ *
9
+ * `getQValue`/`setQValue` operate on any `MemoryRecord`-shaped object (a
10
+ * structural `{ metadata?: Record<string, unknown> }`), so no runtime or
11
+ * type import of `MemoryRecord` is required here.
12
+ */
13
+ /**
14
+ * EMA update: q ← (1-η)·q + η·reward.
15
+ *
16
+ * A convex combination of two [0,1] inputs stays in [0,1]; the clamp guards
17
+ * against float drift and out-of-range callers (e.g. reward slightly outside
18
+ * [0,1]) rather than relying on that invariant alone.
19
+ *
20
+ * Non-finite guards (a malformed reward, learning rate, or prior must not
21
+ * corrupt the stored q-value): a non-finite `reward` or `eta` falls back to
22
+ * the clamped `qOld`; a non-finite `qOld` (for which `clamp01` would itself
23
+ * yield NaN) falls back to `0`. This branch is unreachable under the intended
24
+ * `getQValue`-first contract (which always yields a finite value in [0,1]).
25
+ */
26
+ export declare function applyEmaUpdate(qOld: number, reward: number, eta: number): number;
27
+ /**
28
+ * B.5 — Propagation EMA step (soft Q-propagation). Applies a fractionally
29
+ * REDUCED reward step to a RELATED memory, reusing the SAME EMA mechanism as a
30
+ * direct reward but scaling the effective learning rate by `fraction`:
31
+ *
32
+ * applyPropagatedEmaUpdate(q, r, η, f) = applyEmaUpdate(q, r, η·f)
33
+ * = q + η·f·(r − q)
34
+ *
35
+ * so a related memory shifts by EXACTLY `fraction` times the shift a direct
36
+ * reward would produce from the same `qOld` (whose shift is η·(r − q)). This is
37
+ * the precise, testable meaning of "the related memory shifts by the fraction"
38
+ * (SC-005). It is algebraically identical to the alternative framing — pulling
39
+ * the reward `fraction` of the way from `qOld` toward `r` and then applying η —
40
+ * because η·(qOld + f·(r − qOld) − qOld) = η·f·(r − qOld).
41
+ *
42
+ * `fraction` is clamped to (0, 1]: a non-finite or ≤0 fraction yields NO shift
43
+ * (returns the clamped `qOld`) — propagation degrades to a no-op rather than
44
+ * corrupting the stored value; a fraction >1 is capped at 1 so a propagated
45
+ * step can never exceed the direct step it derives from (blast-radius guard).
46
+ */
47
+ export declare function applyPropagatedEmaUpdate(qOld: number, reward: number, eta: number, fraction: number): number;
48
+ /**
49
+ * Read a memory record's stored q-value from `metadata.qValue`, falling back
50
+ * to `fallback` when absent, non-numeric, non-finite, or out of [0,1].
51
+ */
52
+ export declare function getQValue(record: {
53
+ metadata?: Record<string, unknown>;
54
+ }, fallback?: number): number;
55
+ /**
56
+ * Return a NEW record-shaped object with `metadata.qValue` set to the
57
+ * clamped value, preserving all other metadata (immutable — does not
58
+ * mutate `record`).
59
+ */
60
+ export declare function setQValue<T extends {
61
+ metadata?: Record<string, unknown>;
62
+ }>(record: T, value: number): T;
@@ -0,0 +1,119 @@
1
+ /**
2
+ * A.4/B.2 — Council reward capture (positive terminal reward only).
3
+ *
4
+ * Closes the memory learning loop: when an APPROVE council verdict advances a
5
+ * task to `complete`, every DISTINCT memory recalled and attributable to that
6
+ * task earns a single EMA reward step toward the terminal reward (1.0 for
7
+ * APPROVE).
8
+ *
9
+ * Design constraints (resolved decision C-6, Phase A; unitId narrowing B.2):
10
+ * - POSITIVE reward only. No negative/REJECT/CONCERNS/max-rounds trigger here;
11
+ * that is the finalize-time sweep (B.6).
12
+ * - unitId-narrowed with run_id fallback: bundles are listed by `runId`
13
+ * (session), then narrowed to the verdict's `opts.unitId` so sibling
14
+ * tasks' recalls in the same session are NOT rewarded for this verdict.
15
+ * A bundle is kept when it is untagged (`bundle.unitId == null`, the
16
+ * legacy/unattributable case), when the verdict itself has no `unitId`
17
+ * (cannot narrow — degrades to full session-scoped reward, today's
18
+ * behavior), or when `bundle.unitId === opts.unitId` (precise match). A
19
+ * bundle is excluded only when both ids are present and differ (a
20
+ * different task's tagged bundle). `unitId` is also recorded on each
21
+ * reward event for audit/attribution.
22
+ * - DISTINCT dedup: a memory recalled in several KEPT bundles this session
23
+ * gets exactly ONE EMA step.
24
+ *
25
+ * This module owns NO error isolation beyond the optional-method (`?.`) guards
26
+ * on capabilities the provider may not implement. The calling HOOK owns
27
+ * try/catch isolation so a reward-capture failure can never affect task
28
+ * completion. (Exception: the B.5 soft-propagation pass below is wrapped in its
29
+ * OWN best-effort try/catch so that a propagation failure can never discard the
30
+ * direct reward that already succeeded — the direct EMA steps are persisted and
31
+ * counted BEFORE propagation runs at all, so they are unaffected by any
32
+ * propagation-time error. Propagation itself is NOT guaranteed all-or-nothing:
33
+ * if a mid-loop `upsert` throws (e.g. after some propagated targets already
34
+ * received their step), the targets updated before the throw keep their
35
+ * propagated step and the remaining scheduled targets are simply never
36
+ * reached — partial propagation is an accepted outcome, per FR-004's "better
37
+ * to under-propagate than over-propagate".)
38
+ *
39
+ * B.5 — soft Q-propagation (FR-004 / SC-005). After each DIRECT reward, a
40
+ * FRACTION of that reward is propagated ONE HOP to closely-related memories so
41
+ * learning generalizes to similar memories, strictly bounded to avoid runaway
42
+ * updates. Relatedness = same scope + same kind + high Jaccard token overlap;
43
+ * only memories retrieved within `propagationWindowDays` (from recall-usage
44
+ * timestamps) are eligible; at most `propagationFanoutCap` per source (top-by-
45
+ * overlap, deterministic); each related memory gets at most ONE propagated
46
+ * step regardless of how many sources reach it; directly-rewarded ids and the
47
+ * source itself are excluded; propagated updates never re-enter propagation.
48
+ * See the constant/helper docs below for the exact formula and thresholds.
49
+ */
50
+ import type { QLearningConfig } from './config';
51
+ import type { MemoryProvider } from './provider';
52
+ export interface CouncilRewardOptions {
53
+ /** Session id — bundles are listed by this `runId`. */
54
+ runId: string;
55
+ /**
56
+ * taskId — narrows bundle attribution (see module header for the exact
57
+ * predicate) and is recorded on reward events for audit/attribution.
58
+ */
59
+ unitId?: string;
60
+ /** Terminal reward on the [0,1] utility scale (1.0 for APPROVE). */
61
+ reward: number;
62
+ /** EMA learning rate η (config.memory.qLearning.learningRate). */
63
+ eta: number;
64
+ /** Neutral fallback q-value for records without a stored qValue. */
65
+ initialQValue: number;
66
+ /**
67
+ * Full q-learning config. B.5 reads the propagation parameters
68
+ * (`propagationFraction`, `propagationFanoutCap`, `propagationWindowDays`)
69
+ * from here. Optional for back-compat with A.4 call sites; defaults to
70
+ * `DEFAULT_QLEARNING_CONFIG` when omitted. `eta`/`initialQValue` above are
71
+ * kept as explicit fields (the direct-reward contract predates this) and are
72
+ * NOT overridden by this config.
73
+ */
74
+ qLearning?: QLearningConfig;
75
+ /** Already-truncated council-synthesis payload (FR-010). */
76
+ verdictSynthesisJson?: string;
77
+ /** ISO 8601 timestamp, caller-supplied. */
78
+ timestamp: string;
79
+ /**
80
+ * True label persisted on the DIRECT reward event's `verdict` field (and,
81
+ * with the `_PROPAGATED` suffix, on any B.5 propagated event it produces).
82
+ * Defaults to `'APPROVE'` when omitted — byte-identical to this module's
83
+ * pre-existing behavior, so A.4 (delegation-gate.ts, which never passes this)
84
+ * is unaffected. Callers with a graded or negative-terminal reward (B.3's
85
+ * phase verdict, B.6's finalize sweep) should pass the reason so the
86
+ * value-log audit is not mislabeled `'APPROVE'` for a REJECT/CONCERNS/
87
+ * session-terminated reward.
88
+ */
89
+ verdictLabel?: string;
90
+ }
91
+ export interface CouncilRewardResult {
92
+ /** Count of distinct memories that received an EMA step. */
93
+ memoriesRewarded: number;
94
+ }
95
+ /**
96
+ * Apply the positive council reward to every distinct memory recalled during
97
+ * the session identified by `opts.runId` that is attributable to
98
+ * `opts.unitId` (with the run_id fallback described in the module header for
99
+ * untagged bundles and unitId-less verdicts).
100
+ *
101
+ * Uses the provider's DIRECT `upsert` (a SYSTEM-level utility update that
102
+ * bypasses the propose/curator flow, like maintenance/compaction). `setQValue`
103
+ * changes only `metadata.qValue`, leaving scope/kind/text (and therefore the
104
+ * record id) unchanged, so `upsert` replaces the record in place.
105
+ */
106
+ export declare function applyCouncilReward(provider: MemoryProvider, opts: CouncilRewardOptions): Promise<CouncilRewardResult>;
107
+ /**
108
+ * FB-003 [MEDIUM]: Truncate a plain object so its JSON representation fits
109
+ * within a byte cap while always producing valid JSON. The source object is
110
+ * truncated before stringification so the output can never be a JSON fragment.
111
+ *
112
+ * Strategy:
113
+ * 1. If the full JSON already fits → return unchanged.
114
+ * 2. If adding a `__truncated__: true` marker still fits → return with marker.
115
+ * 3. Last resort: return a placeholder with metadata about the original size.
116
+ */
117
+ export declare function truncateObjectForJson<T extends Record<string, unknown>>(obj: T, capBytes: number): T & {
118
+ __truncated__?: boolean;
119
+ };