jamgate 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 jam
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,229 @@
1
+ # Jamgate
2
+
3
+ [![CI](https://github.com/amirj4m/jamgate/actions/workflows/ci.yml/badge.svg)](https://github.com/amirj4m/jamgate/actions/workflows/ci.yml)
4
+ [![npm version](https://img.shields.io/npm/v/jamgate.svg)](https://www.npmjs.com/package/jamgate)
5
+ [![license: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](./LICENSE)
6
+
7
+ > A neutral memory quality-gate for AI agents — **a gate, not a store.** One shared
8
+ > memory of you — who you are, how you're doing, what you're working on — that every AI
9
+ > agent reads from and writes to, kept honest at write time. Local-first, no cloud calls,
10
+ > one dependency.
11
+
12
+ ```bash
13
+ claude mcp add jamgate -- npx jamgate
14
+ ```
15
+
16
+ ## The problem: memory quality, not storage
17
+
18
+ You are one person, but every AI you use is a separate island. You design with one,
19
+ research with another, code with a third — and none of them know what the others know,
20
+ so you re-explain "what I'm working on" every time.
21
+
22
+ The tools that try to fix this mostly **store everything**, so shared memory bloats with
23
+ junk: one production audit of a leading memory system found **97.8% of its stored entries
24
+ were junk** ([source](https://github.com/mem0ai/mem0/issues/4573)) — duplicates, trivia,
25
+ one-off chatter, stale states. Sharing memory is the easy part. Keeping the shared memory
26
+ *clean and current* is the unsolved part.
27
+
28
+ Jamgate sits in the **write path** and decides what deserves to be remembered, before it
29
+ is stored:
30
+
31
+ ```
32
+ without a gate with Jamgate
33
+ ┌──────────────────────────────────┐ ┌──────────────────────────────────────┐
34
+ │ "remember I'm on a call" │ │ ✗ rejected — not durable │
35
+ │ "I use Windows" ← from 6mo ago │ │ ⇄ superseded — "I use Linux" wins │
36
+ │ "I use Windows" (again) │ │ ✗ duplicate — already known │
37
+ │ "I use Linux" │ │ ✓ saved — durable, changes answers │
38
+ │ "my name is Sam" (agent guessed) │ │ ⚠ conflict — lower trust, ask first │
39
+ └──────────────────────────────────┘ └──────────────────────────────────────┘
40
+ everything piles up, 98% junk small, current, trustworthy
41
+ ```
42
+
43
+ ## The idea
44
+
45
+ **Jamgate is one shared memory of you that every agent plugs into — kept honest by a
46
+ quality gate.** It runs as an [MCP](https://modelcontextprotocol.io) server, so any
47
+ MCP-capable agent (Claude Code, Claude Desktop, Cursor, …) connects to the *same* memory
48
+ on your machine. Because it filters at write time, that memory stays small, accurate, and
49
+ contradiction-free instead of bloating with junk.
50
+
51
+ ```
52
+ Agent → [ Jamgate quality gate ] → local store (~/.jamgate/memory.json)
53
+ save_memory / recall_memory / forget_memory
54
+ ```
55
+
56
+ ## What it does — the gate layers
57
+
58
+ A memory is kept only if it is **durable** (still true after this session) and would
59
+ **change a future answer**. The gate is a hybrid pipeline, cheapest checks first:
60
+
61
+ | Layer | What it does |
62
+ | --- | --- |
63
+ | **Rule pre-filter** | Drops obvious non-durable noise ("I'm on a call now") before it reaches the store. |
64
+ | **Agent salience** | Uses the calling agent's own understanding as the main "is this worth remembering?" filter — no extra LLM call of our own. |
65
+ | **Exact dedup** | Identical facts are never stored twice. |
66
+ | **Time-aware supersession** | Every memory is a timestamped event; a newer fact retires an older one on the same `subject` by recency — no contradiction pile-up, and it never throws your own stale words back at you. |
67
+ | **Trust hierarchy** | A lower-trust source (an agent's guess) can't silently overwrite a higher-trust fact (something you said explicitly). The gate refers the conflict back to you instead. |
68
+ | **Semantic near-dup** *(optional)* | With local embeddings on, a save that *means* the same as an existing memory returns as a `possible_duplicate` to confirm, rather than piling up. |
69
+ | **Type-based expiry** | Volatile state ages out (~2 days) while identity never does, so recall stays current automatically. |
70
+
71
+ Everything is taggable, expirable, and deletable — you always see and control what's
72
+ remembered.
73
+
74
+ ## Quick start
75
+
76
+ Jamgate runs **locally** — your memory never leaves your machine. Requires Node.js 20+.
77
+ No install step: `npx` fetches and runs it on demand.
78
+
79
+ ### Claude Code
80
+
81
+ ```bash
82
+ claude mcp add jamgate -- npx jamgate
83
+ ```
84
+
85
+ ### Claude Desktop
86
+
87
+ Add to `claude_desktop_config.json` (Settings → Developer → Edit Config):
88
+
89
+ ```json
90
+ {
91
+ "mcpServers": {
92
+ "jamgate": {
93
+ "command": "npx",
94
+ "args": ["jamgate"]
95
+ }
96
+ }
97
+ }
98
+ ```
99
+
100
+ ### Cursor
101
+
102
+ Add to `~/.cursor/mcp.json` (or `.cursor/mcp.json` in a project):
103
+
104
+ ```json
105
+ {
106
+ "mcpServers": {
107
+ "jamgate": {
108
+ "command": "npx",
109
+ "args": ["jamgate"]
110
+ }
111
+ }
112
+ }
113
+ ```
114
+
115
+ Restart the agent. It now has three tools:
116
+
117
+ - **`save_memory`** — store a durable fact. The gate rejects junk, drops exact
118
+ duplicates, supersedes outdated facts by recency (pass a `subject` like
119
+ `operating-system` so a newer fact retires the older one — or let the gate derive one),
120
+ and refers trust conflicts back to you.
121
+ - **`recall_memory`** — fetch what's known, relevant to a query (active facts only).
122
+ - **`forget_memory`** — delete a memory by id.
123
+
124
+ Your memory lives in `~/.jamgate/memory.json`. Same machine, every agent → one shared
125
+ memory.
126
+
127
+ ## Optional: local semantic search
128
+
129
+ By default, recall is **fuzzy lexical** matching (stemming, typo-tolerance, trigrams) —
130
+ fast, deterministic, and dependency-free, but blind to synonyms. To also match on
131
+ *meaning* (so "automobile" recalls a memory about your "car"), install the optional
132
+ embedding backend:
133
+
134
+ ```bash
135
+ npm install @huggingface/transformers
136
+ ```
137
+
138
+ On first use it downloads a small sentence-embedding model (all-MiniLM-L6-v2, ~23 MB,
139
+ quantized) and runs it **entirely on your machine — no text is ever sent to any cloud
140
+ AI.** With it enabled, recall blends semantic similarity into the ranking, and a save
141
+ that is semantically near-identical to an existing memory comes back as a
142
+ `possible_duplicate` for you to confirm. **If the package isn't installed, Jamgate runs
143
+ on fuzzy recall — nothing breaks.**
144
+
145
+ ## Configuration
146
+
147
+ All configuration is via environment variables; every one has a sensible default.
148
+
149
+ | Variable | Default | What it does |
150
+ | --- | --- | --- |
151
+ | `JAMGATE_STORE` | `~/.jamgate/memory.json` | Path to the memory store file. |
152
+ | `JAMGATE_EMBEDDINGS` | auto | `off` disables the semantic layer even if the model is installed. |
153
+ | `JAMGATE_DUP_THRESHOLD` | `0.88` | Semantic near-duplicate sensitivity (0–1); higher = stricter. |
154
+ | `JAMGATE_GATE_LOG` | on | `off` disables the local decision log. |
155
+ | `JAMGATE_TTL_<TYPE>_DAYS` | per type | Override the freshness window for a memory type, e.g. `JAMGATE_TTL_PROJECT_DAYS=180`. |
156
+
157
+ ## How it compares
158
+
159
+ Jamgate is deliberately small and opinionated. It is **not** trying to be a hosted memory
160
+ platform or a knowledge graph — it's the write-time quality layer those systems are
161
+ weakest at, packaged as a drop-in local MCP server.
162
+
163
+ | | **Jamgate** | **Mem0 / OpenMemory** | **Zep / Graphiti** |
164
+ | --- | --- | --- | --- |
165
+ | Core model | Write-time quality **gate** over a flat store | LLM-extracted memory layer | Temporal knowledge **graph** |
166
+ | Where memory lives | Local file on your machine | Hosted platform or self-hosted store | Graph server (self-hosted or cloud) |
167
+ | Their strength | — | Rich extraction, broad SDKs/integrations, scale | Powerful entity/relationship & temporal modeling |
168
+ | Gate **before** write | ✅ core design | Partial (dedup/update) | Partial |
169
+ | Source-trust hierarchy | ✅ | — | — |
170
+ | Refers conflicts back to you | ✅ | — | — |
171
+ | LLM calls of its own | ❌ none | ✅ required | ✅ required |
172
+ | Dependencies / infra | 1 dep, no server | SDK + service/DB | Graph DB + service |
173
+ | Best for | Keeping one shared personal memory clean, locally | Full-featured app-scale memory | Complex relational/temporal reasoning |
174
+
175
+ Mem0, OpenMemory, Zep, and Graphiti are capable systems built for different goals; if you
176
+ need managed scale or graph reasoning, they're the right tool. Jamgate's bet is that for
177
+ *personal* cross-agent memory, the hard part is quality at write time — and that it should
178
+ be local, free, and one command to install.
179
+
180
+ ## Privacy
181
+
182
+ - **Everything is local.** The memory store, the gate, and (if enabled) the embedding
183
+ model all run on your machine. Jamgate makes **no network calls** and talks to no cloud
184
+ AI.
185
+ - **The decision log is local too.** Every gate decision (saved / duplicate / superseded /
186
+ conflict / possible_duplicate / rejected, with its reason) is appended to
187
+ `~/.jamgate/gate.log`, a strictly local, size-capped JSONL file that rotates
188
+ automatically and **never leaves your machine**. It exists to collect real usage data
189
+ for a future local quality classifier. Disable it with `JAMGATE_GATE_LOG=off`.
190
+ - **Nothing leaves the machine** — no telemetry, no accounts, no keys.
191
+
192
+ ## Status
193
+
194
+ Early but real, and now installable with one command. The MVP core, robustness, and
195
+ intelligence layers all work today (see [`CHANGELOG.md`](./CHANGELOG.md) for the full
196
+ 0.1.0 scope):
197
+
198
+ - **Gate core** — rule pre-filter, exact dedup, time-aware supersession, source-trust
199
+ conflict guard, over a local flat-file store.
200
+ - **Robustness** — atomic durable writes, type-based expiry, concurrency-safe locking,
201
+ automatic schema migration.
202
+ - **Intelligence** — trusted client provenance, fuzzy recall, optional local embeddings
203
+ with graceful fallback, auto-subject derivation, local decision log.
204
+
205
+ Verified end-to-end over the MCP protocol and covered by an automated test suite (89
206
+ tests) on Node 20.x and 22.x. Next: a thin classifier for ambiguous cases (trained on the
207
+ local decision log) and multi-device sync (see [`DECISIONS.md`](./DECISIONS.md)).
208
+ **Goal: impact, not profit — open-source (MIT), built in the open.**
209
+
210
+ ## Development
211
+
212
+ ```bash
213
+ npm install
214
+ npm run build # compile TypeScript to dist/
215
+ npm test # compile and run the test suite (built-in node:test, no extra deps)
216
+ ```
217
+
218
+ CI runs the build and tests on Node 20.x and 22.x for every push and pull request.
219
+
220
+ ## Contributing
221
+
222
+ This is an impact project. The most valuable contributions are around **write-time
223
+ quality** (selective capture, dedup, contradiction handling, expiry) — the part the whole
224
+ field is weakest at. See [`AGENTS.md`](./AGENTS.md) to get oriented, then
225
+ [`RULES.md`](./RULES.md).
226
+
227
+ ## License
228
+
229
+ [MIT](./LICENSE)
@@ -0,0 +1,57 @@
1
+ // Optional local embedding backend (Phase 3, item 4).
2
+ //
3
+ // Jamgate's base install stays zero-heavy-deps: `@huggingface/transformers` is an OPTIONAL
4
+ // peer dependency, not a hard one. This module dynamically imports it only on demand and
5
+ // degrades gracefully — if the package (or its model) is absent, `loadTransformersEmbedder`
6
+ // returns null and the store runs on fuzzy lexical recall alone. Nothing here runs in CI:
7
+ // loading the model would hit the network to download it. The store depends only on the
8
+ // small `Embedder` interface, so tests inject a deterministic mock instead.
9
+ //
10
+ // Model: all-MiniLM-L6-v2 (the Xenova ONNX port), 384-dimensional sentence embeddings,
11
+ // mean-pooled and L2-normalized. It is a ~23 MB quantized download, fetched locally on
12
+ // first use and cached by Transformers.js under its own cache dir. No text ever leaves the
13
+ // machine — inference is fully local (RULES: never send data to any cloud AI; D-026).
14
+ /** The near-duplicate similarity threshold, overridable via env. Falls back to the module
15
+ * default when unset or garbage. Kept here so the whole embedding config lives in one place. */
16
+ export function resolveDupThreshold(env = process.env) {
17
+ const raw = env.JAMGATE_DUP_THRESHOLD;
18
+ if (raw === undefined)
19
+ return undefined; // caller uses its own default
20
+ const n = Number(raw);
21
+ if (!Number.isFinite(n) || n <= 0 || n > 1)
22
+ return undefined;
23
+ return n;
24
+ }
25
+ const MODEL_ID = "Xenova/all-MiniLM-L6-v2";
26
+ const DIMENSIONS = 384;
27
+ /**
28
+ * Try to build the real Transformers.js embedder. Returns null (never throws) when the
29
+ * optional dependency or the model is unavailable, so the caller can fall back to fuzzy
30
+ * recall. Embedding is opt-out via `JAMGATE_EMBEDDINGS=off`.
31
+ *
32
+ * The heavy import is dynamic and behind a runtime string so the base build never resolves
33
+ * the module at type-check time and a missing package is a graceful null, not a crash.
34
+ */
35
+ export async function loadTransformersEmbedder(env = process.env) {
36
+ const flag = env.JAMGATE_EMBEDDINGS?.trim().toLowerCase();
37
+ if (flag && ["off", "none", "0", "false"].includes(flag))
38
+ return null;
39
+ try {
40
+ const pkg = "@huggingface/transformers";
41
+ const mod = (await import(/* @vite-ignore */ pkg));
42
+ const extractor = await mod.pipeline("feature-extraction", MODEL_ID);
43
+ return {
44
+ id: MODEL_ID,
45
+ dimensions: DIMENSIONS,
46
+ async embed(text) {
47
+ const output = await extractor(text, { pooling: "mean", normalize: true });
48
+ return Array.from(output.data);
49
+ },
50
+ };
51
+ }
52
+ catch (err) {
53
+ // Package missing, model download blocked, or runtime error → degrade to fuzzy recall.
54
+ console.error("jamgate: optional embeddings unavailable, falling back to fuzzy recall:", err?.message ?? err);
55
+ return null;
56
+ }
57
+ }
@@ -0,0 +1,54 @@
1
+ // Pure vector math for the optional embedding layer (Phase 3, item 4).
2
+ //
3
+ // This module is deliberately dependency-free and has NO knowledge of any ML runtime — it
4
+ // is just the arithmetic that the semantic layer needs: cosine similarity, blending a
5
+ // semantic score with the fuzzy lexical score, and the near-duplicate threshold. Keeping it
6
+ // pure means it is fully unit-testable in CI with hand-built vectors, no model download.
7
+ /** Default similarity at/above which two texts are treated as semantic near-duplicates.
8
+ * Tuned for all-MiniLM-L6-v2 normalized embeddings: paraphrases of the same statement
9
+ * sit high (~0.85–0.95) while genuinely different facts (even similar phrasing like
10
+ * "uses Windows" vs "uses Linux") sit well below. Overridable via env (see embedder). */
11
+ export const DEFAULT_DUP_THRESHOLD = 0.88;
12
+ /** Blend weights for combining the lexical (fuzzy) score with the semantic score during
13
+ * recall. Semantic leads because it is what adds synonym reach; fuzzy anchors on exact
14
+ * surface matches the embedding can under-weight. Both operands are expected in [0, 1]. */
15
+ const SEMANTIC_WEIGHT = 0.6;
16
+ const LEXICAL_WEIGHT = 0.4;
17
+ /** Minimum semantic similarity for an embedding match to pull an otherwise lexically
18
+ * irrelevant memory into recall. High enough to admit true synonyms (~0.6–0.8 on MiniLM)
19
+ * while excluding the moderate baseline similarity (~0.2–0.4) unrelated short texts show —
20
+ * without this floor, semantic noise would flood recall. */
21
+ export const DEFAULT_SEMANTIC_MIN = 0.5;
22
+ /**
23
+ * Cosine similarity of two equal-length vectors, in [-1, 1]. Returns 0 for a zero vector
24
+ * or a length mismatch (defensive: a corrupt/older embedding must not throw during recall).
25
+ */
26
+ export function cosineSimilarity(a, b) {
27
+ if (a.length !== b.length || a.length === 0)
28
+ return 0;
29
+ let dot = 0;
30
+ let normA = 0;
31
+ let normB = 0;
32
+ for (let i = 0; i < a.length; i++) {
33
+ dot += a[i] * b[i];
34
+ normA += a[i] * a[i];
35
+ normB += b[i] * b[i];
36
+ }
37
+ if (normA === 0 || normB === 0)
38
+ return 0;
39
+ return dot / (Math.sqrt(normA) * Math.sqrt(normB));
40
+ }
41
+ /**
42
+ * Blend a lexical (fuzzy) relevance score with a semantic similarity into one recall score.
43
+ * Clamps the semantic input to [0, 1] first (cosine can be slightly negative for unrelated
44
+ * text) so the blend stays in [0, 1] and comparable to the pure-fuzzy path.
45
+ */
46
+ export function blendRelevance(lexical, semantic) {
47
+ const sem = Math.max(0, Math.min(1, semantic));
48
+ const lex = Math.max(0, Math.min(1, lexical));
49
+ return LEXICAL_WEIGHT * lex + SEMANTIC_WEIGHT * sem;
50
+ }
51
+ /** Is `similarity` at/above the near-duplicate threshold? */
52
+ export function isNearDuplicate(similarity, threshold = DEFAULT_DUP_THRESHOLD) {
53
+ return similarity >= threshold;
54
+ }
@@ -0,0 +1,77 @@
1
+ // Local-only gate decision log (Phase 3, item 3).
2
+ //
3
+ // Every save runs through the gate and comes out with a decision: saved, duplicate,
4
+ // superseded, conflict, possible_duplicate, or rejected — each with a reason. Appending
5
+ // those decisions to a local JSONL file gives us real, labelled data to later train the
6
+ // thin "is this worth keeping?" classifier (D-004) on actual usage instead of guesses.
7
+ //
8
+ // STRICTLY LOCAL. This log never leaves the machine — same local-first promise as the
9
+ // store (D-010). It lives next to the store (~/.jamgate/gate.log by default), is
10
+ // size-capped with single-file rotation so it can't grow without bound, and logging can be
11
+ // turned off entirely (JAMGATE_GATE_LOG=off). Logged text is truncated to keep lines small
12
+ // (so appends stay atomic on POSIX) and to bound the footprint. Logging is best-effort: a
13
+ // failure to write the log must NEVER fail or slow the user's save, so all errors are
14
+ // swallowed to stderr.
15
+ import { promises as fs } from "node:fs";
16
+ import { homedir } from "node:os";
17
+ import { dirname, join } from "node:path";
18
+ const DEFAULT_PATH = join(homedir(), ".jamgate", "gate.log");
19
+ const DEFAULT_MAX_BYTES = 5 * 1024 * 1024; // 5 MiB
20
+ const DEFAULT_MAX_TEXT_CHARS = 500;
21
+ /** Resolve logging config from the environment (all overridable). `JAMGATE_GATE_LOG` sets
22
+ * the path; `off`/`none`/`0` disables. `JAMGATE_GATE_LOG_MAX_BYTES` caps the size. */
23
+ export function resolveGateLogConfig(env = process.env) {
24
+ const raw = env.JAMGATE_GATE_LOG?.trim();
25
+ const disabled = raw !== undefined && ["off", "none", "0", ""].includes(raw.toLowerCase());
26
+ const path = disabled ? null : raw && raw.length > 0 ? raw : DEFAULT_PATH;
27
+ const maxBytesRaw = Number(env.JAMGATE_GATE_LOG_MAX_BYTES);
28
+ const maxBytes = Number.isFinite(maxBytesRaw) && maxBytesRaw > 0 ? maxBytesRaw : DEFAULT_MAX_BYTES;
29
+ return { path, maxBytes, maxTextChars: DEFAULT_MAX_TEXT_CHARS };
30
+ }
31
+ /**
32
+ * Append one gate decision to the log. Best-effort and non-throwing: any error is
33
+ * swallowed to stderr so logging can never break a save. Rotates the log to `<path>.1`
34
+ * (overwriting the previous rotation) once it exceeds `maxBytes`, bounding disk use to
35
+ * roughly 2× the cap.
36
+ */
37
+ export async function appendGateLog(entry, config = resolveGateLogConfig()) {
38
+ if (config.path === null)
39
+ return; // logging disabled
40
+ try {
41
+ const dir = dirname(config.path);
42
+ await fs.mkdir(dir, { recursive: true });
43
+ await rotateIfNeeded(config.path, config.maxBytes);
44
+ const record = {
45
+ ts: new Date().toISOString(),
46
+ decision: entry.decision,
47
+ ...(entry.reason ? { reason: entry.reason } : {}),
48
+ ...(entry.type ? { type: entry.type } : {}),
49
+ ...(entry.subject ? { subject: entry.subject } : {}),
50
+ ...(entry.source ? { source: entry.source } : {}),
51
+ ...(entry.client ? { client: entry.client } : {}),
52
+ text: truncate(entry.text, config.maxTextChars),
53
+ };
54
+ await fs.appendFile(config.path, `${JSON.stringify(record)}\n`, "utf8");
55
+ }
56
+ catch (err) {
57
+ // Never let a diagnostic log break the actual save.
58
+ console.error("jamgate: gate log write failed (ignored):", err);
59
+ }
60
+ }
61
+ function truncate(text, max) {
62
+ return text.length <= max ? text : `${text.slice(0, max)}…`;
63
+ }
64
+ /** Rotate `path` → `path.1` when it grows past `maxBytes`. Single-file rotation keeps the
65
+ * most recent two generations; older data is intentionally discarded (this is a rolling
66
+ * training buffer, not an audit log). */
67
+ async function rotateIfNeeded(path, maxBytes) {
68
+ try {
69
+ const { size } = await fs.stat(path);
70
+ if (size >= maxBytes)
71
+ await fs.rename(path, `${path}.1`);
72
+ }
73
+ catch (err) {
74
+ if (err.code !== "ENOENT")
75
+ throw err; // no file yet → nothing to rotate
76
+ }
77
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Cheap rule pre-filter (RULES §5.1): kill the obvious junk before spending any AI.
3
+ * This is layer 1 of the gate only — salience scoring and the thin classifier come later.
4
+ */
5
+ const PLEASANTRIES = new Set([
6
+ "hi", "hello", "hey", "thanks", "thank you", "ok", "okay", "k",
7
+ "cool", "nice", "lol", "yes", "no", "sure", "yep", "nope", "bye",
8
+ ]);
9
+ export function prefilter(text) {
10
+ const t = text.trim();
11
+ if (t.length < 4)
12
+ return { ok: false, reason: "too short" };
13
+ if (PLEASANTRIES.has(t.toLowerCase())) {
14
+ return { ok: false, reason: "pleasantry / no durable content" };
15
+ }
16
+ return { ok: true };
17
+ }
@@ -0,0 +1,138 @@
1
+ // Fuzzy, dependency-free relevance scoring for recall (Phase 3, item 2).
2
+ //
3
+ // The MVP recall scored by crude word overlap: for each query word, does the memory text
4
+ // contain it as a substring? That is brittle — "projects" misses "project", "berln" misses
5
+ // "berlin", and "cat" spuriously matches "category". This module replaces it with a
6
+ // deterministic, ML-free scorer that is markedly more forgiving of the small surface
7
+ // variations real queries carry, while staying fully local and reproducible.
8
+ //
9
+ // It is NOT semantic: it has no notion that "car" and "automobile" mean the same thing.
10
+ // Synonyms are the job of the optional embedding layer (Phase 3, item 4); this layer earns
11
+ // its keep on morphology and typos, deterministically and with zero dependencies.
12
+ //
13
+ // The score blends two signals:
14
+ // 1. weighted token overlap — each query token scored by its best fuzzy match among the
15
+ // text tokens (exact/stemmed match = 1, else a trigram-similarity partial credit),
16
+ // weighted so longer, more-informative tokens count for more and stopwords for nothing;
17
+ // 2. whole-string trigram similarity — a character-level Dice coefficient that rewards
18
+ // overall shared substance and catches matches the tokenizer splits apart.
19
+ // Token overlap dominates (it is the precise signal); trigram similarity is a smaller
20
+ // tie-breaker that mainly helps on typos and word-boundary noise.
21
+ /** Blend weight: token overlap is the primary signal, trigram a secondary tie-breaker. */
22
+ const TOKEN_WEIGHT = 0.75;
23
+ const TRIGRAM_WEIGHT = 0.25;
24
+ /** Below this, a memory is treated as not matching the query at all (keeps trigram noise
25
+ * from surfacing unrelated memories). Chosen so a single solid token match clears it. */
26
+ export const MIN_RELEVANCE = 0.1;
27
+ /** A fuzzy token match below this trigram similarity earns no credit. Prevents two words
28
+ * that merely share a first letter ("berlin"/"build") from scoring as a near-match, while
29
+ * still crediting genuine typos ("berlin"/"berln" ≈ 0.6). */
30
+ const FUZZY_TOKEN_FLOOR = 0.4;
31
+ /** Common function words that carry no discriminating signal. Deliberately small — only
32
+ * words that are near-always noise in a short factual memory. If a query is made ENTIRELY
33
+ * of these, we fall back to using them so the query still matches something. */
34
+ const STOPWORDS = new Set([
35
+ "a", "an", "and", "are", "as", "at", "be", "by", "do", "does", "for", "from",
36
+ "has", "have", "how", "i", "in", "is", "it", "its", "me", "my", "of", "on",
37
+ "or", "so", "that", "the", "their", "them", "they", "this", "to", "was", "were",
38
+ "what", "when", "where", "which", "who", "why", "with", "you", "your",
39
+ ]);
40
+ /** Split text into lowercase alphanumeric tokens, punctuation stripped. */
41
+ export function tokenize(text) {
42
+ return text
43
+ .toLowerCase()
44
+ .split(/[^a-z0-9]+/)
45
+ .filter(Boolean);
46
+ }
47
+ /**
48
+ * Stemming-lite: strip a few common English inflectional suffixes so "projects",
49
+ * "projecting" and "project" collapse to one stem. Intentionally crude and conservative
50
+ * (no Porter stemmer, no dependency) — it only trims suffixes when a reasonable stem
51
+ * length remains, so short tokens are left intact rather than mangled.
52
+ */
53
+ export function stem(token) {
54
+ let t = token;
55
+ const trim = (suffix, min) => {
56
+ if (t.length >= suffix.length + min && t.endsWith(suffix)) {
57
+ t = t.slice(0, -suffix.length);
58
+ return true;
59
+ }
60
+ return false;
61
+ };
62
+ // Order matters: try longer/derivational suffixes before the bare plural -s.
63
+ trim("ing", 3) || trim("edly", 3) || trim("ed", 3) || trim("ly", 3);
64
+ trim("ies", 2) && (t += "y"); // "memories" -> "memory"
65
+ trim("es", 3) || trim("s", 3);
66
+ return t;
67
+ }
68
+ /** Content stems of a text: tokenized, stopwords removed, each stemmed. Falls back to the
69
+ * full stemmed token list when the text is nothing but stopwords, so it never goes empty
70
+ * for a non-empty input. */
71
+ export function contentStems(text) {
72
+ const tokens = tokenize(text);
73
+ const content = tokens.filter((t) => !STOPWORDS.has(t));
74
+ const kept = content.length > 0 ? content : tokens;
75
+ return kept.map(stem);
76
+ }
77
+ /** Character trigrams of a string (padded so short strings still produce trigrams). */
78
+ function trigrams(s) {
79
+ const padded = ` ${s} `;
80
+ const grams = new Set();
81
+ for (let i = 0; i < padded.length - 2; i++)
82
+ grams.add(padded.slice(i, i + 3));
83
+ return grams;
84
+ }
85
+ /** Sørensen–Dice similarity of two strings over character trigrams, in [0, 1]. */
86
+ export function trigramSimilarity(a, b) {
87
+ if (a === b)
88
+ return 1;
89
+ if (!a || !b)
90
+ return 0;
91
+ const ga = trigrams(a);
92
+ const gb = trigrams(b);
93
+ let shared = 0;
94
+ for (const g of ga)
95
+ if (gb.has(g))
96
+ shared++;
97
+ return (2 * shared) / (ga.size + gb.size);
98
+ }
99
+ /** How well a single query stem matches a single text stem: exact = 1, else trigram
100
+ * partial credit (so "berln" still scores against "berlin"). */
101
+ function tokenSimilarity(q, t) {
102
+ if (q === t)
103
+ return 1;
104
+ const sim = trigramSimilarity(q, t);
105
+ return sim >= FUZZY_TOKEN_FLOOR ? sim : 0;
106
+ }
107
+ /**
108
+ * Relevance of `text` to `query`, in [0, 1]. Deterministic and dependency-free.
109
+ * 0 means no meaningful overlap. Higher is more relevant.
110
+ */
111
+ export function relevanceScore(query, text) {
112
+ const qStems = contentStems(query);
113
+ const tStems = contentStems(text);
114
+ if (qStems.length === 0 || tStems.length === 0)
115
+ return 0;
116
+ // Weighted token overlap: each query stem earns its best fuzzy match among text stems,
117
+ // weighted by stem length so a hit on "operating" counts for more than a hit on "os".
118
+ let weighted = 0;
119
+ let totalWeight = 0;
120
+ for (const q of qStems) {
121
+ const weight = q.length;
122
+ let best = 0;
123
+ for (const t of tStems) {
124
+ const sim = tokenSimilarity(q, t);
125
+ if (sim > best)
126
+ best = sim;
127
+ if (best === 1)
128
+ break;
129
+ }
130
+ weighted += weight * best;
131
+ totalWeight += weight;
132
+ }
133
+ const tokenScore = totalWeight > 0 ? weighted / totalWeight : 0;
134
+ // Whole-string trigram similarity over the stemmed content — a smaller corroborating
135
+ // signal that catches shared substance the token loop misses.
136
+ const trigramScore = trigramSimilarity(qStems.join(" "), tStems.join(" "));
137
+ return TOKEN_WEIGHT * tokenScore + TRIGRAM_WEIGHT * trigramScore;
138
+ }