From aa9423b72fd76c3a9f0b79410ebd0284c54d0669 Mon Sep 17 00:00:00 2001 From: KT <677465+kevintseng@users.noreply.github.com> Date: Sat, 18 Apr 2026 13:10:18 +0800 Subject: [PATCH 01/51] fix(serializer): overwrite import now clears old data before re-populating The overwrite merge strategy previously archived the entity then called createEntity(), which has reactivation logic that un-archives and APPENDS new observations to the old ones. Now uses clearEntityData() to delete observations + tags (keeping the entity row) before re-populating, ensuring overwrite truly replaces content. --- src/core/serializer.ts | 4 ++-- src/knowledge-graph.ts | 24 ++++++++++++++++++++++++ tests/core/integration.test.ts | 16 ++++++++++++++++ 3 files changed, 42 insertions(+), 2 deletions(-) diff --git a/src/core/serializer.ts b/src/core/serializer.ts index ce65ea59..a423c340 100644 --- a/src/core/serializer.ts +++ b/src/core/serializer.ts @@ -76,8 +76,8 @@ export function importMemories(args: ImportInput): ImportResult { appended++; continue; } - // overwrite: archive existing, then create fresh below - kg.archiveEntity(entity.name); + // overwrite: clear existing data, then re-populate below + kg.clearEntityData(entity.name); } kg.createEntity(entity.name, entity.type, { diff --git a/src/knowledge-graph.ts b/src/knowledge-graph.ts index 0d67c77b..3d84fe0c 100644 --- a/src/knowledge-graph.ts +++ b/src/knowledge-graph.ts @@ -468,6 +468,30 @@ export class KnowledgeGraph { return results; } + /** + * Clear all observations and tags for an entity without deleting the entity row. + * Used by overwrite import to start fresh before re-adding data. + */ + clearEntityData(name: string): void { + const row = this.db + .prepare('SELECT id FROM entities WHERE name = ?') + .get(name) as EntityRow | undefined; + if (!row) return; + + // Capture current observations text for FTS delete before clearing + const prevObs = this.db + .prepare('SELECT content FROM observations WHERE entity_id = ?') + .all(row.id) as { content: string }[]; + const prevObsText = prevObs.length > 0 + ? prevObs.map((o) => o.content).join(' ') + : undefined; + + this.db.prepare('DELETE FROM observations WHERE entity_id = ?').run(row.id); + this.db.prepare('DELETE FROM tags WHERE entity_id = ?').run(row.id); + // Rebuild FTS with empty content (removes old indexed text) + this.rebuildFts(row.id, name, prevObsText); + } + archiveEntity(name: string): { archived: boolean; name?: string; previousStatus?: string } { const row = this.db .prepare('SELECT id, status FROM entities WHERE name = ?') diff --git a/tests/core/integration.test.ts b/tests/core/integration.test.ts index 268ec289..4027e8b6 100644 --- a/tests/core/integration.test.ts +++ b/tests/core/integration.test.ts @@ -193,4 +193,20 @@ describe('Integration: export → import round-trip', () => { process.env.MEMESH_DB_PATH = path.join(tmpDir, 'test.db'); openDatabase(); }); + + it('overwrite import replaces observations completely', () => { + remember({ name: 'overwrite-test', type: 'test', observations: ['old-1', 'old-2', 'old-3'], tags: ['old-tag'] }); + + const exported = exportMemories({}); + const entity = exported.entities.find(e => e.name === 'overwrite-test')!; + entity.observations = ['new-only']; + entity.tags = ['new-tag']; + + importMemories({ data: exported, merge_strategy: 'overwrite' }); + + const result = recall({ query: 'overwrite-test', limit: 1 }); + expect(result[0].observations).toEqual(['new-only']); + expect(result[0].observations).not.toContain('old-1'); + expect(result[0].tags).toContain('new-tag'); + }); }); From 2c4c5275b372bebbd1ae1062660d49ca768614d1 Mon Sep 17 00:00:00 2001 From: KT <677465+kevintseng@users.noreply.github.com> Date: Sat, 18 Apr 2026 13:11:48 +0800 Subject: [PATCH 02/51] fix(serializer): apply namespace filter at query level, not post-filter exportMemories() previously applied namespace filter AFTER the SQL LIMIT, so if limit=10 and the first 10 entities were all 'personal', requesting namespace='team' returned 0 results even if team entities existed. Now passes namespace directly to kg.search() which applies the filter in the SQL WHERE clause before LIMIT. --- src/core/serializer.ts | 10 +++------- tests/core/integration.test.ts | 17 +++++++++++++++++ 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/src/core/serializer.ts b/src/core/serializer.ts index a423c340..76bc0b08 100644 --- a/src/core/serializer.ts +++ b/src/core/serializer.ts @@ -15,17 +15,13 @@ export function exportMemories(args: ExportInput): ExportResult { const db = getDatabase(); const kg = new KnowledgeGraph(db); - let entities = kg.search(undefined, { + const entities = kg.search(undefined, { tag: args.tag, limit: args.limit || 1000, includeArchived: false, + namespace: args.namespace, }); - // Filter by namespace if specified (search() doesn't filter by namespace) - if (args.namespace) { - entities = entities.filter((e) => (e.namespace ?? 'personal') === args.namespace); - } - return { version: '3.0.0', exported_at: new Date().toISOString(), @@ -46,7 +42,7 @@ export function exportMemories(args: ExportInput): ExportResult { * merge_strategy controls how existing entities are handled: * - 'skip': leave existing entities untouched, only create new ones * - 'append': add observations to existing entities - * - 'overwrite': archive existing, then create fresh + * - 'overwrite': clear existing data, then re-populate */ export function importMemories(args: ImportInput): ImportResult { const db = getDatabase(); diff --git a/tests/core/integration.test.ts b/tests/core/integration.test.ts index 4027e8b6..5ea0dbee 100644 --- a/tests/core/integration.test.ts +++ b/tests/core/integration.test.ts @@ -210,3 +210,20 @@ describe('Integration: export → import round-trip', () => { expect(result[0].tags).toContain('new-tag'); }); }); + +// ── export with namespace filter ────────────────────────────────────────────── + +describe('Integration: export namespace filtering', () => { + it('export with namespace filter returns correct entities within limit', () => { + for (let i = 0; i < 5; i++) { + remember({ name: `ns-personal-${i}`, type: 'test', observations: [`p${i}`], namespace: 'personal' }); + remember({ name: `ns-team-${i}`, type: 'test', observations: [`t${i}`], namespace: 'team' }); + } + + const result = exportMemories({ namespace: 'team', limit: 3 }); + expect(result.entity_count).toBe(3); + for (const e of result.entities) { + expect(e.namespace).toBe('team'); + } + }); +}); From 7cbc6c0efad75ef4e3996467417e6043eb7a0e68 Mon Sep 17 00:00:00 2001 From: KT <677465+kevintseng@users.noreply.github.com> Date: Sat, 18 Apr 2026 13:16:52 +0800 Subject: [PATCH 03/51] feat(core): add neural embedding module (@xenova/transformers, 384-dim) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add embedder.ts with local ONNX embedding generation using Xenova/all-MiniLM-L6-v2. Provides isEmbeddingAvailable(), embedText(), embedAndStore(), and vectorSearch() — all gracefully degrade when the package is missing. Fire-and-forget design keeps remember() synchronous. --- package-lock.json | 458 +++++++++++++++++++++++++++++++++++- package.json | 1 + src/core/embedder.ts | 129 ++++++++++ tests/core/embedder.test.ts | 37 +++ 4 files changed, 620 insertions(+), 5 deletions(-) create mode 100644 src/core/embedder.ts create mode 100644 tests/core/embedder.test.ts diff --git a/package-lock.json b/package-lock.json index 65f6effe..aaee481d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,16 @@ { "name": "@pcircle/memesh", - "version": "3.0.1", + "version": "3.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@pcircle/memesh", - "version": "3.0.1", + "version": "3.1.1", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", + "@xenova/transformers": "^2.17.2", "better-sqlite3": "^12.9.0", "commander": "^14.0.3", "express": "^5.2.1", @@ -17,7 +18,9 @@ "zod": "^4.3.6" }, "bin": { - "memesh": "dist/mcp/server.js", + "memesh": "dist/transports/cli/cli.js", + "memesh-http": "dist/transports/http/server.js", + "memesh-mcp": "dist/mcp/server.js", "memesh-view": "dist/cli/view.js" }, "devDependencies": { @@ -485,6 +488,15 @@ "hono": "^4" } }, + "node_modules/@huggingface/jinja": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@huggingface/jinja/-/jinja-0.2.2.tgz", + "integrity": "sha512-/KPde26khDUIPkTGU82jdtTW9UAuvUTumCAbFs/7giR0SxsvZC4hru51PBvpijH6BVkHcROcvZM/lpy5h1jRRA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -532,6 +544,70 @@ } } }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", + "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", + "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1", + "@protobufjs/inquire": "^1.1.0" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", + "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", + "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", + "license": "BSD-3-Clause" + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.59.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", @@ -977,11 +1053,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/long": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz", + "integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "25.3.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.0.tgz", "integrity": "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~7.18.0" @@ -1133,6 +1214,20 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@xenova/transformers": { + "version": "2.17.2", + "resolved": "https://registry.npmjs.org/@xenova/transformers/-/transformers-2.17.2.tgz", + "integrity": "sha512-lZmHqzrVIkSvZdKZEx7IYY51TK0WDrC8eR0c5IMnBsO8di8are1zzw8BlLhyO2TklZKLN5UffNGs1IJwT6oOqQ==", + "license": "Apache-2.0", + "dependencies": { + "@huggingface/jinja": "^0.2.2", + "onnxruntime-web": "1.14.0", + "sharp": "^0.32.0" + }, + "optionalDependencies": { + "onnxruntime-node": "1.14.0" + } + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -1189,6 +1284,111 @@ "node": ">=12" } }, + "node_modules/b4a": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.0.tgz", + "integrity": "sha512-qRuSmNSkGQaHwNbM7J78Wwy+ghLEYF1zNrSeMxj4Kgw6y33O3mXcQ6Ie9fRvfU/YnxWkOchPXbaLb73TkIsfdg==", + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, + "node_modules/bare-events": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz", + "integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==", + "license": "Apache-2.0", + "peerDependencies": { + "bare-abort-controller": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + } + } + }, + "node_modules/bare-fs": { + "version": "4.7.1", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.7.1.tgz", + "integrity": "sha512-WDRsyVN52eAx/lBamKD6uyw8H4228h/x0sGGGegOamM2cd7Pag88GfMQalobXI+HaEUxpCkbKQUDOQqt9wawRw==", + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.5.4", + "bare-path": "^3.0.0", + "bare-stream": "^2.6.4", + "bare-url": "^2.2.2", + "fast-fifo": "^1.3.2" + }, + "engines": { + "bare": ">=1.16.0" + }, + "peerDependencies": { + "bare-buffer": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + } + } + }, + "node_modules/bare-os": { + "version": "3.8.7", + "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.8.7.tgz", + "integrity": "sha512-G4Gr1UsGeEy2qtDTZwL7JFLo2wapUarz7iTMcYcMFdS89AIQuBoyjgXZz0Utv7uHs3xA9LckhVbeBi8lEQrC+w==", + "license": "Apache-2.0", + "engines": { + "bare": ">=1.14.0" + } + }, + "node_modules/bare-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz", + "integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==", + "license": "Apache-2.0", + "dependencies": { + "bare-os": "^3.0.1" + } + }, + "node_modules/bare-stream": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.0.tgz", + "integrity": "sha512-3zAJRZMDFGjdn+RVnNpF9kuELw+0Fl3lpndM4NcEOhb9zwtSo/deETfuIwMSE5BXanA0FrN1qVjffGwAg2Y7EA==", + "license": "Apache-2.0", + "dependencies": { + "streamx": "^2.25.0", + "teex": "^1.0.1" + }, + "peerDependencies": { + "bare-abort-controller": "*", + "bare-buffer": "*", + "bare-events": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + }, + "bare-buffer": { + "optional": true + }, + "bare-events": { + "optional": true + } + } + }, + "node_modules/bare-url": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.4.0.tgz", + "integrity": "sha512-NSTU5WN+fy/L0DDenfE8SXQna4voXuW0FHM7wH8i3/q9khUSchfPbPezO4zSFMnDGIf9YE+mt/RWhZgNRKRIXA==", + "license": "Apache-2.0", + "dependencies": { + "bare-path": "^3.0.0" + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -1345,6 +1545,47 @@ "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", "license": "ISC" }, + "node_modules/color": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1", + "color-string": "^1.9.0" + }, + "engines": { + "node": ">=12.5.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "license": "MIT", + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, "node_modules/commander": { "version": "14.0.3", "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", @@ -1626,6 +1867,15 @@ "node": ">= 0.6" } }, + "node_modules/events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.7.0" + } + }, "node_modules/eventsource": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", @@ -1733,6 +1983,12 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "license": "MIT" }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "license": "MIT" + }, "node_modules/fast-uri": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", @@ -1794,6 +2050,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/flatbuffers": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/flatbuffers/-/flatbuffers-1.12.0.tgz", + "integrity": "sha512-c7CZADjRcl6j0PlvFy0ZqXQ67qSEZfrVPynmnL+2zPc+NtMvrF8Y0QceMo7QqnSPc7+uWjUIAbvCQ5WIKlMVdQ==", + "license": "SEE LICENSE IN LICENSE.txt" + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -1897,6 +2159,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/guid-typescript": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/guid-typescript/-/guid-typescript-1.0.9.tgz", + "integrity": "sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ==", + "license": "ISC" + }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -2016,6 +2284,12 @@ "node": ">= 0.10" } }, + "node_modules/is-arrayish": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz", + "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", + "license": "MIT" + }, "node_modules/is-promise": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", @@ -2049,6 +2323,12 @@ "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", "license": "BSD-2-Clause" }, + "node_modules/long": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz", + "integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==", + "license": "Apache-2.0" + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -2193,6 +2473,12 @@ "node": ">=10" } }, + "node_modules/node-addon-api": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz", + "integrity": "sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA==", + "license": "MIT" + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -2246,6 +2532,50 @@ "wrappy": "1" } }, + "node_modules/onnx-proto": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/onnx-proto/-/onnx-proto-4.0.4.tgz", + "integrity": "sha512-aldMOB3HRoo6q/phyB6QRQxSt895HNNw82BNyZ2CMh4bjeKv7g/c+VpAFtJuEMVfYLMbRx61hbuqnKceLeDcDA==", + "license": "MIT", + "dependencies": { + "protobufjs": "^6.8.8" + } + }, + "node_modules/onnxruntime-common": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.14.0.tgz", + "integrity": "sha512-3LJpegM2iMNRX2wUmtYfeX/ytfOzNwAWKSq1HbRrKc9+uqG/FsEA0bbKZl1btQeZaXhC26l44NWpNUeXPII7Ew==", + "license": "MIT" + }, + "node_modules/onnxruntime-node": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/onnxruntime-node/-/onnxruntime-node-1.14.0.tgz", + "integrity": "sha512-5ba7TWomIV/9b6NH/1x/8QEeowsb+jBEvFzU6z0T4mNsFwdPqXeFUM7uxC6QeSRkEbWu3qEB0VMjrvzN/0S9+w==", + "license": "MIT", + "optional": true, + "os": [ + "win32", + "darwin", + "linux" + ], + "dependencies": { + "onnxruntime-common": "~1.14.0" + } + }, + "node_modules/onnxruntime-web": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/onnxruntime-web/-/onnxruntime-web-1.14.0.tgz", + "integrity": "sha512-Kcqf43UMfW8mCydVGcX9OMXI2VN17c0p6XvR7IPSZzBf/6lteBzXHvcEVWDPmCKuGombl997HgLqj91F11DzXw==", + "license": "MIT", + "dependencies": { + "flatbuffers": "^1.12.0", + "guid-typescript": "^1.0.9", + "long": "^4.0.0", + "onnx-proto": "^4.0.4", + "onnxruntime-common": "~1.14.0", + "platform": "^1.3.6" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -2310,6 +2640,12 @@ "node": ">=16.20.0" } }, + "node_modules/platform": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/platform/-/platform-1.3.6.tgz", + "integrity": "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==", + "license": "MIT" + }, "node_modules/postcss": { "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", @@ -2365,6 +2701,32 @@ "node": ">=10" } }, + "node_modules/protobufjs": { + "version": "6.11.5", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-6.11.5.tgz", + "integrity": "sha512-OKjVH3hDoXdIZ/s5MLv8O2X0s+wOxGfV7ar6WFSKGaSAxi/6gYn3px5POS4vi+mc/0zCOdL7Jkwrj0oT1Yst2A==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/long": "^4.0.1", + "@types/node": ">=13.7.0", + "long": "^4.0.0" + }, + "bin": { + "pbjs": "bin/pbjs", + "pbts": "bin/pbts" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -2624,6 +2986,55 @@ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", "license": "ISC" }, + "node_modules/sharp": { + "version": "0.32.6", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.32.6.tgz", + "integrity": "sha512-KyLTWwgcR9Oe4d9HwCwNM2l7+J0dUQwn/yf7S0EnTtb0eVS4RxO0eUSvxPtzT4F3SY+C4K6fqdv/DO27sJ/v/w==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "color": "^4.2.3", + "detect-libc": "^2.0.2", + "node-addon-api": "^6.1.0", + "prebuild-install": "^7.1.1", + "semver": "^7.5.4", + "simple-get": "^4.0.1", + "tar-fs": "^3.0.4", + "tunnel-agent": "^0.6.0" + }, + "engines": { + "node": ">=14.15.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/sharp/node_modules/tar-fs": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.2.tgz", + "integrity": "sha512-QGxxTxxyleAdyM3kpFs14ymbYmNFrfY+pHj7Z8FgtbZ7w2//VAgLMac7sT6nRpIHjppXO2AwwEOg0bPFVRcmXw==", + "license": "MIT", + "dependencies": { + "pump": "^3.0.0", + "tar-stream": "^3.1.5" + }, + "optionalDependencies": { + "bare-fs": "^4.0.1", + "bare-path": "^3.0.0" + } + }, + "node_modules/sharp/node_modules/tar-stream": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.8.tgz", + "integrity": "sha512-U6QpVRyCGHva435KoNWy9PRoi2IFYCgtEhq9nmrPPpbRacPs9IH4aJ3gbrFC8dPcXvdSZ4XXfXT5Fshbp2MtlQ==", + "license": "MIT", + "dependencies": { + "b4a": "^1.6.4", + "bare-fs": "^4.5.5", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -2769,6 +3180,15 @@ "simple-concat": "^1.0.0" } }, + "node_modules/simple-swizzle": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz", + "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -2880,6 +3300,17 @@ "dev": true, "license": "MIT" }, + "node_modules/streamx": { + "version": "2.25.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.25.0.tgz", + "integrity": "sha512-0nQuG6jf1w+wddNEEXCF4nTg3LtufWINB5eFEN+5TNZW7KWJp6x87+JFL43vaAUPyCfH1wID+mNVyW6OHtFamg==", + "license": "MIT", + "dependencies": { + "events-universal": "^1.0.0", + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + } + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -2917,6 +3348,24 @@ "node": ">=6" } }, + "node_modules/teex": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", + "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==", + "license": "MIT", + "dependencies": { + "streamx": "^2.12.5" + } + }, + "node_modules/text-decoder": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", + "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==", + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4" + } + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -3014,7 +3463,6 @@ "version": "7.18.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", - "dev": true, "license": "MIT" }, "node_modules/unpipe": { diff --git a/package.json b/package.json index b285cfd8..1f21b705 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ }, "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", + "@xenova/transformers": "^2.17.2", "better-sqlite3": "^12.9.0", "commander": "^14.0.3", "express": "^5.2.1", diff --git a/src/core/embedder.ts b/src/core/embedder.ts new file mode 100644 index 00000000..b2e571e2 --- /dev/null +++ b/src/core/embedder.ts @@ -0,0 +1,129 @@ +// ============================================================================= +// Embedder — local neural embedding generation via ONNX +// Uses @xenova/transformers (Xenova/all-MiniLM-L6-v2, 384 dimensions) +// Gracefully no-ops if package is not installed or model unavailable +// ============================================================================= + +import { createRequire } from 'node:module'; +import { getDatabase } from '../db.js'; +import { homedir } from 'os'; +import { join } from 'path'; + +let pipelineInstance: any = null; +let pipelineLoading: Promise | null = null; +let availableChecked = false; +let availableResult = false; + +/** + * Check if @xenova/transformers can be imported. + * Cached after first check. + */ +export function isEmbeddingAvailable(): boolean { + if (availableChecked) return availableResult; + availableChecked = true; + try { + // ESM project — use createRequire for synchronous resolution check + const require = createRequire(import.meta.url); + require.resolve('@xenova/transformers'); + availableResult = true; + } catch { + availableResult = false; + } + return availableResult; +} + +/** + * Reset the cached availability check (for testing). + */ +export function resetEmbeddingState(): void { + availableChecked = false; + availableResult = false; + pipelineInstance = null; + pipelineLoading = null; +} + +/** + * Get or create the embedding pipeline (singleton). + * First call downloads the model (~30MB) to ~/.memesh/models/. + */ +async function getPipeline(): Promise { + if (pipelineInstance) return pipelineInstance; + if (pipelineLoading) return pipelineLoading; + + pipelineLoading = (async () => { + // Dynamic import — type as any to avoid TS errors with optional dependency + const mod: any = await import('@xenova/transformers'); + const createPipeline = mod.pipeline; + const env = mod.env; + if (env) { + env.cacheDir = join(homedir(), '.memesh', 'models'); + env.allowLocalModels = true; + } + pipelineInstance = await createPipeline( + 'feature-extraction', + 'Xenova/all-MiniLM-L6-v2' + ); + return pipelineInstance; + })(); + + return pipelineLoading; +} + +/** + * Generate a 384-dim embedding for the given text. + * Returns null if embedding is unavailable or fails. + */ +export async function embedText(text: string): Promise { + if (!isEmbeddingAvailable()) return null; + try { + const pipe = await getPipeline(); + const output = await pipe(text, { pooling: 'mean', normalize: true }); + return new Float32Array(output.data); + } catch { + return null; + } +} + +/** + * Generate embedding for entity text and store in entities_vec. + * Silently skips if embedding generation fails. + */ +export async function embedAndStore( + entityId: number, + text: string +): Promise { + const embedding = await embedText(text); + if (!embedding) return; + + try { + const db = getDatabase(); + db.prepare( + 'INSERT OR REPLACE INTO entities_vec (rowid, embedding) VALUES (?, ?)' + ).run(entityId, Buffer.from(embedding.buffer)); + } catch { + // DB write failed — skip silently + } +} + +/** + * Search entities_vec for similar embeddings by cosine distance. + * Returns entity IDs sorted by distance (lower = more similar). + */ +export function vectorSearch( + queryEmbedding: Float32Array, + limit: number = 20 +): Array<{ id: number; distance: number }> { + try { + const db = getDatabase(); + return db + .prepare( + 'SELECT rowid AS id, distance FROM entities_vec WHERE embedding MATCH ? ORDER BY distance LIMIT ?' + ) + .all( + Buffer.from(queryEmbedding.buffer), + limit + ) as Array<{ id: number; distance: number }>; + } catch { + return []; + } +} diff --git a/tests/core/embedder.test.ts b/tests/core/embedder.test.ts new file mode 100644 index 00000000..23d844e7 --- /dev/null +++ b/tests/core/embedder.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { + isEmbeddingAvailable, + resetEmbeddingState, +} from '../../src/core/embedder.js'; + +describe('Embedder', () => { + beforeEach(() => { + resetEmbeddingState(); + }); + + it('isEmbeddingAvailable returns boolean', () => { + const result = isEmbeddingAvailable(); + expect(typeof result).toBe('boolean'); + }); + + it('isEmbeddingAvailable is consistent on repeated calls', () => { + const first = isEmbeddingAvailable(); + const second = isEmbeddingAvailable(); + expect(first).toBe(second); + }); + + it('isEmbeddingAvailable returns true when @xenova/transformers is installed', () => { + // In this test environment, @xenova/transformers IS installed as a dependency + const result = isEmbeddingAvailable(); + expect(result).toBe(true); + }); + + it('resetEmbeddingState allows re-checking availability', () => { + const first = isEmbeddingAvailable(); + resetEmbeddingState(); + const second = isEmbeddingAvailable(); + // Both should be true (package is installed), but the point is + // resetEmbeddingState actually clears the cache + expect(first).toBe(second); + }); +}); From 33e631d05e010abbba3275b47829efa3d461a98d Mon Sep 17 00:00:00 2001 From: KT <677465+kevintseng@users.noreply.github.com> Date: Sat, 18 Apr 2026 13:19:02 +0800 Subject: [PATCH 04/51] feat(core): integrate neural embeddings into remember and recall - remember(): fire-and-forget embedAndStore() after entity creation (non-blocking, keeps function synchronous) - recallEnhanced(): vector search supplements FTS5 results in both LLM expansion path and Level 0 fallback path - Graceful degradation: if embeddings unavailable, search falls back to FTS5-only (no behavioral change for existing users) --- src/core/operations.ts | 67 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 65 insertions(+), 2 deletions(-) diff --git a/src/core/operations.ts b/src/core/operations.ts index 8bb0190a..cb96830b 100644 --- a/src/core/operations.ts +++ b/src/core/operations.ts @@ -14,6 +14,7 @@ import { KnowledgeGraph } from '../knowledge-graph.js'; import { expandQuery, isExpansionAvailable } from './query-expander.js'; import { rankEntities } from './scoring.js'; import { createExplicitLesson } from './lesson-engine.js'; +import { embedAndStore, isEmbeddingAvailable, embedText, vectorSearch } from './embedder.js'; import path from 'path'; import type { RememberInput, @@ -69,6 +70,12 @@ export function remember(args: RememberInput): RememberResult { } } + // Fire-and-forget: generate embedding asynchronously (don't block sync remember) + if (isEmbeddingAvailable() && args.observations?.length) { + const text = `${args.name} ${args.observations.join(' ')}`; + embedAndStore(entityId, text).catch(() => {}); + } + return { stored: true, entityId, @@ -149,7 +156,35 @@ export async function recallEnhanced(args: RecallInput): Promise { } } - const merged = [...allResults.values()]; + let merged = [...allResults.values()]; + + // Supplement with vector search results if embeddings available + if (isEmbeddingAvailable()) { + try { + const queryEmb = await embedText(args.query); + if (queryEmb) { + const vectorHits = vectorSearch(queryEmb, args.limit ?? 20); + if (vectorHits.length > 0) { + const kg2 = new KnowledgeGraph(db); + const hitIds = vectorHits.map(h => h.id); + const hitEntities = kg2.getEntitiesByIds(hitIds); + for (let i = 0; i < hitEntities.length; i++) { + const entity = hitEntities[i]; + if (!allResults.has(entity.name)) { + merged.push(entity); + // Convert distance to similarity (cosine distance: 0=identical, 2=opposite) + const dist = vectorHits.find(h => h.id === entity.id)?.distance ?? 1; + const similarity = Math.max(0, 1 - dist); + relevanceMap.set(entity.name, similarity); + } + } + } + } + } catch { + // Vector search failed — FTS5 + expanded results still valid + } + } + return rankEntities(merged, relevanceMap).slice(0, args.limit ?? 20); } catch { // Fallback to regular search on any expansion error @@ -171,7 +206,35 @@ export async function recallEnhanced(args: RecallInput): Promise { relevanceMap.set(e.name, args.query ? 1.0 : 0.5); } - return rankEntities(entities, relevanceMap).slice(0, args.limit ?? 20); + // Supplement with vector search results if embeddings available + let mergedEntities = [...entities]; + if (args.query && isEmbeddingAvailable()) { + try { + const queryEmb = await embedText(args.query); + if (queryEmb) { + const vectorHits = vectorSearch(queryEmb, args.limit ?? 20); + if (vectorHits.length > 0) { + const kg2 = new KnowledgeGraph(db); + const hitIds = vectorHits.map(h => h.id); + const hitEntities = kg2.getEntitiesByIds(hitIds); + for (let i = 0; i < hitEntities.length; i++) { + const entity = hitEntities[i]; + if (!relevanceMap.has(entity.name)) { + mergedEntities.push(entity); + // Convert distance to similarity (cosine distance: 0=identical, 2=opposite) + const dist = vectorHits.find(h => h.id === entity.id)?.distance ?? 1; + const similarity = Math.max(0, 1 - dist); + relevanceMap.set(entity.name, similarity); + } + } + } + } + } catch { + // Vector search failed — FTS5 results still valid + } + } + + return rankEntities(mergedEntities, relevanceMap).slice(0, args.limit ?? 20); } // --- Consolidation (extracted to consolidator.ts) --- From 89b58c382cefd1ac160d8af41dfab16fb48c2620 Mon Sep 17 00:00:00 2001 From: KT <677465+kevintseng@users.noreply.github.com> Date: Sat, 18 Apr 2026 13:26:54 +0800 Subject: [PATCH 05/51] feat(dashboard): add Graph and Lessons tabs with i18n support Graph tab: force-directed canvas visualization of entity relationships with drag, hover tooltips, and type-based coloring. No external deps. Lessons tab: structured lesson_learned cards with parsed Error/Root cause/Fix/Prevention fields, severity coloring, and confidence badges. Both tabs include translations for all 11 supported languages. --- dashboard/src/App.tsx | 8 +- dashboard/src/components/GraphTab.tsx | 302 ++++++++++++++++++++++++ dashboard/src/components/LessonsTab.tsx | 146 ++++++++++++ dashboard/src/lib/api.ts | 15 ++ dashboard/src/lib/i18n.ts | 121 ++++++++++ 5 files changed, 591 insertions(+), 1 deletion(-) create mode 100644 dashboard/src/components/GraphTab.tsx create mode 100644 dashboard/src/components/LessonsTab.tsx diff --git a/dashboard/src/App.tsx b/dashboard/src/App.tsx index d4d2efb0..6839a8c2 100644 --- a/dashboard/src/App.tsx +++ b/dashboard/src/App.tsx @@ -5,18 +5,22 @@ import { SearchTab } from './components/SearchTab'; import { BrowseTab } from './components/BrowseTab'; import { AnalyticsTab } from './components/AnalyticsTab'; import { SettingsTab } from './components/SettingsTab'; +import { GraphTab } from './components/GraphTab'; +import { LessonsTab } from './components/LessonsTab'; import { api, type HealthData } from './lib/api'; import { initLocale, t } from './lib/i18n'; initLocale(); -const TAB_KEYS = ['Search', 'Browse', 'Analytics', 'Manage', 'Settings'] as const; +const TAB_KEYS = ['Search', 'Browse', 'Analytics', 'Graph', 'Lessons', 'Manage', 'Settings'] as const; type Tab = typeof TAB_KEYS[number]; const TAB_I18N_KEYS: Record = { Search: 'tab.search', Browse: 'tab.browse', Analytics: 'tab.analytics', + Graph: 'tab.graph', + Lessons: 'tab.lessons', Manage: 'tab.manage', Settings: 'tab.settings', }; @@ -43,6 +47,8 @@ export function App() {
+
{tab === 'Graph' && }
+
{tab === 'Lessons' && }
{tab === 'Manage' && }
{tab === 'Settings' && }
diff --git a/dashboard/src/components/GraphTab.tsx b/dashboard/src/components/GraphTab.tsx new file mode 100644 index 00000000..19f85631 --- /dev/null +++ b/dashboard/src/components/GraphTab.tsx @@ -0,0 +1,302 @@ +import { useState, useEffect, useRef, useCallback } from 'preact/hooks'; +import { fetchGraph, type GraphData } from '../lib/api'; +import { t } from '../lib/i18n'; + +interface Node { + id: string; + type: string; + x: number; + y: number; + vx: number; + vy: number; + radius: number; +} + +interface Edge { + from: string; + to: string; + type: string; +} + +const TYPE_COLORS: Record = { + decision: '#3b82f6', + pattern: '#22c55e', + lesson_learned: '#f97316', + commit: '#8b5cf6', + 'session-insight': '#6b7280', +}; +const DEFAULT_COLOR = '#94a3b8'; + +function getColor(type: string): string { + return TYPE_COLORS[type] || DEFAULT_COLOR; +} + +export function GraphTab() { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + const canvasRef = useRef(null); + const nodesRef = useRef([]); + const edgesRef = useRef([]); + const animRef = useRef(0); + const dragRef = useRef<{ node: Node | null; offsetX: number; offsetY: number }>({ node: null, offsetX: 0, offsetY: 0 }); + const hoverRef = useRef(null); + const tooltipRef = useRef<{ x: number; y: number; node: Node | null }>({ x: 0, y: 0, node: null }); + + useEffect(() => { + fetchGraph() + .then(setData) + .catch((e) => setError(e.message)) + .finally(() => setLoading(false)); + }, []); + + // Build nodes and edges when data arrives + useEffect(() => { + if (!data) return; + const canvas = canvasRef.current; + if (!canvas) return; + + const w = canvas.parentElement?.clientWidth || 800; + const h = 500; + canvas.width = w * window.devicePixelRatio; + canvas.height = h * window.devicePixelRatio; + canvas.style.width = w + 'px'; + canvas.style.height = h + 'px'; + + const nodeMap = new Map(); + data.entities.forEach((e) => { + nodeMap.set(e.name, { + id: e.name, + type: e.type, + x: Math.random() * w * 0.8 + w * 0.1, + y: Math.random() * h * 0.8 + h * 0.1, + vx: 0, + vy: 0, + radius: 6, + }); + }); + nodesRef.current = Array.from(nodeMap.values()); + edgesRef.current = data.relations.filter((r) => nodeMap.has(r.from) && nodeMap.has(r.to)); + + // Start simulation + const simulate = () => { + const nodes = nodesRef.current; + const edges = edgesRef.current; + const dpr = window.devicePixelRatio; + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + // Force simulation step + const damping = 0.85; + const repulsion = 2000; + const springLen = 80; + const springK = 0.02; + const centerForce = 0.005; + + // Repulsion between all nodes + for (let i = 0; i < nodes.length; i++) { + for (let j = i + 1; j < nodes.length; j++) { + const dx = nodes[j].x - nodes[i].x; + const dy = nodes[j].y - nodes[i].y; + const dist = Math.sqrt(dx * dx + dy * dy) || 1; + const force = repulsion / (dist * dist); + const fx = (dx / dist) * force; + const fy = (dy / dist) * force; + nodes[i].vx -= fx; + nodes[i].vy -= fy; + nodes[j].vx += fx; + nodes[j].vy += fy; + } + } + + // Spring force for edges + const nodeById = new Map(nodes.map((n) => [n.id, n])); + for (const edge of edges) { + const a = nodeById.get(edge.from); + const b = nodeById.get(edge.to); + if (!a || !b) continue; + const dx = b.x - a.x; + const dy = b.y - a.y; + const dist = Math.sqrt(dx * dx + dy * dy) || 1; + const force = (dist - springLen) * springK; + const fx = (dx / dist) * force; + const fy = (dy / dist) * force; + a.vx += fx; + a.vy += fy; + b.vx -= fx; + b.vy -= fy; + } + + // Center gravity + const cx = w / 2; + const cy = h / 2; + for (const n of nodes) { + n.vx += (cx - n.x) * centerForce; + n.vy += (cy - n.y) * centerForce; + } + + // Apply velocities with damping + for (const n of nodes) { + if (dragRef.current.node === n) continue; + n.vx *= damping; + n.vy *= damping; + n.x += n.vx; + n.y += n.vy; + // Clamp to bounds + n.x = Math.max(20, Math.min(w - 20, n.x)); + n.y = Math.max(20, Math.min(h - 20, n.y)); + } + + // Render + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.clearRect(0, 0, w, h); + + // Draw edges + ctx.lineWidth = 1; + ctx.strokeStyle = 'rgba(113, 113, 122, 0.3)'; + ctx.font = '9px system-ui, sans-serif'; + ctx.fillStyle = 'rgba(113, 113, 122, 0.5)'; + for (const edge of edges) { + const a = nodeById.get(edge.from); + const b = nodeById.get(edge.to); + if (!a || !b) continue; + ctx.beginPath(); + ctx.moveTo(a.x, a.y); + ctx.lineTo(b.x, b.y); + ctx.stroke(); + // Edge label + const mx = (a.x + b.x) / 2; + const my = (a.y + b.y) / 2; + ctx.fillText(edge.type, mx + 2, my - 2); + } + + // Draw nodes + for (const n of nodes) { + const isHovered = hoverRef.current === n; + const r = isHovered ? 9 : n.radius; + ctx.beginPath(); + ctx.arc(n.x, n.y, r, 0, Math.PI * 2); + ctx.fillStyle = getColor(n.type); + ctx.fill(); + if (isHovered) { + ctx.strokeStyle = '#fff'; + ctx.lineWidth = 2; + ctx.stroke(); + } + // Node label + ctx.fillStyle = '#d4d4d8'; + ctx.font = '10px system-ui, sans-serif'; + const label = n.id.length > 20 ? n.id.slice(0, 18) + '...' : n.id; + ctx.fillText(label, n.x + r + 4, n.y + 3); + } + + // Tooltip + const tip = tooltipRef.current; + if (tip.node) { + const tx = tip.x + 12; + const ty = tip.y - 10; + const text = `${tip.node.id} (${tip.node.type})`; + const textW = ctx.measureText(text).width; + ctx.fillStyle = 'rgba(15, 15, 18, 0.92)'; + ctx.strokeStyle = 'rgba(59, 130, 246, 0.3)'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.roundRect(tx - 4, ty - 12, textW + 8, 18, 4); + ctx.fill(); + ctx.stroke(); + ctx.fillStyle = '#fafafa'; + ctx.font = '11px system-ui, sans-serif'; + ctx.fillText(text, tx, ty); + } + + animRef.current = requestAnimationFrame(simulate); + }; + + animRef.current = requestAnimationFrame(simulate); + return () => cancelAnimationFrame(animRef.current); + }, [data]); + + const findNodeAt = useCallback((mx: number, my: number): Node | null => { + for (const n of nodesRef.current) { + const dx = n.x - mx; + const dy = n.y - my; + if (dx * dx + dy * dy < 144) return n; // 12px radius hit area + } + return null; + }, []); + + const getCanvasPos = useCallback((e: MouseEvent) => { + const canvas = canvasRef.current; + if (!canvas) return { x: 0, y: 0 }; + const rect = canvas.getBoundingClientRect(); + return { x: e.clientX - rect.left, y: e.clientY - rect.top }; + }, []); + + const onMouseDown = useCallback((e: MouseEvent) => { + const pos = getCanvasPos(e); + const node = findNodeAt(pos.x, pos.y); + if (node) { + dragRef.current = { node, offsetX: pos.x - node.x, offsetY: pos.y - node.y }; + } + }, [findNodeAt, getCanvasPos]); + + const onMouseMove = useCallback((e: MouseEvent) => { + const pos = getCanvasPos(e); + if (dragRef.current.node) { + dragRef.current.node.x = pos.x - dragRef.current.offsetX; + dragRef.current.node.y = pos.y - dragRef.current.offsetY; + dragRef.current.node.vx = 0; + dragRef.current.node.vy = 0; + } + const node = findNodeAt(pos.x, pos.y); + hoverRef.current = node; + tooltipRef.current = node ? { x: pos.x, y: pos.y, node } : { x: 0, y: 0, node: null }; + const canvas = canvasRef.current; + if (canvas) canvas.style.cursor = node ? 'grab' : 'default'; + }, [findNodeAt, getCanvasPos]); + + const onMouseUp = useCallback(() => { + dragRef.current = { node: null, offsetX: 0, offsetY: 0 }; + }, []); + + if (loading) return
; + if (error) return
{t('common.error')}: {error}
; + if (!data) return
{t('common.error')}: No data
; + + const typeGroups = new Map(); + data.entities.forEach((e) => typeGroups.set(e.type, (typeGroups.get(e.type) || 0) + 1)); + + return ( +
+
+
+
{data.entities.length.toLocaleString()}
+
{t('graph.entities')}
+
+
+
{data.relations.length.toLocaleString()}
+
{t('graph.relations')}
+
+
+
+
+ {t('tab.graph')} + {Array.from(typeGroups.entries()).map(([type, count]) => ( + + + {type} ({count}) + + ))} +
+ +
+
+ ); +} diff --git a/dashboard/src/components/LessonsTab.tsx b/dashboard/src/components/LessonsTab.tsx new file mode 100644 index 00000000..60e59310 --- /dev/null +++ b/dashboard/src/components/LessonsTab.tsx @@ -0,0 +1,146 @@ +import { useState, useEffect } from 'preact/hooks'; +import { fetchLessons, type Entity } from '../lib/api'; +import { t } from '../lib/i18n'; + +interface ParsedLesson { + entity: Entity; + error: string; + rootCause: string; + fix: string; + prevention: string; + severity: 'critical' | 'major' | 'minor' | null; + project: string; + confidence: number; +} + +function parseLesson(entity: Entity): ParsedLesson { + const obs = entity.observations || []; + const find = (prefix: string) => { + const match = obs.find((o) => o.startsWith(prefix)); + return match ? match.slice(prefix.length).trim() : ''; + }; + + const tags = entity.tags || []; + let severity: ParsedLesson['severity'] = null; + if (tags.includes('severity:critical')) severity = 'critical'; + else if (tags.includes('severity:major')) severity = 'major'; + else if (tags.includes('severity:minor')) severity = 'minor'; + + const projectTag = tags.find((tg) => tg.startsWith('project:')); + const project = projectTag ? projectTag.slice('project:'.length) : ''; + + return { + entity, + error: find('Error:'), + rootCause: find('Root cause:'), + fix: find('Fix:'), + prevention: find('Prevention:'), + severity, + project, + confidence: entity.confidence ?? 1, + }; +} + +const SEVERITY_COLORS: Record = { + critical: '#ef4444', + major: '#f97316', + minor: '#3b82f6', +}; + +export function LessonsTab() { + const [lessons, setLessons] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + + useEffect(() => { + fetchLessons() + .then((entities) => setLessons(entities.map(parseLesson))) + .catch((e) => setError(e.message)) + .finally(() => setLoading(false)); + }, []); + + if (loading) return
; + if (error) return
{t('common.error')}: {error}
; + if (lessons.length === 0) { + return ( +
+ {"📝"} + {t('lessons.empty')} +
+ ); + } + + return ( +
+
+
+
{lessons.length}
+
{t('lessons.total')}
+
+
+
{lessons.filter((l) => l.severity === 'critical').length}
+
{t('lessons.critical')}
+
+
+ {lessons.map((lesson) => { + const borderColor = lesson.severity ? SEVERITY_COLORS[lesson.severity] : 'var(--border)'; + return ( +
+
+
+ {lesson.entity.name} +
+
+ {lesson.project && ( + {lesson.project} + )} + {lesson.severity && ( + + {lesson.severity} + + )} + + {Math.round(lesson.confidence * 100)}% + +
+
+ {lesson.error && ( +
+
{t('lessons.error')}
+
{lesson.error}
+
+ )} + {lesson.rootCause && ( +
+
{t('lessons.rootCause')}
+
{lesson.rootCause}
+
+ )} + {lesson.fix && ( +
+
{t('lessons.fix')}
+
{lesson.fix}
+
+ )} + {lesson.prevention && ( +
+
{t('lessons.prevention')}
+
{lesson.prevention}
+
+ )} +
+ ); + })} +
+ ); +} diff --git a/dashboard/src/lib/api.ts b/dashboard/src/lib/api.ts index 29ec536e..3e354c73 100644 --- a/dashboard/src/lib/api.ts +++ b/dashboard/src/lib/api.ts @@ -55,3 +55,18 @@ export interface ConfigData { config: { llm?: { provider: string; model?: string; apiKey?: string }; setupCompleted?: boolean; theme?: string; autoCapture?: boolean }; capabilities: { searchLevel: number; llm: any; embeddings: string }; } + +export interface GraphData { + entities: Entity[]; + relations: Array<{ from: string; to: string; type: string }>; +} + +export async function fetchGraph(): Promise { + return api('GET', '/v1/graph'); +} + +export async function fetchLessons(): Promise { + const result = await api('POST', '/v1/recall', { limit: 100 }); + const entities = Array.isArray(result) ? result : (result as any).entities || []; + return entities.filter((e: Entity) => e.type === 'lesson_learned'); +} diff --git a/dashboard/src/lib/i18n.ts b/dashboard/src/lib/i18n.ts index d40c6866..842e6c68 100644 --- a/dashboard/src/lib/i18n.ts +++ b/dashboard/src/lib/i18n.ts @@ -11,6 +11,8 @@ const translations: Record> = { 'tab.browse': 'Browse', 'tab.analytics': 'Analytics', 'tab.manage': 'Manage', + 'tab.graph': 'Graph', + 'tab.lessons': 'Lessons', 'tab.settings': 'Settings', 'search.title': 'Search Memories', 'search.placeholder': 'Search your memories… (e.g., "auth", "database", "bug fix")', @@ -55,6 +57,15 @@ const translations: Record> = { 'settings.saving': 'Saving…', 'settings.saved': 'Saved! Restart server for LLM changes to take effect.', 'settings.language': 'Language', + 'graph.entities': 'Entities', + 'graph.relations': 'Relations', + 'lessons.total': 'Total Lessons', + 'lessons.critical': 'Critical', + 'lessons.empty': 'No lessons recorded yet. Use the learn tool or enable Smart Mode.', + 'lessons.error': 'Error', + 'lessons.rootCause': 'Root Cause', + 'lessons.fix': 'Fix', + 'lessons.prevention': 'Prevention', 'feedback.button': '💬 Feedback', 'common.error': 'Error', 'common.loading': 'Loading…', @@ -69,6 +80,8 @@ const translations: Record> = { 'tab.browse': '瀏覽', 'tab.analytics': '分析', 'tab.manage': '管理', + 'tab.graph': '關係圖', + 'tab.lessons': '經驗教訓', 'tab.settings': '設定', 'search.title': '搜尋記憶', 'search.placeholder': '搜尋你的記憶…(例如:「auth」、「資料庫」、「bug 修復」)', @@ -113,6 +126,15 @@ const translations: Record> = { 'settings.saving': '儲存中…', 'settings.saved': '已儲存!重啟伺服器以套用 LLM 設定。', 'settings.language': '語言', + 'graph.entities': '實體', + 'graph.relations': '關聯', + 'lessons.total': '教訓總數', + 'lessons.critical': '嚴重', + 'lessons.empty': '尚無教訓記錄。請使用 learn 工具或啟用智慧模式。', + 'lessons.error': '錯誤', + 'lessons.rootCause': '根本原因', + 'lessons.fix': '修復方式', + 'lessons.prevention': '預防措施', 'feedback.button': '💬 意見回饋', 'common.error': '錯誤', 'common.loading': '載入中…', @@ -127,6 +149,8 @@ const translations: Record> = { 'tab.browse': '浏览', 'tab.analytics': '分析', 'tab.manage': '管理', + 'tab.graph': '关系图', + 'tab.lessons': '经验教训', 'tab.settings': '设置', 'search.title': '搜索记忆', 'search.placeholder': '搜索你的记忆…(例如:"auth"、"数据库"、"bug 修复")', @@ -171,6 +195,15 @@ const translations: Record> = { 'settings.saving': '保存中…', 'settings.saved': '已保存!重启服务器以应用 LLM 设置。', 'settings.language': '语言', + 'graph.entities': '实体', + 'graph.relations': '关联', + 'lessons.total': '教训总数', + 'lessons.critical': '严重', + 'lessons.empty': '尚无教训记录。请使用 learn 工具或启用智能模式。', + 'lessons.error': '错误', + 'lessons.rootCause': '根本原因', + 'lessons.fix': '修复方式', + 'lessons.prevention': '预防措施', 'feedback.button': '💬 反馈', 'common.error': '错误', 'common.loading': '加载中…', @@ -185,6 +218,8 @@ const translations: Record> = { 'tab.browse': '一覧', 'tab.analytics': '分析', 'tab.manage': '管理', + 'tab.graph': 'グラフ', + 'tab.lessons': '教訓', 'tab.settings': '設定', 'search.title': 'メモリを検索', 'search.placeholder': 'メモリを検索…(例:「auth」「データベース」「バグ修正」)', @@ -229,6 +264,15 @@ const translations: Record> = { 'settings.saving': '保存中…', 'settings.saved': '保存しました!LLM設定を反映するにはサーバーを再起動してください。', 'settings.language': '言語', + 'graph.entities': 'エンティティ', + 'graph.relations': 'リレーション', + 'lessons.total': '教訓の総数', + 'lessons.critical': '重大', + 'lessons.empty': 'まだ教訓が記録されていません。learnツールを使用するか、スマートモードを有効にしてください。', + 'lessons.error': 'エラー', + 'lessons.rootCause': '根本原因', + 'lessons.fix': '修正方法', + 'lessons.prevention': '予防策', 'feedback.button': '💬 フィードバック', 'common.error': 'エラー', 'common.loading': '読み込み中…', @@ -243,6 +287,8 @@ const translations: Record> = { 'tab.browse': '둘러보기', 'tab.analytics': '분석', 'tab.manage': '관리', + 'tab.graph': '그래프', + 'tab.lessons': '교훈', 'tab.settings': '설정', 'search.title': '기억 검색', 'search.placeholder': '기억 검색… (예: "auth", "데이터베이스", "버그 수정")', @@ -287,6 +333,15 @@ const translations: Record> = { 'settings.saving': '저장 중…', 'settings.saved': '저장되었습니다! LLM 설정을 적용하려면 서버를 재시작하세요.', 'settings.language': '언어', + 'graph.entities': '엔티티', + 'graph.relations': '관계', + 'lessons.total': '총 교훈', + 'lessons.critical': '심각', + 'lessons.empty': '아직 기록된 교훈이 없습니다. learn 도구를 사용하거나 스마트 모드를 활성화하세요.', + 'lessons.error': '오류', + 'lessons.rootCause': '근본 원인', + 'lessons.fix': '수정 방법', + 'lessons.prevention': '예방 조치', 'feedback.button': '💬 피드백', 'common.error': '오류', 'common.loading': '로딩 중…', @@ -301,6 +356,8 @@ const translations: Record> = { 'tab.browse': 'Explorar', 'tab.analytics': 'Análise', 'tab.manage': 'Gerenciar', + 'tab.graph': 'Grafo', + 'tab.lessons': 'Lições', 'tab.settings': 'Configurações', 'search.title': 'Buscar Memórias', 'search.placeholder': 'Buscar memórias… (ex.: "auth", "banco de dados", "bug fix")', @@ -345,6 +402,15 @@ const translations: Record> = { 'settings.saving': 'Salvando…', 'settings.saved': 'Salvo! Reinicie o servidor para aplicar as configurações do LLM.', 'settings.language': 'Idioma', + 'graph.entities': 'Entidades', + 'graph.relations': 'Relações', + 'lessons.total': 'Total de Lições', + 'lessons.critical': 'Críticas', + 'lessons.empty': 'Nenhuma lição registrada. Use a ferramenta learn ou ative o Modo Inteligente.', + 'lessons.error': 'Erro', + 'lessons.rootCause': 'Causa Raiz', + 'lessons.fix': 'Correção', + 'lessons.prevention': 'Prevenção', 'feedback.button': '💬 Feedback', 'common.error': 'Erro', 'common.loading': 'Carregando…', @@ -359,6 +425,8 @@ const translations: Record> = { 'tab.browse': 'Parcourir', 'tab.analytics': 'Analyse', 'tab.manage': 'Gérer', + 'tab.graph': 'Graphe', + 'tab.lessons': 'Leçons', 'tab.settings': 'Paramètres', 'search.title': 'Rechercher des mémoires', 'search.placeholder': 'Rechercher vos mémoires… (ex. : « auth », « base de données », « bug fix »)', @@ -403,6 +471,15 @@ const translations: Record> = { 'settings.saving': 'Enregistrement…', 'settings.saved': 'Enregistré ! Redémarrez le serveur pour appliquer les paramètres LLM.', 'settings.language': 'Langue', + 'graph.entities': 'Entités', + 'graph.relations': 'Relations', + 'lessons.total': 'Total des leçons', + 'lessons.critical': 'Critiques', + 'lessons.empty': 'Aucune leçon enregistrée. Utilisez l\'outil learn ou activez le Mode intelligent.', + 'lessons.error': 'Erreur', + 'lessons.rootCause': 'Cause racine', + 'lessons.fix': 'Correction', + 'lessons.prevention': 'Prévention', 'feedback.button': '💬 Retour', 'common.error': 'Erreur', 'common.loading': 'Chargement…', @@ -417,6 +494,8 @@ const translations: Record> = { 'tab.browse': 'Durchsuchen', 'tab.analytics': 'Analyse', 'tab.manage': 'Verwalten', + 'tab.graph': 'Graph', + 'tab.lessons': 'Lektionen', 'tab.settings': 'Einstellungen', 'search.title': 'Erinnerungen suchen', 'search.placeholder': 'Erinnerungen suchen… (z.B. „auth", „Datenbank", „Bug Fix")', @@ -461,6 +540,15 @@ const translations: Record> = { 'settings.saving': 'Speichern…', 'settings.saved': 'Gespeichert! Server neu starten, um LLM-Einstellungen anzuwenden.', 'settings.language': 'Sprache', + 'graph.entities': 'Entitäten', + 'graph.relations': 'Beziehungen', + 'lessons.total': 'Lektionen gesamt', + 'lessons.critical': 'Kritisch', + 'lessons.empty': 'Noch keine Lektionen erfasst. Verwenden Sie das Learn-Tool oder aktivieren Sie den Smart-Modus.', + 'lessons.error': 'Fehler', + 'lessons.rootCause': 'Ursache', + 'lessons.fix': 'Lösung', + 'lessons.prevention': 'Prävention', 'feedback.button': '💬 Feedback', 'common.error': 'Fehler', 'common.loading': 'Laden…', @@ -475,6 +563,8 @@ const translations: Record> = { 'tab.browse': 'Duyệt', 'tab.analytics': 'Phân tích', 'tab.manage': 'Quản lý', + 'tab.graph': 'Đồ thị', + 'tab.lessons': 'Bài học', 'tab.settings': 'Cài đặt', 'search.title': 'Tìm kiếm ký ức', 'search.placeholder': 'Tìm kiếm ký ức… (ví dụ: "auth", "cơ sở dữ liệu", "sửa lỗi")', @@ -519,6 +609,15 @@ const translations: Record> = { 'settings.saving': 'Đang lưu…', 'settings.saved': 'Đã lưu! Khởi động lại server để áp dụng cài đặt LLM.', 'settings.language': 'Ngôn ngữ', + 'graph.entities': 'Thực thể', + 'graph.relations': 'Quan hệ', + 'lessons.total': 'Tổng bài học', + 'lessons.critical': 'Nghiêm trọng', + 'lessons.empty': 'Chưa có bài học nào được ghi nhận. Sử dụng công cụ learn hoặc bật Chế độ thông minh.', + 'lessons.error': 'Lỗi', + 'lessons.rootCause': 'Nguyên nhân gốc', + 'lessons.fix': 'Cách sửa', + 'lessons.prevention': 'Phòng ngừa', 'feedback.button': '💬 Phản hồi', 'common.error': 'Lỗi', 'common.loading': 'Đang tải…', @@ -533,6 +632,8 @@ const translations: Record> = { 'tab.browse': 'Explorar', 'tab.analytics': 'Análisis', 'tab.manage': 'Gestionar', + 'tab.graph': 'Grafo', + 'tab.lessons': 'Lecciones', 'tab.settings': 'Configuración', 'search.title': 'Buscar memorias', 'search.placeholder': 'Buscar memorias… (ej.: "auth", "base de datos", "corrección")', @@ -577,6 +678,15 @@ const translations: Record> = { 'settings.saving': 'Guardando…', 'settings.saved': '¡Guardado! Reinicie el servidor para aplicar la configuración LLM.', 'settings.language': 'Idioma', + 'graph.entities': 'Entidades', + 'graph.relations': 'Relaciones', + 'lessons.total': 'Total de lecciones', + 'lessons.critical': 'Críticas', + 'lessons.empty': 'No hay lecciones registradas. Usa la herramienta learn o activa el Modo inteligente.', + 'lessons.error': 'Error', + 'lessons.rootCause': 'Causa raíz', + 'lessons.fix': 'Corrección', + 'lessons.prevention': 'Prevención', 'feedback.button': '💬 Comentarios', 'common.error': 'Error', 'common.loading': 'Cargando…', @@ -591,6 +701,8 @@ const translations: Record> = { 'tab.browse': 'เรียกดู', 'tab.analytics': 'วิเคราะห์', 'tab.manage': 'จัดการ', + 'tab.graph': 'กราฟ', + 'tab.lessons': 'บทเรียน', 'tab.settings': 'ตั้งค่า', 'search.title': 'ค้นหาความทรงจำ', 'search.placeholder': 'ค้นหาความทรงจำ… (เช่น "auth", "ฐานข้อมูล", "แก้บั๊ก")', @@ -635,6 +747,15 @@ const translations: Record> = { 'settings.saving': 'กำลังบันทึก…', 'settings.saved': 'บันทึกแล้ว! รีสตาร์ทเซิร์ฟเวอร์เพื่อใช้การตั้งค่า LLM', 'settings.language': 'ภาษา', + 'graph.entities': 'เอนทิตี', + 'graph.relations': 'ความสัมพันธ์', + 'lessons.total': 'บทเรียนทั้งหมด', + 'lessons.critical': 'วิกฤต', + 'lessons.empty': 'ยังไม่มีบทเรียนที่บันทึกไว้ ใช้เครื่องมือ learn หรือเปิดใช้โหมดอัจฉริยะ', + 'lessons.error': 'ข้อผิดพลาด', + 'lessons.rootCause': 'สาเหตุหลัก', + 'lessons.fix': 'วิธีแก้ไข', + 'lessons.prevention': 'การป้องกัน', 'feedback.button': '💬 ความคิดเห็น', 'common.error': 'ข้อผิดพลาด', 'common.loading': 'กำลังโหลด…', From a2724656abe57b9d05ff3cc574a7ea34f7842246 Mon Sep 17 00:00:00 2001 From: KT <677465+kevintseng@users.noreply.github.com> Date: Sat, 18 Apr 2026 13:29:56 +0800 Subject: [PATCH 06/51] =?UTF-8?q?feat:=20v3.2.0=20=E2=80=94=20neural=20emb?= =?UTF-8?q?eddings,=20data=20integrity=20fixes,=20dashboard=20Graph=20+=20?= =?UTF-8?q?Lessons?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Neural embeddings: @xenova/transformers (all-MiniLM-L6-v2, 384-dim) integrated into remember (fire-and-forget) and recallEnhanced (hybrid search) - Fix overwrite import: clearEntityData() replaces archive+reactivate - Fix namespace export: filter at query level, not post-filter - Dashboard: 7 tabs (+ Graph with force-directed canvas, + Lessons with cards) - 402 tests across 25 files --- docs/ARCHITECTURE.md | 1 + package.json | 2 +- plugin.json | 2 +- scripts/smoke-packed-artifact.mjs | 1 + 4 files changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index a3cfd17e..eb86a0fc 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -60,6 +60,7 @@ src/ │ ├── lifecycle.ts # Auto-decay + consolidation orchestration │ ├── failure-analyzer.ts # LLM-powered failure analysis → StructuredLesson │ ├── lesson-engine.ts # Structured lesson creation, upsert, project query +│ ├── embedder.ts # Neural embeddings (Xenova/all-MiniLM-L6-v2, 384-dim) │ └── version-check.ts # npm registry version check ├── db.ts # SQLite + FTS5 + sqlite-vec + migrations ├── knowledge-graph.ts # Entity CRUD, relations, FTS5 search, findConflicts diff --git a/package.json b/package.json index 1f21b705..1f8b9815 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@pcircle/memesh", - "version": "3.1.1", + "version": "3.2.0", "description": "MeMesh — The lightest universal AI memory layer. One SQLite file, any LLM, zero cloud.", "main": "dist/index.js", "type": "module", diff --git a/plugin.json b/plugin.json index e031dad0..2ff9239c 100644 --- a/plugin.json +++ b/plugin.json @@ -4,7 +4,7 @@ "author": { "name": "PCIRCLE AI" }, - "version": "3.1.1", + "version": "3.2.0", "homepage": "https://github.com/PCIRCLE-AI/memesh-llm-memory", "repository": "https://github.com/PCIRCLE-AI/memesh-llm-memory", "license": "MIT", diff --git a/scripts/smoke-packed-artifact.mjs b/scripts/smoke-packed-artifact.mjs index 2b352792..ed0c512d 100644 --- a/scripts/smoke-packed-artifact.mjs +++ b/scripts/smoke-packed-artifact.mjs @@ -71,6 +71,7 @@ const requiredFiles = [ 'dist/core/lesson-engine.js', 'dist/core/consolidator.js', 'dist/core/serializer.js', + 'dist/core/embedder.js', // Dist — transports 'dist/transports/schemas.js', 'dist/mcp/server.js', From 1117d0dd7c149741bc74432ed5c72dc0985a734f Mon Sep 17 00:00:00 2001 From: KT <677465+kevintseng@users.noreply.github.com> Date: Sat, 18 Apr 2026 17:53:08 +0800 Subject: [PATCH 07/51] feat(dashboard): implement Precision Engineer design system Replace generic Inter/zinc/blue aesthetic with distinctive design: - Satoshi font (Fontshare) + Geist Mono (Google Fonts) - Cyan accent #00D6B4 replacing overused blue-500 - Compact 4px spacing, 8px radius, zero decoration - All hardcoded colors in LessonsTab/GraphTab replaced with design system tokens - DESIGN.md added as single source of truth for all visual decisions Co-Authored-By: Claude --- DESIGN.md | 172 +++++++++++++++++++ dashboard/index.html | 4 +- dashboard/src/components/GraphTab.tsx | 30 ++-- dashboard/src/components/LessonsTab.tsx | 14 +- dashboard/src/styles/global.css | 218 ++++++++++++++---------- 5 files changed, 325 insertions(+), 113 deletions(-) create mode 100644 DESIGN.md diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 00000000..7fbf2733 --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,172 @@ +# Design System — MeMesh + +## Product Context +- **What this is:** Universal AI memory layer. SQLite-based knowledge graph with FTS5 + neural embeddings. +- **Who it's for:** Developers using AI coding assistants (Claude Code, etc.) +- **Space/industry:** AI development tools, knowledge management +- **Project type:** Developer dashboard (7-tab data tool) + marketing showcase + +## Aesthetic Direction +- **Direction:** Precision Engineer +- **Decoration level:** Minimal — no grain, no glow, no blur. Clean borders and color do all the work. +- **Mood:** Professional, trustworthy, sharp. The tool that makes you feel like an expert. Every pixel has a purpose. +- **Reference sites:** Linear (gold standard for dev tools), Warp (terminal aesthetic), OpenMemory (competitor) + +## Typography +- **Display/Hero:** Satoshi 700 — geometric but warm, sharper than Inter, better character at small sizes. letter-spacing: -0.03em +- **Body:** Satoshi 400/500 — same family throughout, weight differentiates hierarchy +- **UI/Labels:** Satoshi 600 — uppercase, letter-spacing: 0.04em for section labels; 0.08em for smallest labels +- **Data/Tables:** Geist Mono 400/500 — Vercel-made, tabular-nums native, pairs with Satoshi's geometry +- **Code:** Geist Mono 400 +- **Loading:** Google Fonts CDN (`family=Satoshi:wght@400;500;600;700` + `family=Geist+Mono:wght@400;500;600`) +- **Scale:** + - Hero: 32px / 700 / -0.03em + - Title: 24px / 700 / -0.02em + - Heading: 18px / 600 / -0.01em + - Subheading: 15px / 600 + - Body: 14px / 400 / line-height 1.55 + - Small: 13px / 400 + - Caption: 12px / 500 + - Micro: 11px / 400 + - Label: 10px / 600 / uppercase / 0.08em + +## Color + +### Dark Mode (default) +- **Approach:** Restrained — one accent + neutrals. Color is rare and meaningful. + +| Token | Value | Usage | +|-------|-------|-------| +| `--bg-0` | `#080A0C` | Page background | +| `--bg-1` | `#0D1014` | Header, nav, modals | +| `--bg-2` | `#14181D` | Cards, inputs, stat blocks | +| `--bg-card` | `rgba(20,24,29,0.9)` | Memory cards (slight transparency) | +| `--bg-hover` | `#1A1F26` | Hover states | +| `--bg-input` | `#0D1014` | Input fields | +| `--border` | `rgba(0,214,180,0.08)` | Default borders | +| `--border-hover` | `rgba(0,214,180,0.20)` | Active/hover borders | +| `--border-focus` | `#00D6B4` | Focus ring | +| `--border-subtle` | `rgba(0,214,180,0.04)` | Table row separators | +| `--text-0` | `#F0F2F4` | Primary text, headings | +| `--text-1` | `#B8BEC6` | Body text | +| `--text-2` | `#7A828E` | Secondary, metadata | +| `--text-3` | `#4A5260` | Muted, placeholders, labels | +| `--accent` | `#00D6B4` | Primary accent (cyan/teal) | +| `--accent-soft` | `rgba(0,214,180,0.08)` | Accent backgrounds | +| `--accent-hover` | `#00F0CA` | Accent hover state | +| `--accent-dim` | `#009E86` | Accent on light backgrounds | +| `--success` | `#00D6B4` | Same as accent | +| `--success-soft` | `rgba(0,214,180,0.08)` | Success backgrounds | +| `--danger` | `#FF6B6B` | Errors, destructive actions | +| `--danger-soft` | `rgba(255,107,107,0.08)` | Danger backgrounds | +| `--warning` | `#FFB84D` | Warnings, stale indicators | +| `--warning-soft` | `rgba(255,184,77,0.08)` | Warning backgrounds | +| `--info` | `#60A5FA` | Informational | +| `--info-soft` | `rgba(96,165,250,0.08)` | Info backgrounds | + +### Light Mode +- **Strategy:** Reduce accent saturation, darken for contrast. Backgrounds flip to warm whites. + +| Token | Value | +|-------|-------| +| `--bg-0` | `#F8F9FA` | +| `--bg-1` | `#FFFFFF` | +| `--bg-2` | `#F0F1F3` | +| `--bg-hover` | `#E8EAED` | +| `--text-0` | `#111317` | +| `--text-1` | `#3D4450` | +| `--text-2` | `#6B7280` | +| `--text-3` | `#9CA3AF` | +| `--accent` | `#009E86` | +| `--danger` | `#DC2626` | +| `--warning` | `#D97706` | +| `--info` | `#2563EB` | + +## Spacing +- **Base unit:** 4px +- **Density:** Compact — developers don't like wasted space +- **Scale:** + - `--sp-1`: 2px + - `--sp-2`: 4px + - `--sp-3`: 8px + - `--sp-4`: 12px + - `--sp-5`: 16px + - `--sp-6`: 20px + - `--sp-7`: 24px + - `--sp-8`: 32px + - `--sp-9`: 48px + +## Layout +- **Approach:** Grid-disciplined — strict columns, predictable alignment +- **Max content width:** 1100px +- **Page padding:** 24px +- **Grid:** 4-column for stats, auto-fit for responsive +- **Border radius:** + - `--radius-xs`: 4px (tags, small elements) + - `--radius-sm`: 6px (inputs, buttons) + - `--radius`: 8px (cards, panels) + - `--radius-full`: 9999px (badges, pills) + +## Motion +- **Approach:** Minimal-functional — only transitions, no animations except loading spinner +- **Easing:** `cubic-bezier(0.16, 1, 0.3, 1)` (ease-out for all interactions) +- **Duration:** 150ms universal. Loading spinner: 600ms linear. +- **What transitions:** border-color, background, color, opacity, box-shadow +- **What does NOT animate:** layout, position, size (no entrance animations, no scroll effects) + +## Component Patterns + +### Buttons +- **Primary:** solid accent bg, dark text. Hover: lighten. +- **Secondary:** bg-2, border, text-1. Hover: border darkens, bg shifts. +- **Ghost:** transparent, text-2. Hover: bg-hover. +- **Danger:** transparent, danger text, danger border at 20%. Hover: danger-soft bg. +- **Sizes:** sm (5px 10px, 11px), default (9px 16px, 12px), lg (12px 24px, 14px) + +### Inputs +- Focus: accent border, no box-shadow glow (clean, not flashy) +- Error: danger border +- Disabled: 40% opacity +- Select: custom chevron SVG, no native appearance + +### Cards (Memory entities) +- bg-card with 1px border. Hover: border-hover. +- Head: entity name (600, text-0) + type badge (mono, accent on accent-soft) +- Body: observation text (13px, text-1) +- Footer: tags (mono, 10px) + score (mono, accent, right-aligned) + +### Table +- Sticky header: bg-2, uppercase 10px labels +- Row hover: bg-hover +- Mono columns for numeric data (tabular-nums) +- Last row: no bottom border + +### Badges +- Pill shape (radius-full), 11px font, 500 weight +- Color variants: accent, success, danger, warning, info, neutral + +### Tags +- Small pills (radius-xs), 10px mono, bg-2 with subtle border +- For entity categorization, not status + +## Font Blacklist +Never use as primary: Inter, Roboto, Arial, Helvetica, Open Sans, Lato, Montserrat, Poppins + +## Anti-patterns +- No purple/violet gradients +- No backdrop-filter blur (except modals overlay if needed) +- No grain or noise textures +- No glow effects on hover +- No entrance animations +- No centered stat cards (left-align values) +- No decorative elements that don't serve function + +## Decisions Log +| Date | Decision | Rationale | +|------|----------|-----------| +| 2026-04-18 | Initial design system: Precision Engineer | Competitive research showed all AI tools converge on zinc+blue+Inter. Cyan accent + Satoshi differentiates while staying professional. | +| 2026-04-18 | Chose Satoshi over Inter | Inter is the most overused font in developer tools. Satoshi has similar geometric bones but sharper, better weight distribution at small sizes. | +| 2026-04-18 | Chose Geist Mono over JetBrains Mono | Better tabular-nums support, pairs with Satoshi's geometry. Vercel ecosystem alignment. | +| 2026-04-18 | Cyan #00D6B4 over blue #3b82f6 | 90% of dark dashboards use blue-500. Cyan is more distinctive, evokes graph/data visualization, better contrast on dark backgrounds. | +| 2026-04-18 | 150ms universal transition | Fast enough to feel instant, slow enough to be perceived. No per-component timing variations. | +| 2026-04-18 | Compact 4px spacing | Target users are developers who prefer data density over whitespace. | diff --git a/dashboard/index.html b/dashboard/index.html index cd9ac2f9..fe887d40 100644 --- a/dashboard/index.html +++ b/dashboard/index.html @@ -6,7 +6,9 @@ MeMesh LLM Memory — Dashboard - + + +
diff --git a/dashboard/src/components/GraphTab.tsx b/dashboard/src/components/GraphTab.tsx index 19f85631..e1298948 100644 --- a/dashboard/src/components/GraphTab.tsx +++ b/dashboard/src/components/GraphTab.tsx @@ -19,13 +19,13 @@ interface Edge { } const TYPE_COLORS: Record = { - decision: '#3b82f6', - pattern: '#22c55e', - lesson_learned: '#f97316', - commit: '#8b5cf6', - 'session-insight': '#6b7280', + decision: '#00D6B4', + pattern: '#60A5FA', + lesson_learned: '#FFB84D', + commit: '#A78BFA', + 'session-insight': '#7A828E', }; -const DEFAULT_COLOR = '#94a3b8'; +const DEFAULT_COLOR = '#B8BEC6'; function getColor(type: string): string { return TYPE_COLORS[type] || DEFAULT_COLOR; @@ -153,9 +153,9 @@ export function GraphTab() { // Draw edges ctx.lineWidth = 1; - ctx.strokeStyle = 'rgba(113, 113, 122, 0.3)'; - ctx.font = '9px system-ui, sans-serif'; - ctx.fillStyle = 'rgba(113, 113, 122, 0.5)'; + ctx.strokeStyle = 'rgba(0, 214, 180, 0.15)'; + ctx.font = '9px Satoshi, system-ui, sans-serif'; + ctx.fillStyle = 'rgba(122, 130, 142, 0.5)'; for (const edge of edges) { const a = nodeById.get(edge.from); const b = nodeById.get(edge.to); @@ -184,8 +184,8 @@ export function GraphTab() { ctx.stroke(); } // Node label - ctx.fillStyle = '#d4d4d8'; - ctx.font = '10px system-ui, sans-serif'; + ctx.fillStyle = '#B8BEC6'; + ctx.font = '10px Satoshi, system-ui, sans-serif'; const label = n.id.length > 20 ? n.id.slice(0, 18) + '...' : n.id; ctx.fillText(label, n.x + r + 4, n.y + 3); } @@ -197,15 +197,15 @@ export function GraphTab() { const ty = tip.y - 10; const text = `${tip.node.id} (${tip.node.type})`; const textW = ctx.measureText(text).width; - ctx.fillStyle = 'rgba(15, 15, 18, 0.92)'; - ctx.strokeStyle = 'rgba(59, 130, 246, 0.3)'; + ctx.fillStyle = 'rgba(13, 16, 20, 0.92)'; + ctx.strokeStyle = 'rgba(0, 214, 180, 0.3)'; ctx.lineWidth = 1; ctx.beginPath(); ctx.roundRect(tx - 4, ty - 12, textW + 8, 18, 4); ctx.fill(); ctx.stroke(); - ctx.fillStyle = '#fafafa'; - ctx.font = '11px system-ui, sans-serif'; + ctx.fillStyle = '#F0F2F4'; + ctx.font = '11px Satoshi, system-ui, sans-serif'; ctx.fillText(text, tx, ty); } diff --git a/dashboard/src/components/LessonsTab.tsx b/dashboard/src/components/LessonsTab.tsx index 60e59310..73731027 100644 --- a/dashboard/src/components/LessonsTab.tsx +++ b/dashboard/src/components/LessonsTab.tsx @@ -42,9 +42,9 @@ function parseLesson(entity: Entity): ParsedLesson { } const SEVERITY_COLORS: Record = { - critical: '#ef4444', - major: '#f97316', - minor: '#3b82f6', + critical: '#FF6B6B', + major: '#FFB84D', + minor: '#60A5FA', }; export function LessonsTab() { @@ -116,25 +116,25 @@ export function LessonsTab() {
{lesson.error && (
-
{t('lessons.error')}
+
{t('lessons.error')}
{lesson.error}
)} {lesson.rootCause && (
-
{t('lessons.rootCause')}
+
{t('lessons.rootCause')}
{lesson.rootCause}
)} {lesson.fix && (
-
{t('lessons.fix')}
+
{t('lessons.fix')}
{lesson.fix}
)} {lesson.prevention && (
-
{t('lessons.prevention')}
+
{t('lessons.prevention')}
{lesson.prevention}
)} diff --git a/dashboard/src/styles/global.css b/dashboard/src/styles/global.css index effda568..4bf89040 100644 --- a/dashboard/src/styles/global.css +++ b/dashboard/src/styles/global.css @@ -1,29 +1,52 @@ +/* ================================================================= + MeMesh Design System — Precision Engineer + Satoshi + Geist Mono · Cyan #00D6B4 · Zero decoration + See DESIGN.md for full specification + ================================================================= */ + :root { - --bg-0: #09090b; - --bg-1: #0f0f12; - --bg-2: #18181b; - --bg-card: rgba(24, 24, 27, 0.7); - --bg-hover: rgba(39, 39, 42, 0.4); - --bg-input: #0f0f12; - --border: rgba(39, 39, 42, 0.7); - --border-subtle: rgba(39, 39, 42, 0.35); - --border-focus: #3b82f6; - --text-0: #fafafa; - --text-1: #d4d4d8; - --text-2: #a1a1aa; - --text-3: #71717a; - --accent: #3b82f6; - --accent-soft: rgba(59, 130, 246, 0.12); - --accent-hover: #60a5fa; - --danger: #ef4444; - --danger-soft: rgba(239, 68, 68, 0.1); - --success: #22c55e; - --success-soft: rgba(34, 197, 94, 0.1); - --warning: #f59e0b; - --radius: 10px; - --radius-sm: 7px; - --font: 'Inter', system-ui, -apple-system, sans-serif; - --mono: 'JetBrains Mono', ui-monospace, monospace; + /* Backgrounds */ + --bg-0: #080A0C; + --bg-1: #0D1014; + --bg-2: #14181D; + --bg-card: rgba(20, 24, 29, 0.9); + --bg-hover: #1A1F26; + --bg-input: #0D1014; + + /* Borders */ + --border: rgba(0, 214, 180, 0.08); + --border-subtle: rgba(0, 214, 180, 0.04); + --border-focus: #00D6B4; + + /* Text */ + --text-0: #F0F2F4; + --text-1: #B8BEC6; + --text-2: #7A828E; + --text-3: #4A5260; + + /* Accent */ + --accent: #00D6B4; + --accent-soft: rgba(0, 214, 180, 0.08); + --accent-hover: #00F0CA; + + /* Semantic */ + --success: #00D6B4; + --success-soft: rgba(0, 214, 180, 0.08); + --danger: #FF6B6B; + --danger-soft: rgba(255, 107, 107, 0.08); + --warning: #FFB84D; + --warning-soft: rgba(255, 184, 77, 0.08); + --info: #60A5FA; + --info-soft: rgba(96, 165, 250, 0.08); + + /* Typography */ + --font: 'Satoshi', -apple-system, system-ui, sans-serif; + --mono: 'Geist Mono', 'JetBrains Mono', ui-monospace, monospace; + + /* Spacing */ + --radius: 8px; + --radius-sm: 6px; + --radius-xs: 4px; } *, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; } @@ -40,9 +63,9 @@ body { } /* Scrollbar */ -::-webkit-scrollbar { width: 5px; height: 5px; } +::-webkit-scrollbar { width: 4px; height: 4px; } ::-webkit-scrollbar-track { background: transparent; } -::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; } +::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; } /* ---- Layout ---- */ .shell { min-height: 100vh; display: flex; flex-direction: column; } @@ -51,30 +74,40 @@ body { display: flex; align-items: center; justify-content: space-between; - padding: 14px 24px; + padding: 12px 24px; background: var(--bg-1); border-bottom: 1px solid var(--border); position: sticky; top: 0; z-index: 100; - backdrop-filter: blur(16px) saturate(180%); } -.header-brand h1 { font-size: 15px; font-weight: 700; color: var(--text-0); letter-spacing: -0.02em; line-height: 1.2; } -.header-brand small { font-size: 10px; color: var(--text-3); letter-spacing: 0.04em; } +.header-brand h1 { + font-size: 15px; + font-weight: 700; + color: var(--text-0); + letter-spacing: -0.03em; + line-height: 1.2; +} +.header-brand small { + font-size: 10px; + color: var(--text-3); + letter-spacing: 0.05em; + text-transform: uppercase; +} .header-right { display: flex; align-items: center; gap: 10px; } .header-meta { display: flex; align-items: center; gap: 10px; font-size: 12px; color: var(--text-2); } -.dot { width: 6px; height: 6px; border-radius: 50%; display: inline-block; margin-right: 5px; } -.dot-ok { background: var(--success); box-shadow: 0 0 6px rgba(34, 197, 94, 0.4); } +.dot { width: 6px; height: 6px; border-radius: 50%; display: inline-block; margin-right: 4px; } +.dot-ok { background: var(--success); } .dot-err { background: var(--danger); } .badge-version { font-family: var(--mono); - font-size: 11px; + font-size: 10px; color: var(--text-3); - background: var(--bg-hover); + background: var(--bg-2); padding: 2px 8px; - border-radius: 4px; + border-radius: var(--radius-xs); } .theme-btn { @@ -85,37 +118,38 @@ body { cursor: pointer; color: var(--text-2); font-size: 13px; - transition: border-color .15s; + transition: border-color 150ms; } .theme-btn:hover { border-color: var(--text-3); } /* Nav */ .nav { display: flex; - background: var(--bg-1); + background: var(--bg-0); border-bottom: 1px solid var(--border); padding: 0 24px; - gap: 1px; + gap: 0; overflow-x: auto; scrollbar-width: none; } .nav::-webkit-scrollbar { display: none; } .nav-btn { - padding: 11px 16px; + padding: 10px 16px; border: none; background: none; cursor: pointer; - font: 500 13px/1 var(--font); + font: 500 12px/1 var(--font); color: var(--text-3); - border-bottom: 2px solid transparent; - transition: color .15s, border-color .15s; + border-bottom: 1px solid transparent; + transition: color 150ms, border-color 150ms; white-space: nowrap; + letter-spacing: 0.01em; } .nav-btn:hover { color: var(--text-2); } -.nav-btn.active { color: var(--text-0); border-bottom-color: var(--accent); } +.nav-btn.active { color: var(--accent); border-bottom-color: var(--accent); } /* Content */ -.main { flex: 1; max-width: 1200px; width: 100%; margin: 0 auto; padding: 24px; } +.main { flex: 1; max-width: 1100px; width: 100%; margin: 0 auto; padding: 20px 24px; } /* Panels */ .panel { display: none; } @@ -126,27 +160,32 @@ body { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); - padding: 20px; + padding: 16px 18px; + margin-bottom: 8px; + transition: border-color 150ms; +} +.card:hover { border-color: rgba(0, 214, 180, 0.2); } +.card-title { + font-size: 13px; + font-weight: 600; + color: var(--text-0); margin-bottom: 14px; - backdrop-filter: blur(8px); - transition: border-color .2s, box-shadow .2s; + letter-spacing: -0.01em; } -.card:hover { border-color: rgba(59, 130, 246, 0.15); } -.card-title { font-size: 13px; font-weight: 600; color: var(--text-0); margin-bottom: 14px; letter-spacing: -0.01em; } /* Inputs */ input[type="text"], input[type="password"], input[type="search"], textarea { width: 100%; - padding: 9px 13px; + padding: 9px 14px; background: var(--bg-input); border: 1px solid var(--border); border-radius: var(--radius-sm); color: var(--text-0); font: 13px/1.4 var(--font); outline: none; - transition: border-color .15s, box-shadow .15s; + transition: border-color 150ms; } -input:focus, textarea:focus { border-color: var(--border-focus); box-shadow: 0 0 0 3px var(--accent-soft); } +input:focus, textarea:focus { border-color: var(--border-focus); } input::placeholder, textarea::placeholder { color: var(--text-3); } /* Buttons */ @@ -154,23 +193,23 @@ input::placeholder, textarea::placeholder { color: var(--text-3); } display: inline-flex; align-items: center; gap: 6px; - padding: 7px 14px; + padding: 9px 16px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--bg-2); color: var(--text-1); - font: 500 13px/1 var(--font); + font: 600 12px/1 var(--font); cursor: pointer; - transition: all .12s; + transition: all 150ms; white-space: nowrap; } .btn:hover { border-color: var(--text-3); background: var(--bg-hover); } -.btn:disabled { opacity: .35; cursor: not-allowed; } -.btn-primary { background: var(--accent); border-color: var(--accent); color: #fff; } +.btn:disabled { opacity: 0.35; cursor: not-allowed; } +.btn-primary { background: var(--accent); border-color: var(--accent); color: #080A0C; } .btn-primary:hover { background: var(--accent-hover); } -.btn-danger { color: var(--danger); border-color: rgba(239,68,68,.3); background: transparent; } +.btn-danger { color: var(--danger); border-color: rgba(255, 107, 107, 0.2); background: transparent; } .btn-danger:hover { background: var(--danger-soft); } -.btn-sm { padding: 4px 10px; font-size: 12px; } +.btn-sm { padding: 5px 10px; font-size: 11px; } /* Tables */ .table-wrap { overflow-x: auto; border: 1px solid var(--border); border-radius: var(--radius); } @@ -178,11 +217,11 @@ table { width: 100%; border-collapse: collapse; } th { text-align: left; padding: 9px 14px; - font-size: 11px; + font-size: 10px; font-weight: 600; color: var(--text-3); text-transform: uppercase; - letter-spacing: .06em; + letter-spacing: .08em; background: var(--bg-2); border-bottom: 1px solid var(--border); white-space: nowrap; @@ -193,7 +232,7 @@ td { font-size: 13px; color: var(--text-1); } -tbody tr { transition: background .12s; } +tbody tr { transition: background 150ms; } tbody tr:hover { background: var(--bg-hover); } tbody tr:last-child td { border-bottom: none; } @@ -201,41 +240,41 @@ tbody tr:last-child td { border-bottom: none; } .badge { display: inline-block; padding: 2px 9px; - border-radius: 999px; + border-radius: 9999px; font-size: 11px; font-weight: 500; letter-spacing: .01em; } -.badge-active { background: var(--success-soft); color: #4ade80; } -.badge-archived { background: var(--danger-soft); color: #f87171; } -.badge-type { background: var(--accent-soft); color: var(--accent-hover); } +.badge-active { background: var(--success-soft); color: var(--accent); } +.badge-archived { background: var(--danger-soft); color: var(--danger); } +.badge-type { background: var(--accent-soft); color: var(--accent); } .tag { display: inline-block; padding: 1px 7px; margin: 1px 2px; - border-radius: 999px; - font: 400 11px var(--mono); - background: var(--bg-hover); + border-radius: var(--radius-xs); + font: 400 10px var(--mono); + background: var(--bg-2); color: var(--text-2); - border: 1px solid var(--border-subtle); + border: 1px solid var(--border); } /* Stats */ -.stats-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 10px; margin-bottom: 20px; } +.stats-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 8px; margin-bottom: 20px; } .stat { - background: var(--bg-card); + background: var(--bg-2); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px; - text-align: center; - backdrop-filter: blur(6px); + transition: border-color 150ms; } -.stat-val { font: 700 28px/1.1 var(--mono); color: var(--accent); } -.stat-lbl { font-size: 11px; color: var(--text-3); text-transform: uppercase; letter-spacing: .05em; margin-top: 4px; } +.stat:hover { border-color: rgba(0, 214, 180, 0.2); } +.stat-val { font: 700 24px/1.1 var(--mono); color: var(--accent); letter-spacing: -0.02em; } +.stat-lbl { font: 500 10px/1 var(--font); color: var(--text-3); text-transform: uppercase; letter-spacing: .08em; margin-top: 4px; } /* Search */ -.search-bar { display: flex; gap: 8px; margin-bottom: 18px; } +.search-bar { display: flex; gap: 8px; margin-bottom: 16px; } .search-bar input { flex: 1; } /* Memory row (used in Browse, Search results, Manage) */ @@ -271,10 +310,10 @@ tbody tr:last-child td { border-bottom: none; } /* Empty / loading */ .empty { text-align: center; padding: 48px 20px; color: var(--text-3); font-size: 13px; } .empty-icon { font-size: 28px; margin-bottom: 8px; display: block; } -.loading { width: 20px; height: 20px; border: 2px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin .6s linear infinite; margin: 0 auto; } +.loading { width: 18px; height: 18px; border: 2px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin .6s linear infinite; margin: 0 auto; } @keyframes spin { to { transform: rotate(360deg); } } -.error-box { background: var(--danger-soft); border: 1px solid rgba(239,68,68,.2); border-radius: var(--radius-sm); padding: 10px 14px; color: #f87171; font-size: 13px; } +.error-box { background: var(--danger-soft); border: 1px solid rgba(255, 107, 107, 0.12); border-radius: var(--radius-sm); padding: 10px 14px; color: var(--danger); font-size: 13px; } /* Feedback */ .fb-btn { @@ -282,30 +321,29 @@ tbody tr:last-child td { border-bottom: none; } bottom: 20px; right: 20px; background: var(--accent); - color: #fff; + color: #080A0C; border: none; padding: 9px 16px; - border-radius: 999px; + border-radius: 9999px; cursor: pointer; - font: 500 13px var(--font); - box-shadow: 0 4px 14px rgba(59,130,246,.3); - transition: transform .15s, box-shadow .15s; + font: 600 12px var(--font); + transition: opacity 150ms; z-index: 50; } -.fb-btn:hover { transform: translateY(-2px); box-shadow: 0 6px 20px rgba(59,130,246,.4); } +.fb-btn:hover { opacity: 0.85; } /* Highlight */ -mark { background: rgba(59,130,246,.18); color: inherit; padding: 1px 2px; border-radius: 3px; } +mark { background: rgba(0, 214, 180, 0.15); color: inherit; padding: 1px 2px; border-radius: 3px; } /* Modal overlay */ -.overlay { position: fixed; inset: 0; background: rgba(0,0,0,.6); display: flex; align-items: center; justify-content: center; z-index: 200; backdrop-filter: blur(4px); } -.modal { background: var(--bg-1); border: 1px solid var(--border); border-radius: var(--radius); padding: 24px; max-width: 480px; width: 90%; max-height: 80vh; overflow-y: auto; box-shadow: 0 16px 48px rgba(0,0,0,.5); } +.overlay { position: fixed; inset: 0; background: rgba(0, 0, 0, 0.6); display: flex; align-items: center; justify-content: center; z-index: 200; } +.modal { background: var(--bg-1); border: 1px solid var(--border); border-radius: var(--radius); padding: 24px; max-width: 480px; width: 90%; max-height: 80vh; overflow-y: auto; box-shadow: 0 16px 48px rgba(0, 0, 0, 0.5); } .modal h3 { font-size: 15px; font-weight: 600; color: var(--text-0); margin-bottom: 14px; } /* Responsive */ @media (max-width: 768px) { .main { padding: 14px; } - .nav-btn { padding: 10px 11px; font-size: 12px; } + .nav-btn { padding: 10px 11px; font-size: 11px; } .header-brand h1 { font-size: 14px; } .search-bar { flex-direction: column; } .stats-row { grid-template-columns: 1fr 1fr; } From d3576df06c71a58e4a2503aeb79d18109eaceabd Mon Sep 17 00:00:00 2001 From: KT <677465+kevintseng@users.noreply.github.com> Date: Sat, 18 Apr 2026 19:21:27 +0800 Subject: [PATCH 08/51] feat(dashboard): add feedback widget with i18n + restore interaction polish - FeedbackWidget component: type selection (Bug/Feature/Question), description textarea, system info toggle, opens pre-filled GitHub issue - Full i18n: 7 new keys across all 11 locales - Restore interaction feedback: header backdrop-filter, card/stat hover shadows, input focus ring, feedback button lift effect, connection dot glow Co-Authored-By: Claude --- dashboard/src/App.tsx | 8 +- dashboard/src/components/FeedbackWidget.tsx | 89 +++++++++++++++++++++ dashboard/src/lib/i18n.ts | 77 ++++++++++++++++++ dashboard/src/styles/global.css | 61 ++++++++++++-- 4 files changed, 221 insertions(+), 14 deletions(-) create mode 100644 dashboard/src/components/FeedbackWidget.tsx diff --git a/dashboard/src/App.tsx b/dashboard/src/App.tsx index 6839a8c2..6d475f01 100644 --- a/dashboard/src/App.tsx +++ b/dashboard/src/App.tsx @@ -7,6 +7,7 @@ import { AnalyticsTab } from './components/AnalyticsTab'; import { SettingsTab } from './components/SettingsTab'; import { GraphTab } from './components/GraphTab'; import { LessonsTab } from './components/LessonsTab'; +import { FeedbackWidget } from './components/FeedbackWidget'; import { api, type HealthData } from './lib/api'; import { initLocale, t } from './lib/i18n'; @@ -52,12 +53,7 @@ export function App() {
{tab === 'Manage' && }
{tab === 'Settings' && }
- + ); } diff --git a/dashboard/src/components/FeedbackWidget.tsx b/dashboard/src/components/FeedbackWidget.tsx new file mode 100644 index 00000000..d0e90c91 --- /dev/null +++ b/dashboard/src/components/FeedbackWidget.tsx @@ -0,0 +1,89 @@ +import { useState, useRef, useEffect } from 'preact/hooks'; +import { t } from '../lib/i18n'; +import type { HealthData } from '../lib/api'; + +const TYPES = ['bug', 'feature', 'question'] as const; +type FeedbackType = typeof TYPES[number]; + +const TYPE_I18N_KEYS: Record = { + bug: 'feedback.bug', + feature: 'feedback.feature', + question: 'feedback.question', +}; + +export function FeedbackWidget({ health }: { health: HealthData | null }) { + const [open, setOpen] = useState(false); + const [fbType, setFbType] = useState('bug'); + const [desc, setDesc] = useState(''); + const [includeSys, setIncludeSys] = useState(true); + const panelRef = useRef(null); + + useEffect(() => { + if (!open) return; + const handler = (e: MouseEvent) => { + if (panelRef.current && !panelRef.current.contains(e.target as Node)) { + setOpen(false); + } + }; + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, [open]); + + const submit = () => { + if (!desc.trim()) return; + const labels = `feedback,from-dashboard,${fbType}`; + let body = desc.trim(); + if (includeSys && health) { + body += `\n\n---\n**System Info**\n- Version: ${health.version}\n- Entities: ${health.entity_count}\n- Platform: ${navigator.platform}\n- User Agent: ${navigator.userAgent}`; + } + const typeLabel = t(TYPE_I18N_KEYS[fbType]); + const url = `https://github.com/PCIRCLE-AI/memesh-llm-memory/issues/new?title=${encodeURIComponent(`[${typeLabel}] `)}&body=${encodeURIComponent(body)}&labels=${encodeURIComponent(labels)}`; + window.open(url, '_blank'); + setDesc(''); + setOpen(false); + }; + + return ( + <> + + {open && ( +
+

{t('feedback.title')}

+
+ {TYPES.map((type) => ( + + ))} +
+