@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.
- package/dist/adapters.d.ts +26 -6
- package/dist/config.d.ts +1 -0
- package/dist/config.js +6 -0
- package/dist/fusion.d.ts +22 -2
- package/dist/fusion.js +36 -4
- package/dist/index.d.ts +3 -1
- package/dist/index.js +3 -1
- package/dist/memory-store.js +7 -1
- package/dist/observe.d.ts +1 -1
- package/dist/observe.js +169 -35
- package/dist/recall.d.ts +41 -7
- package/dist/recall.js +185 -62
- package/dist/scope.d.ts +19 -4
- package/dist/scope.js +48 -21
- package/dist/store-conformance.js +79 -0
- package/dist/temporal.d.ts +62 -0
- package/dist/temporal.js +203 -0
- package/dist/volatile.d.ts +12 -0
- package/dist/volatile.js +24 -0
- package/package.json +2 -1
package/dist/adapters.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
|
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 || !
|
|
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
|
+
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
|
+
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';
|
package/dist/memory-store.js
CHANGED
|
@@ -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) =>
|
|
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
|
-
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
if (
|
|
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: '
|
|
166
|
-
answer:
|
|
184
|
+
question: 'interval',
|
|
185
|
+
answer: `${bounds.validFrom ?? 'turn'} .. ${bounds.validTo ?? 'open'}`,
|
|
167
186
|
probability: 1,
|
|
168
187
|
});
|
|
169
188
|
}
|
|
170
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
208
|
-
// starting in the future leaves the prior current until
|
|
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:
|
|
215
|
-
expiredAt: Date.parse(
|
|
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
|
-
|
|
254
|
-
|
|
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
|
-
|
|
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
|
-
...
|
|
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(
|
|
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
|
|
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,
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
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
|
+
};
|