@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/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/recall.js
CHANGED
|
@@ -1,22 +1,28 @@
|
|
|
1
1
|
import { activation } from './activation.js';
|
|
2
|
-
import { fuse,
|
|
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,
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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,
|
|
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
|
|
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:
|
|
191
|
+
hydrated: false,
|
|
222
192
|
},
|
|
223
193
|
});
|
|
224
194
|
}
|
|
225
|
-
await
|
|
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:
|
|
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,
|
|
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 || !
|
|
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
|
-
|
|
443
|
+
const { lines } = fitForInjection(r.memories, budgetChars, now);
|
|
444
|
+
if (lines.length === 0)
|
|
332
445
|
return '';
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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
|
-
|
|
338
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
|
28
|
-
* `projectRootFor` returns, so a binding made through a symlink
|
|
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 (
|
|
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
|
-
/**
|
|
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 =
|
|
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 =
|
|
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
|
|
138
|
-
* `projectRootFor` returns, so a binding made through a symlink
|
|
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), [
|
|
171
|
+
writeProjectLinks(home, { ...readProjectLinks(home), [canonicalPath(root)]: id });
|
|
145
172
|
}
|
|
146
|
-
/** Removes `root`'s binding (
|
|
173
|
+
/** Removes `root`'s binding (canonicalised, as `linkProject` keys it); false when there was none. */
|
|
147
174
|
export function unlinkProject(home, root) {
|
|
148
|
-
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;
|