@oscharko-dev/keiko-memory-governance 0.2.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.
package/dist/forget.js ADDED
@@ -0,0 +1,142 @@
1
+ // Forget / delete selection + envelope construction.
2
+ //
3
+ // Two pure functions:
4
+ //
5
+ // selectMemoriesForForget — filters a caller-supplied MemoryRecord array down to the
6
+ // subset matched by a ForgetSelector. Default `protectPinned: true` excludes pinned
7
+ // memories (the user must explicitly unpin first). Default `protectArchived: false`
8
+ // does NOT exclude archived memories (they are already out of active retrieval; a
9
+ // retention sweep is allowed to re-select them).
10
+ //
11
+ // buildForgetOperations — maps each selected MemoryRecord to a MemoryForget envelope.
12
+ // The contracts type literally pins `userAcknowledgedDestructive` to the literal
13
+ // `true`, so the envelope cannot structurally be constructed without acknowledgement.
14
+ // Every envelope is revalidated through validateMemoryForget before returning.
15
+ //
16
+ // The two-stage shape is deliberate: callers can present the selection to the user for
17
+ // confirmation (MemoriaViva UI #211) before materialising the destructive envelopes.
18
+ import { validateMemoryForget } from "@oscharko-dev/keiko-contracts/memory";
19
+ import { GovernanceError } from "./errors.js";
20
+ // ─── Scope coordinate equality ────────────────────────────────────────────────
21
+ // Pure: two scopes match when their discriminator AND coordinate field match exactly.
22
+ // Implemented via a canonical "kind:coordinate" string projection to collapse the
23
+ // per-kind branching (memory pattern from issue #205 scopeCoordinateKey).
24
+ function scopeCoordinateKey(scope) {
25
+ switch (scope.kind) {
26
+ case "user":
27
+ return `user:${scope.userId}`;
28
+ case "workspace":
29
+ return `workspace:${scope.workspaceId}`;
30
+ case "project":
31
+ return `project:${scope.projectId}`;
32
+ case "workflow":
33
+ return `workflow:${scope.workflowDefinitionId}`;
34
+ case "global":
35
+ return "global:";
36
+ default: {
37
+ const _exhaustive = scope;
38
+ void _exhaustive;
39
+ return "unknown:";
40
+ }
41
+ }
42
+ }
43
+ function scopeEquals(a, b) {
44
+ return scopeCoordinateKey(a) === scopeCoordinateKey(b);
45
+ }
46
+ // ─── Per-selector filter predicates ───────────────────────────────────────────
47
+ function matchById(record, selector) {
48
+ return record.id === selector.memoryId;
49
+ }
50
+ function matchByScope(record, selector) {
51
+ return scopeEquals(record.scope, selector.scope);
52
+ }
53
+ function matchByType(record, selector) {
54
+ return scopeEquals(record.scope, selector.scope) && record.type === selector.type;
55
+ }
56
+ function matchBySourceConversation(record, selector) {
57
+ if (!scopeEquals(record.scope, selector.scope))
58
+ return false;
59
+ return record.provenance.sourceConversationId === selector.sourceConversationId;
60
+ }
61
+ function matchByTimeWindow(record, selector, nowMs) {
62
+ if (!scopeEquals(record.scope, selector.scope))
63
+ return false;
64
+ return record.createdAt <= nowMs - selector.olderThanMs;
65
+ }
66
+ function assertSelectorWellFormed(selector) {
67
+ if (selector.kind === "by-time-window") {
68
+ if (!Number.isFinite(selector.olderThanMs) || selector.olderThanMs < 0) {
69
+ throw new GovernanceError("invalid-selector-input", "by-time-window olderThanMs must be a finite non-negative number");
70
+ }
71
+ }
72
+ }
73
+ function applySelector(record, selector, nowMs) {
74
+ switch (selector.kind) {
75
+ case "by-id":
76
+ return matchById(record, selector);
77
+ case "by-scope":
78
+ return matchByScope(record, selector);
79
+ case "by-type":
80
+ return matchByType(record, selector);
81
+ case "by-source-conversation":
82
+ return matchBySourceConversation(record, selector);
83
+ case "by-time-window":
84
+ return matchByTimeWindow(record, selector, nowMs);
85
+ default: {
86
+ // Exhaustiveness gate: a future widening of ForgetSelector surfaces here at
87
+ // compile time and at runtime as an unsupported-selector error.
88
+ const _exhaustive = selector;
89
+ void _exhaustive;
90
+ throw new GovernanceError("unsupported-selector", "unknown ForgetSelector kind");
91
+ }
92
+ }
93
+ }
94
+ // ─── Selection ────────────────────────────────────────────────────────────────
95
+ export function selectMemoriesForForget(memories, selector, options) {
96
+ assertSelectorWellFormed(selector);
97
+ const protectPinned = options.protectPinned ?? true;
98
+ const protectArchived = options.protectArchived ?? false;
99
+ const selected = [];
100
+ for (const record of memories) {
101
+ if (protectPinned && record.pinned)
102
+ continue;
103
+ if (protectArchived && record.status === "archived")
104
+ continue;
105
+ if (record.status === "forgotten")
106
+ continue;
107
+ if (!applySelector(record, selector, options.nowMs))
108
+ continue;
109
+ selected.push(record);
110
+ }
111
+ return selected;
112
+ }
113
+ // ─── Envelope construction ────────────────────────────────────────────────────
114
+ const DEFAULT_FORGET_REASON = "user-requested forget";
115
+ function buildForgetEnvelope(record, context, options) {
116
+ const env = {
117
+ schemaVersion: "1",
118
+ memoryId: record.id,
119
+ reviewerId: context.reviewerId,
120
+ forgottenAt: context.nowMs,
121
+ reason: options.reason ?? DEFAULT_FORGET_REASON,
122
+ userAcknowledgedDestructive: true,
123
+ };
124
+ const v = validateMemoryForget(env);
125
+ if (!v.ok) {
126
+ throw new GovernanceError("envelope-validation-failed", `forget envelope failed contracts validation for memory ${record.id}`, v.errors);
127
+ }
128
+ return env;
129
+ }
130
+ export function buildForgetOperations(memories, context, options) {
131
+ // `writeTombstone` is currently observed only at the storage seam (vault #206 writes
132
+ // an audit tombstone unconditionally). The flag remains on the option bundle as a
133
+ // future-extension surface and as a forcing function for caller intent — the BFF
134
+ // route handler MUST decide consciously whether the destructive operation is
135
+ // tombstone-yielding.
136
+ void options.writeTombstone;
137
+ const envelopes = [];
138
+ for (const record of memories) {
139
+ envelopes.push(buildForgetEnvelope(record, context, options));
140
+ }
141
+ return envelopes;
142
+ }
@@ -0,0 +1,12 @@
1
+ export { KEIKO_MEMORY_GOVERNANCE_VERSION } from "./version.js";
2
+ export type { BuildForgetOperationsOptions, ConflictPair, ConflictReason, ConflictResolution, ForgetSelector, ForgetSelectorKind, GovernanceContext, SelectMemoriesForForgetOptions, StatusTransition, } from "./types.js";
3
+ export { FORGET_SELECTOR_KINDS } from "./types.js";
4
+ export { GovernanceError, type GovernanceErrorCode } from "./errors.js";
5
+ export { buildCorrection, type BuildCorrectionInput, type CorrectionEnvelopes, } from "./correction.js";
6
+ export { buildConflictTransitions, type ConflictTransitionResult, detectConflictPair, } from "./conflict.js";
7
+ export { buildForgetOperations, selectMemoriesForForget } from "./forget.js";
8
+ export { buildExpirationUpdate, supersededValidity } from "./retention.js";
9
+ export { buildArchiveOperation, buildPinOperation, buildUnpinOperation } from "./status-ops.js";
10
+ export { isMemorySuppressedFromRetrieval, type SuppressionOptions, type SuppressionReason, type SuppressionResult, } from "./suppression.js";
11
+ export { effectiveStrength, planMemoryMaintenance, MEMORY_MAINTENANCE_DEFAULTS, type MemoryAccessStatLike, type MemoryMaintenancePlan, type MemoryMaintenancePolicy, type PlanMaintenanceOptions, } from "./maintenance.js";
12
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAYA,OAAO,EAAE,+BAA+B,EAAE,MAAM,cAAc,CAAC;AAG/D,YAAY,EACV,4BAA4B,EAC5B,YAAY,EACZ,cAAc,EACd,kBAAkB,EAClB,cAAc,EACd,kBAAkB,EAClB,iBAAiB,EACjB,8BAA8B,EAC9B,gBAAgB,GACjB,MAAM,YAAY,CAAC;AACpB,OAAO,EAAE,qBAAqB,EAAE,MAAM,YAAY,CAAC;AAGnD,OAAO,EAAE,eAAe,EAAE,KAAK,mBAAmB,EAAE,MAAM,aAAa,CAAC;AAGxE,OAAO,EACL,eAAe,EACf,KAAK,oBAAoB,EACzB,KAAK,mBAAmB,GACzB,MAAM,iBAAiB,CAAC;AAGzB,OAAO,EACL,wBAAwB,EACxB,KAAK,wBAAwB,EAC7B,kBAAkB,GACnB,MAAM,eAAe,CAAC;AAGvB,OAAO,EAAE,qBAAqB,EAAE,uBAAuB,EAAE,MAAM,aAAa,CAAC;AAG7E,OAAO,EAAE,qBAAqB,EAAE,kBAAkB,EAAE,MAAM,gBAAgB,CAAC;AAG3E,OAAO,EAAE,qBAAqB,EAAE,iBAAiB,EAAE,mBAAmB,EAAE,MAAM,iBAAiB,CAAC;AAGhG,OAAO,EACL,+BAA+B,EAC/B,KAAK,kBAAkB,EACvB,KAAK,iBAAiB,EACtB,KAAK,iBAAiB,GACvB,MAAM,kBAAkB,CAAC;AAG1B,OAAO,EACL,iBAAiB,EACjB,qBAAqB,EACrB,2BAA2B,EAC3B,KAAK,oBAAoB,EACzB,KAAK,qBAAqB,EAC1B,KAAK,uBAAuB,EAC5B,KAAK,sBAAsB,GAC5B,MAAM,kBAAkB,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,29 @@
1
+ // Public surface of @oscharko-dev/keiko-memory-governance (Epic #204 child #209).
2
+ // Keeping this file the SOLE entry point prevents downstream packages from reaching into
3
+ // private modules (ADR-0019 trust rule 7). Internal modules are package-private.
4
+ //
5
+ // Every function in this barrel is pure: same input + same `GovernanceContext` =>
6
+ // byte-identical output. The package never reads a clock, never invokes randomness, never
7
+ // touches the filesystem. The caller supplies `nowMs` and a `reviewerId` through
8
+ // `GovernanceContext`. Every returned envelope is REVALIDATED through the
9
+ // `@oscharko-dev/keiko-contracts` validators before being returned; a construction bug
10
+ // surfaces as a `GovernanceError("envelope-validation-failed", …)` rather than letting an
11
+ // invalid envelope cross the API boundary.
12
+ export { KEIKO_MEMORY_GOVERNANCE_VERSION } from "./version.js";
13
+ export { FORGET_SELECTOR_KINDS } from "./types.js";
14
+ // ─── Errors ──────────────────────────────────────────────────────────────────
15
+ export { GovernanceError } from "./errors.js";
16
+ // ─── Correction ──────────────────────────────────────────────────────────────
17
+ export { buildCorrection, } from "./correction.js";
18
+ // ─── Conflict ────────────────────────────────────────────────────────────────
19
+ export { buildConflictTransitions, detectConflictPair, } from "./conflict.js";
20
+ // ─── Forget ──────────────────────────────────────────────────────────────────
21
+ export { buildForgetOperations, selectMemoriesForForget } from "./forget.js";
22
+ // ─── Retention ───────────────────────────────────────────────────────────────
23
+ export { buildExpirationUpdate, supersededValidity } from "./retention.js";
24
+ // ─── Pin / unpin / archive ───────────────────────────────────────────────────
25
+ export { buildArchiveOperation, buildPinOperation, buildUnpinOperation } from "./status-ops.js";
26
+ // ─── Retrieval suppression ───────────────────────────────────────────────────
27
+ export { isMemorySuppressedFromRetrieval, } from "./suppression.js";
28
+ // ─── Maintenance planner (#204) ──────────────────────────────────────────────
29
+ export { effectiveStrength, planMemoryMaintenance, MEMORY_MAINTENANCE_DEFAULTS, } from "./maintenance.js";
@@ -0,0 +1,33 @@
1
+ import type { MemoryId, MemoryRecord } from "@oscharko-dev/keiko-contracts/memory";
2
+ export interface MemoryAccessStatLike {
3
+ readonly lastAccessedAt: number;
4
+ readonly accessCount: number;
5
+ readonly outcomeCount?: number;
6
+ readonly utilitySum?: number;
7
+ }
8
+ export interface MemoryMaintenancePolicy {
9
+ readonly halfLifeMs: number;
10
+ readonly promoteStrength: number;
11
+ readonly archiveMaxStrength: number;
12
+ readonly archiveMinAgeMs: number;
13
+ readonly forgetArchivedMinAgeMs: number;
14
+ readonly forgetProposedMaxStrength: number;
15
+ readonly forgetProposedMinAgeMs: number;
16
+ readonly maxForgetPerRun: number;
17
+ }
18
+ export declare const MEMORY_MAINTENANCE_DEFAULTS: MemoryMaintenancePolicy;
19
+ export interface MemoryMaintenancePlan {
20
+ readonly promote: MemoryId[];
21
+ readonly archive: MemoryId[];
22
+ readonly forget: {
23
+ id: MemoryId;
24
+ reason: string;
25
+ }[];
26
+ }
27
+ export interface PlanMaintenanceOptions {
28
+ readonly nowMs: number;
29
+ readonly policy?: Partial<MemoryMaintenancePolicy>;
30
+ }
31
+ export declare function effectiveStrength(record: MemoryRecord, stat: MemoryAccessStatLike | undefined, nowMs: number, halfLifeMs?: number): number;
32
+ export declare function planMemoryMaintenance(records: readonly MemoryRecord[], accessStats: ReadonlyMap<MemoryId, MemoryAccessStatLike>, options: PlanMaintenanceOptions): MemoryMaintenancePlan;
33
+ //# sourceMappingURL=maintenance.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"maintenance.d.ts","sourceRoot":"","sources":["../src/maintenance.ts"],"names":[],"mappings":"AAsCA,OAAO,KAAK,EAAE,QAAQ,EAAE,YAAY,EAAE,MAAM,sCAAsC,CAAC;AAInF,MAAM,WAAW,oBAAoB;IACnC,QAAQ,CAAC,cAAc,EAAE,MAAM,CAAC;IAChC,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAI7B,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;IAC/B,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC;CAC9B;AAED,MAAM,WAAW,uBAAuB;IACtC,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,eAAe,EAAE,MAAM,CAAC;IACjC,QAAQ,CAAC,kBAAkB,EAAE,MAAM,CAAC;IACpC,QAAQ,CAAC,eAAe,EAAE,MAAM,CAAC;IACjC,QAAQ,CAAC,sBAAsB,EAAE,MAAM,CAAC;IACxC,QAAQ,CAAC,yBAAyB,EAAE,MAAM,CAAC;IAC3C,QAAQ,CAAC,sBAAsB,EAAE,MAAM,CAAC;IACxC,QAAQ,CAAC,eAAe,EAAE,MAAM,CAAC;CAClC;AAID,eAAO,MAAM,2BAA2B,EAAE,uBASzC,CAAC;AAEF,MAAM,WAAW,qBAAqB;IACpC,QAAQ,CAAC,OAAO,EAAE,QAAQ,EAAE,CAAC;IAC7B,QAAQ,CAAC,OAAO,EAAE,QAAQ,EAAE,CAAC;IAC7B,QAAQ,CAAC,MAAM,EAAE;QAAE,EAAE,EAAE,QAAQ,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;CACrD;AAED,MAAM,WAAW,sBAAsB;IACrC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,MAAM,CAAC,EAAE,OAAO,CAAC,uBAAuB,CAAC,CAAC;CACpD;AAgCD,wBAAgB,iBAAiB,CAC/B,MAAM,EAAE,YAAY,EACpB,IAAI,EAAE,oBAAoB,GAAG,SAAS,EACtC,KAAK,EAAE,MAAM,EACb,UAAU,GAAE,MAA+C,GAC1D,MAAM,CAMR;AAsID,wBAAgB,qBAAqB,CACnC,OAAO,EAAE,SAAS,YAAY,EAAE,EAChC,WAAW,EAAE,WAAW,CAAC,QAAQ,EAAE,oBAAoB,CAAC,EACxD,OAAO,EAAE,sBAAsB,GAC9B,qBAAqB,CAiBvB"}
@@ -0,0 +1,180 @@
1
+ // Memory maintenance planner (#204) — the "consolidate + forget" decision engine.
2
+ //
3
+ // PURE: same input + same nowMs => byte-identical plan. No clock reads, no IO, no randomness. The
4
+ // caller (BFF maintenance orchestrator) pre-fetches the records and the access stats, calls this to
5
+ // compute a plan, and applies the plan back to the vault + audit ledger. The split mirrors the
6
+ // consolidation engine: planning is a pure function, application is the impure caller's job.
7
+ //
8
+ // Each record receives AT MOST ONE decision. Priority (highest first): forget > archive > promote.
9
+ // A pinned record is never decayed, archived, or forgotten (its strength is pinned to 1); it may
10
+ // still be promoted since that only strengthens it.
11
+ //
12
+ // Strength model (human-memory analogue):
13
+ // base = provenance.confidence (calibrated [0,1])
14
+ // freqBoost = 1 + 0.15 * ln(1 + accessCount) (recall strengthens)
15
+ // recencyFactor= exp(-ln2 * (now - lastTouch) / HALF_LIFE) (disuse decays; 45-day half-life)
16
+ // utilityFactor= 0.5 + meanOutcomeUtility (outcome-gated; [0.5,1.5], default 1)
17
+ // strength = pinned ? 1 : clamp(base * freqBoost * recencyFactor * utilityFactor, 0, 1)
18
+ // lastTouch is the last access timestamp, falling back to createdAt when never accessed.
19
+ //
20
+ // OUTCOME-DRIVEN FORGETTING (#204, O-V1). Disuse is not the only reason to forget: a memory can be
21
+ // recent and frequently recalled yet keep leading to the WRONG answer. `utilityFactor` folds the
22
+ // mean of a memory's governed retention outcomes (proposal accepted/rejected, conflict won/lost,
23
+ // accepted correction superseding its origin) into the strength: all-bad outcomes (mean 0) halve it
24
+ // so the memory archives/forgets sooner; all-good (mean 1) raise it 1.5x so a proven-useful memory
25
+ // resists disuse decay. With NO outcomes the factor is exactly 1 and the model is byte-identical to
26
+ // the pre-O-V1 curve. The factor is bounded so outcomes shift, but never dominate, the prior signal.
27
+ //
28
+ // CONFIDENCE IS IMMUTABLE PROVENANCE (#204, O-V2). This pass NEVER overwrites provenance.confidence.
29
+ // Confidence is the calibrated veridicality of a memory at capture time and is changed only by an
30
+ // explicit, governed user correction/edit — never by a background job. "Reinforcement" and "decay"
31
+ // are not persisted nudges to confidence (that conflated veridicality with activation, lost the
32
+ // original value, and compounded non-idempotently as 0.6^n). Instead:
33
+ // - reinforcement-on-reuse is realised LIVE at retrieval time via the access-derived strength
34
+ // subscore (keiko-memory-retrieval strength.ts), and
35
+ // - disuse-decay is computed ON THE FLY here through `recencyFactor` inside `effectiveStrength`,
36
+ // which already gates archive/forget. So a faded memory still archives/forgets, but its
37
+ // provenance stays intact and every run is idempotent.
38
+ const DAY_MS = 864e5;
39
+ export const MEMORY_MAINTENANCE_DEFAULTS = {
40
+ halfLifeMs: 45 * DAY_MS,
41
+ promoteStrength: 0.45,
42
+ archiveMaxStrength: 0.2,
43
+ archiveMinAgeMs: 3 * DAY_MS,
44
+ forgetArchivedMinAgeMs: 30 * DAY_MS,
45
+ forgetProposedMaxStrength: 0.1,
46
+ forgetProposedMinAgeMs: 14 * DAY_MS,
47
+ maxForgetPerRun: 25,
48
+ };
49
+ function clamp01(n) {
50
+ if (n < 0)
51
+ return 0;
52
+ if (n > 1)
53
+ return 1;
54
+ return n;
55
+ }
56
+ function recencyFactorOf(record, stat, nowMs, halfLifeMs) {
57
+ // Only a genuine recall advances the recency anchor. An outcome-only row (accessCount 0, written
58
+ // by recordOutcome before the memory was ever recalled) carries no "last use", so recency falls
59
+ // back to createdAt — exactly as a never-tracked memory does. For every real access row
60
+ // (accessCount >= 1) this is byte-identical to the prior `stat.lastAccessedAt ?? createdAt`.
61
+ const lastTouch = stat !== undefined && stat.accessCount > 0 ? stat.lastAccessedAt : record.createdAt;
62
+ return Math.exp((-Math.LN2 * (nowMs - lastTouch)) / halfLifeMs);
63
+ }
64
+ // Outcome-gated utility factor (#204, O-V1). Mean utility of the memory's recorded retention
65
+ // outcomes mapped linearly onto [0.5, 1.5]; no outcomes => exactly 1 (strength model unchanged).
66
+ function utilityFactor(stat) {
67
+ const count = stat?.outcomeCount ?? 0;
68
+ if (count <= 0)
69
+ return 1;
70
+ const meanUtility = clamp01((stat?.utilitySum ?? 0) / count);
71
+ return 0.5 + meanUtility;
72
+ }
73
+ export function effectiveStrength(record, stat, nowMs, halfLifeMs = MEMORY_MAINTENANCE_DEFAULTS.halfLifeMs) {
74
+ if (record.pinned)
75
+ return 1;
76
+ const base = record.provenance.confidence;
77
+ const freqBoost = 1 + 0.15 * Math.log1p(stat?.accessCount ?? 0);
78
+ const recencyFactor = recencyFactorOf(record, stat, nowMs, halfLifeMs);
79
+ return clamp01(base * freqBoost * recencyFactor * utilityFactor(stat));
80
+ }
81
+ function isValidityExpired(record, nowMs) {
82
+ const until = record.validity.validUntil;
83
+ return until !== undefined && until <= nowMs;
84
+ }
85
+ function shouldForget(c, p, nowMs) {
86
+ if (isValidityExpired(c.record, nowMs))
87
+ return "validity-expired";
88
+ // Multi-condition prune guard (#204, O-V5). Hard-deleting an archived memory requires it to be
89
+ // BOTH aged out AND genuinely faint — never age alone. The archive ROUTE accepts any memory
90
+ // regardless of strength (a user can deliberately archive a high-confidence record to
91
+ // de-prioritise it), so age-only pruning would silently delete a still-valuable, explicitly-kept
92
+ // memory 30 days on. Reusing `archiveMaxStrength` as the faintness floor keeps the policy
93
+ // symmetric: a record is pruned only once it is at least as faint as the bar that would archive
94
+ // it. Archive (de-prioritise) is not consent to delete; only disuse is.
95
+ if (c.record.status === "archived" &&
96
+ c.ageMs > p.forgetArchivedMinAgeMs &&
97
+ c.strength < p.archiveMaxStrength) {
98
+ return "archived-aged-out";
99
+ }
100
+ if (c.record.status === "proposed" &&
101
+ c.strength < p.forgetProposedMaxStrength &&
102
+ c.accessCount === 0 &&
103
+ c.ageMs > p.forgetProposedMinAgeMs) {
104
+ return "proposed-faint-aged-out";
105
+ }
106
+ return null;
107
+ }
108
+ function shouldArchive(c, p) {
109
+ return (c.record.status === "accepted" &&
110
+ c.strength < p.archiveMaxStrength &&
111
+ c.ageMs > p.archiveMinAgeMs);
112
+ }
113
+ function shouldPromote(c, p) {
114
+ return (c.record.status === "proposed" &&
115
+ c.record.provenance.sensitivity === "public" &&
116
+ c.strength >= p.promoteStrength);
117
+ }
118
+ function decideForLive(c, p, nowMs) {
119
+ if (!c.record.pinned) {
120
+ const forgetReason = shouldForget(c, p, nowMs);
121
+ if (forgetReason !== null)
122
+ return { kind: "forget", reason: forgetReason };
123
+ if (shouldArchive(c, p))
124
+ return { kind: "archive" };
125
+ }
126
+ if (shouldPromote(c, p))
127
+ return { kind: "promote" };
128
+ return { kind: "none" };
129
+ }
130
+ function buildContext(record, stat, nowMs, policy) {
131
+ return {
132
+ record,
133
+ stat,
134
+ strength: effectiveStrength(record, stat, nowMs, policy.halfLifeMs),
135
+ ageMs: nowMs - record.createdAt,
136
+ accessCount: stat?.accessCount ?? 0,
137
+ };
138
+ }
139
+ function applyDecision(acc, c, decision) {
140
+ const id = c.record.id;
141
+ switch (decision.kind) {
142
+ case "forget":
143
+ acc.forgetCandidates.push({ id, reason: decision.reason ?? "forget", strength: c.strength });
144
+ return;
145
+ case "archive":
146
+ acc.archive.push(id);
147
+ return;
148
+ case "promote":
149
+ acc.promote.push(id);
150
+ return;
151
+ case "none":
152
+ return;
153
+ }
154
+ }
155
+ // Forget is bounded per run and ordered by ascending strength so the faintest memories go first.
156
+ // Ties break on id for determinism.
157
+ function boundForget(candidates, maxForgetPerRun) {
158
+ return [...candidates]
159
+ .sort((a, b) => a.strength !== b.strength ? a.strength - b.strength : a.id.localeCompare(b.id))
160
+ .slice(0, maxForgetPerRun)
161
+ .map((c) => ({ id: c.id, reason: c.reason }));
162
+ }
163
+ export function planMemoryMaintenance(records, accessStats, options) {
164
+ const policy = { ...MEMORY_MAINTENANCE_DEFAULTS, ...options.policy };
165
+ const acc = {
166
+ promote: [],
167
+ archive: [],
168
+ forgetCandidates: [],
169
+ };
170
+ for (const record of records) {
171
+ const stat = accessStats.get(record.id);
172
+ const ctx = buildContext(record, stat, options.nowMs, policy);
173
+ applyDecision(acc, ctx, decideForLive(ctx, policy, options.nowMs));
174
+ }
175
+ return {
176
+ promote: acc.promote,
177
+ archive: acc.archive,
178
+ forget: boundForget(acc.forgetCandidates, policy.maxForgetPerRun),
179
+ };
180
+ }
@@ -0,0 +1,5 @@
1
+ import type { MemoryRecord, MemoryUpdate, MemoryValidityInterval } from "@oscharko-dev/keiko-contracts/memory";
2
+ import type { GovernanceContext } from "./types.js";
3
+ export declare function supersededValidity(record: MemoryRecord, nowMs: number): MemoryValidityInterval | null;
4
+ export declare function buildExpirationUpdate(memory: MemoryRecord, newValidUntilMs: number, context: GovernanceContext): MemoryUpdate;
5
+ //# sourceMappingURL=retention.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"retention.d.ts","sourceRoot":"","sources":["../src/retention.ts"],"names":[],"mappings":"AAYA,OAAO,KAAK,EACV,YAAY,EACZ,YAAY,EACZ,sBAAsB,EACvB,MAAM,sCAAsC,CAAC;AAI9C,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAC;AASpD,wBAAgB,kBAAkB,CAChC,MAAM,EAAE,YAAY,EACpB,KAAK,EAAE,MAAM,GACZ,sBAAsB,GAAG,IAAI,CAK/B;AAED,wBAAgB,qBAAqB,CACnC,MAAM,EAAE,YAAY,EACpB,eAAe,EAAE,MAAM,EACvB,OAAO,EAAE,iBAAiB,GACzB,YAAY,CAiCd"}
@@ -0,0 +1,54 @@
1
+ // Expiration / retention update builder.
2
+ //
3
+ // buildExpirationUpdate produces a MemoryUpdate envelope that patches ONLY the validity
4
+ // interval. The semantics are:
5
+ // - validFrom is preserved from the existing record (an expiration update never
6
+ // rewrites the underlying fact's start time).
7
+ // - validUntil is set to the new value supplied by the caller.
8
+ //
9
+ // Throws GovernanceError('invalid-validity-window') if newValidUntilMs <= validFrom,
10
+ // because a zero-or-negative-duration validity is structurally meaningless and would
11
+ // also fail the contracts validateMemoryValidityInterval rule downstream.
12
+ import { validateMemoryUpdate } from "@oscharko-dev/keiko-contracts/memory";
13
+ import { GovernanceError } from "./errors.js";
14
+ // Bi-temporal-lite (#204, C1): the validity interval for a record being SUPERSEDED at `nowMs` — its
15
+ // belief window CLOSED at the moment its replacement takes over. With this, "what did we believe as
16
+ // of date T" is answerable (validFrom <= T AND (validUntil IS NULL OR validUntil > T)) and a closed
17
+ // window drops out of default retrieval automatically. Returns null when closing would not form a
18
+ // valid interval (nowMs <= validFrom) or would EXTEND an already-closed window (existing validUntil
19
+ // <= nowMs), so an already-expired fact is never silently un-expired. Pure; no validation throw —
20
+ // the caller applies it as an additive patch alongside the status transition.
21
+ export function supersededValidity(record, nowMs) {
22
+ const { validFrom, validUntil } = record.validity;
23
+ if (!Number.isFinite(nowMs) || nowMs <= validFrom)
24
+ return null;
25
+ if (validUntil !== undefined && validUntil <= nowMs)
26
+ return null;
27
+ return { validFrom, validUntil: nowMs };
28
+ }
29
+ export function buildExpirationUpdate(memory, newValidUntilMs, context) {
30
+ if (!Number.isFinite(newValidUntilMs)) {
31
+ throw new GovernanceError("invalid-validity-window", "newValidUntilMs must be a finite number");
32
+ }
33
+ if (newValidUntilMs <= memory.validity.validFrom) {
34
+ throw new GovernanceError("invalid-validity-window", "newValidUntilMs must be strictly greater than memory.validity.validFrom", [
35
+ `validFrom: ${String(memory.validity.validFrom)}`,
36
+ `newValidUntilMs: ${String(newValidUntilMs)}`,
37
+ ]);
38
+ }
39
+ const update = {
40
+ schemaVersion: "1",
41
+ memoryId: memory.id,
42
+ reviewerId: context.reviewerId,
43
+ updatedAt: context.nowMs,
44
+ validityPatch: {
45
+ validFrom: memory.validity.validFrom,
46
+ validUntil: newValidUntilMs,
47
+ },
48
+ };
49
+ const v = validateMemoryUpdate(update);
50
+ if (!v.ok) {
51
+ throw new GovernanceError("envelope-validation-failed", "expiration update failed contracts validation", v.errors);
52
+ }
53
+ return update;
54
+ }
@@ -0,0 +1,6 @@
1
+ import type { MemoryArchive, MemoryPin, MemoryRecord, MemoryUnpin } from "@oscharko-dev/keiko-contracts/memory";
2
+ import type { GovernanceContext } from "./types.js";
3
+ export declare function buildPinOperation(memory: MemoryRecord, context: GovernanceContext, reason?: string): MemoryPin;
4
+ export declare function buildUnpinOperation(memory: MemoryRecord, context: GovernanceContext, reason?: string): MemoryUnpin;
5
+ export declare function buildArchiveOperation(memory: MemoryRecord, context: GovernanceContext, reason?: string): MemoryArchive;
6
+ //# sourceMappingURL=status-ops.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"status-ops.d.ts","sourceRoot":"","sources":["../src/status-ops.ts"],"names":[],"mappings":"AAeA,OAAO,KAAK,EACV,aAAa,EACb,SAAS,EACT,YAAY,EACZ,WAAW,EACZ,MAAM,sCAAsC,CAAC;AAS9C,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAC;AAGpD,wBAAgB,iBAAiB,CAC/B,MAAM,EAAE,YAAY,EACpB,OAAO,EAAE,iBAAiB,EAC1B,MAAM,CAAC,EAAE,MAAM,GACd,SAAS,CAsBX;AAGD,wBAAgB,mBAAmB,CACjC,MAAM,EAAE,YAAY,EACpB,OAAO,EAAE,iBAAiB,EAC1B,MAAM,CAAC,EAAE,MAAM,GACd,WAAW,CAsBb;AA0BD,wBAAgB,qBAAqB,CACnC,MAAM,EAAE,YAAY,EACpB,OAAO,EAAE,iBAAiB,EAC1B,MAAM,CAAC,EAAE,MAAM,GACd,aAAa,CAkBf"}
@@ -0,0 +1,86 @@
1
+ // Pin / unpin / archive envelope builders.
2
+ //
3
+ // Each builder is idempotency-rejecting: pinning an already-pinned memory throws
4
+ // GovernanceError("idempotent-noop"), and likewise for unpin/archive. The check happens
5
+ // BEFORE the envelope is constructed so the caller never receives a "valid" envelope
6
+ // representing a no-op. Every emitted envelope is revalidated through the contracts
7
+ // validator (validateMemoryPin / validateMemoryUnpin / validateMemoryArchive) as a
8
+ // defence-in-depth guard against future contract drift.
9
+ //
10
+ // Archive additionally rejects memories whose current status forbids the transition to
11
+ // archived (per MEMORY_STATUS_TRANSITIONS: archive is legal from accepted, superseded,
12
+ // conflicted, expired — and ILLEGAL from proposed, rejected, archived, forgotten). The
13
+ // check is delegated to the same checkStatusTransition helper the conflict layer uses so
14
+ // the two layers agree on transition legality.
15
+ import { checkStatusTransition, validateMemoryArchive, validateMemoryPin, validateMemoryUnpin, } from "@oscharko-dev/keiko-contracts/memory";
16
+ import { GovernanceError } from "./errors.js";
17
+ // ─── Pin ──────────────────────────────────────────────────────────────────────
18
+ export function buildPinOperation(memory, context, reason) {
19
+ if (memory.pinned) {
20
+ throw new GovernanceError("idempotent-noop", `memory ${memory.id} is already pinned`, [
21
+ `memoryId: ${memory.id}`,
22
+ ]);
23
+ }
24
+ const env = {
25
+ schemaVersion: "1",
26
+ memoryId: memory.id,
27
+ reviewerId: context.reviewerId,
28
+ pinnedAt: context.nowMs,
29
+ ...(reason !== undefined ? { reason } : {}),
30
+ };
31
+ const v = validateMemoryPin(env);
32
+ if (!v.ok) {
33
+ throw new GovernanceError("envelope-validation-failed", "pin envelope failed contracts validation", v.errors);
34
+ }
35
+ return env;
36
+ }
37
+ // ─── Unpin ────────────────────────────────────────────────────────────────────
38
+ export function buildUnpinOperation(memory, context, reason) {
39
+ if (!memory.pinned) {
40
+ throw new GovernanceError("idempotent-noop", `memory ${memory.id} is not pinned`, [
41
+ `memoryId: ${memory.id}`,
42
+ ]);
43
+ }
44
+ const env = {
45
+ schemaVersion: "1",
46
+ memoryId: memory.id,
47
+ reviewerId: context.reviewerId,
48
+ unpinnedAt: context.nowMs,
49
+ ...(reason !== undefined ? { reason } : {}),
50
+ };
51
+ const v = validateMemoryUnpin(env);
52
+ if (!v.ok) {
53
+ throw new GovernanceError("envelope-validation-failed", "unpin envelope failed contracts validation", v.errors);
54
+ }
55
+ return env;
56
+ }
57
+ // ─── Archive ──────────────────────────────────────────────────────────────────
58
+ function assertArchivable(memory) {
59
+ if (memory.status === "archived") {
60
+ throw new GovernanceError("idempotent-noop", `memory ${memory.id} is already archived`, [
61
+ `memoryId: ${memory.id}`,
62
+ ]);
63
+ }
64
+ if (memory.status === "forgotten") {
65
+ throw new GovernanceError("memory-not-eligible", `memory ${memory.id} is forgotten and cannot be archived`, [`memoryId: ${memory.id}`, `status: ${memory.status}`]);
66
+ }
67
+ const check = checkStatusTransition(memory.status, "archived");
68
+ if (!check.ok) {
69
+ throw new GovernanceError("illegal-status-transition", check.reason ?? `illegal transition: ${memory.status} -> archived`, [`memoryId: ${memory.id}`, `from: ${memory.status}`, `to: archived`]);
70
+ }
71
+ }
72
+ export function buildArchiveOperation(memory, context, reason) {
73
+ assertArchivable(memory);
74
+ const env = {
75
+ schemaVersion: "1",
76
+ memoryId: memory.id,
77
+ reviewerId: context.reviewerId,
78
+ archivedAt: context.nowMs,
79
+ ...(reason !== undefined ? { reason } : {}),
80
+ };
81
+ const v = validateMemoryArchive(env);
82
+ if (!v.ok) {
83
+ throw new GovernanceError("envelope-validation-failed", "archive envelope failed contracts validation", v.errors);
84
+ }
85
+ return env;
86
+ }
@@ -0,0 +1,11 @@
1
+ import type { MemoryRecord } from "@oscharko-dev/keiko-contracts/memory";
2
+ export type SuppressionReason = "archived" | "forgotten" | "conflicted" | "expired" | "proposed" | "rejected" | "stale-low-confidence";
3
+ export interface SuppressionResult {
4
+ readonly suppressed: boolean;
5
+ readonly reason?: SuppressionReason;
6
+ }
7
+ export interface SuppressionOptions {
8
+ readonly staleConfidenceThreshold?: number;
9
+ }
10
+ export declare function isMemorySuppressedFromRetrieval(memory: MemoryRecord, nowMs: number, options?: SuppressionOptions): SuppressionResult;
11
+ //# sourceMappingURL=suppression.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"suppression.d.ts","sourceRoot":"","sources":["../src/suppression.ts"],"names":[],"mappings":"AAkBA,OAAO,KAAK,EAAE,YAAY,EAAgB,MAAM,sCAAsC,CAAC;AAIvF,MAAM,MAAM,iBAAiB,GACzB,UAAU,GACV,WAAW,GACX,YAAY,GACZ,SAAS,GACT,UAAU,GACV,UAAU,GACV,sBAAsB,CAAC;AAE3B,MAAM,WAAW,iBAAiB;IAChC,QAAQ,CAAC,UAAU,EAAE,OAAO,CAAC;IAC7B,QAAQ,CAAC,MAAM,CAAC,EAAE,iBAAiB,CAAC;CACrC;AAED,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,wBAAwB,CAAC,EAAE,MAAM,CAAC;CAC5C;AA8DD,wBAAgB,+BAA+B,CAC7C,MAAM,EAAE,YAAY,EACpB,KAAK,EAAE,MAAM,EACb,OAAO,GAAE,kBAAuB,GAC/B,iBAAiB,CASnB"}