@tanstack/ai-memory 0.0.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/esm/index.d.ts +2 -0
- package/dist/esm/index.js +2 -0
- package/dist/esm/internal/store.d.ts +95 -0
- package/dist/esm/internal/store.js +201 -0
- package/dist/esm/internal/store.js.map +1 -0
- package/dist/esm/internal/store.test.d.ts +1 -0
- package/dist/esm/middleware.d.ts +73 -0
- package/dist/esm/middleware.js +250 -0
- package/dist/esm/middleware.js.map +1 -0
- package/dist/esm/providers/hindsight/index.d.ts +50 -0
- package/dist/esm/providers/hindsight/index.js +152 -0
- package/dist/esm/providers/hindsight/index.js.map +1 -0
- package/dist/esm/providers/hindsight/tools.d.ts +16 -0
- package/dist/esm/providers/hindsight/tools.js +119 -0
- package/dist/esm/providers/hindsight/tools.js.map +1 -0
- package/dist/esm/providers/honcho/index.d.ts +16 -0
- package/dist/esm/providers/honcho/index.js +150 -0
- package/dist/esm/providers/honcho/index.js.map +1 -0
- package/dist/esm/providers/in-memory/index.d.ts +19 -0
- package/dist/esm/providers/in-memory/index.js +46 -0
- package/dist/esm/providers/in-memory/index.js.map +1 -0
- package/dist/esm/providers/mem0/index.d.ts +14 -0
- package/dist/esm/providers/mem0/index.js +150 -0
- package/dist/esm/providers/mem0/index.js.map +1 -0
- package/dist/esm/providers/redis/index.d.ts +54 -0
- package/dist/esm/providers/redis/index.js +118 -0
- package/dist/esm/providers/redis/index.js.map +1 -0
- package/dist/esm/types.d.ts +112 -0
- package/package.json +100 -0
- package/skills/tanstack-ai-memory/SKILL.md +99 -0
- package/skills/tanstack-ai-memory-hindsight/SKILL.md +40 -0
- package/skills/tanstack-ai-memory-honcho/SKILL.md +40 -0
- package/skills/tanstack-ai-memory-in-memory/SKILL.md +51 -0
- package/skills/tanstack-ai-memory-mem0/SKILL.md +36 -0
- package/skills/tanstack-ai-memory-redis/SKILL.md +83 -0
- package/src/index.ts +20 -0
- package/src/internal/store.test.ts +63 -0
- package/src/internal/store.ts +378 -0
- package/src/middleware.ts +386 -0
- package/src/providers/hindsight/index.ts +237 -0
- package/src/providers/hindsight/tools.ts +139 -0
- package/src/providers/honcho/index.ts +238 -0
- package/src/providers/in-memory/index.ts +63 -0
- package/src/providers/mem0/index.ts +199 -0
- package/src/providers/redis/index.ts +179 -0
- package/src/types.ts +159 -0
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
export { memoryMiddleware, MEMORY_STATE_EVENT, type MemoryMiddlewareOptions, type MemoryMiddlewareRole, type MemoryRecallInfo, type MemorySaveInfo, type MemoryStateEventValue, } from './middleware.js';
|
|
2
|
+
export type { MemoryAdapter, MemoryScope, MemoryTurn, MemoryFragment, RecallResult, SaveReceipt, MemorySnapshot, MemoryFact, } from './types.js';
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { MemoryFact, MemoryScope, MemorySnapshot, MemoryTurn, RecallResult, SaveReceipt } from '../types.js';
|
|
2
|
+
export type MemoryKind = 'message' | 'summary' | 'fact' | 'preference';
|
|
3
|
+
export type MemoryRole = 'user' | 'assistant';
|
|
4
|
+
/** Internal stored record. Never crosses the public boundary. */
|
|
5
|
+
export interface MemoryRecord {
|
|
6
|
+
id: string;
|
|
7
|
+
scope: MemoryScope;
|
|
8
|
+
text: string;
|
|
9
|
+
kind: MemoryKind;
|
|
10
|
+
role?: MemoryRole;
|
|
11
|
+
createdAt: number;
|
|
12
|
+
updatedAt?: number;
|
|
13
|
+
expiresAt?: number;
|
|
14
|
+
importance?: number;
|
|
15
|
+
embedding?: Array<number>;
|
|
16
|
+
metadata?: Record<string, unknown>;
|
|
17
|
+
}
|
|
18
|
+
/** Pluggable extractor: turn a completed turn into extra records to persist. */
|
|
19
|
+
export type ExtractFn = (turn: MemoryTurn, scope: MemoryScope) => Promise<Array<ExtractedFact> | undefined> | Array<ExtractedFact> | undefined;
|
|
20
|
+
export interface ExtractedFact {
|
|
21
|
+
text: string;
|
|
22
|
+
kind?: MemoryKind;
|
|
23
|
+
importance?: number;
|
|
24
|
+
metadata?: Record<string, unknown>;
|
|
25
|
+
}
|
|
26
|
+
export interface Embedder {
|
|
27
|
+
embed: (text: string) => Promise<Array<number>>;
|
|
28
|
+
}
|
|
29
|
+
/** Options common to the built-in adapters. */
|
|
30
|
+
export interface BuiltinOptions {
|
|
31
|
+
/** Max hits returned by recall. Defaults to 6. */
|
|
32
|
+
topK?: number;
|
|
33
|
+
/** Drop hits scoring below this. Defaults to 0.15. */
|
|
34
|
+
minScore?: number;
|
|
35
|
+
/** Restrict recall to these kinds. Defaults to all. */
|
|
36
|
+
kinds?: Array<MemoryKind>;
|
|
37
|
+
/** Optional embedder for semantic scoring on both save and recall. */
|
|
38
|
+
embedder?: Embedder;
|
|
39
|
+
/** Optional extractor run on `save` to persist derived facts/preferences. */
|
|
40
|
+
extract?: ExtractFn;
|
|
41
|
+
/** Replace the built-in prompt renderer. */
|
|
42
|
+
render?: (hits: Array<MemoryHit>) => string;
|
|
43
|
+
}
|
|
44
|
+
export interface MemoryHit {
|
|
45
|
+
record: MemoryRecord;
|
|
46
|
+
score: number;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Minimal storage backend the built-in adapters run on. `add` upserts by id;
|
|
50
|
+
* `loadScope` returns the live (non-expired) records for exactly this scope.
|
|
51
|
+
*/
|
|
52
|
+
export interface RecordStore {
|
|
53
|
+
add: (records: Array<MemoryRecord>) => Promise<void>;
|
|
54
|
+
loadScope: (scope: MemoryScope) => Promise<Array<MemoryRecord>>;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Exact scope match for built-in stores. `threadId` must match, and optional
|
|
58
|
+
* `userId` / `tenantId` must match exactly on both sides (including both
|
|
59
|
+
* unset). A query that omits `tenantId` does **not** match a record written
|
|
60
|
+
* with a tenant — same isolation model as Redis composite index keys.
|
|
61
|
+
* `namespace` is reserved and ignored until a subsystem keys on it.
|
|
62
|
+
*/
|
|
63
|
+
export declare function sameScope(record: MemoryScope, query: MemoryScope): boolean;
|
|
64
|
+
export declare function cosine(a?: Array<number>, b?: Array<number>): number;
|
|
65
|
+
export declare function lexicalOverlap(query: string, text: string): number;
|
|
66
|
+
export declare function recencyScore(createdAt: number, halfLifeMs?: number, now?: number): number;
|
|
67
|
+
export declare function isExpired(record: MemoryRecord, now?: number): boolean;
|
|
68
|
+
/**
|
|
69
|
+
* Reference ranking: weighted sum of semantic (0.55), lexical (0.20), recency
|
|
70
|
+
* (0.15), and importance (0.10). Unset importance contributes 0 — no mid-range
|
|
71
|
+
* fallback, so recent records don't automatically clear the `minScore` floor.
|
|
72
|
+
*/
|
|
73
|
+
export declare function defaultScoreHit(args: {
|
|
74
|
+
record: MemoryRecord;
|
|
75
|
+
queryText: string;
|
|
76
|
+
queryEmbedding?: Array<number>;
|
|
77
|
+
now?: number;
|
|
78
|
+
}): number;
|
|
79
|
+
export declare function defaultRenderMemory(hits: Array<MemoryHit>): string;
|
|
80
|
+
/** Portable record id — real UUID where available, deterministic fallback otherwise. */
|
|
81
|
+
export declare function newRecordId(): string;
|
|
82
|
+
/**
|
|
83
|
+
* Build the records for a completed turn: the raw user/assistant messages
|
|
84
|
+
* (importance 0.4) plus anything the optional extractor returns, embedding each
|
|
85
|
+
* when an embedder is configured.
|
|
86
|
+
*/
|
|
87
|
+
export declare function buildTurnRecords(scope: MemoryScope, turn: MemoryTurn, options: BuiltinOptions): Promise<Array<MemoryRecord>>;
|
|
88
|
+
/** Persist a turn to the store and return one receipt for the batch. */
|
|
89
|
+
export declare function saveTurn(store: RecordStore, scope: MemoryScope, turn: MemoryTurn, options: BuiltinOptions): Promise<Array<SaveReceipt>>;
|
|
90
|
+
/** Score the scoped records against the query and render a recall result. */
|
|
91
|
+
export declare function recallRecords(store: RecordStore, scope: MemoryScope, query: string, options: BuiltinOptions): Promise<RecallResult>;
|
|
92
|
+
/** Devtools inspect over a scope's live records. */
|
|
93
|
+
export declare function inspectRecords(store: RecordStore, scope: MemoryScope): Promise<MemorySnapshot>;
|
|
94
|
+
/** Devtools flat fact list over a scope's live records. */
|
|
95
|
+
export declare function listRecordFacts(store: RecordStore, scope: MemoryScope): Promise<Array<MemoryFact>>;
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
//#region src/internal/store.ts
|
|
2
|
+
/**
|
|
3
|
+
* Normalize an optional scope dimension: empty string is treated as unset so
|
|
4
|
+
* `''` and `undefined` compare equal.
|
|
5
|
+
*/
|
|
6
|
+
function scopeDimValue(value) {
|
|
7
|
+
return value != null && value !== "" ? value : void 0;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Exact scope match for built-in stores. `threadId` must match, and optional
|
|
11
|
+
* `userId` / `tenantId` must match exactly on both sides (including both
|
|
12
|
+
* unset). A query that omits `tenantId` does **not** match a record written
|
|
13
|
+
* with a tenant — same isolation model as Redis composite index keys.
|
|
14
|
+
* `namespace` is reserved and ignored until a subsystem keys on it.
|
|
15
|
+
*/
|
|
16
|
+
function sameScope(record, query) {
|
|
17
|
+
if (record.threadId !== query.threadId) return false;
|
|
18
|
+
if (scopeDimValue(record.userId) !== scopeDimValue(query.userId)) return false;
|
|
19
|
+
if (scopeDimValue(record.tenantId) !== scopeDimValue(query.tenantId)) return false;
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
22
|
+
var DEFAULT_HALF_LIFE_MS = 1e3 * 60 * 60 * 24 * 30;
|
|
23
|
+
function cosine(a, b) {
|
|
24
|
+
if (!a || !b || a.length !== b.length || a.length === 0) return 0;
|
|
25
|
+
let dot = 0;
|
|
26
|
+
let aMag = 0;
|
|
27
|
+
let bMag = 0;
|
|
28
|
+
for (let i = 0; i < a.length; i++) {
|
|
29
|
+
const av = a[i];
|
|
30
|
+
const bv = b[i];
|
|
31
|
+
dot += av * bv;
|
|
32
|
+
aMag += av ** 2;
|
|
33
|
+
bMag += bv ** 2;
|
|
34
|
+
}
|
|
35
|
+
if (aMag === 0 || bMag === 0) return 0;
|
|
36
|
+
return dot / (Math.sqrt(aMag) * Math.sqrt(bMag));
|
|
37
|
+
}
|
|
38
|
+
function lexicalOverlap(query, text) {
|
|
39
|
+
const queryTokens = new Set(query.toLowerCase().split(/\W+/).filter(Boolean));
|
|
40
|
+
if (queryTokens.size === 0) return 0;
|
|
41
|
+
const textTokens = new Set(text.toLowerCase().split(/\W+/).filter(Boolean));
|
|
42
|
+
let overlap = 0;
|
|
43
|
+
for (const token of queryTokens) if (textTokens.has(token)) overlap++;
|
|
44
|
+
return overlap / queryTokens.size;
|
|
45
|
+
}
|
|
46
|
+
function recencyScore(createdAt, halfLifeMs = DEFAULT_HALF_LIFE_MS, now = Date.now()) {
|
|
47
|
+
const age = Math.max(0, now - createdAt);
|
|
48
|
+
return Math.pow(.5, age / halfLifeMs);
|
|
49
|
+
}
|
|
50
|
+
function isExpired(record, now = Date.now()) {
|
|
51
|
+
return record.expiresAt !== void 0 && record.expiresAt < now;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Reference ranking: weighted sum of semantic (0.55), lexical (0.20), recency
|
|
55
|
+
* (0.15), and importance (0.10). Unset importance contributes 0 — no mid-range
|
|
56
|
+
* fallback, so recent records don't automatically clear the `minScore` floor.
|
|
57
|
+
*/
|
|
58
|
+
function defaultScoreHit(args) {
|
|
59
|
+
const { record, queryText, queryEmbedding, now } = args;
|
|
60
|
+
const semantic = cosine(queryEmbedding, record.embedding);
|
|
61
|
+
const lexical = lexicalOverlap(queryText, record.text);
|
|
62
|
+
const recency = recencyScore(record.createdAt, void 0, now);
|
|
63
|
+
const importance = record.importance ?? 0;
|
|
64
|
+
return semantic * .55 + lexical * .2 + recency * .15 + importance * .1;
|
|
65
|
+
}
|
|
66
|
+
function defaultRenderMemory(hits) {
|
|
67
|
+
if (hits.length === 0) return "";
|
|
68
|
+
return [
|
|
69
|
+
"Relevant memory:",
|
|
70
|
+
"Use this information only when it is relevant to the current user request.",
|
|
71
|
+
"Do not mention memory directly unless the user asks about it.",
|
|
72
|
+
"If current conversation context contradicts memory, prefer the current conversation.",
|
|
73
|
+
"",
|
|
74
|
+
...hits.map((hit, index) => `${index + 1}. [${hit.record.kind}] ${JSON.stringify(hit.record.text)}`)
|
|
75
|
+
].join("\n");
|
|
76
|
+
}
|
|
77
|
+
/** Portable record id — real UUID where available, deterministic fallback otherwise. */
|
|
78
|
+
function newRecordId() {
|
|
79
|
+
try {
|
|
80
|
+
return crypto.randomUUID();
|
|
81
|
+
} catch {
|
|
82
|
+
return `mem-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Build the records for a completed turn: the raw user/assistant messages
|
|
87
|
+
* (importance 0.4) plus anything the optional extractor returns, embedding each
|
|
88
|
+
* when an embedder is configured.
|
|
89
|
+
*/
|
|
90
|
+
async function buildTurnRecords(scope, turn, options) {
|
|
91
|
+
const now = Date.now();
|
|
92
|
+
const records = [];
|
|
93
|
+
async function embed(text) {
|
|
94
|
+
if (!options.embedder) return void 0;
|
|
95
|
+
return options.embedder.embed(text);
|
|
96
|
+
}
|
|
97
|
+
if (turn.user) records.push({
|
|
98
|
+
id: newRecordId(),
|
|
99
|
+
scope,
|
|
100
|
+
text: turn.user,
|
|
101
|
+
kind: "message",
|
|
102
|
+
role: "user",
|
|
103
|
+
createdAt: now,
|
|
104
|
+
importance: .4,
|
|
105
|
+
embedding: await embed(turn.user)
|
|
106
|
+
});
|
|
107
|
+
if (turn.assistant) records.push({
|
|
108
|
+
id: newRecordId(),
|
|
109
|
+
scope,
|
|
110
|
+
text: turn.assistant,
|
|
111
|
+
kind: "message",
|
|
112
|
+
role: "assistant",
|
|
113
|
+
createdAt: now,
|
|
114
|
+
importance: .4,
|
|
115
|
+
embedding: await embed(turn.assistant)
|
|
116
|
+
});
|
|
117
|
+
const extracted = await options.extract?.(turn, scope);
|
|
118
|
+
if (extracted) for (const fact of extracted) records.push({
|
|
119
|
+
id: newRecordId(),
|
|
120
|
+
scope,
|
|
121
|
+
text: fact.text,
|
|
122
|
+
kind: fact.kind ?? "fact",
|
|
123
|
+
createdAt: now,
|
|
124
|
+
importance: fact.importance,
|
|
125
|
+
embedding: await embed(fact.text),
|
|
126
|
+
metadata: fact.metadata
|
|
127
|
+
});
|
|
128
|
+
return records;
|
|
129
|
+
}
|
|
130
|
+
/** Persist a turn to the store and return one receipt for the batch. */
|
|
131
|
+
async function saveTurn(store, scope, turn, options) {
|
|
132
|
+
const startedAt = Date.now();
|
|
133
|
+
try {
|
|
134
|
+
const records = await buildTurnRecords(scope, turn, options);
|
|
135
|
+
if (records.length > 0) await store.add(records);
|
|
136
|
+
return [{
|
|
137
|
+
ok: true,
|
|
138
|
+
latencyMs: Date.now() - startedAt,
|
|
139
|
+
raw: { addedIds: records.map((r) => r.id) }
|
|
140
|
+
}];
|
|
141
|
+
} catch (error) {
|
|
142
|
+
return [{
|
|
143
|
+
ok: false,
|
|
144
|
+
latencyMs: Date.now() - startedAt,
|
|
145
|
+
error: error instanceof Error ? error.message : String(error)
|
|
146
|
+
}];
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
/** Score the scoped records against the query and render a recall result. */
|
|
150
|
+
async function recallRecords(store, scope, query, options) {
|
|
151
|
+
const topK = options.topK ?? 6;
|
|
152
|
+
const minScore = options.minScore ?? .15;
|
|
153
|
+
const now = Date.now();
|
|
154
|
+
const queryEmbedding = options.embedder ? await options.embedder.embed(query) : void 0;
|
|
155
|
+
const records = await store.loadScope(scope);
|
|
156
|
+
const kinds = options.kinds;
|
|
157
|
+
const hits = (kinds && kinds.length > 0 ? records.filter((r) => kinds.includes(r.kind)) : records).map((record) => ({
|
|
158
|
+
record,
|
|
159
|
+
score: defaultScoreHit({
|
|
160
|
+
record,
|
|
161
|
+
queryText: query,
|
|
162
|
+
queryEmbedding,
|
|
163
|
+
now
|
|
164
|
+
})
|
|
165
|
+
})).filter((h) => h.score >= minScore).sort((a, b) => b.score - a.score).slice(0, topK);
|
|
166
|
+
return {
|
|
167
|
+
systemPrompt: (options.render ?? defaultRenderMemory)(hits),
|
|
168
|
+
fragments: hits.map((h) => ({
|
|
169
|
+
text: h.record.text,
|
|
170
|
+
source: h.record.id
|
|
171
|
+
}))
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
/** Devtools inspect over a scope's live records. */
|
|
175
|
+
async function inspectRecords(store, scope) {
|
|
176
|
+
const records = await store.loadScope(scope);
|
|
177
|
+
return {
|
|
178
|
+
takenAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
179
|
+
data: { records: records.map((r) => ({
|
|
180
|
+
id: r.id,
|
|
181
|
+
text: r.text,
|
|
182
|
+
kind: r.kind,
|
|
183
|
+
role: r.role,
|
|
184
|
+
createdAt: r.createdAt,
|
|
185
|
+
importance: r.importance
|
|
186
|
+
})) }
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
/** Devtools flat fact list over a scope's live records. */
|
|
190
|
+
async function listRecordFacts(store, scope) {
|
|
191
|
+
return (await store.loadScope(scope)).map((r) => ({
|
|
192
|
+
id: r.id,
|
|
193
|
+
text: r.text,
|
|
194
|
+
source: r.role ?? r.kind,
|
|
195
|
+
createdAt: new Date(r.createdAt).toISOString()
|
|
196
|
+
}));
|
|
197
|
+
}
|
|
198
|
+
//#endregion
|
|
199
|
+
export { buildTurnRecords, cosine, defaultRenderMemory, defaultScoreHit, inspectRecords, isExpired, lexicalOverlap, listRecordFacts, newRecordId, recallRecords, recencyScore, sameScope, saveTurn };
|
|
200
|
+
|
|
201
|
+
//# sourceMappingURL=store.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"store.js","names":[],"sources":["../../../src/internal/store.ts"],"sourcesContent":["/**\n * Shared internals for the built-in `inMemory()` and `redis()` adapters.\n *\n * NOT part of the public contract — nothing here is exported from the package\n * root. Both built-in adapters keep a set of scored, optionally-embedded\n * `MemoryRecord`s and expose only `recall`/`save`; this module holds the record\n * model, the scoring/rendering helpers, and the extract→store→score→render\n * pipeline they share. The only thing an adapter supplies is a {@link RecordStore}\n * (a Map for in-memory, Redis keys for redis).\n */\n\nimport type {\n MemoryFact,\n MemoryFragment,\n MemoryScope,\n MemorySnapshot,\n MemoryTurn,\n RecallResult,\n SaveReceipt,\n} from '../types'\n\nexport type MemoryKind = 'message' | 'summary' | 'fact' | 'preference'\nexport type MemoryRole = 'user' | 'assistant'\n\n/** Internal stored record. Never crosses the public boundary. */\nexport interface MemoryRecord {\n id: string\n scope: MemoryScope\n text: string\n kind: MemoryKind\n role?: MemoryRole\n createdAt: number\n updatedAt?: number\n expiresAt?: number\n importance?: number\n embedding?: Array<number>\n metadata?: Record<string, unknown>\n}\n\n/** Pluggable extractor: turn a completed turn into extra records to persist. */\nexport type ExtractFn = (\n turn: MemoryTurn,\n scope: MemoryScope,\n) =>\n | Promise<Array<ExtractedFact> | undefined>\n | Array<ExtractedFact>\n | undefined\n\nexport interface ExtractedFact {\n text: string\n kind?: MemoryKind\n importance?: number\n metadata?: Record<string, unknown>\n}\n\nexport interface Embedder {\n embed: (text: string) => Promise<Array<number>>\n}\n\n/** Options common to the built-in adapters. */\nexport interface BuiltinOptions {\n /** Max hits returned by recall. Defaults to 6. */\n topK?: number\n /** Drop hits scoring below this. Defaults to 0.15. */\n minScore?: number\n /** Restrict recall to these kinds. Defaults to all. */\n kinds?: Array<MemoryKind>\n /** Optional embedder for semantic scoring on both save and recall. */\n embedder?: Embedder\n /** Optional extractor run on `save` to persist derived facts/preferences. */\n extract?: ExtractFn\n /** Replace the built-in prompt renderer. */\n render?: (hits: Array<MemoryHit>) => string\n}\n\nexport interface MemoryHit {\n record: MemoryRecord\n score: number\n}\n\n/**\n * Minimal storage backend the built-in adapters run on. `add` upserts by id;\n * `loadScope` returns the live (non-expired) records for exactly this scope.\n */\nexport interface RecordStore {\n add: (records: Array<MemoryRecord>) => Promise<void>\n loadScope: (scope: MemoryScope) => Promise<Array<MemoryRecord>>\n}\n\n// ===========================\n// Scope\n// ===========================\n\n/**\n * Normalize an optional scope dimension: empty string is treated as unset so\n * `''` and `undefined` compare equal.\n */\nfunction scopeDimValue(value: string | undefined): string | undefined {\n return value != null && value !== '' ? value : undefined\n}\n\n/**\n * Exact scope match for built-in stores. `threadId` must match, and optional\n * `userId` / `tenantId` must match exactly on both sides (including both\n * unset). A query that omits `tenantId` does **not** match a record written\n * with a tenant — same isolation model as Redis composite index keys.\n * `namespace` is reserved and ignored until a subsystem keys on it.\n */\nexport function sameScope(record: MemoryScope, query: MemoryScope): boolean {\n if (record.threadId !== query.threadId) return false\n if (scopeDimValue(record.userId) !== scopeDimValue(query.userId)) return false\n if (scopeDimValue(record.tenantId) !== scopeDimValue(query.tenantId)) {\n return false\n }\n return true\n}\n\n// ===========================\n// Scoring helpers\n// ===========================\n\nconst DEFAULT_HALF_LIFE_MS = 1000 * 60 * 60 * 24 * 30 // 30 days\n\nexport function cosine(a?: Array<number>, b?: Array<number>): number {\n if (!a || !b || a.length !== b.length || a.length === 0) return 0\n let dot = 0\n let aMag = 0\n let bMag = 0\n for (let i = 0; i < a.length; i++) {\n const av = a[i] as number\n const bv = b[i] as number\n dot += av * bv\n aMag += av ** 2\n bMag += bv ** 2\n }\n if (aMag === 0 || bMag === 0) return 0\n return dot / (Math.sqrt(aMag) * Math.sqrt(bMag))\n}\n\nexport function lexicalOverlap(query: string, text: string): number {\n const queryTokens = new Set(query.toLowerCase().split(/\\W+/).filter(Boolean))\n if (queryTokens.size === 0) return 0\n const textTokens = new Set(text.toLowerCase().split(/\\W+/).filter(Boolean))\n let overlap = 0\n for (const token of queryTokens) {\n if (textTokens.has(token)) overlap++\n }\n return overlap / queryTokens.size\n}\n\nexport function recencyScore(\n createdAt: number,\n halfLifeMs: number = DEFAULT_HALF_LIFE_MS,\n now: number = Date.now(),\n): number {\n const age = Math.max(0, now - createdAt)\n return Math.pow(0.5, age / halfLifeMs)\n}\n\nexport function isExpired(\n record: MemoryRecord,\n now: number = Date.now(),\n): boolean {\n return record.expiresAt !== undefined && record.expiresAt < now\n}\n\n/**\n * Reference ranking: weighted sum of semantic (0.55), lexical (0.20), recency\n * (0.15), and importance (0.10). Unset importance contributes 0 — no mid-range\n * fallback, so recent records don't automatically clear the `minScore` floor.\n */\nexport function defaultScoreHit(args: {\n record: MemoryRecord\n queryText: string\n queryEmbedding?: Array<number>\n now?: number\n}): number {\n const { record, queryText, queryEmbedding, now } = args\n const semantic = cosine(queryEmbedding, record.embedding)\n const lexical = lexicalOverlap(queryText, record.text)\n const recency = recencyScore(record.createdAt, undefined, now)\n const importance = record.importance ?? 0\n return semantic * 0.55 + lexical * 0.2 + recency * 0.15 + importance * 0.1\n}\n\nexport function defaultRenderMemory(hits: Array<MemoryHit>): string {\n if (hits.length === 0) return ''\n return [\n 'Relevant memory:',\n 'Use this information only when it is relevant to the current user request.',\n 'Do not mention memory directly unless the user asks about it.',\n 'If current conversation context contradicts memory, prefer the current conversation.',\n '',\n // JSON.stringify the text so persisted content with newlines or\n // instruction-shaped text can't break out of the list and steer the turn.\n ...hits.map(\n (hit, index) =>\n `${index + 1}. [${hit.record.kind}] ${JSON.stringify(hit.record.text)}`,\n ),\n ].join('\\n')\n}\n\n// ===========================\n// Shared recall / save pipeline\n// ===========================\n\n/** Portable record id — real UUID where available, deterministic fallback otherwise. */\nexport function newRecordId(): string {\n try {\n return crypto.randomUUID()\n } catch {\n return `mem-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`\n }\n}\n\n/**\n * Build the records for a completed turn: the raw user/assistant messages\n * (importance 0.4) plus anything the optional extractor returns, embedding each\n * when an embedder is configured.\n */\nexport async function buildTurnRecords(\n scope: MemoryScope,\n turn: MemoryTurn,\n options: BuiltinOptions,\n): Promise<Array<MemoryRecord>> {\n const now = Date.now()\n const records: Array<MemoryRecord> = []\n\n async function embed(text: string): Promise<Array<number> | undefined> {\n if (!options.embedder) return undefined\n return options.embedder.embed(text)\n }\n\n if (turn.user) {\n records.push({\n id: newRecordId(),\n scope,\n text: turn.user,\n kind: 'message',\n role: 'user',\n createdAt: now,\n importance: 0.4,\n embedding: await embed(turn.user),\n })\n }\n if (turn.assistant) {\n records.push({\n id: newRecordId(),\n scope,\n text: turn.assistant,\n kind: 'message',\n role: 'assistant',\n createdAt: now,\n importance: 0.4,\n embedding: await embed(turn.assistant),\n })\n }\n\n const extracted = await options.extract?.(turn, scope)\n if (extracted) {\n for (const fact of extracted) {\n records.push({\n id: newRecordId(),\n scope,\n text: fact.text,\n kind: fact.kind ?? 'fact',\n createdAt: now,\n importance: fact.importance,\n embedding: await embed(fact.text),\n metadata: fact.metadata,\n })\n }\n }\n return records\n}\n\n/** Persist a turn to the store and return one receipt for the batch. */\nexport async function saveTurn(\n store: RecordStore,\n scope: MemoryScope,\n turn: MemoryTurn,\n options: BuiltinOptions,\n): Promise<Array<SaveReceipt>> {\n const startedAt = Date.now()\n try {\n const records = await buildTurnRecords(scope, turn, options)\n if (records.length > 0) await store.add(records)\n return [\n {\n ok: true,\n latencyMs: Date.now() - startedAt,\n raw: { addedIds: records.map((r) => r.id) },\n },\n ]\n } catch (error) {\n return [\n {\n ok: false,\n latencyMs: Date.now() - startedAt,\n error: error instanceof Error ? error.message : String(error),\n },\n ]\n }\n}\n\n/** Score the scoped records against the query and render a recall result. */\nexport async function recallRecords(\n store: RecordStore,\n scope: MemoryScope,\n query: string,\n options: BuiltinOptions,\n): Promise<RecallResult> {\n const topK = options.topK ?? 6\n const minScore = options.minScore ?? 0.15\n const now = Date.now()\n\n const queryEmbedding = options.embedder\n ? await options.embedder.embed(query)\n : undefined\n\n const records = await store.loadScope(scope)\n const kinds = options.kinds\n const candidates =\n kinds && kinds.length > 0\n ? records.filter((r) => kinds.includes(r.kind))\n : records\n\n const hits = candidates\n .map((record) => ({\n record,\n score: defaultScoreHit({ record, queryText: query, queryEmbedding, now }),\n }))\n .filter((h) => h.score >= minScore)\n .sort((a, b) => b.score - a.score)\n .slice(0, topK)\n\n const systemPrompt = (options.render ?? defaultRenderMemory)(hits)\n const fragments: Array<MemoryFragment> = hits.map((h) => ({\n text: h.record.text,\n source: h.record.id,\n }))\n return { systemPrompt, fragments }\n}\n\n/** Devtools inspect over a scope's live records. */\nexport async function inspectRecords(\n store: RecordStore,\n scope: MemoryScope,\n): Promise<MemorySnapshot> {\n const records = await store.loadScope(scope)\n return {\n takenAt: new Date().toISOString(),\n data: {\n records: records.map((r) => ({\n id: r.id,\n text: r.text,\n kind: r.kind,\n role: r.role,\n createdAt: r.createdAt,\n importance: r.importance,\n })),\n },\n }\n}\n\n/** Devtools flat fact list over a scope's live records. */\nexport async function listRecordFacts(\n store: RecordStore,\n scope: MemoryScope,\n): Promise<Array<MemoryFact>> {\n const records = await store.loadScope(scope)\n return records.map((r) => ({\n id: r.id,\n text: r.text,\n source: r.role ?? r.kind,\n createdAt: new Date(r.createdAt).toISOString(),\n }))\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { ChatMiddleware, ChatMiddlewareContext } from '@tanstack/ai';
|
|
2
|
+
import { MemoryAdapter, MemoryFact, MemoryScope, MemoryTurn, RecallResult, SaveReceipt } from './types.js';
|
|
3
|
+
/**
|
|
4
|
+
* CUSTOM stream-event name carrying server-side memory state to the browser.
|
|
5
|
+
* The middleware injects one of these per turn (via `onChunk`); the client
|
|
6
|
+
* devtools bridge (`@tanstack/ai-client`) recognizes it and re-emits `memory:*`
|
|
7
|
+
* on the browser event bus. This is how server-side memory reaches the browser
|
|
8
|
+
* DevTools panel — server-emitted `aiEventClient` events never cross runtimes;
|
|
9
|
+
* everything the panel shows is re-derived client-side from the chat stream
|
|
10
|
+
* (mirrors how generation results ride `CUSTOM` events — see `GENERATION_EVENTS`).
|
|
11
|
+
*/
|
|
12
|
+
export declare const MEMORY_STATE_EVENT = "memory:state";
|
|
13
|
+
/** Payload of the {@link MEMORY_STATE_EVENT} CUSTOM chunk. Captures memory state
|
|
14
|
+
* as of the turn's START — the snapshot reflects every prior turn's save; this
|
|
15
|
+
* turn's own save (deferred) surfaces in the next turn's snapshot. */
|
|
16
|
+
export interface MemoryStateEventValue {
|
|
17
|
+
scope: MemoryScope;
|
|
18
|
+
adapter: string;
|
|
19
|
+
/** The recall query (last user text). */
|
|
20
|
+
query: string;
|
|
21
|
+
/** Recall metrics for the operations timeline. */
|
|
22
|
+
recall: {
|
|
23
|
+
fragmentCount: number;
|
|
24
|
+
hasTools: boolean;
|
|
25
|
+
systemPromptChars: number;
|
|
26
|
+
durationMs: number;
|
|
27
|
+
};
|
|
28
|
+
/** Live store snapshot, when the adapter supports `inspect`/`listFacts`. */
|
|
29
|
+
snapshot?: {
|
|
30
|
+
takenAt: string;
|
|
31
|
+
data: unknown;
|
|
32
|
+
facts: Array<MemoryFact>;
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* How the middleware participates in the run:
|
|
37
|
+
* - `'recall+save'` (default): recall on init (inject prompt + tools), save on finish.
|
|
38
|
+
* - `'save-only'`: skip recall entirely — persist the turn but never read/inject.
|
|
39
|
+
*/
|
|
40
|
+
export type MemoryMiddlewareRole = 'recall+save' | 'save-only';
|
|
41
|
+
export interface MemoryRecallInfo {
|
|
42
|
+
scope: MemoryScope;
|
|
43
|
+
query: string;
|
|
44
|
+
result: RecallResult;
|
|
45
|
+
}
|
|
46
|
+
export interface MemorySaveInfo {
|
|
47
|
+
scope: MemoryScope;
|
|
48
|
+
turn: MemoryTurn;
|
|
49
|
+
receipts: Array<SaveReceipt>;
|
|
50
|
+
}
|
|
51
|
+
export interface MemoryMiddlewareOptions {
|
|
52
|
+
/** The memory backend to recall from / save to. */
|
|
53
|
+
adapter: MemoryAdapter;
|
|
54
|
+
/**
|
|
55
|
+
* Scope for every adapter call. The function form is the safer default for
|
|
56
|
+
* multi-tenant apps: derive scope per request from trusted, server-validated
|
|
57
|
+
* chat context — never from client input.
|
|
58
|
+
*/
|
|
59
|
+
scope: MemoryScope | ((ctx: ChatMiddlewareContext) => MemoryScope | Promise<MemoryScope>);
|
|
60
|
+
/** Participation role. Defaults to `'recall+save'`. */
|
|
61
|
+
role?: MemoryMiddlewareRole;
|
|
62
|
+
/** Fired after `recall` completes (post-injection), for app telemetry. */
|
|
63
|
+
onRecall?: (info: MemoryRecallInfo) => void | Promise<void>;
|
|
64
|
+
/** Fired after the deferred `save` completes, for app telemetry. */
|
|
65
|
+
onSave?: (info: MemorySaveInfo) => void | Promise<void>;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Server-side memory middleware. Recalls relevant memory into the prompt before
|
|
69
|
+
* the model runs, then defers `save` of the completed turn after it finishes.
|
|
70
|
+
* All extraction/ranking/rendering lives in the adapter — this middleware only
|
|
71
|
+
* wires `recall`/`save` into the chat lifecycle and emits devtools events.
|
|
72
|
+
*/
|
|
73
|
+
export declare function memoryMiddleware(options: MemoryMiddlewareOptions): ChatMiddleware;
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import { aiEventClient } from "@tanstack/ai-event-client";
|
|
2
|
+
//#region src/middleware.ts
|
|
3
|
+
/**
|
|
4
|
+
* CUSTOM stream-event name carrying server-side memory state to the browser.
|
|
5
|
+
* The middleware injects one of these per turn (via `onChunk`); the client
|
|
6
|
+
* devtools bridge (`@tanstack/ai-client`) recognizes it and re-emits `memory:*`
|
|
7
|
+
* on the browser event bus. This is how server-side memory reaches the browser
|
|
8
|
+
* DevTools panel — server-emitted `aiEventClient` events never cross runtimes;
|
|
9
|
+
* everything the panel shows is re-derived client-side from the chat stream
|
|
10
|
+
* (mirrors how generation results ride `CUSTOM` events — see `GENERATION_EVENTS`).
|
|
11
|
+
*/
|
|
12
|
+
var MEMORY_STATE_EVENT = "memory:state";
|
|
13
|
+
var stateByCtx = /* @__PURE__ */ new WeakMap();
|
|
14
|
+
/**
|
|
15
|
+
* Server-side memory middleware. Recalls relevant memory into the prompt before
|
|
16
|
+
* the model runs, then defers `save` of the completed turn after it finishes.
|
|
17
|
+
* All extraction/ranking/rendering lives in the adapter — this middleware only
|
|
18
|
+
* wires `recall`/`save` into the chat lifecycle and emits devtools events.
|
|
19
|
+
*/
|
|
20
|
+
function memoryMiddleware(options) {
|
|
21
|
+
const role = options.role ?? "recall+save";
|
|
22
|
+
async function resolveScope(ctx, state) {
|
|
23
|
+
if (state.resolvedScope) return state.resolvedScope;
|
|
24
|
+
state.resolvedScope = typeof options.scope === "function" ? await options.scope(ctx) : options.scope;
|
|
25
|
+
return state.resolvedScope;
|
|
26
|
+
}
|
|
27
|
+
return {
|
|
28
|
+
name: `memory:${options.adapter.id}`,
|
|
29
|
+
async onConfig(ctx, config) {
|
|
30
|
+
if (ctx.phase !== "init") return;
|
|
31
|
+
const state = { lastUserText: "" };
|
|
32
|
+
stateByCtx.set(ctx, state);
|
|
33
|
+
state.lastUserText = getMessageText(findLastUserMessage(config.messages));
|
|
34
|
+
if (!state.lastUserText || role === "save-only") return;
|
|
35
|
+
const startedAt = Date.now();
|
|
36
|
+
let scope;
|
|
37
|
+
let result;
|
|
38
|
+
try {
|
|
39
|
+
scope = await resolveScope(ctx, state);
|
|
40
|
+
safeEmit("memory:retrieve:started", {
|
|
41
|
+
scope,
|
|
42
|
+
adapter: options.adapter.id,
|
|
43
|
+
query: state.lastUserText,
|
|
44
|
+
timestamp: startedAt
|
|
45
|
+
});
|
|
46
|
+
result = await options.adapter.recall(scope, state.lastUserText);
|
|
47
|
+
} catch (error) {
|
|
48
|
+
safeEmit("memory:error", {
|
|
49
|
+
...state.resolvedScope ? { scope: state.resolvedScope } : {},
|
|
50
|
+
adapter: options.adapter.id,
|
|
51
|
+
phase: "recall",
|
|
52
|
+
error: errorInfo(error),
|
|
53
|
+
timestamp: Date.now()
|
|
54
|
+
});
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
const tools = result.tools ?? [];
|
|
58
|
+
const recallMetrics = {
|
|
59
|
+
fragmentCount: result.fragments?.length ?? 0,
|
|
60
|
+
hasTools: tools.length > 0,
|
|
61
|
+
systemPromptChars: result.systemPrompt.length,
|
|
62
|
+
durationMs: Date.now() - startedAt
|
|
63
|
+
};
|
|
64
|
+
safeEmit("memory:retrieve:completed", {
|
|
65
|
+
scope,
|
|
66
|
+
adapter: options.adapter.id,
|
|
67
|
+
...recallMetrics,
|
|
68
|
+
timestamp: Date.now()
|
|
69
|
+
});
|
|
70
|
+
await options.onRecall?.({
|
|
71
|
+
scope,
|
|
72
|
+
query: state.lastUserText,
|
|
73
|
+
result
|
|
74
|
+
});
|
|
75
|
+
const snapshot = await gatherSnapshot(options.adapter, scope);
|
|
76
|
+
state.stateChunk = {
|
|
77
|
+
emitted: false,
|
|
78
|
+
value: {
|
|
79
|
+
scope,
|
|
80
|
+
adapter: options.adapter.id,
|
|
81
|
+
query: state.lastUserText,
|
|
82
|
+
recall: recallMetrics,
|
|
83
|
+
...snapshot ? { snapshot } : {}
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
const additions = [result.toolGuidance ?? "", result.systemPrompt].filter((p) => p.length > 0);
|
|
87
|
+
if (additions.length === 0 && tools.length === 0) return;
|
|
88
|
+
return {
|
|
89
|
+
systemPrompts: [...config.systemPrompts, ...additions],
|
|
90
|
+
tools: [...config.tools, ...tools]
|
|
91
|
+
};
|
|
92
|
+
},
|
|
93
|
+
onChunk(ctx, chunk) {
|
|
94
|
+
const state = stateByCtx.get(ctx);
|
|
95
|
+
if (!state?.stateChunk || state.stateChunk.emitted) return;
|
|
96
|
+
state.stateChunk.emitted = true;
|
|
97
|
+
return [chunk, {
|
|
98
|
+
type: "CUSTOM",
|
|
99
|
+
name: MEMORY_STATE_EVENT,
|
|
100
|
+
value: state.stateChunk.value,
|
|
101
|
+
timestamp: Date.now()
|
|
102
|
+
}];
|
|
103
|
+
},
|
|
104
|
+
onFinish(ctx, info) {
|
|
105
|
+
const state = stateByCtx.get(ctx);
|
|
106
|
+
stateByCtx.delete(ctx);
|
|
107
|
+
const userText = state?.lastUserText || getMessageText(findLastUserMessage(ctx.messages));
|
|
108
|
+
const assistant = info.content;
|
|
109
|
+
if (!userText || !assistant) return;
|
|
110
|
+
const scope = state?.resolvedScope;
|
|
111
|
+
ctx.defer((async () => {
|
|
112
|
+
let resolved;
|
|
113
|
+
try {
|
|
114
|
+
resolved = scope ?? await resolveScope(ctx, { lastUserText: userText });
|
|
115
|
+
} catch (error) {
|
|
116
|
+
safeEmit("memory:error", {
|
|
117
|
+
adapter: options.adapter.id,
|
|
118
|
+
phase: "save",
|
|
119
|
+
error: errorInfo(error),
|
|
120
|
+
timestamp: Date.now()
|
|
121
|
+
});
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
const turn = {
|
|
125
|
+
user: userText,
|
|
126
|
+
assistant
|
|
127
|
+
};
|
|
128
|
+
const startedAt = Date.now();
|
|
129
|
+
safeEmit("memory:persist:started", {
|
|
130
|
+
scope: resolved,
|
|
131
|
+
adapter: options.adapter.id,
|
|
132
|
+
timestamp: startedAt
|
|
133
|
+
});
|
|
134
|
+
let receipts;
|
|
135
|
+
try {
|
|
136
|
+
receipts = await options.adapter.save(resolved, turn);
|
|
137
|
+
} catch (error) {
|
|
138
|
+
receipts = [{
|
|
139
|
+
ok: false,
|
|
140
|
+
error: String(error)
|
|
141
|
+
}];
|
|
142
|
+
safeEmit("memory:error", {
|
|
143
|
+
scope: resolved,
|
|
144
|
+
adapter: options.adapter.id,
|
|
145
|
+
phase: "save",
|
|
146
|
+
error: errorInfo(error),
|
|
147
|
+
timestamp: Date.now()
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
safeEmit("memory:persist:completed", {
|
|
151
|
+
scope: resolved,
|
|
152
|
+
adapter: options.adapter.id,
|
|
153
|
+
receiptCount: receipts.length,
|
|
154
|
+
okCount: receipts.filter((r) => r.ok).length,
|
|
155
|
+
durationMs: Date.now() - startedAt,
|
|
156
|
+
timestamp: Date.now()
|
|
157
|
+
});
|
|
158
|
+
await emitSnapshot(options.adapter, resolved);
|
|
159
|
+
await options.onSave?.({
|
|
160
|
+
scope: resolved,
|
|
161
|
+
turn,
|
|
162
|
+
receipts
|
|
163
|
+
});
|
|
164
|
+
})());
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Read the adapter's current stored state via the optional `inspect`/`listFacts`
|
|
170
|
+
* introspection methods. Returns `undefined` for adapters that don't implement
|
|
171
|
+
* `inspect` (they degrade to the metrics-only timeline). Fully guarded:
|
|
172
|
+
* introspection must never affect chat.
|
|
173
|
+
*/
|
|
174
|
+
async function gatherSnapshot(adapter, scope) {
|
|
175
|
+
if (!adapter.inspect) return void 0;
|
|
176
|
+
try {
|
|
177
|
+
const snapshot = await adapter.inspect(scope);
|
|
178
|
+
const facts = await adapter.listFacts?.(scope) ?? [];
|
|
179
|
+
return {
|
|
180
|
+
takenAt: snapshot.takenAt,
|
|
181
|
+
data: snapshot.data,
|
|
182
|
+
facts
|
|
183
|
+
};
|
|
184
|
+
} catch {
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* DevTools-only: after a save, emit the adapter's current stored state on the
|
|
190
|
+
* (in-process) event bus, so a devtools consumer running in the SAME runtime as
|
|
191
|
+
* the chat (client-side execution / server-side listener) sees "what's in
|
|
192
|
+
* memory". For the standard server-side topology, the browser panel instead
|
|
193
|
+
* gets state via the {@link MEMORY_STATE_EVENT} stream chunk (see `onChunk`).
|
|
194
|
+
*/
|
|
195
|
+
async function emitSnapshot(adapter, scope) {
|
|
196
|
+
const snapshot = await gatherSnapshot(adapter, scope);
|
|
197
|
+
if (!snapshot) return;
|
|
198
|
+
safeEmit("memory:snapshot", {
|
|
199
|
+
scope,
|
|
200
|
+
adapter: adapter.id,
|
|
201
|
+
...snapshot,
|
|
202
|
+
timestamp: Date.now()
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
function findLastUserMessage(messages) {
|
|
206
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
207
|
+
const message = messages[i];
|
|
208
|
+
if (message && message.role === "user") return message;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Extract plain text from a `ModelMessage`. Text lives on `part.content` for
|
|
213
|
+
* `TextPart`; bare strings in the content array are tolerated. All other
|
|
214
|
+
* content kinds (tool-call, image, …) yield '' so they don't pollute the
|
|
215
|
+
* recall query.
|
|
216
|
+
*/
|
|
217
|
+
function getMessageText(message) {
|
|
218
|
+
if (!message) return "";
|
|
219
|
+
if (typeof message.content === "string") return message.content;
|
|
220
|
+
if (Array.isArray(message.content)) return message.content.map((part) => {
|
|
221
|
+
if (typeof part === "string") return part;
|
|
222
|
+
if (part.type === "text" && typeof part.content === "string") return part.content;
|
|
223
|
+
return "";
|
|
224
|
+
}).filter(Boolean).join("\n");
|
|
225
|
+
return "";
|
|
226
|
+
}
|
|
227
|
+
function errorInfo(error) {
|
|
228
|
+
if (error instanceof Error) return {
|
|
229
|
+
name: error.name,
|
|
230
|
+
message: error.message
|
|
231
|
+
};
|
|
232
|
+
if (error && typeof error === "object" && "name" in error && typeof error.name === "string") return {
|
|
233
|
+
name: error.name,
|
|
234
|
+
message: String(error.message ?? error)
|
|
235
|
+
};
|
|
236
|
+
return {
|
|
237
|
+
name: "Error",
|
|
238
|
+
message: String(error)
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
/** Fire-and-forget devtools emit — telemetry failures must never affect chat. */
|
|
242
|
+
function safeEmit(...args) {
|
|
243
|
+
try {
|
|
244
|
+
aiEventClient.emit(...args);
|
|
245
|
+
} catch {}
|
|
246
|
+
}
|
|
247
|
+
//#endregion
|
|
248
|
+
export { MEMORY_STATE_EVENT, memoryMiddleware };
|
|
249
|
+
|
|
250
|
+
//# sourceMappingURL=middleware.js.map
|