@oortmem/core 0.1.0 → 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.
@@ -25,9 +25,21 @@ export interface GateDecision {
25
25
  salience: number;
26
26
  /** Probability per memory type; absent types are treated as 0. */
27
27
  types: Partial<Record<MemoryType, number>>;
28
+ /**
29
+ * Probability that the fact is likely to change within weeks (a status, a current version).
30
+ * Optional; a controller that sets it also records a `gate`/`volatile` decision, which is how
31
+ * the stored memory remembers it.
32
+ */
33
+ volatile?: number;
28
34
  decisions: Decision[];
29
35
  }
30
- export type ReconcileAction = 'NOOP' | 'REINFORCE' | 'ADD' | 'UPDATE' | 'SUPERSEDE';
36
+ /**
37
+ * Stage C actions (CON-49). REFINE adds detail to the same fact and rewrites it in place.
38
+ * SUPERSEDE means the situation changed: the old fact was true until the new one starts.
39
+ * CORRECT means the old fact was wrong from the start: it is retracted in transaction time and
40
+ * the correction takes its place in valid time. UPDATE is the earlier name for REFINE.
41
+ */
42
+ export type ReconcileAction = 'NOOP' | 'REINFORCE' | 'ADD' | 'REFINE' | 'SUPERSEDE' | 'CORRECT' | 'UPDATE';
31
43
  /** Stage C: what to do with the observation relative to one candidate. */
32
44
  export interface ReconcileDecision {
33
45
  /** The existing memory this decision concerns; `null` for a standalone ADD. */
@@ -75,6 +87,15 @@ export interface GuardedWriteResult {
75
87
  * silent no-op), so a caller learns nothing about records it cannot see.
76
88
  * Callers never post-filter.
77
89
  */
90
+ /** Filters and shape for `MemoryStore.list`. */
91
+ export interface ListOptions {
92
+ tier?: Tier;
93
+ validOnly?: boolean;
94
+ /** true: pinned memories only. */
95
+ pinned?: boolean;
96
+ /** false: skip reading embeddings (default true). */
97
+ embeddings?: boolean;
98
+ }
78
99
  export interface MemoryStore {
79
100
  /**
80
101
  * Insert or replace the memory with `m.id`. When `m.tier` is not `cold`, any
@@ -121,12 +142,11 @@ export interface MemoryStore {
121
142
  /**
122
143
  * Listing within `grant.read`, newest `updatedAt` first. `validOnly`
123
144
  * defaults to true (only memories for which `isCurrent(m, now)` holds);
124
- * `tier` restricts to one tier.
145
+ * `tier` restricts to one tier and `pinned: true` to pinned memories (both
146
+ * filtered in the store). `embeddings: false` leaves `embedding` unset, for
147
+ * callers that read only text and metadata.
125
148
  */
126
- list(grant: Grant, opts?: {
127
- tier?: Tier;
128
- validOnly?: boolean;
129
- }): Promise<Memory[]>;
149
+ list(grant: Grant, opts?: ListOptions): Promise<Memory[]>;
130
150
  /**
131
151
  * Marks each snapshot's record expired: sets `expiredAt = at`, and
132
152
  * `endReason = "expired"` where it is null. No other field is written.
package/dist/config.d.ts CHANGED
@@ -41,6 +41,7 @@ export declare const OortConfig: z.ZodObject<{
41
41
  lexicalFirst: z.ZodDefault<z.ZodBoolean>;
42
42
  rerankBudgetMs: z.ZodDefault<z.ZodNumber>;
43
43
  queryCacheTtlSec: z.ZodDefault<z.ZodNumber>;
44
+ staleAfterDays: z.ZodDefault<z.ZodNumber>;
44
45
  }, z.core.$strict>>;
45
46
  project: z.ZodDefault<z.ZodObject<{
46
47
  id: z.ZodOptional<z.ZodString>;
package/dist/config.js CHANGED
@@ -64,6 +64,11 @@ const recall = z
64
64
  rerankBudgetMs: z.number().int().min(0).default(400),
65
65
  /** How long a query embedding is reused, in seconds. 0 turns the cache off. */
66
66
  queryCacheTtlSec: z.number().int().min(0).default(600),
67
+ /**
68
+ * Days after which a volatile memory (a status, a current version) that nobody confirmed is
69
+ * marked "may be outdated" when injected, and listed by `oort revalidate`. 0 turns it off.
70
+ */
71
+ staleAfterDays: z.number().int().min(0).default(30),
67
72
  })
68
73
  .default({
69
74
  lexicalK: 50,
@@ -78,6 +83,7 @@ const recall = z
78
83
  lexicalFirst: true,
79
84
  rerankBudgetMs: 400,
80
85
  queryCacheTtlSec: 600,
86
+ staleAfterDays: 30,
81
87
  });
82
88
  const project = z
83
89
  .strictObject({
package/dist/fusion.d.ts CHANGED
@@ -1,6 +1,15 @@
1
1
  import { type ActivationParams } from './activation.js';
2
2
  import type { Hit } from './adapters.js';
3
3
  import { type Memory } from './model.js';
4
+ import type { RecallMode } from './temporal.js';
5
+ /** Which slice of time a recall reads (CON-34). */
6
+ export interface TemporalView {
7
+ mode: RecallMode;
8
+ /** For `as-of`: the instant in valid time. */
9
+ asOf?: string;
10
+ /** For `known-at`: the instant in transaction time. */
11
+ knownAt?: string;
12
+ }
4
13
  /**
5
14
  * Weight of the activation boost: fused = rrf * (1 + ACTIVATION_BOOST * sigmoid(A)). With one RRF
6
15
  * list (k=60) ranks 1 and r differ by (r-1)/61, so a band of B reorders about 61*B ranks (about 3).
@@ -14,6 +23,8 @@ export interface FuseParams {
14
23
  activation: ActivationParams;
15
24
  /** ISO timestamp: fuse over what was true then instead of what is true now. */
16
25
  asOf?: string;
26
+ /** The temporal view; wins over `asOf`. Defaults to current, or as-of `asOf`. */
27
+ view?: TemporalView;
17
28
  }
18
29
  /**
19
30
  * Reciprocal rank fusion: score(id) = Σ over lists of 1 / (k + rank), rank
@@ -22,10 +33,19 @@ export interface FuseParams {
22
33
  export declare function rrf(lists: Hit[][], k?: number): Map<string, number>;
23
34
  /**
24
35
  * Current now (`isCurrent(m, now)`), or, with `asOf`, valid in the world at
25
- * that instant. As-of ignores `expiredAt`: it asks what was true then, not
26
- * what the system currently believes.
36
+ * that instant. As-of ignores `expiredAt` (it asks what was true then, not
37
+ * what the system currently believes), but a corrected fact was never true.
27
38
  */
28
39
  export declare function isValid(m: Memory, now: Date, asOf: string | undefined): boolean;
40
+ /**
41
+ * Validity is a hard filter, per view:
42
+ * - current: true now and still believed (`isCurrent`).
43
+ * - as-of T: true at T in valid time, whatever we learned since; corrected facts never are.
44
+ * - known-at X: what we believed at X: recorded by then, not yet retracted then, and true at X
45
+ * as far as we knew (an end recorded after X was not known yet), corrected facts included.
46
+ * - history: every version of a fact, except corrected ones.
47
+ */
48
+ export declare function isVisible(m: Memory, now: Date, v: TemporalView): boolean;
29
49
  /**
30
50
  * RRF over the first-stage lists, filtered by temporal validity and
31
51
  * scaled by a bounded activation boost. Ids absent from `memories` are
package/dist/fusion.js CHANGED
@@ -36,13 +36,44 @@ export function rrf(lists, k = 60) {
36
36
  }
37
37
  /**
38
38
  * Current now (`isCurrent(m, now)`), or, with `asOf`, valid in the world at
39
- * that instant. As-of ignores `expiredAt`: it asks what was true then, not
40
- * what the system currently believes.
39
+ * that instant. As-of ignores `expiredAt` (it asks what was true then, not
40
+ * what the system currently believes), but a corrected fact was never true.
41
41
  */
42
42
  export function isValid(m, now, asOf) {
43
43
  if (asOf === undefined)
44
44
  return isCurrent(m, now);
45
- return isCurrent({ ...m, expiredAt: null }, new Date(asOf));
45
+ return isVisible(m, now, { mode: 'as-of', asOf });
46
+ }
47
+ const t = (iso) => Date.parse(iso);
48
+ /**
49
+ * Validity is a hard filter, per view:
50
+ * - current: true now and still believed (`isCurrent`).
51
+ * - as-of T: true at T in valid time, whatever we learned since; corrected facts never are.
52
+ * - known-at X: what we believed at X: recorded by then, not yet retracted then, and true at X
53
+ * as far as we knew (an end recorded after X was not known yet), corrected facts included.
54
+ * - history: every version of a fact, except corrected ones.
55
+ */
56
+ export function isVisible(m, now, v) {
57
+ switch (v.mode) {
58
+ case 'current':
59
+ return isCurrent(m, now);
60
+ case 'as-of':
61
+ return (m.endReason !== 'corrected' &&
62
+ isCurrent({ ...m, expiredAt: null }, v.asOf === undefined ? now : new Date(v.asOf)));
63
+ case 'known-at': {
64
+ const x = v.knownAt === undefined ? now.getTime() : t(v.knownAt);
65
+ const retractedLater = m.expiredAt !== null && t(m.expiredAt) > x;
66
+ // Only a supersession or correction sets an end later than the fact was written, so only
67
+ // then was the end unknown at X. An end the fact stated itself (or expiry) always counts.
68
+ const endLearnedLater = retractedLater && (m.endReason === 'superseded' || m.endReason === 'corrected');
69
+ return (t(m.createdAt) <= x &&
70
+ (m.expiredAt === null || retractedLater) &&
71
+ t(m.validFrom) <= x &&
72
+ (m.validTo === null || t(m.validTo) > x || endLearnedLater));
73
+ }
74
+ case 'history':
75
+ return m.endReason !== 'corrected' && t(m.validFrom) <= now.getTime();
76
+ }
46
77
  }
47
78
  /**
48
79
  * RRF over the first-stage lists, filtered by temporal validity and
@@ -51,9 +82,10 @@ export function isValid(m, now, asOf) {
51
82
  */
52
83
  export function fuse(hits, memories, now, p) {
53
84
  const out = [];
85
+ const view = p.view ?? (p.asOf === undefined ? { mode: 'current' } : { mode: 'as-of', asOf: p.asOf });
54
86
  for (const [id, base] of rrf(hits)) {
55
87
  const m = memories.get(id);
56
- if (m === undefined || !isValid(m, now, p.asOf))
88
+ if (m === undefined || !isVisible(m, now, view))
57
89
  continue;
58
90
  const a = activation(m.accessLog, m.salience, now, p.activation);
59
91
  out.push({ id, score: base * activationBoost(a), rrf: base });
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export declare const VERSION = "0.1.0";
1
+ export declare const VERSION = "0.2.0";
2
2
  export * from './model.js';
3
3
  export * from './scope.js';
4
4
  export * from './config.js';
@@ -13,6 +13,8 @@ export * from './gate.js';
13
13
  export * from './recall.js';
14
14
  export * from './relevance.js';
15
15
  export * from './observe.js';
16
+ export * from './temporal.js';
17
+ export * from './volatile.js';
16
18
  export * from './scrub.js';
17
19
  export * from './cap.js';
18
20
  export * from './consolidate.js';
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- export const VERSION = '0.1.0';
1
+ export const VERSION = '0.2.0';
2
2
  export * from './model.js';
3
3
  export * from './scope.js';
4
4
  export * from './config.js';
@@ -13,6 +13,8 @@ export * from './gate.js';
13
13
  export * from './recall.js';
14
14
  export * from './relevance.js';
15
15
  export * from './observe.js';
16
+ export * from './temporal.js';
17
+ export * from './volatile.js';
16
18
  export * from './scrub.js';
17
19
  export * from './cap.js';
18
20
  export * from './consolidate.js';
@@ -207,8 +207,14 @@ export function createMemoryStore(opts = {}) {
207
207
  return scoped(grant.read)
208
208
  .filter((m) => !validOnly || isCurrent(m, now))
209
209
  .filter((m) => listOpts.tier === undefined || m.tier === listOpts.tier)
210
+ .filter((m) => listOpts.pinned !== true || m.pinned)
210
211
  .sort((x, y) => Date.parse(y.updatedAt) - Date.parse(x.updatedAt))
211
- .map((m) => structuredClone(m));
212
+ .map((m) => {
213
+ if (listOpts.embeddings !== false)
214
+ return structuredClone(m);
215
+ const { embedding: _e, ...rest } = m;
216
+ return structuredClone(rest);
217
+ });
212
218
  },
213
219
  expireMany,
214
220
  coldPut: (grant, m) => coldPutMany(grant, [m]),
package/dist/observe.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import type { DecisionController, Embedder, MemoryStore, MemoryWriter, Observation } from './adapters.js';
2
2
  import type { ResolvedConfig } from './config.js';
3
3
  import type { Decision, Scope } from './model.js';
4
- export type MemoryOutcomeKind = 'skipped' | 'reinforced' | 'added' | 'updated' | 'superseded';
4
+ export type MemoryOutcomeKind = 'skipped' | 'reinforced' | 'added' | 'updated' | 'superseded' | 'corrected';
5
5
  /** What happened to one memory the turn produced. */
6
6
  export interface MemoryOutcome {
7
7
  outcome: MemoryOutcomeKind;
package/dist/observe.js CHANGED
@@ -2,7 +2,21 @@ import { randomUUID } from 'node:crypto';
2
2
  import { localGrant } from './grant.js';
3
3
  import { capObservation } from './cap.js';
4
4
  import { scrubObservation } from './scrub.js';
5
+ import { checkWriterDate, factInterval, utcOffset } from './temporal.js';
5
6
  import { tokenizeLexical } from './tokenize.js';
7
+ /** How the writer is asked to author each action; its interface predates REFINE and CORRECT. */
8
+ const WRITER_ACTION = {
9
+ ADD: 'ADD',
10
+ REFINE: 'UPDATE',
11
+ SUPERSEDE: 'SUPERSEDE',
12
+ CORRECT: 'SUPERSEDE',
13
+ };
14
+ const OUTCOME = {
15
+ ADD: 'added',
16
+ REFINE: 'updated',
17
+ SUPERSEDE: 'superseded',
18
+ CORRECT: 'corrected',
19
+ };
6
20
  function countOf(ms) {
7
21
  const counts = {};
8
22
  for (const m of ms)
@@ -49,6 +63,7 @@ async function confirm(store, grant, id, nowIso) {
49
63
  if (m !== null)
50
64
  await store.upsert(grant, { ...m, lastConfirmedAt: nowIso });
51
65
  }
66
+ const DAY_MS = 86_400_000;
52
67
  const GATE_THRESHOLD = 0.5;
53
68
  const HOT_SALIENCE = 0.8;
54
69
  const CANDIDATES = 5;
@@ -89,6 +104,8 @@ function bestOf(rs) {
89
104
  async function apply(a) {
90
105
  const { obs, scope, grant, deps, now, candidates, decisions } = a;
91
106
  const { store } = deps;
107
+ // Decisions from here on are this memory's own (the turn's gate and reconcile come before).
108
+ const firstOwn = decisions.length;
92
109
  let best = bestOf(a.rs);
93
110
  if (best === undefined && a.noopMeansNew === true && a.rs.length > 0) {
94
111
  // Every candidate is unrelated. The writer already said this is a fact worth keeping,
@@ -111,7 +128,7 @@ async function apply(a) {
111
128
  await confirm(store, grant, target.id, now.toISOString());
112
129
  return { outcome: 'reinforced', memoryId: target.id };
113
130
  }
114
- let action = best.action;
131
+ let action = best.action === 'UPDATE' ? 'REFINE' : best.action;
115
132
  let prior = action === 'ADD' ? undefined : candidates.find((c) => c.id === best.candidateId);
116
133
  if (prior !== undefined && (prior.scope.projectId ?? null) !== (scope.projectId ?? null)) {
117
134
  // Candidates include global memories. A project's observation must not end or
@@ -126,7 +143,7 @@ async function apply(a) {
126
143
  prior = undefined;
127
144
  }
128
145
  if (prior !== undefined && a.claimed?.has(prior.id) === true) {
129
- // An earlier memory of this turn already ended this prior: this one stands on its own.
146
+ // An earlier memory of this turn already ended or rewrote this prior: this one stands on its own.
130
147
  decisions.push({
131
148
  stage: 'write',
132
149
  question: 'prior-claimed',
@@ -144,30 +161,78 @@ async function apply(a) {
144
161
  action = 'ADD';
145
162
  if (prior !== undefined)
146
163
  a.claimed?.add(prior.id);
147
- const w = await a.author(action, prior);
164
+ // The turn's time and UTC offset: relative dates are read in the offset the turn was written
165
+ // in. A turn with no usable timestamp starts now: an empty or unparseable validFrom would make
166
+ // the memory never current (and consolidation would evict it).
167
+ const usable = !Number.isNaN(Date.parse(obs.at));
168
+ const turnAt = usable ? new Date(obs.at) : now;
169
+ const offset = usable ? (utcOffset(obs.at) ?? 0) : 0;
170
+ const w = dated(obs, await a.author(WRITER_ACTION[action], prior), turnAt, offset, decisions);
148
171
  const id = randomUUID();
149
172
  const iso = now.toISOString();
150
- // Temporal order of the new fact against the one it replaces. `deep` means it is
151
- // also older than the prior's own predecessor; chains are re-slotted at most one
152
- // step back (deeper out-of-order chains are a Phase 2 concern).
153
- // A turn with no usable timestamp starts now: an empty or unparseable validFrom
154
- // would make the memory never current (and consolidation would evict it).
155
- const validFrom = Number.isNaN(Date.parse(obs.at)) ? iso : obs.at;
156
- const inOrder = prior === undefined || Date.parse(prior.validFrom) <= Date.parse(validFrom);
157
- let pred = null;
158
- if (prior !== undefined && !inOrder && prior.supersedes !== null) {
159
- pred = await store.get(grant, prior.supersedes);
160
- }
161
- const deep = pred !== null && Date.parse(validFrom) < Date.parse(pred.validFrom);
162
- if (prior !== undefined) {
173
+ const writeDecision = {
174
+ stage: 'write',
175
+ question: 'action',
176
+ answer: action,
177
+ probability: best.probability,
178
+ };
179
+ // The fact's own bounds ("until Friday", "starting next week"), resolved in code.
180
+ const bounds = factInterval(w.text, turnAt, offset);
181
+ if (bounds.validFrom !== undefined || bounds.validTo !== undefined) {
163
182
  decisions.push({
164
183
  stage: 'temporal',
165
- question: 'order',
166
- answer: inOrder ? 'in-order' : deep ? 'out-of-order-deep' : 'out-of-order',
184
+ question: 'interval',
185
+ answer: `${bounds.validFrom ?? 'turn'} .. ${bounds.validTo ?? 'open'}`,
167
186
  probability: 1,
168
187
  });
169
188
  }
170
- const memory = {
189
+ // A start, moved back a day before an end it would pass: an end already in the past ("the
190
+ // freeze ran until yesterday") is kept, and the fact is stored already ended.
191
+ const startBefore = (from, to) => to !== null && Date.parse(to) <= Date.parse(from)
192
+ ? new Date(Date.parse(to) - DAY_MS).toISOString()
193
+ : from;
194
+ const validTo = bounds.validTo ?? null;
195
+ const validFrom = startBefore(bounds.validFrom ?? turnAt.toISOString(), validTo);
196
+ if (action === 'REFINE' && prior !== undefined) {
197
+ // Same fact, more detail: rewrite in place. The earlier wording stays in the decisions, and
198
+ // only this turn's own decisions are appended, so the list grows by a few per refine.
199
+ // A cold memory is promoted: the store drops its cold copy, so hydration can't undo this.
200
+ const to = bounds.validTo ?? prior.validTo;
201
+ await store.upsert(grant, {
202
+ ...prior,
203
+ type: w.type,
204
+ text: w.text,
205
+ stub: w.stub,
206
+ entities: w.entities,
207
+ embedding: a.embedding,
208
+ tier: prior.tier === 'cold' ? 'warm' : prior.tier,
209
+ salience: Math.max(prior.salience, a.salience),
210
+ validFrom: startBefore(bounds.validFrom ?? prior.validFrom, to),
211
+ validTo: to,
212
+ lastConfirmedAt: iso,
213
+ updatedAt: iso,
214
+ source: {
215
+ ...prior.source,
216
+ ...(obs.sessionId !== undefined && { sessionId: obs.sessionId }),
217
+ ...(obs.turnRef !== undefined && { turnRef: obs.turnRef }),
218
+ decisions: [
219
+ ...prior.source.decisions,
220
+ ...decisions.slice(0, firstOwn).filter(isVolatileDecision),
221
+ ...best.decisions,
222
+ ...decisions.slice(firstOwn),
223
+ {
224
+ stage: 'refine',
225
+ question: 'previous-text',
226
+ answer: prior.text,
227
+ probability: best.probability,
228
+ },
229
+ writeDecision,
230
+ ],
231
+ },
232
+ });
233
+ return { outcome: 'updated', memoryId: prior.id };
234
+ }
235
+ const base = {
171
236
  id,
172
237
  scope,
173
238
  type: w.type,
@@ -179,7 +244,7 @@ async function apply(a) {
179
244
  tier: a.salience >= HOT_SALIENCE ? 'hot' : 'warm',
180
245
  accessLog: { count: 0, recent: [], compactedSum: 0, compactedAt: null },
181
246
  validFrom,
182
- validTo: null,
247
+ validTo,
183
248
  expiredAt: null,
184
249
  endReason: null,
185
250
  lastConfirmedAt: null,
@@ -190,29 +255,69 @@ async function apply(a) {
190
255
  harness: deps.harness ?? 'unknown',
191
256
  ...(obs.sessionId !== undefined && { sessionId: obs.sessionId }),
192
257
  ...(obs.turnRef !== undefined && { turnRef: obs.turnRef }),
193
- decisions: [
194
- ...decisions,
195
- { stage: 'write', question: 'action', answer: action, probability: best.probability },
196
- ],
258
+ decisions: [],
197
259
  },
198
260
  createdAt: iso,
199
261
  updatedAt: iso,
200
262
  };
201
- // A supersede, update or out-of-order insert changes several records; they land
263
+ const withDecisions = (m) => ({
264
+ ...m,
265
+ source: { ...m.source, decisions: [...decisions, writeDecision] },
266
+ });
267
+ if (action === 'CORRECT' && prior !== undefined) {
268
+ // The prior was never true: retract it in transaction time and put the correction in its
269
+ // place in valid time. Its valid interval is left alone, so as-of reads skip it by reason.
270
+ // The correction keeps bounds its own text states, else takes the prior's.
271
+ const to = bounds.validTo ?? prior.validTo;
272
+ const from = startBefore(bounds.validFrom ?? prior.validFrom, to);
273
+ await store.upsertMany(grant, [
274
+ withDecisions({ ...base, validFrom: from, validTo: to }),
275
+ {
276
+ ...prior,
277
+ expiredAt: iso,
278
+ endReason: 'corrected',
279
+ supersededBy: id,
280
+ updatedAt: iso,
281
+ },
282
+ ]);
283
+ return { outcome: 'corrected', memoryId: id };
284
+ }
285
+ // Temporal order of the new fact against the one it replaces. `deep` means it is
286
+ // also older than the prior's own predecessor; chains are re-slotted at most one
287
+ // step back (deeper out-of-order chains are a Phase 2 concern).
288
+ // Order is the turn's: a turn after the prior that says "since <earlier day>" is an update and
289
+ // becomes current; only a turn that itself arrives late slots in behind the prior.
290
+ const inOrder = prior === undefined || Date.parse(prior.validFrom) <= turnAt.getTime();
291
+ let pred = null;
292
+ if (prior !== undefined && !inOrder && prior.supersedes !== null) {
293
+ pred = await store.get(grant, prior.supersedes);
294
+ }
295
+ const deep = pred !== null && Date.parse(validFrom) < Date.parse(pred.validFrom);
296
+ if (prior !== undefined) {
297
+ decisions.push({
298
+ stage: 'temporal',
299
+ question: 'order',
300
+ answer: inOrder ? 'in-order' : deep ? 'out-of-order-deep' : 'out-of-order',
301
+ probability: 1,
302
+ });
303
+ }
304
+ const memory = withDecisions(base);
305
+ // A supersede or out-of-order insert changes several records; they land
202
306
  // together in one atomic write so a crash cannot leave a half-linked chain.
203
307
  if (prior === undefined) {
204
308
  await store.upsert(grant, memory);
205
309
  }
206
310
  else if (inOrder) {
207
- // Close the prior where the new fact starts, not where we observed it. A fact
208
- // starting in the future leaves the prior current until then (`expiredAt` stays
209
- // null; consolidation expires it once `validTo` has passed).
311
+ // Close the prior where the new fact starts, not where we observed it (but never before
312
+ // the prior's own start). A fact starting in the future leaves the prior current until
313
+ // then (`expiredAt` stays null; consolidation expires it once `validTo` has passed).
314
+ const closeAt = Date.parse(validFrom) > Date.parse(prior.validFrom) ? validFrom : prior.validFrom;
210
315
  await store.upsertMany(grant, [
211
316
  memory,
212
317
  {
213
318
  ...prior,
214
- validTo: validFrom,
215
- expiredAt: Date.parse(validFrom) <= now.getTime() ? iso : null,
319
+ validTo: closeAt,
320
+ expiredAt: Date.parse(closeAt) <= now.getTime() ? iso : null,
216
321
  endReason: 'superseded',
217
322
  supersededBy: id,
218
323
  updatedAt: iso,
@@ -250,8 +355,34 @@ async function apply(a) {
250
355
  ...(pred === null ? [] : [{ ...pred, validTo: validFrom, supersededBy: id, updatedAt: iso }]),
251
356
  ]);
252
357
  }
253
- const outcome = action === 'ADD' ? 'added' : action === 'UPDATE' ? 'updated' : 'superseded';
254
- return { outcome, memoryId: id };
358
+ return { outcome: OUTCOME[action], memoryId: id };
359
+ }
360
+ /** The gate's volatile answer: recorded on the memory, it is what marks a fact as volatile. */
361
+ const isVolatileDecision = (d) => d.stage === 'gate' && d.question === 'volatile';
362
+ /**
363
+ * The writer's absolute date checked against the turn (CON-34): when the turn says "yesterday"
364
+ * and the fact carries one ISO day a day or two off, that day is chrono's reading, not the
365
+ * model's arithmetic. A stated or distant date is kept and the difference logged.
366
+ */
367
+ function dated(obs, w, at, offset, decisions) {
368
+ const r = checkWriterDate(`${obs.userText}\n${obs.assistantText}`, w.text, at, offset);
369
+ if (r.mismatch !== undefined) {
370
+ decisions.push({
371
+ stage: 'temporal',
372
+ question: 'writer-date',
373
+ answer: `kept ${r.mismatch.written} (turn reads ${r.mismatch.resolved})`,
374
+ probability: 1,
375
+ });
376
+ }
377
+ if (r.fixed === undefined)
378
+ return w;
379
+ decisions.push({
380
+ stage: 'temporal',
381
+ question: 'writer-date',
382
+ answer: `${r.fixed.from} -> ${r.fixed.to}`,
383
+ probability: 1,
384
+ });
385
+ return { ...w, text: r.text, stub: w.stub.split(r.fixed.from).join(r.fixed.to) };
255
386
  }
256
387
  /** The same fact twice (whitespace and case aside) is written once. */
257
388
  function dedupe(ms) {
@@ -351,11 +482,14 @@ async function observeMany(obs, scope, grant, deps, g, now) {
351
482
  throw new Error('reconcileMany returned the wrong number of answers');
352
483
  const claimed = new Set();
353
484
  const memories = [];
354
- const all = [...g.decisions];
485
+ // The gate's volatile answer is about the whole turn, not each fact of it: until there is a
486
+ // per-fact question, facts split from a turn carry none (so none is ever marked stale).
487
+ const turnDecisions = g.decisions.filter((d) => !isVolatileDecision(d));
488
+ const all = [...turnDecisions];
355
489
  for (const [i, f] of facts.entries()) {
356
490
  const rs = rss[i];
357
491
  const decisions = [
358
- ...g.decisions,
492
+ ...turnDecisions,
359
493
  { stage: 'write', question: 'fact', answer: `${i + 1}/${facts.length}`, probability: 1 },
360
494
  ...rs.flatMap((r) => r.decisions),
361
495
  ];
@@ -374,7 +508,7 @@ async function observeMany(obs, scope, grant, deps, g, now) {
374
508
  claimed,
375
509
  noopMeansNew: true,
376
510
  }));
377
- all.push(...decisions.slice(g.decisions.length));
511
+ all.push(...decisions.slice(turnDecisions.length));
378
512
  }
379
513
  const head = memories.find((m) => m.outcome !== 'skipped') ?? memories[0];
380
514
  return {
package/dist/recall.d.ts CHANGED
@@ -1,12 +1,28 @@
1
1
  import type { Embedder, MemoryStore, Reranker, ScopeFilter } from './adapters.js';
2
2
  import type { ResolvedConfig } from './config.js';
3
- import type { Memory } from './model.js';
3
+ import { type Memory } from './model.js';
4
+ import { type RecallMode } from './temporal.js';
4
5
  export interface RecallQuery {
5
6
  text: string;
6
7
  scope: ScopeFilter;
7
8
  budgetChars?: number;
8
- /** ISO timestamp: recall what was true then. */
9
+ /** ISO timestamp: recall what was true then (valid time). Implies `mode: 'as-of'`. */
9
10
  asOf?: string;
11
+ /** ISO timestamp: recall what was believed then (transaction time). Implies `mode: 'known-at'`. */
12
+ knownAt?: string;
13
+ /**
14
+ * Which slice of time to read (CON-34): `current` (the default), `as-of` (`asOf`), `history`
15
+ * (every version, along the supersede chain) or `known-at` (`knownAt`). Without a mode, `asOf`
16
+ * or `knownAt`, the mode is read from the query ("on 2 April", "how has … changed").
17
+ */
18
+ mode?: RecallMode;
19
+ }
20
+ /** The temporal view a recall used, and whether it was read from the query. */
21
+ export interface RecallTemporal {
22
+ mode: RecallMode;
23
+ asOf?: string;
24
+ knownAt?: string;
25
+ inferred: boolean;
10
26
  }
11
27
  export interface RecalledMemory extends Memory {
12
28
  explanation: {
@@ -23,6 +39,10 @@ export interface RecalledMemory extends Memory {
23
39
  dense?: number;
24
40
  activation: number;
25
41
  hydrated: boolean;
42
+ /** History mode: found by walking the supersede chain, not by the search. */
43
+ chain?: true;
44
+ /** A volatile fact nobody confirmed within `recall.staleAfterDays`. */
45
+ stale?: true;
26
46
  };
27
47
  }
28
48
  export type Degradation = 'no-embedder' | 'no-reranker' | 'embed-failed' | 'rerank-failed'
@@ -33,7 +53,9 @@ export type Degradation = 'no-embedder' | 'no-reranker' | 'embed-failed' | 'rera
33
53
  /** The reranker did not answer within `recall.rerankBudgetMs`: the fused order stands. */
34
54
  | 'rerank-timeout'
35
55
  /** Reading or promoting a cold copy failed: those memories stay cold, unhydrated. */
36
- | 'hydrate-failed';
56
+ | 'hydrate-failed'
57
+ /** Recording the access failed: the results stand, but their activation was not bumped. */
58
+ | 'access-failed';
37
59
  export interface RecallResult {
38
60
  memories: RecalledMemory[];
39
61
  /** Reserved for the hook layer (gate decision); `recall` never sets it. */
@@ -51,6 +73,8 @@ export interface RecallResult {
51
73
  rerankSkipped?: 'confident';
52
74
  /** The query was not embedded, because the lexical search already had a clear answer. */
53
75
  embedSkipped?: 'lexical-confident';
76
+ /** The temporal view this recall read. Always set by `recall`. */
77
+ temporal?: RecallTemporal;
54
78
  timings: Record<string, number>;
55
79
  }
56
80
  export interface RecallDeps {
@@ -61,10 +85,12 @@ export interface RecallDeps {
61
85
  now?: Date;
62
86
  }
63
87
  /**
64
- * Hybrid recall: lexical + dense first stage, cold hydration, activation-aware
65
- * fusion, optional rerank. Adapter failures degrade, never throw; so do store
66
- * failures in the dense search and in cold hydration (lexical-only, with the
67
- * reason in `degraded`). The lexical search is the floor: its failure rejects.
88
+ * Hybrid recall: lexical + dense first stage, activation-aware fusion, optional
89
+ * rerank, relevance gate, then (only for what is returned, within `budgetChars`
90
+ * when set) cold hydration and the access record. Adapter failures degrade,
91
+ * never throw; so do store failures in the dense search, in cold hydration and
92
+ * in the access record (with the reason in `degraded`). The lexical search is
93
+ * the floor: its failure rejects.
68
94
  */
69
95
  export declare function recall(q: RecallQuery, deps: RecallDeps): Promise<RecallResult>;
70
96
  /**
@@ -73,3 +99,11 @@ export declare function recall(q: RecallQuery, deps: RecallDeps): Promise<Recall
73
99
  * memory stored before a rule existed, written by another tool, or missed by a regression.
74
100
  */
75
101
  export declare function formatForInjection(r: RecallResult, budgetChars: number, now?: Date): string;
102
+ /**
103
+ * The memories `formatForInjection` renders within `budgetChars`, in order (a prefix: whole
104
+ * lines only, stopping at the first that does not fit), with their lines.
105
+ */
106
+ export declare function fitForInjection<M extends RecalledMemory>(memories: M[], budgetChars: number, now?: Date): {
107
+ kept: M[];
108
+ lines: string[];
109
+ };