@oortmem/core 0.1.1 → 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/recall.js CHANGED
@@ -1,22 +1,28 @@
1
1
  import { activation } from './activation.js';
2
- import { fuse, isValid } from './fusion.js';
2
+ import { fuse, isVisible } from './fusion.js';
3
3
  import { localGrant } from './grant.js';
4
4
  import { scrub } from './scrub.js';
5
+ import { isCurrent } from './model.js';
6
+ import { temporalIntent } from './temporal.js';
7
+ import { isStale, lastConfirmed } from './volatile.js';
5
8
  import { CONFIDENT_COVERAGE, CONFIDENT_COVERAGE_GAP, informativeTokens, queryCoverage, } from './relevance.js';
6
9
  const clock = () => performance.now();
7
10
  /**
8
- * Hybrid recall: lexical + dense first stage, cold hydration, activation-aware
9
- * fusion, optional rerank. Adapter failures degrade, never throw; so do store
10
- * failures in the dense search and in cold hydration (lexical-only, with the
11
- * reason in `degraded`). The lexical search is the floor: its failure rejects.
11
+ * Hybrid recall: lexical + dense first stage, activation-aware fusion, optional
12
+ * rerank, relevance gate, then (only for what is returned, within `budgetChars`
13
+ * when set) cold hydration and the access record. Adapter failures degrade,
14
+ * never throw; so do store failures in the dense search, in cold hydration and
15
+ * in the access record (with the reason in `degraded`). The lexical search is
16
+ * the floor: its failure rejects.
12
17
  */
13
18
  export async function recall(q, deps) {
14
19
  const { store, config } = deps;
15
20
  // The store enforces scope; recall reads and hydrates only inside q.scope.
16
21
  const grant = localGrant(q.scope);
17
- // Scrub once: the query text reaches embedders and rerankers, which may be remote.
18
- const text = scrub(q.text).text;
19
22
  const now = deps.now ?? new Date();
23
+ // Scrub once: the query text reaches embedders and rerankers, which may be remote. Temporal
24
+ // words ("on 2 April", "changed over time") pick the view and are not searched for.
25
+ const { view, temporal, text } = viewFor(q, scrub(q.text).text, now);
20
26
  const t0 = clock();
21
27
  const degraded = [];
22
28
  const timings = {
@@ -56,7 +62,7 @@ export async function recall(q, deps) {
56
62
  let embedSkipped;
57
63
  if (deps.embed && config.recall.lexicalFirst) {
58
64
  const first = await lexicalP;
59
- if (await lexicalIsConfident(first, queryTokens, read, now, q.asOf)) {
65
+ if (await lexicalIsConfident(first, queryTokens, read, now, view)) {
60
66
  embedSkipped = 'lexical-confident';
61
67
  }
62
68
  }
@@ -86,52 +92,16 @@ export async function recall(q, deps) {
86
92
  return [];
87
93
  })),
88
94
  ]);
95
+ // Live rows only: a cold row keeps its text, stub and entities, which is all fusion, the gate
96
+ // and the reranker read. Hydration waits until we know what is returned (CON-47).
89
97
  const ids = [...new Set([...lexical, ...dense].map((h) => h.id))];
90
- const coldIds = new Set([...lexical, ...dense].filter((h) => h.tier === 'cold').map((h) => h.id));
91
- const th = clock();
92
- const hydratedIds = new Set();
93
- let hydrateFailed = false;
94
- const loaded = await Promise.all(ids.map(async (id) => {
95
- const live = await read(id);
96
- if (live === null)
97
- return null;
98
- if (live.tier !== 'cold' && !coldIds.has(id))
99
- return live;
100
- try {
101
- const blob = await store.coldGet(grant, id);
102
- // A lost or torn blob: the memory stays cold, lexical-only.
103
- // TODO(Phase 2): re-embed on hydration when an embedder is present.
104
- if (!blob?.embedding)
105
- return live;
106
- const hydrated = {
107
- ...live,
108
- text: blob.text,
109
- stub: blob.stub,
110
- entities: blob.entities,
111
- embedding: blob.embedding,
112
- tier: 'warm',
113
- };
114
- await store.upsert(grant, hydrated);
115
- hydratedIds.add(id);
116
- return hydrated;
117
- }
118
- catch {
119
- hydrateFailed = true;
120
- return live;
121
- }
122
- }));
123
- timings['hydrateMs'] = clock() - th;
124
- if (hydrateFailed)
125
- degraded.push('hydrate-failed');
98
+ const loaded = await Promise.all(ids.map((id) => read(id)));
126
99
  const memories = new Map();
127
100
  for (const m of loaded)
128
101
  if (m !== null)
129
102
  memories.set(m.id, m);
130
103
  const tf = clock();
131
- const fused = fuse([lexical, dense], memories, now, {
132
- activation: config.activation,
133
- ...(q.asOf !== undefined ? { asOf: q.asOf } : {}),
134
- });
104
+ const fused = fuse([lexical, dense], memories, now, { activation: config.activation, view });
135
105
  timings['fuseMs'] = clock() - tf;
136
106
  const coverage = new Map();
137
107
  for (const f of fused) {
@@ -218,14 +188,68 @@ export async function recall(q, deps) {
218
188
  coverage: coverage.get(f.id) ?? 0,
219
189
  ...(ds !== undefined ? { dense: ds } : {}),
220
190
  activation: activation(m.accessLog, m.salience, now, config.activation),
221
- hydrated: hydratedIds.has(f.id),
191
+ hydrated: false,
222
192
  },
223
193
  });
224
194
  }
225
- await store.recordAccess(grant, out.map((m) => m.id), now);
195
+ let result = view.mode === 'history' ? await withChains(out, read, now, view, queryTokens, config) : out;
196
+ for (const m of result) {
197
+ if (isCurrent(m, now) && isStale(m, now, config.recall.staleAfterDays)) {
198
+ m.explanation.stale = true;
199
+ }
200
+ }
201
+ // With a budget, return only what the injection block holds, so nothing cut is touched below.
202
+ if (q.budgetChars !== undefined)
203
+ result = fitForInjection(result, q.budgetChars, now).kept;
204
+ // Touch only what is returned: the search's own hits (not chain-only versions). Cold ones that
205
+ // are current come back to warm; a version that is no longer current is shown from its live row,
206
+ // since consolidate would only send it back to cold.
207
+ const returned = new Set(result.map((m) => m.id));
208
+ const touched = new Set(out.filter((m) => returned.has(m.id)).map((m) => m.id));
209
+ const th = clock();
210
+ let hydrateFailed = false;
211
+ result = await Promise.all(result.map(async (m) => {
212
+ if (!touched.has(m.id) || m.tier !== 'cold' || !isCurrent(m, now))
213
+ return m;
214
+ try {
215
+ const blob = await store.coldGet(grant, m.id);
216
+ // A lost or torn blob: the memory stays cold, lexical-only.
217
+ // TODO(Phase 2): re-embed on hydration when an embedder is present.
218
+ if (!blob?.embedding)
219
+ return m;
220
+ const { explanation, ...live } = m;
221
+ const hydrated = {
222
+ ...live,
223
+ text: blob.text,
224
+ stub: blob.stub,
225
+ entities: blob.entities,
226
+ embedding: blob.embedding,
227
+ tier: 'warm',
228
+ };
229
+ await store.upsert(grant, hydrated);
230
+ return { ...hydrated, explanation: { ...explanation, hydrated: true } };
231
+ }
232
+ catch {
233
+ hydrateFailed = true;
234
+ return m;
235
+ }
236
+ }));
237
+ timings['hydrateMs'] = clock() - th;
238
+ if (hydrateFailed)
239
+ degraded.push('hydrate-failed');
240
+ // Access drives tiering, so it is recorded for the returned memories only. A failed write (e.g.
241
+ // SQLITE_BUSY) is reported, never a reason to lose the answer.
242
+ if (touched.size > 0) {
243
+ try {
244
+ await store.recordAccess(grant, [...touched], now);
245
+ }
246
+ catch {
247
+ degraded.push('access-failed');
248
+ }
249
+ }
226
250
  timings['totalMs'] = clock() - t0;
227
251
  return {
228
- memories: out,
252
+ memories: result,
229
253
  ...(degraded.length > 0 ? { degraded } : {}),
230
254
  ...(out.length === 0 && candidates.length > 0 ? { abstained: true } : {}),
231
255
  ...(dropped > 0
@@ -239,9 +263,89 @@ export async function recall(q, deps) {
239
263
  : {}),
240
264
  ...(rerankSkipped !== undefined ? { rerankSkipped } : {}),
241
265
  ...(embedSkipped !== undefined ? { embedSkipped } : {}),
266
+ temporal,
242
267
  timings,
243
268
  };
244
269
  }
270
+ /** The view a query asks for: explicit options first, else read from the query in code. */
271
+ function viewFor(q, text, now) {
272
+ const at = (iso) => iso ?? now.toISOString();
273
+ let view;
274
+ let inferred = false;
275
+ let search = text;
276
+ if (q.mode !== undefined) {
277
+ view = { mode: q.mode };
278
+ if (q.mode === 'as-of')
279
+ view.asOf = at(q.asOf);
280
+ if (q.mode === 'known-at')
281
+ view.knownAt = at(q.knownAt);
282
+ }
283
+ else if (q.asOf !== undefined) {
284
+ view = { mode: 'as-of', asOf: q.asOf };
285
+ }
286
+ else if (q.knownAt !== undefined) {
287
+ view = { mode: 'known-at', knownAt: q.knownAt };
288
+ }
289
+ else {
290
+ const i = temporalIntent(text, now);
291
+ view = {
292
+ mode: i.mode,
293
+ ...(i.asOf !== undefined && { asOf: i.asOf }),
294
+ ...(i.knownAt !== undefined && { knownAt: i.knownAt }),
295
+ };
296
+ inferred = true;
297
+ search = i.text ?? text;
298
+ }
299
+ return { view, temporal: { ...view, inferred }, text: search };
300
+ }
301
+ /** Longest stretch of a supersede chain walked in each direction. */
302
+ const CHAIN_MAX = 8;
303
+ /**
304
+ * History mode: each recalled fact with the rest of its supersede chain, oldest first, the
305
+ * first-ranked chain first. Corrected versions are skipped (they were never true) but walked
306
+ * through. Versions only the walk found are marked `chain`.
307
+ */
308
+ async function withChains(out, read, now, view, queryTokens, config) {
309
+ const byId = new Map(out.map((m) => [m.id, m]));
310
+ const seen = new Set();
311
+ const result = [];
312
+ for (const head of out) {
313
+ if (seen.has(head.id))
314
+ continue;
315
+ const links = [head];
316
+ for (const dir of ['supersedes', 'supersededBy']) {
317
+ let cur = head;
318
+ for (let n = 0; n < CHAIN_MAX; n++) {
319
+ const next = cur[dir];
320
+ if (next === null || seen.has(next) || links.some((l) => l.id === next))
321
+ break;
322
+ const m = await read(next);
323
+ if (m === null)
324
+ break;
325
+ links.push(m);
326
+ cur = m;
327
+ }
328
+ }
329
+ links.sort((a, b) => Date.parse(a.validFrom) - Date.parse(b.validFrom));
330
+ for (const m of links) {
331
+ seen.add(m.id);
332
+ if (!isVisible(m, now, view))
333
+ continue;
334
+ result.push(byId.get(m.id) ?? {
335
+ ...m,
336
+ explanation: {
337
+ fused: 0,
338
+ rrf: 0,
339
+ coverage: queryCoverage(queryTokens, m),
340
+ activation: activation(m.accessLog, m.salience, now, config.activation),
341
+ hydrated: false,
342
+ chain: true,
343
+ },
344
+ });
345
+ }
346
+ }
347
+ return result;
348
+ }
245
349
  const TIMED_OUT = Symbol('timed-out');
246
350
  const RERANK_TIMEOUT = new Error('rerank-timeout');
247
351
  /** `p`, or `TIMED_OUT` after `ms` (0: no limit). A late result is dropped, never rejected. */
@@ -268,14 +372,14 @@ async function withBudget(p, ms) {
268
372
  * informative words, the top lexical hit is valid now (or at `asOf`) and holds every one of them,
269
373
  * and the next hit holds clearly fewer. Dense search could only confirm such a result.
270
374
  */
271
- async function lexicalIsConfident(lexical, queryTokens, read, now, asOf) {
375
+ async function lexicalIsConfident(lexical, queryTokens, read, now, view) {
272
376
  if (queryTokens.size < 2)
273
377
  return false;
274
378
  const top = lexical[0];
275
379
  if (top === undefined)
276
380
  return false;
277
381
  const m = await read(top.id);
278
- if (m === null || !isValid(m, now, asOf))
382
+ if (m === null || !isVisible(m, now, view))
279
383
  return false;
280
384
  if (queryCoverage(queryTokens, m) < 1)
281
385
  return false;
@@ -308,6 +412,14 @@ function isConfident(ordered, lexical, dense, coverage) {
308
412
  return true;
309
413
  return (coverage.get(second.id) ?? 0) <= topCov - CONFIDENT_COVERAGE_GAP;
310
414
  }
415
+ const ymd = (iso) => iso.slice(0, 10);
416
+ /** How a memory is dated in the block: its age when current, else the interval it held. */
417
+ function when(m, now) {
418
+ if (isCurrent(m, now))
419
+ return age(m.validFrom, now);
420
+ const span = `${ymd(m.validFrom)} to ${m.validTo === null ? 'now' : ymd(m.validTo)}`;
421
+ return m.endReason === 'corrected' ? `${span}, later corrected` : span;
422
+ }
311
423
  function age(validFrom, now) {
312
424
  const days = Math.max(0, Math.floor((now.getTime() - Date.parse(validFrom)) / 86_400_000));
313
425
  if (days < 1)
@@ -328,22 +440,33 @@ function age(validFrom, now) {
328
440
  * memory stored before a rule existed, written by another tool, or missed by a regression.
329
441
  */
330
442
  export function formatForInjection(r, budgetChars, now = new Date()) {
331
- if (r.memories.length === 0)
443
+ const { lines } = fitForInjection(r.memories, budgetChars, now);
444
+ if (lines.length === 0)
332
445
  return '';
333
- const open = '<oort-memory>\n';
334
- const close = '\n</oort-memory>';
335
- let total = open.length + close.length;
446
+ return INJECT_OPEN + lines.join('\n') + INJECT_CLOSE;
447
+ }
448
+ const INJECT_OPEN = '<oort-memory>\n';
449
+ const INJECT_CLOSE = '\n</oort-memory>';
450
+ /**
451
+ * The memories `formatForInjection` renders within `budgetChars`, in order (a prefix: whole
452
+ * lines only, stopping at the first that does not fit), with their lines.
453
+ */
454
+ export function fitForInjection(memories, budgetChars, now = new Date()) {
455
+ let total = INJECT_OPEN.length + INJECT_CLOSE.length;
336
456
  const lines = [];
337
- for (const m of r.memories) {
338
- const line = `- [${m.type}, ${age(m.validFrom, now)}] ${scrub(m.text).text}`;
457
+ const kept = [];
458
+ for (const m of memories) {
459
+ const note = m.explanation.stale === true
460
+ ? ` (may be outdated; last confirmed ${age(lastConfirmed(m), now)})`
461
+ : '';
462
+ const line = `- [${m.type}, ${when(m, now)}] ${scrub(m.text).text}${note}`;
339
463
  const cost = line.length + (lines.length > 0 ? 1 : 0);
340
464
  if (total + cost > budgetChars)
341
465
  break;
342
466
  lines.push(line);
467
+ kept.push(m);
343
468
  total += cost;
344
469
  }
345
- if (lines.length === 0)
346
- return '';
347
- return open + lines.join('\n') + close;
470
+ return { kept, lines };
348
471
  }
349
472
  //# sourceMappingURL=recall.js.map
package/dist/scope.d.ts CHANGED
@@ -1,7 +1,21 @@
1
1
  import type { Scope } from './model.js';
2
2
  export type { Scope };
3
3
  export declare function localScope(projectId?: string): Scope;
4
- /** Walk up from `start` to the first directory containing `.git`. */
4
+ /**
5
+ * `p` resolved, then realpathed by the OS (`realpath(3)`), or `p` resolved when
6
+ * it cannot be (it vanished, a permission error). The native call returns the
7
+ * spelling on disk, so on a case-insensitive filesystem (APFS, HFS+, NTFS)
8
+ * `~/Repos/Oort` and `~/repos/oort` give one path, where `fs.realpathSync`
9
+ * would keep the case as typed and split one repository into two project ids.
10
+ * Every path that feeds an id or a trust binding goes through this.
11
+ */
12
+ export declare function canonicalPath(p: string): string;
13
+ /**
14
+ * Walk up from `start` to the first directory containing `.git`. The walk
15
+ * starts from `start` realpathed, as git does (it walks from the physical
16
+ * cwd): a symlink inside one repository that leads into another must land on
17
+ * the other, never on the repository the symlink happens to sit in.
18
+ */
5
19
  export declare function findGitRoot(start: string): string | undefined;
6
20
  /** The main repository root above `cwd` (a linked worktree resolves to its main checkout). */
7
21
  export declare function projectRootFor(cwd: string): string | undefined;
@@ -24,11 +38,12 @@ export declare const PROJECT_LINKS_FILE = "project-links.json";
24
38
  export declare function readProjectLinks(home: string): Record<string, string>;
25
39
  /**
26
40
  * Binds main repository root `root` to project id `id` (unprefixed) in the
27
- * user's trust file (0600). The key is `root` realpathed, the form
28
- * `projectRootFor` returns, so a binding made through a symlink still applies.
41
+ * user's trust file (0600). The key is `canonicalPath(root)`, the form
42
+ * `projectRootFor` returns, so a binding made through a symlink or with other
43
+ * case still applies.
29
44
  */
30
45
  export declare function linkProject(home: string, root: string, id: string): void;
31
- /** Removes `root`'s binding (realpathed, as `linkProject` keys it); false when there was none. */
46
+ /** Removes `root`'s binding (canonicalised, as `linkProject` keys it); false when there was none. */
32
47
  export declare function unlinkProject(home: string, root: string): boolean;
33
48
  export interface ProjectResolution {
34
49
  /** The project id, or undefined outside a repository. */
package/dist/scope.js CHANGED
@@ -9,9 +9,31 @@ export function localScope(projectId) {
9
9
  scope.projectId = projectId;
10
10
  return scope;
11
11
  }
12
- /** Walk up from `start` to the first directory containing `.git`. */
12
+ /**
13
+ * `p` resolved, then realpathed by the OS (`realpath(3)`), or `p` resolved when
14
+ * it cannot be (it vanished, a permission error). The native call returns the
15
+ * spelling on disk, so on a case-insensitive filesystem (APFS, HFS+, NTFS)
16
+ * `~/Repos/Oort` and `~/repos/oort` give one path, where `fs.realpathSync`
17
+ * would keep the case as typed and split one repository into two project ids.
18
+ * Every path that feeds an id or a trust binding goes through this.
19
+ */
20
+ export function canonicalPath(p) {
21
+ const resolved = path.resolve(p);
22
+ try {
23
+ return fs.realpathSync.native(resolved);
24
+ }
25
+ catch {
26
+ return resolved;
27
+ }
28
+ }
29
+ /**
30
+ * Walk up from `start` to the first directory containing `.git`. The walk
31
+ * starts from `start` realpathed, as git does (it walks from the physical
32
+ * cwd): a symlink inside one repository that leads into another must land on
33
+ * the other, never on the repository the symlink happens to sit in.
34
+ */
13
35
  export function findGitRoot(start) {
14
- let dir = path.resolve(start);
36
+ let dir = canonicalPath(start);
15
37
  for (;;) {
16
38
  if (fs.existsSync(path.join(dir, '.git')))
17
39
  return dir;
@@ -21,15 +43,6 @@ export function findGitRoot(start) {
21
43
  dir = parent;
22
44
  }
23
45
  }
24
- /** `p` realpathed, or `p` itself when it cannot be (it vanished, a permission error). */
25
- function realOr(p) {
26
- try {
27
- return fs.realpathSync(p);
28
- }
29
- catch {
30
- return p;
31
- }
32
- }
33
46
  /**
34
47
  * The main repository root for a checkout root, always realpathed, so derived
35
48
  * ids and trust bindings agree however the checkout was reached. A `.git`
@@ -47,10 +60,23 @@ function realOr(p) {
47
60
  * from that verified `commonDir` and nothing else: a lexical resolution of the
48
61
  * written path could run through an attacker's symlink to another repository.
49
62
  * Any mismatch, missing file or read error (a submodule has no `commondir`)
50
- * falls back to `root` itself.
63
+ * falls back to `root` itself. That includes a stale worktree whose main
64
+ * checkout was moved or deleted (its `gitdir` no longer exists) or replaced
65
+ * by an unrelated repository (no back-pointer naming this checkout): it gets
66
+ * the id of its own root, never an error and never the new repository's id.
67
+ *
68
+ * Stale admin dirs (decided for CON-53): a worktree deleted without
69
+ * `git worktree prune` leaves `<commonDir>/worktrees/<name>/` behind, so a
70
+ * directory recreated at exactly that path with the same `.git` file passes
71
+ * both checks and shares the main repository's id. This matches git, which
72
+ * treats that directory as the worktree and serves it the main repository's
73
+ * objects and refs, so the id grants nothing git does not already. An mtime
74
+ * rule (the admin `gitdir` no older than the checkout's `.git`) was rejected:
75
+ * `git worktree add` writes `gitdir` before `.git`, so it would refuse every
76
+ * fresh worktree, and an extracted archive sets whatever mtimes it carries.
51
77
  */
52
78
  function mainRootOf(root) {
53
- const own = realOr(root);
79
+ const own = canonicalPath(root);
54
80
  const dotGit = path.join(root, '.git');
55
81
  try {
56
82
  // A symlinked `.git` (to a file or a directory) never inherits another
@@ -63,11 +89,11 @@ function mainRootOf(root) {
63
89
  const m = /^gitdir:\s*(.+?)\s*$/m.exec(fs.readFileSync(dotGit, 'utf8'));
64
90
  if (m?.[1] === undefined)
65
91
  return own;
66
- const gitdir = fs.realpathSync(path.resolve(root, m[1]));
92
+ const gitdir = fs.realpathSync.native(path.resolve(root, m[1]));
67
93
  const common = fs.readFileSync(path.join(gitdir, 'commondir'), 'utf8').trim();
68
94
  if (common === '')
69
95
  return own;
70
- const commonDir = fs.realpathSync(path.resolve(gitdir, common));
96
+ const commonDir = fs.realpathSync.native(path.resolve(gitdir, common));
71
97
  if (!fs.statSync(commonDir).isDirectory())
72
98
  return own;
73
99
  if (path.dirname(gitdir) !== path.join(commonDir, 'worktrees'))
@@ -76,7 +102,7 @@ function mainRootOf(root) {
76
102
  if (back === '')
77
103
  return own;
78
104
  // The back-pointer must name this checkout's own `.git`, not wherever it leads.
79
- if (fs.realpathSync(path.resolve(gitdir, back)) !== path.join(own, '.git'))
105
+ if (fs.realpathSync.native(path.resolve(gitdir, back)) !== path.join(own, '.git'))
80
106
  return own;
81
107
  return path.basename(commonDir) === '.git' ? path.dirname(commonDir) : commonDir;
82
108
  }
@@ -134,18 +160,19 @@ function writeProjectLinks(home, links) {
134
160
  }
135
161
  /**
136
162
  * Binds main repository root `root` to project id `id` (unprefixed) in the
137
- * user's trust file (0600). The key is `root` realpathed, the form
138
- * `projectRootFor` returns, so a binding made through a symlink still applies.
163
+ * user's trust file (0600). The key is `canonicalPath(root)`, the form
164
+ * `projectRootFor` returns, so a binding made through a symlink or with other
165
+ * case still applies.
139
166
  */
140
167
  export function linkProject(home, root, id) {
141
168
  if (id === '' || [...id].some((c) => c.charCodeAt(0) < 0x20)) {
142
169
  throw new RangeError('project id must be non-empty, without control characters');
143
170
  }
144
- writeProjectLinks(home, { ...readProjectLinks(home), [realOr(root)]: id });
171
+ writeProjectLinks(home, { ...readProjectLinks(home), [canonicalPath(root)]: id });
145
172
  }
146
- /** Removes `root`'s binding (realpathed, as `linkProject` keys it); false when there was none. */
173
+ /** Removes `root`'s binding (canonicalised, as `linkProject` keys it); false when there was none. */
147
174
  export function unlinkProject(home, root) {
148
- root = realOr(root);
175
+ root = canonicalPath(root);
149
176
  const links = readProjectLinks(home);
150
177
  if (!(root in links))
151
178
  return false;
@@ -141,6 +141,71 @@ export function storeConformance(name, make) {
141
141
  await store.upsertMany(TG, [{ ...makeMemory({ id: 'c2' }), tier: 'hot' }]);
142
142
  expect(await store.coldGet(TG, 'c2')).toBeNull();
143
143
  });
144
+ it('temporal-fields-roundtrip', async () => {
145
+ // CON-34/49: confirmation, correction and the new decision stages survive a round trip.
146
+ const m = makeMemory({
147
+ id: 't1',
148
+ text: 'auth listens on port 3000',
149
+ embedding: vec(1, 2, 3, 4),
150
+ validFrom: '2026-01-01T00:00:00.000Z',
151
+ validTo: '2026-02-01T00:00:00.000Z',
152
+ expiredAt: '2026-01-15T00:00:00.000Z',
153
+ endReason: 'corrected',
154
+ lastConfirmedAt: '2026-01-10T08:30:00.000Z',
155
+ supersededBy: 't2',
156
+ source: {
157
+ harness: 'test',
158
+ turnRef: 'turn-9',
159
+ decisions: [
160
+ { stage: 'gate', question: 'volatile', answer: 'yes', probability: 0.8 },
161
+ {
162
+ stage: 'temporal',
163
+ question: 'interval',
164
+ answer: 'turn .. 2026-02-01',
165
+ probability: 1,
166
+ },
167
+ {
168
+ stage: 'temporal',
169
+ question: 'writer-date',
170
+ answer: '2026-01-02 -> 2026-01-03',
171
+ probability: 1,
172
+ },
173
+ {
174
+ stage: 'refine',
175
+ question: 'previous-text',
176
+ answer: 'auth on 3000',
177
+ probability: 0.7,
178
+ },
179
+ { stage: 'write', question: 'action', answer: 'CORRECT', probability: 0.9 },
180
+ ],
181
+ },
182
+ });
183
+ await store.upsert(TG, m);
184
+ expect(await store.get(TG, 't1')).toEqual(m);
185
+ });
186
+ it('refine-in-place-of-a-cold-record-stays-consistent', async () => {
187
+ // A REFINE rewrites a cold memory in place and promotes it: the cold copy goes, so a later
188
+ // hydration cannot bring the old wording back.
189
+ const m = makeMemory({ id: 'r1', text: 'auth on port 3000', embedding: vec(1, 2, 3, 4) });
190
+ await seedCold(store, m);
191
+ const cold = await store.get(TG, 'r1');
192
+ if (cold === null)
193
+ throw new Error('coldPut lost the main record');
194
+ expect(cold.tier).toBe('cold');
195
+ const refined = {
196
+ ...cold,
197
+ text: 'auth on port 3000 over TLS',
198
+ stub: 'auth port TLS',
199
+ embedding: vec(4, 3, 2, 1),
200
+ tier: 'warm',
201
+ lastConfirmedAt: '2026-01-10T00:00:00.000Z',
202
+ updatedAt: '2026-01-10T00:00:00.000Z',
203
+ };
204
+ await store.upsert(TG, refined);
205
+ expect(await store.get(TG, 'r1')).toEqual(refined);
206
+ expect(await store.coldGet(TG, 'r1')).toBeNull();
207
+ expect((await store.lexical(TG, 'TLS', 5)).map((h) => h.id)).toEqual(['r1']);
208
+ });
144
209
  it('cold-put-many-roundtrip', async () => {
145
210
  const a = makeMemory({ id: 'a', text: 'alpha', embedding: vec(1, 2, 3, 4) });
146
211
  const b = makeMemory({ id: 'b', text: 'beta', tier: 'hot', embedding: vec(4, 3, 2, 1) });
@@ -442,6 +507,20 @@ export function storeConformance(name, make) {
442
507
  ]);
443
508
  expect(ids(await store.list(localGrant(LOCAL), { tier: 'hot' }))).toEqual(['old']);
444
509
  });
510
+ it('list-pinned-only-and-without-embeddings', async () => {
511
+ await store.upsert(TG, makeMemory({ id: 'pin', pinned: true, embedding: vec(1, 2, 3, 4) }));
512
+ await store.upsert(TG, makeMemory({ id: 'hot', tier: 'hot', embedding: vec(1, 2, 3, 4) }));
513
+ await store.upsert(TG, makeMemory({ id: 'plain', embedding: vec(1, 2, 3, 4) }));
514
+ expect(ids(await store.list(localGrant(LOCAL), { pinned: true }))).toEqual(['pin']);
515
+ expect(ids(await store.list(localGrant(LOCAL), { pinned: true, tier: 'hot' }))).toEqual([]);
516
+ // Callers that only read text and metadata (SessionStart) skip the embedding fetch.
517
+ const bare = await store.list(localGrant(LOCAL), { embeddings: false });
518
+ expect(bare).toHaveLength(3);
519
+ for (const m of bare)
520
+ expect(m.embedding).toBeUndefined();
521
+ const full = await store.list(localGrant(LOCAL), { tier: 'hot' });
522
+ expect(full[0]?.embedding).toEqual(vec(1, 2, 3, 4));
523
+ });
445
524
  it('list-valid-only-uses-isCurrent', async () => {
446
525
  await store.upsert(TG, makeMemory({ id: 'future', validTo: '2999-01-01T00:00:00.000Z' }));
447
526
  await store.upsert(TG, makeMemory({
@@ -0,0 +1,62 @@
1
+ /**
2
+ * The UTC offset, in minutes, an ISO timestamp states (`+02:00` is 120, `Z` is 0), or
3
+ * undefined when it states none.
4
+ */
5
+ export declare function utcOffset(iso: string): number | undefined;
6
+ export interface FactInterval {
7
+ /** ISO start of the fact's validity, when the text bounds it ("since", "starting"). */
8
+ validFrom?: string;
9
+ /** ISO end (exclusive) of the fact's validity, when the text bounds it ("until Friday"). */
10
+ validTo?: string;
11
+ }
12
+ /**
13
+ * The validity bounds a written memory states about itself, resolved against `at` (the turn's
14
+ * time) in `offset` minutes east of UTC. "Until Friday" ends the fact after that Friday, and an
15
+ * end in the past is kept (the fact is stored already ended). "Starting next week" looks ahead,
16
+ * "since last Monday" looks back, and "as of", "effective" or "from" take the closer reading
17
+ * within a week, else the past one. "From Monday to Friday" is a schedule, not a start. A plain
18
+ * date ("on 2026-11-03 we chose pnpm") dates an event and sets no bound. An end at or before a
19
+ * stated start contradicts it and is dropped.
20
+ */
21
+ export declare function factInterval(text: string, at: Date, offset?: number): FactInterval;
22
+ export interface WriterDateCheck {
23
+ text: string;
24
+ /** The written day was replaced by the resolved one. */
25
+ fixed?: {
26
+ from: string;
27
+ to: string;
28
+ };
29
+ /** The written day differs from the resolved one but was kept (a stated or distant date). */
30
+ mismatch?: {
31
+ written: string;
32
+ resolved: string;
33
+ };
34
+ }
35
+ /**
36
+ * Checks the writer's absolute date against the turn. When the turn names exactly one relative
37
+ * day ("yesterday") and the written fact carries exactly one ISO day, that day should be
38
+ * chrono's reading of the relative one. It is replaced only when it looks like an arithmetic
39
+ * slip: within two days of the reading, and not a date the turn itself states. Otherwise the
40
+ * difference is reported in `mismatch` and the text is kept ("Today we agreed the cutover is
41
+ * 2026-12-01" is about the cutover, not today).
42
+ */
43
+ export declare function checkWriterDate(turnText: string, factText: string, at: Date, offset?: number): WriterDateCheck;
44
+ export type RecallMode = 'current' | 'as-of' | 'history' | 'known-at';
45
+ export interface TemporalIntent {
46
+ mode: RecallMode;
47
+ /** For `as-of`: the end of the day the query names. */
48
+ asOf?: string;
49
+ /** For `known-at`: the end of the day the query names. */
50
+ knownAt?: string;
51
+ /** The query without its temporal words, to search with; absent when nothing was removed. */
52
+ text?: string;
53
+ }
54
+ /**
55
+ * A cheap read of a query's temporal intent, in code: "what did we believe … on <day>" is
56
+ * known-at that day; an anchored history question ("how has X changed") asks for the chain; a
57
+ * past day with a cue ("on 2 April 2026", "in March", "yesterday") is as-of that day; anything
58
+ * else, including "now", coming weekdays and other future days, is the current state. A month
59
+ * and day with no year that reads more than 30 days ahead means the last one ("in March" asked
60
+ * in November is this year's March).
61
+ */
62
+ export declare function temporalIntent(query: string, now: Date): TemporalIntent;