@100xprompt/chitta 0.1.4 → 0.1.5
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/README.md +20 -2
- package/package.json +1 -1
- package/src/embedded/index.ts +107 -1
- package/src/embedded/ingest.ts +26 -0
- package/src/embedded/local-embeddings.ts +61 -13
- package/src/embedded/memory/consolidate.ts +135 -0
- package/src/embedded/sqlite-store.ts +7 -0
- package/src/embedded/store/memories.ts +156 -0
- package/src/embedded/store/nodes-edges.ts +13 -0
- package/src/embedded/store/schema.ts +39 -0
- package/src/mcp/backend.ts +19 -0
- package/src/mcp/tools/context-about.ts +3 -0
- package/src/mcp/tools/context-forget.ts +35 -0
- package/src/mcp/tools/context-profile.ts +34 -0
- package/src/mcp/tools/get-context.ts +16 -1
- package/src/mcp/tools/index.ts +4 -0
package/README.md
CHANGED
|
@@ -118,9 +118,27 @@ opencode, Kiro, Amp, Factory, Kilo, Trae). Any other MCP client: `--print` and p
|
|
|
118
118
|
|
|
119
119
|
| Tool | Does |
|
|
120
120
|
|---|---|
|
|
121
|
-
| `context_ingest` | Store text → record node + **permission edges** (ACL) + **vector chunks** + **extracted concept graph** |
|
|
122
|
-
| `get_context` | Retrieve ranked, cited, permission-filtered snippets |
|
|
121
|
+
| `context_ingest` | Store text → record node + **permission edges** (ACL) + **vector chunks** + **extracted concept graph** + **atomic memories** |
|
|
122
|
+
| `get_context` | Retrieve ranked, cited, permission-filtered snippets + the **current memory** (latest, contradiction-resolved) |
|
|
123
|
+
| `context_forget` | Forget memories that are no longer true/wanted (soft-delete, within what you may see) |
|
|
124
|
+
| `context_profile` | Synthesize a profile of a person/org/entity (permanent + recent facts + connections) |
|
|
123
125
|
| `context_graph` | Return the knowledge graph (concepts + relationships) the user can access |
|
|
126
|
+
| `context_relate` | Graph queries over the entity graph (neighbors / path / impact / central) |
|
|
127
|
+
|
|
128
|
+
## Living memory (permission-aware)
|
|
129
|
+
|
|
130
|
+
Beyond storing snippets, Chitta maintains a **living-memory layer** - the part most memory
|
|
131
|
+
products treat as proprietary magic, here done natively and **ACL-scoped**:
|
|
132
|
+
|
|
133
|
+
- **Atomic memories** - precise typed facts ("Sarah works at Meta"), not just chunks.
|
|
134
|
+
- **Contradiction → versioning** - a newer single-valued fact **supersedes** the old one
|
|
135
|
+
(`works_at`: Google → Meta); recall returns the current truth, history is kept (v1→vN).
|
|
136
|
+
- **Forgetting** - `context_forget` soft-deletes by description; optional TTL
|
|
137
|
+
(`CONTEXT_MEMORY_TTL_DAYS`) retires dynamic memories, static facts are exempt. It's
|
|
138
|
+
coherent: the underlying graph fact is expired too.
|
|
139
|
+
- **Permission-aware throughout** - you can only recall or forget what your ACL permits,
|
|
140
|
+
across a *shared* org graph. (Most memory layers only isolate per-user pools - they have
|
|
141
|
+
no concept of "who is allowed to remember what" inside a team.)
|
|
124
142
|
|
|
125
143
|
## Run it
|
|
126
144
|
|
package/package.json
CHANGED
package/src/embedded/index.ts
CHANGED
|
@@ -10,7 +10,7 @@ import { SqliteVecService } from "./sqlite-vec-service"
|
|
|
10
10
|
import { LocalHashEmbeddings } from "./local-embeddings"
|
|
11
11
|
import { TransformersEmbeddings, AutoEmbeddings } from "./transformers-embeddings"
|
|
12
12
|
import { Ingestor, type IngestDoc } from "./ingest"
|
|
13
|
-
import { DeterministicExtractor, type KnowledgeExtractor } from "./extract"
|
|
13
|
+
import { DeterministicExtractor, slugify, entityId, type KnowledgeExtractor } from "./extract"
|
|
14
14
|
import { Authorizer } from "./authorizer"
|
|
15
15
|
import { KgqaService } from "./kgqa-service"
|
|
16
16
|
import { GraphQueryService } from "./graph-query"
|
|
@@ -19,8 +19,28 @@ import type { LlmExtractor } from "./llm-extractor"
|
|
|
19
19
|
import type { EmbeddingProvider } from "../provider"
|
|
20
20
|
import type { RetrievalResponse } from "../types"
|
|
21
21
|
import { hybridSearch } from "./retrieval/hybrid-retriever"
|
|
22
|
+
import { cosine } from "./retrieval/passage"
|
|
22
23
|
import type { SearchTrace } from "./retrieval/trace"
|
|
23
24
|
|
|
25
|
+
/** A current memory surfaced to a caller (latest version, not forgotten), ACL-scoped. */
|
|
26
|
+
export interface RecalledMemory {
|
|
27
|
+
memory: string
|
|
28
|
+
version: number
|
|
29
|
+
isStatic: boolean
|
|
30
|
+
updatedAt: number
|
|
31
|
+
rootId: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** A synthesized, ACL-scoped profile of one subject - the permanent facts, the recent
|
|
35
|
+
* (dynamic) facts, and the entities it's most connected to. Supermemory's "user profile",
|
|
36
|
+
* but for ANY principal/entity the caller is permitted to see, not just the caller. */
|
|
37
|
+
export interface Profile {
|
|
38
|
+
subject: string
|
|
39
|
+
staticFacts: string[]
|
|
40
|
+
recentFacts: string[]
|
|
41
|
+
related: string[]
|
|
42
|
+
}
|
|
43
|
+
|
|
24
44
|
export { SqliteStore } from "./sqlite-store"
|
|
25
45
|
export { SqliteGraphProvider } from "./sqlite-graph-provider"
|
|
26
46
|
export { SqliteVecService } from "./sqlite-vec-service"
|
|
@@ -149,6 +169,83 @@ export function buildEmbeddedContext(opts: EmbeddedOptions = {}) {
|
|
|
149
169
|
return hybridSearch({ retrieval, store, graph, embeddings, reranker }, query, userId, orgId, trace, limit)
|
|
150
170
|
}
|
|
151
171
|
|
|
172
|
+
// LIVING MEMORY - the permission-aware atomic-memory layer (Supermemory parity, but
|
|
173
|
+
// ACL-scoped). recallMemories returns the CURRENT truth (latest version, not forgotten)
|
|
174
|
+
// about whatever the query is asking, ranked by semantic similarity, gated by the same
|
|
175
|
+
// accessible-record set the rest of retrieval uses (leak-proof by construction).
|
|
176
|
+
async function recallMemories(query: string, userId: string, orgId: string, limit = 8): Promise<RecalledMemory[]> {
|
|
177
|
+
store.memories.sweep() // lazy TTL: retire any expired dynamic memories first
|
|
178
|
+
const accessible = await graph.getAccessibleVirtualRecordIds({ userId, orgId })
|
|
179
|
+
const vids = [...new Set(Object.values(accessible))]
|
|
180
|
+
const rows = store.memories.recall(vids)
|
|
181
|
+
if (rows.length === 0) return []
|
|
182
|
+
const qv = await (embeddings.embedQuery ? embeddings.embedQuery(query) : embeddings.embedDense(query))
|
|
183
|
+
const scored = rows.map((r) => ({ r, s: r.embedding ? cosine(qv, JSON.parse(r.embedding) as number[]) : 0 }))
|
|
184
|
+
scored.sort((a, b) => b.s - a.s)
|
|
185
|
+
return scored.slice(0, limit).map(({ r }) => ({
|
|
186
|
+
memory: r.memory, version: r.version, isStatic: !!r.is_static, updatedAt: r.updated_at, rootId: r.root_id ?? r.id,
|
|
187
|
+
}))
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Forget memories matching a description (semantic similarity OR substring), within
|
|
191
|
+
// the caller's accessible set only - you can never forget what you can't see. Soft
|
|
192
|
+
// delete (history kept, excluded from recall). Returns the memory texts forgotten.
|
|
193
|
+
async function forgetMemories(query: string, userId: string, orgId: string, reason = "forgotten by user"): Promise<string[]> {
|
|
194
|
+
const accessible = await graph.getAccessibleVirtualRecordIds({ userId, orgId })
|
|
195
|
+
const vids = [...new Set(Object.values(accessible))]
|
|
196
|
+
const rows = store.memories.recall(vids)
|
|
197
|
+
if (rows.length === 0) return []
|
|
198
|
+
const q = query.trim().toLowerCase()
|
|
199
|
+
const qv = await (embeddings.embedQuery ? embeddings.embedQuery(query) : embeddings.embedDense(query))
|
|
200
|
+
const targets = rows.filter((r) => {
|
|
201
|
+
if (r.memory.toLowerCase().includes(q)) return true
|
|
202
|
+
return r.embedding ? cosine(qv, JSON.parse(r.embedding) as number[]) >= 0.6 : false
|
|
203
|
+
})
|
|
204
|
+
if (targets.length === 0) return []
|
|
205
|
+
store.memories.forget(targets.map((r) => r.id), reason)
|
|
206
|
+
// Keep the forget COHERENT across layers: also expire the underlying typed edge so
|
|
207
|
+
// KGQA / graph queries stop asserting the fact too. subject_key is `subj|pred` (a
|
|
208
|
+
// single-valued fact) or `subj|pred|obj` (multi-valued) - both carry entity ids.
|
|
209
|
+
for (const r of targets) {
|
|
210
|
+
const parts = r.subject_key.split("|")
|
|
211
|
+
if (parts.length === 2) store.expireEdges(parts[0], parts[1])
|
|
212
|
+
else if (parts.length === 3) store.expireEdges(parts[0], parts[1], parts[2])
|
|
213
|
+
}
|
|
214
|
+
return targets.map((r) => r.memory)
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// How a fact evolved: the full version chain (v1 → vN) for a memory's root. ACL is
|
|
218
|
+
// enforced by the caller (recallMemories returns only accessible roots).
|
|
219
|
+
function memoryHistory(rootId: string): Array<{ memory: string; version: number; isLatest: boolean; forgotten: boolean }> {
|
|
220
|
+
return store.memories.history(rootId).map((r) => ({
|
|
221
|
+
memory: r.memory, version: r.version, isLatest: !!r.is_latest, forgotten: !!r.is_forgotten,
|
|
222
|
+
}))
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// PROFILE synthesis - roll up everything currently known about one subject into a
|
|
226
|
+
// compact, structured view: permanent facts (static), recent facts (dynamic, newest
|
|
227
|
+
// first), and the entities it's most connected to. ACL-scoped (built only from the
|
|
228
|
+
// caller's accessible memories + graph). Returns null when nothing is known. This is
|
|
229
|
+
// the Supermemory "user profile" surface, generalized to any permitted entity.
|
|
230
|
+
async function buildProfile(subject: string, userId: string, orgId: string): Promise<Profile | null> {
|
|
231
|
+
store.memories.sweep()
|
|
232
|
+
const accessible = await graph.getAccessibleVirtualRecordIds({ userId, orgId })
|
|
233
|
+
const vids = [...new Set(Object.values(accessible))]
|
|
234
|
+
const rows = store.memories.recall(vids)
|
|
235
|
+
const eid = entityId(slugify(subject))
|
|
236
|
+
const prefix = `${eid}|`
|
|
237
|
+
const mine = rows.filter((r) => r.subject_key.startsWith(prefix))
|
|
238
|
+
const nb = await graphQuery.neighbors(subject, userId, orgId)
|
|
239
|
+
const related = (nb?.neighbors ?? []).slice(0, 10).map((n) => n.label)
|
|
240
|
+
if (mine.length === 0 && related.length === 0) return null
|
|
241
|
+
const staticFacts = mine.filter((r) => r.is_static).map((r) => r.memory)
|
|
242
|
+
const recentFacts = mine
|
|
243
|
+
.filter((r) => !r.is_static)
|
|
244
|
+
.sort((a, b) => b.updated_at - a.updated_at)
|
|
245
|
+
.map((r) => r.memory)
|
|
246
|
+
return { subject: nb?.entity ?? subject, staticFacts, recentFacts, related }
|
|
247
|
+
}
|
|
248
|
+
|
|
152
249
|
// Same retrieval, but also returns the pipeline TRACE (for the UI's explainability).
|
|
153
250
|
async function searchTraced(query: string, userId: string, orgId: string) {
|
|
154
251
|
const trace: SearchTrace = { counts: { vector: 0, keyword: 0, graph: 0, fused: 0 }, reranked: false, items: [] }
|
|
@@ -171,6 +268,11 @@ export function buildEmbeddedContext(opts: EmbeddedOptions = {}) {
|
|
|
171
268
|
const emb = await embeddings.embedDense(r.content)
|
|
172
269
|
store.addChunk(r.point_id, r.virtual_record_id, r.org_id, r.content, emb)
|
|
173
270
|
}
|
|
271
|
+
// Memories carry their own embeddings (for semantic recall) - re-embed them too so
|
|
272
|
+
// an embedder switch doesn't leave the memory layer in a stale vector space.
|
|
273
|
+
for (const m of store.memories.all()) {
|
|
274
|
+
store.memories.updateEmbedding(m.id, await embeddings.embedDense(m.memory))
|
|
275
|
+
}
|
|
174
276
|
return rows.length
|
|
175
277
|
}
|
|
176
278
|
|
|
@@ -209,6 +311,10 @@ export function buildEmbeddedContext(opts: EmbeddedOptions = {}) {
|
|
|
209
311
|
deleteRecord,
|
|
210
312
|
searchWithGraph,
|
|
211
313
|
searchTraced,
|
|
314
|
+
recallMemories,
|
|
315
|
+
forgetMemories,
|
|
316
|
+
memoryHistory,
|
|
317
|
+
buildProfile,
|
|
212
318
|
reindex,
|
|
213
319
|
rebuildGraph,
|
|
214
320
|
}
|
package/src/embedded/ingest.ts
CHANGED
|
@@ -8,6 +8,15 @@ import { DeterministicExtractor, stripBoilerplate, slugify, entityId, type Knowl
|
|
|
8
8
|
import { CodeExtractor } from "./code-extractor"
|
|
9
9
|
import { guardIngest } from "../security/limits"
|
|
10
10
|
import { sanitizeBody, sanitizeLabel } from "../security/sanitize"
|
|
11
|
+
import { consolidateTriples } from "./memory/consolidate"
|
|
12
|
+
|
|
13
|
+
// Optional default TTL for dynamic memories (CONTEXT_MEMORY_TTL_DAYS). Unset ⇒ memories
|
|
14
|
+
// never auto-expire; set ⇒ non-static memories get a forget_after and the TTL sweep
|
|
15
|
+
// retires them. Static facts (names, birthplaces) are always exempt.
|
|
16
|
+
function memoryTtlMs(): number | undefined {
|
|
17
|
+
const days = Number(process.env.CONTEXT_MEMORY_TTL_DAYS ?? 0)
|
|
18
|
+
return days > 0 ? days * 24 * 60 * 60 * 1000 : undefined
|
|
19
|
+
}
|
|
11
20
|
|
|
12
21
|
export interface IngestDoc {
|
|
13
22
|
recordId: string
|
|
@@ -195,6 +204,23 @@ export class Ingestor {
|
|
|
195
204
|
: await this.writeGraphFor(doc.recordId, cleanText, doc.recordName)
|
|
196
205
|
}
|
|
197
206
|
|
|
207
|
+
// (5) MEMORIES: the living-memory layer. Consolidate the PRECISE typed triples the
|
|
208
|
+
// caller supplied into atomic memories (contradiction → new version, dedup, TTL).
|
|
209
|
+
// We use only the provided typed predicates - the deterministic extractor emits
|
|
210
|
+
// symmetric "relates_to" co-occurrence, which is graph signal, not an atomic fact.
|
|
211
|
+
// Inherits this record's ACL via virtualRecordId. No-op when no typed triples given.
|
|
212
|
+
if (doc.relations?.length) {
|
|
213
|
+
const typed = doc.relations.filter((r) => (r.type || "").trim().toLowerCase().replace(/\s+/g, "_") !== "relates_to")
|
|
214
|
+
if (typed.length) {
|
|
215
|
+
await consolidateTriples(this.store.memories, this.embeddings, typed, {
|
|
216
|
+
orgId: doc.orgId,
|
|
217
|
+
virtualRecordId: vid,
|
|
218
|
+
sourceRecordId: doc.recordId,
|
|
219
|
+
ttlMs: memoryTtlMs(),
|
|
220
|
+
})
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
198
224
|
return { recordId: doc.recordId, chunks: chunks.length, entities }
|
|
199
225
|
}
|
|
200
226
|
|
|
@@ -1,36 +1,84 @@
|
|
|
1
|
-
// In-process embeddings. This deterministic
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// In-process embeddings. This deterministic, dependency-free embedder is the default
|
|
2
|
+
// (zero downloads - tests and bunx launches run offline). It is NOT a neural model, but
|
|
3
|
+
// it is much stronger than a plain bag-of-words hash: it also hashes CHARACTER N-GRAMS
|
|
4
|
+
// (so morphological variants overlap - "running"~"run", and typos degrade gracefully)
|
|
5
|
+
// and WORD BIGRAMS (so short phrases carry signal), with signed feature hashing to
|
|
6
|
+
// cancel collision bias and sublinear term weighting. For true semantic quality (real
|
|
7
|
+
// synonyms, paraphrase) install @huggingface/transformers and set CONTEXT_EMBEDDINGS=real
|
|
4
8
|
// - it implements the same EmbeddingProvider interface, so nothing above changes.
|
|
5
9
|
|
|
6
10
|
import type { EmbeddingProvider } from "../provider"
|
|
7
11
|
|
|
8
|
-
|
|
12
|
+
// 256 dims (vs the old 64): fewer collisions for the richer feature set. NOTE: changing
|
|
13
|
+
// this value changes the vector space - an existing DB self-heals via the embedder-drift
|
|
14
|
+
// reconcile() (it detects the dim change and reindexes).
|
|
15
|
+
const DIM = 256
|
|
9
16
|
|
|
10
17
|
function tokens(text: string): string[] {
|
|
11
18
|
return text.toLowerCase().match(/[a-z0-9]+/g) ?? []
|
|
12
19
|
}
|
|
13
20
|
|
|
14
|
-
|
|
21
|
+
// FNV-1a → unsigned 32-bit. Used both to pick a bucket and (its high bit) a sign.
|
|
22
|
+
function fnv(s: string): number {
|
|
15
23
|
let h = 2166136261
|
|
16
|
-
for (let i = 0; i <
|
|
17
|
-
h ^=
|
|
24
|
+
for (let i = 0; i < s.length; i++) {
|
|
25
|
+
h ^= s.charCodeAt(i)
|
|
18
26
|
h = Math.imul(h, 16777619)
|
|
19
27
|
}
|
|
20
|
-
return
|
|
28
|
+
return h >>> 0
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Signed feature hashing: bucket = h % DIM, sign = high bit → ±. Signed hashing makes
|
|
32
|
+
// collisions cancel in expectation instead of always adding, so the vector is cleaner.
|
|
33
|
+
function addFeature(v: number[], feat: string, weight: number): void {
|
|
34
|
+
const h = fnv(feat)
|
|
35
|
+
const idx = h % DIM
|
|
36
|
+
const sign = (h & 0x80000000) !== 0 ? 1 : -1
|
|
37
|
+
v[idx] += sign * weight
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Character n-grams of a token, padded so prefixes/suffixes are distinct features.
|
|
41
|
+
function charNGrams(token: string, n: number): string[] {
|
|
42
|
+
const s = `#${token}#`
|
|
43
|
+
if (s.length <= n) return [s]
|
|
44
|
+
const out: string[] = []
|
|
45
|
+
for (let i = 0; i + n <= s.length; i++) out.push(s.slice(i, i + n))
|
|
46
|
+
return out
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function embed(text: string): number[] {
|
|
50
|
+
const v = new Array(DIM).fill(0)
|
|
51
|
+
const toks = tokens(text)
|
|
52
|
+
// Sublinear term frequency: repeated tokens shouldn't dominate (1 + log count).
|
|
53
|
+
const tf = new Map<string, number>()
|
|
54
|
+
for (const t of toks) tf.set(t, (tf.get(t) ?? 0) + 1)
|
|
55
|
+
for (const [t, c] of tf) {
|
|
56
|
+
const w = 1 + Math.log(c)
|
|
57
|
+
addFeature(v, `w:${t}`, w) // whole-word feature
|
|
58
|
+
for (const g of charNGrams(t, 3)) addFeature(v, `c:${g}`, 0.5 * w) // morphology / fuzzy
|
|
59
|
+
}
|
|
60
|
+
// Word bigrams: short phrases ("new york", "machine learning") carry their own signal.
|
|
61
|
+
for (let i = 0; i + 1 < toks.length; i++) addFeature(v, `b:${toks[i]}_${toks[i + 1]}`, 0.7)
|
|
62
|
+
const norm = Math.sqrt(v.reduce((s, x) => s + x * x, 0)) || 1
|
|
63
|
+
return v.map((x) => x / norm)
|
|
21
64
|
}
|
|
22
65
|
|
|
23
66
|
export class LocalHashEmbeddings implements EmbeddingProvider {
|
|
24
67
|
async embedDense(query: string): Promise<number[]> {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
68
|
+
return embed(query)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Symmetric: queries and documents share the same feature space (no asymmetric prefix).
|
|
72
|
+
async embedQuery(query: string): Promise<number[]> {
|
|
73
|
+
return embed(query)
|
|
29
74
|
}
|
|
30
75
|
|
|
31
76
|
async embedSparse(query: string): Promise<{ indices: number[]; values: number[] }> {
|
|
32
77
|
const counts = new Map<number, number>()
|
|
33
|
-
for (const t of tokens(query))
|
|
78
|
+
for (const t of tokens(query)) {
|
|
79
|
+
const idx = fnv(`w:${t}`) % DIM
|
|
80
|
+
counts.set(idx, (counts.get(idx) ?? 0) + 1)
|
|
81
|
+
}
|
|
34
82
|
return { indices: [...counts.keys()], values: [...counts.values()] }
|
|
35
83
|
}
|
|
36
84
|
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// Consolidation - the living-memory "engine". Turns the typed triples a record
|
|
2
|
+
// asserts into ATOMIC memories and decides, per fact, whether it is:
|
|
3
|
+
// • NEW - first time we've seen this subject (create v1), or an independent
|
|
4
|
+
// multi-valued fact (its own chain),
|
|
5
|
+
// • DUPLICATE - the exact same fact re-asserted (just refresh recency),
|
|
6
|
+
// • UPDATE - a single-valued (functional) fact that CONTRADICTS the current one
|
|
7
|
+
// (e.g. works_at: Google → Meta) → supersede: flip the old version's
|
|
8
|
+
// is_latest, write a new version (+1) linked via the chain.
|
|
9
|
+
// This is Supermemory's updates/extends/derives model, but grounded in our typed-graph
|
|
10
|
+
// + permission model: contradictions resolve non-destructively (history kept) and the
|
|
11
|
+
// whole thing inherits the source record's ACL via virtual_record_id. No LLM needed -
|
|
12
|
+
// the calling model already supplied precise triples; an LLM extractor only enriches.
|
|
13
|
+
|
|
14
|
+
import type { EmbeddingProvider } from "../../provider"
|
|
15
|
+
import type { MemoryRepo, NewMemory } from "../store/memories"
|
|
16
|
+
import { slugify, entityId } from "../extract"
|
|
17
|
+
import { sanitizeText } from "../../security/sanitize"
|
|
18
|
+
|
|
19
|
+
export type MemoryAction = "created" | "updated" | "duplicate"
|
|
20
|
+
|
|
21
|
+
// Single-valued predicates: a subject has at most ONE current value, so a new value
|
|
22
|
+
// SUPERSEDES the old (a contradiction → a new memory version). Mirrors
|
|
23
|
+
// FUNCTIONAL_PREDICATES in ingest.ts (kept in sync; both describe the same semantics).
|
|
24
|
+
const FUNCTIONAL = new Set([
|
|
25
|
+
"lives_in", "located_in", "based_in", "works_at", "employed_by", "ceo_of", "led_by",
|
|
26
|
+
"born_in", "current_role", "role_is", "status_is", "owns", "owned_by", "married_to",
|
|
27
|
+
"reports_to", "headquartered_in", "capital_of", "member_of",
|
|
28
|
+
])
|
|
29
|
+
|
|
30
|
+
// Permanent facts that should never auto-expire (TTL sweep skips is_static memories).
|
|
31
|
+
const STATIC = new Set(["born_in", "capital_of", "native_of", "nationality_of", "gender_of"])
|
|
32
|
+
|
|
33
|
+
export interface TripleInput {
|
|
34
|
+
from: string
|
|
35
|
+
to: string
|
|
36
|
+
type: string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface ConsolidateOpts {
|
|
40
|
+
orgId: string
|
|
41
|
+
virtualRecordId: string
|
|
42
|
+
sourceRecordId: string
|
|
43
|
+
/** Default TTL (ms from now) for dynamic memories; omitted ⇒ no expiry. */
|
|
44
|
+
ttlMs?: number
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function newId(): string {
|
|
48
|
+
return `mem:${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 9)}`
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Consolidate one atomic fact into the memory store. Returns what happened. */
|
|
52
|
+
export async function consolidateFact(
|
|
53
|
+
repo: MemoryRepo,
|
|
54
|
+
embeddings: EmbeddingProvider,
|
|
55
|
+
fact: { subjectKey: string; memory: string; functional: boolean; isStatic: boolean },
|
|
56
|
+
opts: ConsolidateOpts,
|
|
57
|
+
): Promise<MemoryAction> {
|
|
58
|
+
const current = repo.latestBySubject(fact.subjectKey)
|
|
59
|
+
const forgetAfter = !fact.isStatic && opts.ttlMs ? Date.now() + opts.ttlMs : null
|
|
60
|
+
|
|
61
|
+
if (!current) {
|
|
62
|
+
const id = newId()
|
|
63
|
+
const embedding = await embeddings.embedDense(fact.memory)
|
|
64
|
+
const base: NewMemory = {
|
|
65
|
+
id, orgId: opts.orgId, virtualRecordId: opts.virtualRecordId, subjectKey: fact.subjectKey,
|
|
66
|
+
memory: fact.memory, embedding, isStatic: fact.isStatic, forgetAfter,
|
|
67
|
+
version: 1, parentId: null, rootId: id, relation: null, sourceRecordId: opts.sourceRecordId,
|
|
68
|
+
}
|
|
69
|
+
repo.insert(base)
|
|
70
|
+
return "created"
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (current.memory === fact.memory) {
|
|
74
|
+
repo.touch(current.id) // exact re-assertion → just refresh recency
|
|
75
|
+
return "duplicate"
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Different value for the same subject. For a FUNCTIONAL predicate this is a
|
|
79
|
+
// contradiction → supersede with a new version. For a multi-valued predicate the
|
|
80
|
+
// subject_key already includes the object, so we never reach here for those (a
|
|
81
|
+
// different object is a different subject_key → "created"). Guard anyway.
|
|
82
|
+
if (!fact.functional) {
|
|
83
|
+
const id = newId()
|
|
84
|
+
const embedding = await embeddings.embedDense(fact.memory)
|
|
85
|
+
repo.insert({
|
|
86
|
+
id, orgId: opts.orgId, virtualRecordId: opts.virtualRecordId, subjectKey: fact.subjectKey,
|
|
87
|
+
memory: fact.memory, embedding, isStatic: fact.isStatic, forgetAfter,
|
|
88
|
+
version: 1, parentId: null, rootId: id, relation: null, sourceRecordId: opts.sourceRecordId,
|
|
89
|
+
})
|
|
90
|
+
return "created"
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const id = newId()
|
|
94
|
+
const embedding = await embeddings.embedDense(fact.memory)
|
|
95
|
+
repo.markSuperseded(current.id)
|
|
96
|
+
repo.insert({
|
|
97
|
+
id, orgId: opts.orgId, virtualRecordId: opts.virtualRecordId, subjectKey: fact.subjectKey,
|
|
98
|
+
memory: fact.memory, embedding, isStatic: fact.isStatic, forgetAfter,
|
|
99
|
+
version: current.version + 1, parentId: current.id, rootId: current.root_id ?? current.id,
|
|
100
|
+
relation: "updates", sourceRecordId: opts.sourceRecordId,
|
|
101
|
+
})
|
|
102
|
+
return "updated"
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Turn the typed triples a record asserts into atomic memories. Functional facts
|
|
106
|
+
* key on (subject|predicate) so a new value supersedes; multi-valued facts key on
|
|
107
|
+
* the full triple so distinct objects coexist and re-asserts dedup. Returns counts. */
|
|
108
|
+
export async function consolidateTriples(
|
|
109
|
+
repo: MemoryRepo,
|
|
110
|
+
embeddings: EmbeddingProvider,
|
|
111
|
+
triples: TripleInput[],
|
|
112
|
+
opts: ConsolidateOpts,
|
|
113
|
+
): Promise<{ created: number; updated: number; duplicate: number }> {
|
|
114
|
+
const tally = { created: 0, updated: 0, duplicate: 0 }
|
|
115
|
+
for (const t of triples) {
|
|
116
|
+
const from = sanitizeText(t.from).trim()
|
|
117
|
+
const to = sanitizeText(t.to).trim()
|
|
118
|
+
const pred = (t.type || "relates_to").trim().toLowerCase().replace(/\s+/g, "_")
|
|
119
|
+
if (!from || !to || !pred) continue
|
|
120
|
+
const subjId = entityId(slugify(from))
|
|
121
|
+
const objId = entityId(slugify(to))
|
|
122
|
+
if (!subjId || !objId) continue
|
|
123
|
+
const functional = FUNCTIONAL.has(pred)
|
|
124
|
+
const subjectKey = functional ? `${subjId}|${pred}` : `${subjId}|${pred}|${objId}`
|
|
125
|
+
const memory = `${from} ${pred.replace(/_/g, " ")} ${to}`
|
|
126
|
+
const action = await consolidateFact(
|
|
127
|
+
repo,
|
|
128
|
+
embeddings,
|
|
129
|
+
{ subjectKey, memory, functional, isStatic: STATIC.has(pred) },
|
|
130
|
+
opts,
|
|
131
|
+
)
|
|
132
|
+
tally[action]++
|
|
133
|
+
}
|
|
134
|
+
return tally
|
|
135
|
+
}
|
|
@@ -18,6 +18,7 @@ import { migrate, tryEnableExtensions, tryLoadVec } from "./store/schema"
|
|
|
18
18
|
import * as graph from "./store/nodes-edges"
|
|
19
19
|
import * as fts from "./store/fts"
|
|
20
20
|
import { ChunkRepo } from "./store/chunks"
|
|
21
|
+
import { MemoryRepo } from "./store/memories"
|
|
21
22
|
import * as salience from "./store/salience"
|
|
22
23
|
|
|
23
24
|
export type Json = Record<string, unknown>
|
|
@@ -26,6 +27,7 @@ export class SqliteStore {
|
|
|
26
27
|
readonly db: Database
|
|
27
28
|
readonly vecEnabled: boolean
|
|
28
29
|
readonly ftsEnabled: boolean
|
|
30
|
+
readonly memories: MemoryRepo
|
|
29
31
|
private readonly chunks: ChunkRepo
|
|
30
32
|
|
|
31
33
|
constructor(path = ":memory:") {
|
|
@@ -45,6 +47,7 @@ export class SqliteStore {
|
|
|
45
47
|
this.vecEnabled = encrypted ? false : tryLoadVec(this.db)
|
|
46
48
|
this.ftsEnabled = fts.tryEnableFts(this.db)
|
|
47
49
|
this.chunks = new ChunkRepo(this.db, this.vecEnabled, this.ftsEnabled, encrypted)
|
|
50
|
+
this.memories = new MemoryRepo(this.db)
|
|
48
51
|
}
|
|
49
52
|
|
|
50
53
|
// ── Graph: nodes & edges ────────────────────────────────────────────────
|
|
@@ -64,6 +67,10 @@ export class SqliteStore {
|
|
|
64
67
|
return graph.supersedeEdge(this.db, src, label, keepDst, atTime)
|
|
65
68
|
}
|
|
66
69
|
|
|
70
|
+
expireEdges(src: string, label: string, dst?: string): number {
|
|
71
|
+
return graph.expireEdges(this.db, src, label, dst)
|
|
72
|
+
}
|
|
73
|
+
|
|
67
74
|
backfillEdgeProvenance(): number {
|
|
68
75
|
return graph.backfillEdgeProvenance(this.db)
|
|
69
76
|
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// Memory repository - SQL primitives for the living-memory layer. Pure persistence:
|
|
2
|
+
// the CLASSIFICATION logic (is this new fact a contradiction → new version, or an
|
|
3
|
+
// independent memory?) lives in ../memory/consolidate.ts; this file just does the
|
|
4
|
+
// reads/writes. Every read is ACL-scoped by the caller passing the accessible
|
|
5
|
+
// virtual_record_id set (gate-first, like the rest of the store) so no memory can
|
|
6
|
+
// leak across a permission boundary - including superseded versions and forgotten rows.
|
|
7
|
+
|
|
8
|
+
import { Database } from "bun:sqlite"
|
|
9
|
+
import { ph } from "./schema"
|
|
10
|
+
|
|
11
|
+
export interface MemoryRow {
|
|
12
|
+
id: string
|
|
13
|
+
org_id: string
|
|
14
|
+
virtual_record_id: string
|
|
15
|
+
subject_key: string
|
|
16
|
+
memory: string
|
|
17
|
+
embedding: string | null
|
|
18
|
+
is_static: number
|
|
19
|
+
is_forgotten: number
|
|
20
|
+
forget_after: number | null
|
|
21
|
+
forget_reason: string | null
|
|
22
|
+
version: number
|
|
23
|
+
parent_id: string | null
|
|
24
|
+
root_id: string | null
|
|
25
|
+
is_latest: number
|
|
26
|
+
relation: string | null
|
|
27
|
+
source_record_id: string | null
|
|
28
|
+
created_at: number
|
|
29
|
+
updated_at: number
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface NewMemory {
|
|
33
|
+
id: string
|
|
34
|
+
orgId: string
|
|
35
|
+
virtualRecordId: string
|
|
36
|
+
subjectKey: string
|
|
37
|
+
memory: string
|
|
38
|
+
embedding: number[]
|
|
39
|
+
isStatic?: boolean
|
|
40
|
+
forgetAfter?: number | null
|
|
41
|
+
version?: number
|
|
42
|
+
parentId?: string | null
|
|
43
|
+
rootId?: string | null
|
|
44
|
+
relation?: string | null
|
|
45
|
+
sourceRecordId?: string | null
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export class MemoryRepo {
|
|
49
|
+
constructor(private readonly db: Database) {}
|
|
50
|
+
|
|
51
|
+
/** The current (live) memory for a subject_key, if any. "Live" = latest, not forgotten. */
|
|
52
|
+
latestBySubject(subjectKey: string): MemoryRow | undefined {
|
|
53
|
+
return this.db
|
|
54
|
+
.query("SELECT * FROM memories WHERE subject_key = ? AND is_latest = 1 AND is_forgotten = 0 ORDER BY version DESC LIMIT 1")
|
|
55
|
+
.get(subjectKey) as MemoryRow | undefined
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
insert(m: NewMemory): void {
|
|
59
|
+
const now = Date.now()
|
|
60
|
+
this.db
|
|
61
|
+
.query(
|
|
62
|
+
`INSERT INTO memories
|
|
63
|
+
(id, org_id, virtual_record_id, subject_key, memory, embedding, is_static,
|
|
64
|
+
is_forgotten, forget_after, forget_reason, version, parent_id, root_id,
|
|
65
|
+
is_latest, relation, source_record_id, created_at, updated_at)
|
|
66
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, NULL, ?, ?, ?, 1, ?, ?, ?, ?)`,
|
|
67
|
+
)
|
|
68
|
+
.run(
|
|
69
|
+
m.id,
|
|
70
|
+
m.orgId,
|
|
71
|
+
m.virtualRecordId,
|
|
72
|
+
m.subjectKey,
|
|
73
|
+
m.memory,
|
|
74
|
+
JSON.stringify(m.embedding),
|
|
75
|
+
m.isStatic ? 1 : 0,
|
|
76
|
+
m.forgetAfter ?? null,
|
|
77
|
+
m.version ?? 1,
|
|
78
|
+
m.parentId ?? null,
|
|
79
|
+
m.rootId ?? m.id,
|
|
80
|
+
m.relation ?? null,
|
|
81
|
+
m.sourceRecordId ?? null,
|
|
82
|
+
now,
|
|
83
|
+
now,
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Close out a memory version: it is no longer the latest (a newer version supersedes it). */
|
|
88
|
+
markSuperseded(id: string): void {
|
|
89
|
+
this.db.query("UPDATE memories SET is_latest = 0, updated_at = ? WHERE id = ?").run(Date.now(), id)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** A re-asserted identical fact: just refresh recency (no new version). */
|
|
93
|
+
touch(id: string): void {
|
|
94
|
+
this.db.query("UPDATE memories SET updated_at = ? WHERE id = ?").run(Date.now(), id)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Forget memories by id (soft-delete with a reason). Returns rows affected. */
|
|
98
|
+
forget(ids: string[], reason: string): number {
|
|
99
|
+
if (ids.length === 0) return 0
|
|
100
|
+
const res = this.db
|
|
101
|
+
.query(`UPDATE memories SET is_forgotten = 1, forget_reason = ?, updated_at = ? WHERE id IN (${ph(ids.length)}) AND is_forgotten = 0`)
|
|
102
|
+
.run(reason, Date.now(), ...ids)
|
|
103
|
+
return Number(res.changes)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** TTL sweep: forget every dynamic memory whose forget_after has passed. Static
|
|
107
|
+
* memories (names, birthplaces) are exempt. Cheap; called lazily before recall/ingest. */
|
|
108
|
+
sweep(now = Date.now()): number {
|
|
109
|
+
const res = this.db
|
|
110
|
+
.query(
|
|
111
|
+
`UPDATE memories SET is_forgotten = 1, forget_reason = 'expired (ttl)', updated_at = ?
|
|
112
|
+
WHERE is_forgotten = 0 AND is_static = 0 AND forget_after IS NOT NULL AND forget_after < ?`,
|
|
113
|
+
)
|
|
114
|
+
.run(now, now)
|
|
115
|
+
return Number(res.changes)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Current memories the caller may see: ACL-scoped to the accessible vids, latest
|
|
119
|
+
* version only, not forgotten, not expired. The gate-first ACL filter - leak-proof
|
|
120
|
+
* by construction (an inaccessible vid is never in the IN-list). */
|
|
121
|
+
recall(accessibleVids: string[], now = Date.now()): MemoryRow[] {
|
|
122
|
+
if (accessibleVids.length === 0) return []
|
|
123
|
+
return this.db
|
|
124
|
+
.query(
|
|
125
|
+
`SELECT * FROM memories
|
|
126
|
+
WHERE virtual_record_id IN (${ph(accessibleVids.length)})
|
|
127
|
+
AND is_latest = 1 AND is_forgotten = 0
|
|
128
|
+
AND (forget_after IS NULL OR forget_after > ?)
|
|
129
|
+
ORDER BY updated_at DESC`,
|
|
130
|
+
)
|
|
131
|
+
.all(...accessibleVids, now) as MemoryRow[]
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Full version history of a memory chain (oldest → newest), for "how did this evolve". */
|
|
135
|
+
history(rootId: string): MemoryRow[] {
|
|
136
|
+
return this.db.query("SELECT * FROM memories WHERE root_id = ? ORDER BY version ASC").all(rootId) as MemoryRow[]
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** All memory rows (for reindex when the embedder dimension changes). */
|
|
140
|
+
all(): MemoryRow[] {
|
|
141
|
+
return this.db.query("SELECT id, memory FROM memories").all() as MemoryRow[]
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
updateEmbedding(id: string, embedding: number[]): void {
|
|
145
|
+
this.db.query("UPDATE memories SET embedding = ? WHERE id = ?").run(JSON.stringify(embedding), id)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
counts(): { total: number; current: number; forgotten: number } {
|
|
149
|
+
const get = (sql: string) => (this.db.query(sql).get() as { c: number }).c
|
|
150
|
+
return {
|
|
151
|
+
total: get("SELECT count(*) c FROM memories"),
|
|
152
|
+
current: get("SELECT count(*) c FROM memories WHERE is_latest = 1 AND is_forgotten = 0"),
|
|
153
|
+
forgotten: get("SELECT count(*) c FROM memories WHERE is_forgotten = 1"),
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
@@ -84,6 +84,19 @@ export function supersedeEdge(db: Database, src: string, label: string, keepDst:
|
|
|
84
84
|
return Number(res.changes)
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
// Expire (close the validity interval on) live edges from `src` with `label`,
|
|
88
|
+
// optionally restricted to a specific `dst`. Used when a memory is FORGOTTEN, so the
|
|
89
|
+
// underlying typed-graph fact stops being asserted by KGQA / graph queries too -
|
|
90
|
+
// keeping the forget coherent across both layers. Non-destructive (history kept).
|
|
91
|
+
export function expireEdges(db: Database, src: string, label: string, dst?: string): number {
|
|
92
|
+
const now = Date.now()
|
|
93
|
+
const sql = dst
|
|
94
|
+
? `UPDATE edges SET invalid_at = ?, expired_at = ? WHERE src = ? AND label = ? AND dst = ? AND expired_at IS NULL`
|
|
95
|
+
: `UPDATE edges SET invalid_at = ?, expired_at = ? WHERE src = ? AND label = ? AND expired_at IS NULL`
|
|
96
|
+
const res = dst ? db.query(sql).run(now, now, src, label, dst) : db.query(sql).run(now, now, src, label)
|
|
97
|
+
return Number(res.changes)
|
|
98
|
+
}
|
|
99
|
+
|
|
87
100
|
// Backfill provenance for LEGACY concept edges that predate provenance tracking
|
|
88
101
|
// (migrated/older data has provenance '[]'). With per-edge ACL now fail-closed, an
|
|
89
102
|
// un-provenanced edge would be hidden from everyone - so attribute each to the
|
|
@@ -57,6 +57,45 @@ export function migrate(db: Database): void {
|
|
|
57
57
|
// sure we are the relationship is real. Added idempotently so existing DBs upgrade.
|
|
58
58
|
const ecols = (db.query("PRAGMA table_info(edges)").all() as Array<{ name: string }>).map((c) => c.name)
|
|
59
59
|
if (!ecols.includes("confidence")) db.exec("ALTER TABLE edges ADD COLUMN confidence REAL NOT NULL DEFAULT 1")
|
|
60
|
+
migrateMemories(db)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// The MEMORIES table - the living-memory layer (Supermemory-style atomic memories,
|
|
64
|
+
// but permission-aware). Each row is ONE atomic fact, not a chunk. It carries the
|
|
65
|
+
// version chain (root_id/parent_id/version/is_latest), the forgetting axes
|
|
66
|
+
// (is_forgotten/forget_after/forget_reason), the static-vs-dynamic flag, and an ACL
|
|
67
|
+
// anchor (virtual_record_id - inherits the source record's permissions, exactly like
|
|
68
|
+
// chunks). A "current" memory has is_latest=1 AND is_forgotten=0. Contradictions
|
|
69
|
+
// supersede (flip is_latest, +1 version) - history is never deleted. The embedding
|
|
70
|
+
// makes memories semantically recallable; the subject_key groups a single-valued
|
|
71
|
+
// fact's versions (functional predicate) and de-duplicates re-asserted triples.
|
|
72
|
+
export function migrateMemories(db: Database): void {
|
|
73
|
+
db.exec(`
|
|
74
|
+
CREATE TABLE IF NOT EXISTS memories (
|
|
75
|
+
id TEXT PRIMARY KEY,
|
|
76
|
+
org_id TEXT,
|
|
77
|
+
virtual_record_id TEXT,
|
|
78
|
+
subject_key TEXT,
|
|
79
|
+
memory TEXT NOT NULL,
|
|
80
|
+
embedding TEXT,
|
|
81
|
+
is_static INTEGER NOT NULL DEFAULT 0,
|
|
82
|
+
is_forgotten INTEGER NOT NULL DEFAULT 0,
|
|
83
|
+
forget_after INTEGER,
|
|
84
|
+
forget_reason TEXT,
|
|
85
|
+
version INTEGER NOT NULL DEFAULT 1,
|
|
86
|
+
parent_id TEXT,
|
|
87
|
+
root_id TEXT,
|
|
88
|
+
is_latest INTEGER NOT NULL DEFAULT 1,
|
|
89
|
+
relation TEXT,
|
|
90
|
+
source_record_id TEXT,
|
|
91
|
+
created_at INTEGER NOT NULL,
|
|
92
|
+
updated_at INTEGER NOT NULL
|
|
93
|
+
);
|
|
94
|
+
CREATE INDEX IF NOT EXISTS idx_memories_acl ON memories(virtual_record_id, is_latest, is_forgotten);
|
|
95
|
+
CREATE INDEX IF NOT EXISTS idx_memories_subject ON memories(subject_key, is_latest);
|
|
96
|
+
CREATE INDEX IF NOT EXISTS idx_memories_root ON memories(root_id);
|
|
97
|
+
CREATE INDEX IF NOT EXISTS idx_memories_source ON memories(source_record_id);
|
|
98
|
+
`)
|
|
60
99
|
}
|
|
61
100
|
|
|
62
101
|
// The edges table is a property-graph relation store shared by ACL (permissions/
|
package/src/mcp/backend.ts
CHANGED
|
@@ -20,6 +20,8 @@ export interface BackendStats {
|
|
|
20
20
|
chunks: number
|
|
21
21
|
entities: number
|
|
22
22
|
relations: number
|
|
23
|
+
/** Living-memory layer counts (local mode). */
|
|
24
|
+
memories?: { total: number; current: number; forgotten: number }
|
|
23
25
|
}
|
|
24
26
|
|
|
25
27
|
export interface ExactAnswer {
|
|
@@ -49,6 +51,15 @@ export interface ContextBackend {
|
|
|
49
51
|
* complete edge set (same as context_relate), as readable fact lines. Null when no
|
|
50
52
|
* entity is named. Lets get_context reach graph-query completeness for breadth recall. */
|
|
51
53
|
relatedFacts?: (q: string, limit?: number) => Promise<{ entity: string; facts: string[] } | null>
|
|
54
|
+
/** Living memory: the CURRENT truth (latest version, not forgotten) for a query,
|
|
55
|
+
* ACL-scoped. Each item carries its version so callers can show what evolved. */
|
|
56
|
+
recallMemories?: (q: string, limit?: number) => Promise<Array<{ memory: string; version: number; isStatic: boolean }>>
|
|
57
|
+
/** Forget memories matching a description (within the caller's accessible set).
|
|
58
|
+
* Soft-delete; returns the memory texts that were forgotten. */
|
|
59
|
+
forget?: (q: string, reason?: string) => Promise<string[]>
|
|
60
|
+
/** Synthesized, ACL-scoped profile of a subject: permanent facts, recent facts, and
|
|
61
|
+
* most-connected entities. Null when nothing is known about it. */
|
|
62
|
+
profile?: (subject: string) => Promise<{ subject: string; staticFacts: string[]; recentFacts: string[]; related: string[] } | null>
|
|
52
63
|
ingest?: (doc: IngestDoc) => Promise<{ recordId: string; chunks: number; entities: number }>
|
|
53
64
|
/** The accessible knowledge graph (entities + relations). Local mode only. */
|
|
54
65
|
graph?: () => Promise<KnowledgeGraph>
|
|
@@ -119,6 +130,13 @@ export function resolveBackend(): ContextBackend {
|
|
|
119
130
|
})
|
|
120
131
|
return { entity: n.entity, facts }
|
|
121
132
|
},
|
|
133
|
+
// Living memory: current truth (latest, non-forgotten), ACL-scoped, version-tagged.
|
|
134
|
+
recallMemories: async (q, limit) => {
|
|
135
|
+
const mems = await ctx.recallMemories(q, ctx.userId, ctx.orgId, limit && limit > 0 ? limit : 8)
|
|
136
|
+
return mems.map((m) => ({ memory: m.memory, version: m.version, isStatic: m.isStatic }))
|
|
137
|
+
},
|
|
138
|
+
forget: (q, reason) => ctx.forgetMemories(q, ctx.userId, ctx.orgId, reason),
|
|
139
|
+
profile: (subject) => ctx.buildProfile(subject, ctx.userId, ctx.orgId),
|
|
122
140
|
ingest: (doc) => ctx.authorizedIngest(ctx.userId, doc), // write-side authorization + ownership
|
|
123
141
|
graph: async () => {
|
|
124
142
|
const accessible = await ctx.graph.getAccessibleVirtualRecordIds({ userId: ctx.userId, orgId: ctx.orgId })
|
|
@@ -140,6 +158,7 @@ export function resolveBackend(): ContextBackend {
|
|
|
140
158
|
chunks: count("SELECT count(*) c FROM chunks"),
|
|
141
159
|
entities: count("SELECT count(*) c FROM nodes WHERE coll = 'entities'"),
|
|
142
160
|
relations: count("SELECT count(*) c FROM edges WHERE label = 'relates_to'"),
|
|
161
|
+
memories: ctx.store.memories.counts(),
|
|
143
162
|
}),
|
|
144
163
|
}
|
|
145
164
|
}
|
|
@@ -45,6 +45,9 @@ async function describe(backend: ContextBackend): Promise<string> {
|
|
|
45
45
|
if (backend.stats) {
|
|
46
46
|
const s = await backend.stats()
|
|
47
47
|
lines.push(`- contents: ${s.records} record(s), ${s.chunks} chunk(s), ${s.entities} concept(s), ${s.relations} relationship(s)`)
|
|
48
|
+
if (s.memories !== undefined) {
|
|
49
|
+
lines.push(`- living memory: ${s.memories.current} current memor(ies), ${s.memories.forgotten} forgotten (of ${s.memories.total} total versions)`)
|
|
50
|
+
}
|
|
48
51
|
}
|
|
49
52
|
lines.push("", "## Tools")
|
|
50
53
|
for (const t of listedTools) lines.push(`- **${t.name}** - ${t.description}`)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { ContextBackend } from "../backend"
|
|
2
|
+
import type { ToolModule, ToolResult } from "./types"
|
|
3
|
+
|
|
4
|
+
const schema = {
|
|
5
|
+
name: "context_forget",
|
|
6
|
+
description:
|
|
7
|
+
"Forget stored memories that are no longer true or wanted. USE WHEN: the user says 'forget that…', " +
|
|
8
|
+
"'that's no longer true', 'delete what you know about…', or a fact is explicitly retracted. Describe " +
|
|
9
|
+
"WHAT to forget in natural language (e.g. 'my old address', 'that I work at Google'); matching memories " +
|
|
10
|
+
"within what YOU can access are soft-deleted (history is kept, they stop appearing in recall). You can " +
|
|
11
|
+
"only ever forget what you're permitted to see. DON'T USE to correct a fact - for that just context_ingest " +
|
|
12
|
+
"the new value (a single-valued fact auto-supersedes the old one).",
|
|
13
|
+
inputSchema: {
|
|
14
|
+
type: "object" as const,
|
|
15
|
+
properties: {
|
|
16
|
+
query: { type: "string", description: "natural-language description of the memory/memories to forget" },
|
|
17
|
+
reason: { type: "string", description: "why it's being forgotten (optional, stored for audit)" },
|
|
18
|
+
},
|
|
19
|
+
required: ["query"],
|
|
20
|
+
},
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function handler(args: Record<string, unknown>, backend: ContextBackend): Promise<ToolResult> {
|
|
24
|
+
const query = String((args as any).query ?? "")
|
|
25
|
+
const reason = (args as any).reason ? String((args as any).reason) : undefined
|
|
26
|
+
if (!query.trim()) return { content: [{ type: "text", text: "Nothing to forget: provide a description." }], isError: true }
|
|
27
|
+
const forgotten = await backend.forget!(query, reason)
|
|
28
|
+
if (forgotten.length === 0) {
|
|
29
|
+
return { content: [{ type: "text", text: `No matching memories found to forget for "${query}".` }] }
|
|
30
|
+
}
|
|
31
|
+
const list = forgotten.map((m) => `• ${m}`).join("\n")
|
|
32
|
+
return { content: [{ type: "text", text: `Forgot ${forgotten.length} memor${forgotten.length === 1 ? "y" : "ies"}:\n${list}` }] }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const contextForgetTool: ToolModule = { schema, handler, available: (b) => !!b.forget }
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { ContextBackend } from "../backend"
|
|
2
|
+
import { sanitizeText } from "../../security/sanitize"
|
|
3
|
+
import type { ToolModule, ToolResult } from "./types"
|
|
4
|
+
|
|
5
|
+
const schema = {
|
|
6
|
+
name: "context_profile",
|
|
7
|
+
description:
|
|
8
|
+
"Get a synthesized profile of a person, org, or any entity - the permanent facts, the most recent facts, " +
|
|
9
|
+
"and what it's most connected to, rolled up from memory. USE WHEN: 'who is X', 'what do you know about X', " +
|
|
10
|
+
"'summarize what we know about X', or before personalizing a response to someone. Returns the CURRENT truth " +
|
|
11
|
+
"(contradictions already resolved, forgotten facts excluded), permission-filtered to what you may see. " +
|
|
12
|
+
"DON'T USE for a one-off fact (use get_context) or general world knowledge.",
|
|
13
|
+
inputSchema: {
|
|
14
|
+
type: "object" as const,
|
|
15
|
+
properties: {
|
|
16
|
+
subject: { type: "string", description: "the person/org/entity to profile (name as written)" },
|
|
17
|
+
},
|
|
18
|
+
required: ["subject"],
|
|
19
|
+
},
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function handler(args: Record<string, unknown>, backend: ContextBackend): Promise<ToolResult> {
|
|
23
|
+
const subject = String((args as any).subject ?? "").trim()
|
|
24
|
+
if (!subject) return { content: [{ type: "text", text: "Provide a subject to profile." }], isError: true }
|
|
25
|
+
const p = await backend.profile!(subject)
|
|
26
|
+
if (!p) return { content: [{ type: "text", text: `No memory about "${subject}" (or you don't have access).` }] }
|
|
27
|
+
const out: string[] = [`Profile - ${sanitizeText(p.subject)}`]
|
|
28
|
+
if (p.staticFacts.length) out.push("", "Permanent:", ...p.staticFacts.map((f) => `• ${sanitizeText(f)}`))
|
|
29
|
+
if (p.recentFacts.length) out.push("", "Current (most recent first):", ...p.recentFacts.map((f) => `• ${sanitizeText(f)}`))
|
|
30
|
+
if (p.related.length) out.push("", `Connected to: ${p.related.map((r) => sanitizeText(r)).join(", ")}`)
|
|
31
|
+
return { content: [{ type: "text", text: out.join("\n") }] }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const contextProfileTool: ToolModule = { schema, handler, available: (b) => !!b.profile }
|
|
@@ -63,6 +63,21 @@ async function handler(args: Record<string, unknown>, backend: ContextBackend):
|
|
|
63
63
|
}
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
// (2b) Living memory: the CURRENT truth (latest version, not forgotten) about the
|
|
67
|
+
// query, ACL-scoped. This is the evolving/deduped/forgetting-aware layer - it reflects
|
|
68
|
+
// contradictions already resolved (e.g. "works at Meta", not the superseded "Google").
|
|
69
|
+
// Distinct from the graph neighborhood (raw edges) and ranked snippets (raw text).
|
|
70
|
+
let memories = ""
|
|
71
|
+
if (backend.recallMemories) {
|
|
72
|
+
const mems = await backend.recallMemories(query, limit && limit > 0 ? limit : 8)
|
|
73
|
+
if (mems.length) {
|
|
74
|
+
const body = mems
|
|
75
|
+
.map((m) => `• ${sanitizeText(m.memory)}${m.version > 1 ? ` (updated, v${m.version})` : ""}`)
|
|
76
|
+
.join("\n")
|
|
77
|
+
memories = `Current memory (latest, contradictions resolved):\n${body}`
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
66
81
|
// (3) Full ranked recall (vector + BM25 + GraphRAG), breadth-aware.
|
|
67
82
|
const res = await backend.query(query, limit)
|
|
68
83
|
const recalled =
|
|
@@ -70,7 +85,7 @@ async function handler(args: Record<string, unknown>, backend: ContextBackend):
|
|
|
70
85
|
? renderRecalled(res.searchResults.map((r) => ({ content: r.content, source: r.metadata.recordName ?? "untitled" })))
|
|
71
86
|
: ""
|
|
72
87
|
|
|
73
|
-
const sections = [highlight, graphFacts, recalled].filter(Boolean)
|
|
88
|
+
const sections = [highlight, memories, graphFacts, recalled].filter(Boolean)
|
|
74
89
|
let text: string
|
|
75
90
|
if (sections.length) text = sections.join("\n\n---\n\n")
|
|
76
91
|
else
|
package/src/mcp/tools/index.ts
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
import type { ContextBackend } from "../backend"
|
|
7
7
|
import { getContextTool } from "./get-context"
|
|
8
8
|
import { contextIngestTool } from "./context-ingest"
|
|
9
|
+
import { contextForgetTool } from "./context-forget"
|
|
10
|
+
import { contextProfileTool } from "./context-profile"
|
|
9
11
|
import { contextGraphTool } from "./context-graph"
|
|
10
12
|
import { contextRebuildTool } from "./context-rebuild"
|
|
11
13
|
import { contextRelateTool } from "./context-relate"
|
|
@@ -15,6 +17,8 @@ import type { ToolModule, ToolResult, ToolSchema } from "./types"
|
|
|
15
17
|
const ALL: ToolModule[] = [
|
|
16
18
|
getContextTool,
|
|
17
19
|
contextIngestTool,
|
|
20
|
+
contextForgetTool,
|
|
21
|
+
contextProfileTool,
|
|
18
22
|
contextGraphTool,
|
|
19
23
|
contextRebuildTool,
|
|
20
24
|
contextRelateTool,
|