@oortmem/core 0.1.1 → 0.2.1

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.
@@ -0,0 +1,203 @@
1
+ import * as chrono from 'chrono-node';
2
+ /**
3
+ * Time handling in code, never in a model (CON-34): relative expressions are resolved with
4
+ * chrono-node against the observation's own timestamp, at day precision, in the turn's UTC
5
+ * offset when its timestamp carries one, else in UTC.
6
+ */
7
+ const DAY_MS = 86_400_000;
8
+ const MIN_MS = 60_000;
9
+ /** chrono's reference: the instant, and the offset in minutes east of UTC. */
10
+ const ref = (at, offset = 0) => ({ instant: at, timezone: offset });
11
+ /** Midnight of `d`'s day in the given offset, as an instant. */
12
+ const startOfDay = (d, offset = 0) => {
13
+ const local = new Date(d.getTime() + offset * MIN_MS);
14
+ return new Date(Date.UTC(local.getUTCFullYear(), local.getUTCMonth(), local.getUTCDate()) - offset * MIN_MS);
15
+ };
16
+ const endOfDay = (d) => new Date(startOfDay(d).getTime() + DAY_MS - 1);
17
+ /** `d`'s calendar day in the given offset. */
18
+ const ymd = (d, offset = 0) => new Date(d.getTime() + offset * MIN_MS).toISOString().slice(0, 10);
19
+ /**
20
+ * The UTC offset, in minutes, an ISO timestamp states (`+02:00` is 120, `Z` is 0), or
21
+ * undefined when it states none.
22
+ */
23
+ export function utcOffset(iso) {
24
+ const m = /(?:([+-])(\d{2}):?(\d{2})|Z)$/i.exec(iso.trim());
25
+ if (m === null)
26
+ return undefined;
27
+ if (m[1] === undefined)
28
+ return 0;
29
+ return (m[1] === '-' ? -1 : 1) * (Number(m[2]) * 60 + Number(m[3]));
30
+ }
31
+ /** Words right before a date that make it the fact's end ("until Friday") or start ("since"). */
32
+ const UNTIL = /\b(until|till|through|thru|expires?|expiring|ends?(\s+on)?)\s*$/;
33
+ const FROM = /\b(since|from|starting|starts|as\s+of|effective|beginning|begins)\s*$/;
34
+ /** Start cues that announce something ahead: they take the coming reading. */
35
+ const AHEAD = /\b(starting|starts|beginning|begins)\s*$/;
36
+ const CUE_PREFIX = /^(.*?\b(?:until|till|through|thru|expires?|expiring|ends?|since|from|starting|starts|as\s+of|effective|beginning|begins))\b/;
37
+ /** The lead-in of a chrono result: the text just before it plus its own leading words. */
38
+ function leadIn(text, r) {
39
+ const before = text.slice(Math.max(0, r.index - 32), r.index);
40
+ // chrono sometimes swallows the cue ("lasts until 2026-11-20"): keep the result's own cue words.
41
+ const own = CUE_PREFIX.exec(r.text.toLowerCase())?.[1] ?? '';
42
+ return `${before} ${own}`.toLowerCase().trimEnd();
43
+ }
44
+ /**
45
+ * The validity bounds a written memory states about itself, resolved against `at` (the turn's
46
+ * time) in `offset` minutes east of UTC. "Until Friday" ends the fact after that Friday, and an
47
+ * end in the past is kept (the fact is stored already ended). "Starting next week" looks ahead,
48
+ * "since last Monday" looks back, and "as of", "effective" or "from" take the closer reading
49
+ * within a week, else the past one. "From Monday to Friday" is a schedule, not a start. A plain
50
+ * date ("on 2026-11-03 we chose pnpm") dates an event and sets no bound. An end at or before a
51
+ * stated start contradicts it and is dropped.
52
+ */
53
+ export function factInterval(text, at, offset = 0) {
54
+ const out = {};
55
+ const forward = chrono.parse(text, ref(at, offset), { forwardDate: true });
56
+ const backward = chrono.parse(text, ref(at, offset), { forwardDate: false });
57
+ const day = (d) => startOfDay(d, offset);
58
+ for (const r of forward) {
59
+ const lead = leadIn(text, r);
60
+ if (out.validTo === undefined && UNTIL.test(lead)) {
61
+ const d = (r.end ?? r.start).date();
62
+ out.validTo = new Date(day(d).getTime() + DAY_MS).toISOString();
63
+ }
64
+ else if (out.validFrom === undefined && FROM.test(lead)) {
65
+ const weekdayOnly = (c) => c.isCertain('weekday') && !c.isCertain('day');
66
+ if (r.end !== null && r.end !== undefined && weekdayOnly(r.start) && weekdayOnly(r.end)) {
67
+ continue; // "from Monday to Friday": a recurring schedule
68
+ }
69
+ const back = backward.find((x) => x.index === r.index) ?? r;
70
+ let pick;
71
+ if (/\bsince\s*$/.test(lead))
72
+ pick = back;
73
+ else if (AHEAD.test(lead) || /\bnext\b/i.test(r.text))
74
+ pick = r;
75
+ else {
76
+ // "As of Monday" on a Wednesday is two days ago: the closer reading within a week.
77
+ const dist = (x) => Math.abs(x.start.date().getTime() - at.getTime());
78
+ const near = dist(r) <= dist(back) ? r : back;
79
+ pick = dist(near) <= 7 * DAY_MS ? near : back;
80
+ }
81
+ out.validFrom = day(pick.start.date()).toISOString();
82
+ if (r.end !== null &&
83
+ r.end !== undefined &&
84
+ out.validTo === undefined &&
85
+ /\bfrom\s*$/.test(lead)) {
86
+ // "From 2026-11-01 to 2026-11-20": both bounds.
87
+ out.validTo = new Date(day(r.end.date()).getTime() + DAY_MS).toISOString();
88
+ }
89
+ }
90
+ }
91
+ if (out.validFrom !== undefined &&
92
+ out.validTo !== undefined &&
93
+ Date.parse(out.validTo) <= Date.parse(out.validFrom)) {
94
+ delete out.validTo;
95
+ }
96
+ return out;
97
+ }
98
+ const RELATIVE = /\b(yesterday|today|tonight|tomorrow|ago|last\s+\w+|next\s+\w+|this\s+(morning|afternoon|evening|week|month))\b/gi;
99
+ const ISO_DAY = /\b(\d{4}-\d{2}-\d{2})\b/g;
100
+ /** Furthest a written day may sit from the turn's relative day and still count as a slip. */
101
+ const SLIP_DAYS = 2;
102
+ /**
103
+ * Checks the writer's absolute date against the turn. When the turn names exactly one relative
104
+ * day ("yesterday") and the written fact carries exactly one ISO day, that day should be
105
+ * chrono's reading of the relative one. It is replaced only when it looks like an arithmetic
106
+ * slip: within two days of the reading, and not a date the turn itself states. Otherwise the
107
+ * difference is reported in `mismatch` and the text is kept ("Today we agreed the cutover is
108
+ * 2026-12-01" is about the cutover, not today).
109
+ */
110
+ export function checkWriterDate(turnText, factText, at, offset = 0) {
111
+ const relatives = turnText.match(RELATIVE) ?? [];
112
+ const days = [...new Set(factText.match(ISO_DAY) ?? [])];
113
+ if (relatives.length !== 1 || days.length !== 1)
114
+ return { text: factText };
115
+ const isRelative = new RegExp(RELATIVE.source, 'i');
116
+ const parsed = chrono
117
+ .parse(turnText, ref(at, offset), { forwardDate: false })
118
+ .filter((r) => isRelative.test(r.text));
119
+ if (parsed.length !== 1)
120
+ return { text: factText };
121
+ const resolved = ymd(parsed[0].start.date(), offset);
122
+ const written = days[0];
123
+ if (resolved === written)
124
+ return { text: factText };
125
+ const apart = Math.abs(Date.parse(resolved) - Date.parse(written)) / DAY_MS;
126
+ if (apart > SLIP_DAYS || turnText.includes(written)) {
127
+ return { text: factText, mismatch: { written, resolved } };
128
+ }
129
+ return { text: factText.split(written).join(resolved), fixed: { from: written, to: resolved } };
130
+ }
131
+ const KNOWN = /\b(what\s+did\s+(we|i|you)\s+(think|believe|know|assume)|believed|we\s+thought|we\s+knew|known\s+at)\b/i;
132
+ /**
133
+ * History questions, anchored and question-shaped: "how has X changed", "history of X", "what
134
+ * did X used to be", "what was X before", "which … previously". A prompt that only contains
135
+ * such words ("fix the test, the config changed") is an instruction about now.
136
+ */
137
+ const HISTORY_QUESTIONS = [
138
+ /^\s*how\s+(has|have|did)\b[^.;!,]*\bchange[sd]?\b[^.;!,]*\??\s*$/i,
139
+ /^\s*((what|what's)\s+(is|was)?\s*the\s+)?history\s+of\b[^.;!,]*\??\s*$/i,
140
+ /^\s*(what|which)\b[^.;!,]*\b(used?\s+to\s+be|before|previously|in\s+the\s+past|over\s+time)\s*\??\s*$/i,
141
+ ];
142
+ const isHistoryQuestion = (q) => HISTORY_QUESTIONS.some((re) => re.test(q));
143
+ /** A preposition left dangling once the date it introduced is removed. */
144
+ const DANGLING = /\b(on|in|at|as\s+of|by|during|since)\s*$/i;
145
+ /** `query` with `part` removed (and a preposition it leaves dangling), whitespace tidied. */
146
+ function without(query, part, dangling = false) {
147
+ const i = query.indexOf(part);
148
+ if (i < 0)
149
+ return query;
150
+ let head = query.slice(0, i);
151
+ if (dangling)
152
+ head = head.replace(DANGLING, '');
153
+ return `${head} ${query.slice(i + part.length)}`.replace(/\s+/g, ' ').trim();
154
+ }
155
+ /**
156
+ * A cheap read of a query's temporal intent, in code: "what did we believe … on <day>" is
157
+ * known-at that day; an anchored history question ("how has X changed") asks for the chain; a
158
+ * past day with a cue ("on 2 April 2026", "in March", "yesterday") is as-of that day; anything
159
+ * else, including "now", coming weekdays and other future days, is the current state. A month
160
+ * and day with no year that reads more than 30 days ahead means the last one ("in March" asked
161
+ * in November is this year's March).
162
+ */
163
+ export function temporalIntent(query, now) {
164
+ const r = chrono.parse(query, ref(now), { forwardDate: false })[0];
165
+ let day;
166
+ if (r !== undefined) {
167
+ let d = r.start.date();
168
+ const c = r.start;
169
+ const relative = /\b(next|this|coming|tomorrow|tonight|in\s+\d+)\b/i.test(r.text) ||
170
+ (c.isCertain('weekday') && !c.isCertain('day'));
171
+ const explicitMonth = c.isCertain('month') && !c.isCertain('year') && !relative;
172
+ if (explicitMonth && d.getTime() > now.getTime() + 30 * DAY_MS) {
173
+ d = new Date(Date.UTC(d.getUTCFullYear() - 1, d.getUTCMonth(), d.getUTCDate(), 12));
174
+ }
175
+ // "This week" or "today" asks about the present, even when chrono dates it a few days back.
176
+ const present = /\b(this|today|tonight|now|currently)\b/i.test(r.text);
177
+ // A bare date is a keyword ("2026-05-16 pooling" looks up the event that day mentions); a
178
+ // question about state then needs a cue: "on 2 April", "in March", "as of", "yesterday".
179
+ const cued = DANGLING.test(query.slice(0, r.index)) ||
180
+ /^(on|in|at|as\s+of|during|by)\b/i.test(r.text) ||
181
+ /\b(yesterday|ago|last|earlier|previous)\b/i.test(r.text);
182
+ if (!present && cued && d.getTime() < startOfDay(now).getTime())
183
+ day = d;
184
+ }
185
+ let rest = query;
186
+ if (day !== undefined && r !== undefined)
187
+ rest = without(rest, r.text, true);
188
+ const known = KNOWN.exec(rest);
189
+ if (known !== null && day !== undefined) {
190
+ return withText({ mode: 'known-at', knownAt: endOfDay(day).toISOString() }, without(rest, known[0]), query);
191
+ }
192
+ // History keeps every word: "changed" or "before" may be what the memory itself says.
193
+ if (isHistoryQuestion(query))
194
+ return { mode: 'history' };
195
+ if (day !== undefined) {
196
+ return withText({ mode: 'as-of', asOf: endOfDay(day).toISOString() }, rest, query);
197
+ }
198
+ return { mode: 'current' };
199
+ }
200
+ function withText(i, rest, query) {
201
+ return rest !== '' && rest !== query ? { ...i, text: rest } : i;
202
+ }
203
+ //# sourceMappingURL=temporal.js.map
@@ -0,0 +1,12 @@
1
+ import type { Memory } from './model.js';
2
+ /** The fields these checks read, so a memory without its embedding (a cloud copy) works too. */
3
+ export type Datable = Pick<Memory, 'source' | 'lastConfirmedAt' | 'validFrom'>;
4
+ /**
5
+ * A fact likely to change within weeks (a status, a current version, an owner). The write gate
6
+ * records it as a `gate`/`volatile` decision; the latest such decision on the memory counts.
7
+ */
8
+ export declare function isVolatile(m: Pick<Memory, 'source'>): boolean;
9
+ /** When the memory was last known to hold: its last confirmation, else its start. */
10
+ export declare function lastConfirmed(m: Pick<Memory, 'lastConfirmedAt' | 'validFrom'>): string;
11
+ /** A volatile memory not confirmed within `staleAfterDays` (0 switches the check off). */
12
+ export declare function isStale(m: Datable, now: Date, staleAfterDays: number): boolean;
@@ -0,0 +1,24 @@
1
+ const DAY_MS = 86_400_000;
2
+ /**
3
+ * A fact likely to change within weeks (a status, a current version, an owner). The write gate
4
+ * records it as a `gate`/`volatile` decision; the latest such decision on the memory counts.
5
+ */
6
+ export function isVolatile(m) {
7
+ for (let i = m.source.decisions.length - 1; i >= 0; i--) {
8
+ const d = m.source.decisions[i];
9
+ if (d.stage === 'gate' && d.question === 'volatile')
10
+ return d.answer === 'yes';
11
+ }
12
+ return false;
13
+ }
14
+ /** When the memory was last known to hold: its last confirmation, else its start. */
15
+ export function lastConfirmed(m) {
16
+ return m.lastConfirmedAt ?? m.validFrom;
17
+ }
18
+ /** A volatile memory not confirmed within `staleAfterDays` (0 switches the check off). */
19
+ export function isStale(m, now, staleAfterDays) {
20
+ if (staleAfterDays <= 0 || !isVolatile(m))
21
+ return false;
22
+ return now.getTime() - Date.parse(lastConfirmed(m)) > staleAfterDays * DAY_MS;
23
+ }
24
+ //# sourceMappingURL=volatile.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oortmem/core",
3
- "version": "0.1.1",
3
+ "version": "0.2.1",
4
4
  "description": "Oort core: the tiered hot, warm and cold memory engine for AI agents (types, scoring, recall, consolidation).",
5
5
  "keywords": [
6
6
  "oort",
@@ -51,6 +51,7 @@
51
51
  "access": "public"
52
52
  },
53
53
  "dependencies": {
54
+ "chrono-node": "2.10.3",
54
55
  "zod": "4.6.7"
55
56
  },
56
57
  "peerDependencies": {