@noetic-tools/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/harness/agent-harness.js +13 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +2 -0
- package/dist/memory/layers/temporal.d.ts +91 -0
- package/dist/memory/layers/temporal.js +245 -0
- package/dist/portable/memory-layers.d.ts +2 -0
- package/dist/portable/memory-layers.js +2 -0
- package/dist/types/common.d.ts +6 -0
- package/package.json +1 -1
|
@@ -50,6 +50,19 @@ function createClient(config) {
|
|
|
50
50
|
if (!apiKey) {
|
|
51
51
|
return undefined;
|
|
52
52
|
}
|
|
53
|
+
if (config?.cache) {
|
|
54
|
+
// Inject `X-OpenRouter-Cache: true` on every request so OpenRouter serves
|
|
55
|
+
// identical model calls from cache without re-billing (deterministic re-runs).
|
|
56
|
+
return new OpenRouter({
|
|
57
|
+
apiKey,
|
|
58
|
+
hooks: {
|
|
59
|
+
beforeRequest: (_ctx, request) => {
|
|
60
|
+
request.headers.set('X-OpenRouter-Cache', 'true');
|
|
61
|
+
return request;
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
}
|
|
53
66
|
return new OpenRouter({
|
|
54
67
|
apiKey,
|
|
55
68
|
});
|
package/dist/index.d.ts
CHANGED
|
@@ -75,6 +75,10 @@ export { staticContent } from './memory/layers/static-content.js';
|
|
|
75
75
|
/** @public */
|
|
76
76
|
export { steering } from './memory/layers/steering.js';
|
|
77
77
|
/** @public */
|
|
78
|
+
export type { FactExtractor, FactSearcher, TemporalFact, TemporalMemoryConfig, TemporalSearchResult, } from './memory/layers/temporal.js';
|
|
79
|
+
/** @public */
|
|
80
|
+
export { temporalMemory } from './memory/layers/temporal.js';
|
|
81
|
+
/** @public */
|
|
78
82
|
export { toolMemoryLayer } from './memory/layers/tool-memory-layer.js';
|
|
79
83
|
/** @public */
|
|
80
84
|
export type { WorkingMemoryConfig, WorkingMemoryState } from './memory/layers/working-memory.js';
|
package/dist/index.js
CHANGED
|
@@ -66,6 +66,8 @@ export { staticContent } from './memory/layers/static-content.js';
|
|
|
66
66
|
/** @public */
|
|
67
67
|
export { steering } from './memory/layers/steering.js';
|
|
68
68
|
/** @public */
|
|
69
|
+
export { temporalMemory } from './memory/layers/temporal.js';
|
|
70
|
+
/** @public */
|
|
69
71
|
export { toolMemoryLayer } from './memory/layers/tool-memory-layer.js';
|
|
70
72
|
/** @public */
|
|
71
73
|
export { workingMemory } from './memory/layers/working-memory.js';
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { MemoryLayer, MemoryScope } from '../../types/memory.js';
|
|
2
|
+
/**
|
|
3
|
+
* A single remembered fact tied to an instant.
|
|
4
|
+
* @public
|
|
5
|
+
*/
|
|
6
|
+
export interface TemporalFact {
|
|
7
|
+
/** ISO-8601 timestamp the fact is associated with (when it happened / was stated). */
|
|
8
|
+
ts: string;
|
|
9
|
+
/** The fact, stated tersely. */
|
|
10
|
+
fact: string;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Result of a temporal memory search.
|
|
14
|
+
* @public
|
|
15
|
+
*/
|
|
16
|
+
export interface TemporalSearchResult {
|
|
17
|
+
/** Matching facts, most relevant first. */
|
|
18
|
+
facts: string[];
|
|
19
|
+
/** A resolved date for the query, when one applies (ISO-8601 or a natural phrase). */
|
|
20
|
+
date?: string;
|
|
21
|
+
/** True when `date` is approximate ("around March", "a few weeks ago"). */
|
|
22
|
+
fuzzy?: boolean;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Extracts timestamped facts from a batch of conversation text. Host-injected so
|
|
26
|
+
* the layer stays LLM-agnostic and tree-shakable (mirrors `observationalMemory`'s
|
|
27
|
+
* `observer`). When omitted, the layer only buffers — it never fabricates facts.
|
|
28
|
+
* @public
|
|
29
|
+
*/
|
|
30
|
+
export type FactExtractor = (input: {
|
|
31
|
+
transcript: string;
|
|
32
|
+
now: string;
|
|
33
|
+
}) => Promise<TemporalFact[]>;
|
|
34
|
+
/**
|
|
35
|
+
* Searches the stored fact ledger for a query, optionally resolving a date.
|
|
36
|
+
* Host-injected. When omitted, search falls back to returning the raw ledger.
|
|
37
|
+
* @public
|
|
38
|
+
*/
|
|
39
|
+
export type FactSearcher = (input: {
|
|
40
|
+
query: string;
|
|
41
|
+
facts: ReadonlyArray<TemporalFact>;
|
|
42
|
+
now: string;
|
|
43
|
+
}) => Promise<TemporalSearchResult>;
|
|
44
|
+
/**
|
|
45
|
+
* Configuration for {@link temporalMemory}.
|
|
46
|
+
* @public
|
|
47
|
+
*/
|
|
48
|
+
export interface TemporalMemoryConfig {
|
|
49
|
+
/** Returns "now" for date grounding/extraction. Defaults to the system clock. */
|
|
50
|
+
now?: () => Date;
|
|
51
|
+
/** Memory scope. Defaults to `'resource'` (long-term, cross-session). */
|
|
52
|
+
scope?: MemoryScope;
|
|
53
|
+
/** LLM-backed fact extractor. Without it, `store` only buffers (no extraction). */
|
|
54
|
+
extract?: FactExtractor;
|
|
55
|
+
/** LLM-backed fact searcher. Without it, `searchMemory` returns the raw ledger. */
|
|
56
|
+
search?: FactSearcher;
|
|
57
|
+
/** Buffered output tokens that trigger an extraction pass. Default 2000. */
|
|
58
|
+
bufferThreshold?: number;
|
|
59
|
+
/** Maximum facts retained (oldest dropped beyond this). Default 200. */
|
|
60
|
+
maxFacts?: number;
|
|
61
|
+
/** Inject a `<current_datetime>` grounding block on recall. Default true. */
|
|
62
|
+
groundDateTime?: boolean;
|
|
63
|
+
/** Inject the fact ledger on recall (vs. on-demand via the search tool). Default false. */
|
|
64
|
+
injectLedger?: boolean;
|
|
65
|
+
}
|
|
66
|
+
interface TemporalState {
|
|
67
|
+
/** ISO timestamp → facts recorded at that instant. */
|
|
68
|
+
facts: Record<string, string[]>;
|
|
69
|
+
/** Accumulated output text awaiting extraction. */
|
|
70
|
+
buffer: string[];
|
|
71
|
+
bufferTokens: number;
|
|
72
|
+
version: number;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* A non-atomic, LLM-backed memory layer that extracts timestamped facts from the
|
|
76
|
+
* conversation into a key-value ledger (key = ISO timestamp) and exposes a
|
|
77
|
+
* `temporal/searchMemory` tool for relative-time recall — given a fact/query it
|
|
78
|
+
* returns matching facts and, when applicable, a resolved (possibly fuzzy) date.
|
|
79
|
+
*
|
|
80
|
+
* The layer is LLM-agnostic: the host injects `extract` and `search` callbacks
|
|
81
|
+
* (mirrors `observationalMemory`'s `observer`), keeping `memory/` tree-shakable.
|
|
82
|
+
* Without them, the layer still buffers text and the search tool returns the raw
|
|
83
|
+
* ledger — it never fabricates facts. A `<current_datetime>` grounding block is
|
|
84
|
+
* injected on recall by default to anchor relative-time reasoning.
|
|
85
|
+
*
|
|
86
|
+
* @public
|
|
87
|
+
* @param config - Clock, scope, injected LLM callbacks, and tuning knobs.
|
|
88
|
+
* @returns A `MemoryLayer` with a fact-ledger store + `searchMemory` tool.
|
|
89
|
+
*/
|
|
90
|
+
export declare function temporalMemory(config?: TemporalMemoryConfig): MemoryLayer<TemporalState>;
|
|
91
|
+
export {};
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { Slot } from '../../types/memory.js';
|
|
3
|
+
import { createMessage, estimateTokens, isOutputText } from '../../util/message-helpers.js';
|
|
4
|
+
import { layerFn } from '../layer-provides.js';
|
|
5
|
+
function emptyState() {
|
|
6
|
+
return {
|
|
7
|
+
facts: {},
|
|
8
|
+
buffer: [],
|
|
9
|
+
bufferTokens: 0,
|
|
10
|
+
version: 0,
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
const DEFAULT_BUFFER_THRESHOLD_TOKENS = 2_000;
|
|
14
|
+
const DEFAULT_MAX_FACTS = 200;
|
|
15
|
+
//#endregion
|
|
16
|
+
//#region Date grounding
|
|
17
|
+
const WEEKDAYS = [
|
|
18
|
+
'Sun',
|
|
19
|
+
'Mon',
|
|
20
|
+
'Tue',
|
|
21
|
+
'Wed',
|
|
22
|
+
'Thu',
|
|
23
|
+
'Fri',
|
|
24
|
+
'Sat',
|
|
25
|
+
];
|
|
26
|
+
/** Formats an instant as `YYYY/MM/DD (Ddd) HH:MM` using local fields. */
|
|
27
|
+
function formatNow(date) {
|
|
28
|
+
const yyyy = date.getFullYear();
|
|
29
|
+
const mm = String(date.getMonth() + 1).padStart(2, '0');
|
|
30
|
+
const dd = String(date.getDate()).padStart(2, '0');
|
|
31
|
+
const day = WEEKDAYS[date.getDay()];
|
|
32
|
+
const hh = String(date.getHours()).padStart(2, '0');
|
|
33
|
+
const min = String(date.getMinutes()).padStart(2, '0');
|
|
34
|
+
return `${yyyy}/${mm}/${dd} (${day}) ${hh}:${min}`;
|
|
35
|
+
}
|
|
36
|
+
function renderDateTimeBlock(date) {
|
|
37
|
+
return [
|
|
38
|
+
'<current_datetime>',
|
|
39
|
+
`Now: ${formatNow(date)}`,
|
|
40
|
+
'Resolve relative time ("N days/weeks/months ago", "last/next <weekday>",',
|
|
41
|
+
'"before/after", "first/earliest/most recent", "how long since/until") against',
|
|
42
|
+
'Now above and compute the difference explicitly before answering. Use the',
|
|
43
|
+
'temporal/searchMemory tool to look up when something happened.',
|
|
44
|
+
'</current_datetime>',
|
|
45
|
+
].join('\n');
|
|
46
|
+
}
|
|
47
|
+
//#endregion
|
|
48
|
+
//#region Ledger helpers
|
|
49
|
+
/** Flattens the KV ledger into a flat, chronologically sorted fact list. */
|
|
50
|
+
function ledgerToFacts(state) {
|
|
51
|
+
const out = [];
|
|
52
|
+
for (const [ts, facts] of Object.entries(state.facts)) {
|
|
53
|
+
for (const fact of facts) {
|
|
54
|
+
out.push({
|
|
55
|
+
ts,
|
|
56
|
+
fact,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
out.sort((a, b) => (a.ts < b.ts ? -1 : a.ts > b.ts ? 1 : 0));
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
/** Merges new facts into the KV ledger, capping total facts to `maxFacts` (oldest dropped). */
|
|
64
|
+
function mergeFacts(state, incoming, maxFacts) {
|
|
65
|
+
const facts = {};
|
|
66
|
+
for (const [ts, list] of Object.entries(state.facts)) {
|
|
67
|
+
facts[ts] = [
|
|
68
|
+
...list,
|
|
69
|
+
];
|
|
70
|
+
}
|
|
71
|
+
for (const { ts, fact } of incoming) {
|
|
72
|
+
const list = facts[ts] ?? [];
|
|
73
|
+
if (!list.includes(fact)) {
|
|
74
|
+
list.push(fact);
|
|
75
|
+
}
|
|
76
|
+
facts[ts] = list;
|
|
77
|
+
}
|
|
78
|
+
// Cap: drop oldest timestamps until under maxFacts.
|
|
79
|
+
let total = 0;
|
|
80
|
+
for (const list of Object.values(facts)) {
|
|
81
|
+
total += list.length;
|
|
82
|
+
}
|
|
83
|
+
if (total <= maxFacts) {
|
|
84
|
+
return facts;
|
|
85
|
+
}
|
|
86
|
+
const sortedTs = Object.keys(facts).sort();
|
|
87
|
+
for (const ts of sortedTs) {
|
|
88
|
+
if (total <= maxFacts) {
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
91
|
+
total -= facts[ts].length;
|
|
92
|
+
delete facts[ts];
|
|
93
|
+
}
|
|
94
|
+
return facts;
|
|
95
|
+
}
|
|
96
|
+
/** Extracts assistant/output text from newly appended items for buffering. */
|
|
97
|
+
function collectText(newItems) {
|
|
98
|
+
return newItems
|
|
99
|
+
.filter((i) => i.type === 'message')
|
|
100
|
+
.map((i) => i.content
|
|
101
|
+
.filter(isOutputText)
|
|
102
|
+
.map((c) => c.text)
|
|
103
|
+
.join(''))
|
|
104
|
+
.filter((t) => t.length > 0);
|
|
105
|
+
}
|
|
106
|
+
//#endregion
|
|
107
|
+
//#region Layer
|
|
108
|
+
/**
|
|
109
|
+
* A non-atomic, LLM-backed memory layer that extracts timestamped facts from the
|
|
110
|
+
* conversation into a key-value ledger (key = ISO timestamp) and exposes a
|
|
111
|
+
* `temporal/searchMemory` tool for relative-time recall — given a fact/query it
|
|
112
|
+
* returns matching facts and, when applicable, a resolved (possibly fuzzy) date.
|
|
113
|
+
*
|
|
114
|
+
* The layer is LLM-agnostic: the host injects `extract` and `search` callbacks
|
|
115
|
+
* (mirrors `observationalMemory`'s `observer`), keeping `memory/` tree-shakable.
|
|
116
|
+
* Without them, the layer still buffers text and the search tool returns the raw
|
|
117
|
+
* ledger — it never fabricates facts. A `<current_datetime>` grounding block is
|
|
118
|
+
* injected on recall by default to anchor relative-time reasoning.
|
|
119
|
+
*
|
|
120
|
+
* @public
|
|
121
|
+
* @param config - Clock, scope, injected LLM callbacks, and tuning knobs.
|
|
122
|
+
* @returns A `MemoryLayer` with a fact-ledger store + `searchMemory` tool.
|
|
123
|
+
*/
|
|
124
|
+
export function temporalMemory(config) {
|
|
125
|
+
const now = config?.now ?? (() => new Date());
|
|
126
|
+
const threshold = config?.bufferThreshold ?? DEFAULT_BUFFER_THRESHOLD_TOKENS;
|
|
127
|
+
const maxFacts = config?.maxFacts ?? DEFAULT_MAX_FACTS;
|
|
128
|
+
const groundDateTime = config?.groundDateTime ?? true;
|
|
129
|
+
const injectLedger = config?.injectLedger ?? false;
|
|
130
|
+
const extract = config?.extract;
|
|
131
|
+
const search = config?.search;
|
|
132
|
+
return {
|
|
133
|
+
id: 'temporal',
|
|
134
|
+
name: 'Temporal Memory',
|
|
135
|
+
// Grounding sits near the top of the window, before reasoning content.
|
|
136
|
+
slot: Slot.REMINDER,
|
|
137
|
+
scope: config?.scope ?? 'resource',
|
|
138
|
+
budget: {
|
|
139
|
+
min: 0,
|
|
140
|
+
max: injectLedger ? 800 : 200,
|
|
141
|
+
},
|
|
142
|
+
timeouts: {
|
|
143
|
+
store: 60_000,
|
|
144
|
+
},
|
|
145
|
+
provides: {
|
|
146
|
+
searchMemory: layerFn({
|
|
147
|
+
description: 'Search remembered facts about the user by topic or fact, and resolve relative time. Returns matching facts and, when the query implies a time, a resolved date (which may be approximate).',
|
|
148
|
+
input: z.object({
|
|
149
|
+
query: z.string(),
|
|
150
|
+
}),
|
|
151
|
+
output: z.object({
|
|
152
|
+
facts: z.array(z.string()),
|
|
153
|
+
date: z.string().optional(),
|
|
154
|
+
fuzzy: z.boolean().optional(),
|
|
155
|
+
}),
|
|
156
|
+
execute: async (args, state) => {
|
|
157
|
+
const facts = ledgerToFacts(state ?? emptyState());
|
|
158
|
+
if (!search) {
|
|
159
|
+
return {
|
|
160
|
+
result: {
|
|
161
|
+
facts: facts.map((f) => `[${f.ts}] ${f.fact}`),
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
const result = await search({
|
|
166
|
+
query: args.query,
|
|
167
|
+
facts,
|
|
168
|
+
now: formatNow(now()),
|
|
169
|
+
});
|
|
170
|
+
return {
|
|
171
|
+
result,
|
|
172
|
+
};
|
|
173
|
+
},
|
|
174
|
+
}),
|
|
175
|
+
},
|
|
176
|
+
hooks: {
|
|
177
|
+
async init({ storage }) {
|
|
178
|
+
const saved = await storage.get('state');
|
|
179
|
+
return {
|
|
180
|
+
state: saved ?? emptyState(),
|
|
181
|
+
};
|
|
182
|
+
},
|
|
183
|
+
async recall({ state }) {
|
|
184
|
+
const blocks = [];
|
|
185
|
+
if (groundDateTime) {
|
|
186
|
+
blocks.push(renderDateTimeBlock(now()));
|
|
187
|
+
}
|
|
188
|
+
if (injectLedger) {
|
|
189
|
+
const facts = ledgerToFacts(state ?? emptyState());
|
|
190
|
+
if (facts.length > 0) {
|
|
191
|
+
const lines = facts.map((f) => `- [${f.ts}] ${f.fact}`).join('\n');
|
|
192
|
+
blocks.push(`<remembered_facts>\n${lines}\n</remembered_facts>`);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
if (blocks.length === 0) {
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
const text = blocks.join('\n\n');
|
|
199
|
+
return {
|
|
200
|
+
items: [
|
|
201
|
+
createMessage(text, 'developer'),
|
|
202
|
+
],
|
|
203
|
+
tokenCount: estimateTokens(text),
|
|
204
|
+
};
|
|
205
|
+
},
|
|
206
|
+
async store({ newItems, state }) {
|
|
207
|
+
const s = state ?? emptyState();
|
|
208
|
+
const texts = collectText(newItems);
|
|
209
|
+
const newBuffer = [
|
|
210
|
+
...s.buffer,
|
|
211
|
+
...texts,
|
|
212
|
+
];
|
|
213
|
+
const newTokens = texts.reduce((sum, t) => sum + estimateTokens(t), 0);
|
|
214
|
+
const totalBufferTokens = s.bufferTokens + newTokens;
|
|
215
|
+
if (extract && totalBufferTokens >= threshold && newBuffer.length > 0) {
|
|
216
|
+
const extracted = await extract({
|
|
217
|
+
transcript: newBuffer.join('\n'),
|
|
218
|
+
now: formatNow(now()),
|
|
219
|
+
});
|
|
220
|
+
return {
|
|
221
|
+
state: {
|
|
222
|
+
facts: mergeFacts(s, extracted, maxFacts),
|
|
223
|
+
buffer: [],
|
|
224
|
+
bufferTokens: 0,
|
|
225
|
+
version: s.version + 1,
|
|
226
|
+
},
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
return {
|
|
230
|
+
state: {
|
|
231
|
+
...s,
|
|
232
|
+
buffer: newBuffer,
|
|
233
|
+
bufferTokens: totalBufferTokens,
|
|
234
|
+
},
|
|
235
|
+
};
|
|
236
|
+
},
|
|
237
|
+
async onSpawn({ parentState }) {
|
|
238
|
+
return {
|
|
239
|
+
childState: structuredClone(parentState),
|
|
240
|
+
};
|
|
241
|
+
},
|
|
242
|
+
},
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
//#endregion
|
|
@@ -7,6 +7,8 @@ export { observationalMemory } from '../memory/layers/observational-memory.js';
|
|
|
7
7
|
/** @public */
|
|
8
8
|
export { planMemory } from '../memory/layers/plan.js';
|
|
9
9
|
/** @public */
|
|
10
|
+
export { temporalMemory } from '../memory/layers/temporal.js';
|
|
11
|
+
/** @public */
|
|
10
12
|
export { toolMemoryLayer } from '../memory/layers/tool-memory-layer.js';
|
|
11
13
|
/** @public */
|
|
12
14
|
export { workingMemory } from '../memory/layers/working-memory.js';
|
|
@@ -7,6 +7,8 @@ export { observationalMemory } from '../memory/layers/observational-memory.js';
|
|
|
7
7
|
/** @public */
|
|
8
8
|
export { planMemory } from '../memory/layers/plan.js';
|
|
9
9
|
/** @public */
|
|
10
|
+
export { temporalMemory } from '../memory/layers/temporal.js';
|
|
11
|
+
/** @public */
|
|
10
12
|
export { toolMemoryLayer } from '../memory/layers/tool-memory-layer.js';
|
|
11
13
|
/** @public */
|
|
12
14
|
export { workingMemory } from '../memory/layers/working-memory.js';
|
package/dist/types/common.d.ts
CHANGED
|
@@ -59,4 +59,10 @@ export interface LLMResponse {
|
|
|
59
59
|
export interface LlmProviderConfig {
|
|
60
60
|
provider: 'openrouter';
|
|
61
61
|
apiKey?: string;
|
|
62
|
+
/**
|
|
63
|
+
* When true, sends the `X-OpenRouter-Cache: true` request header on every
|
|
64
|
+
* model call. OpenRouter then serves identical requests from cache without
|
|
65
|
+
* re-billing — useful for deterministic re-runs (evals, regression suites).
|
|
66
|
+
*/
|
|
67
|
+
cache?: boolean;
|
|
62
68
|
}
|