@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@100xprompt/chitta",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "dependencies": {
5
5
  "@modelcontextprotocol/sdk": "^1.0.0",
6
6
  "sqlite-vec": "^0.1.9",
@@ -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
  }
@@ -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 hashing embedder is dependency-free
2
- // so the embedded stack runs and tests with zero downloads. For real semantic
3
- // quality in the single binary, swap in transformers.js / fastembed (ONNX bge-*)
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
- const DIM = 64
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
- function bucket(token: string): number {
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 < token.length; i++) {
17
- h ^= token.charCodeAt(i)
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 Math.abs(h) % DIM
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
- const v = new Array(DIM).fill(0)
26
- for (const t of tokens(query)) v[bucket(t)] += 1
27
- const norm = Math.sqrt(v.reduce((s, x) => s + x * x, 0)) || 1
28
- return v.map((x) => x / norm)
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)) counts.set(bucket(t), (counts.get(bucket(t)) ?? 0) + 1)
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/
@@ -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
@@ -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,