From 1b918fd12ec09692de67086489e75d2eeaf5da53 Mon Sep 17 00:00:00 2001 From: Jack <278171810@qq.com> Date: Sat, 27 Jun 2026 11:49:19 +0800 Subject: [PATCH 01/20] fix(seed): speed up seeding with short idle timeout and no warmup Seeding bulk-imports historical turns, so the live 600s L1 idle timeout and pipeline warmup add needless latency. Cap l1IdleTimeoutSeconds at 5s, disable enableWarmup, and pass an explicit 30s destroy timeout so the seed runtime drains and exits promptly. Signed-off-by: Jack <278171810@qq.com> --- src/core/seed/seed-runtime.ts | 34 ++++++++++++++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/src/core/seed/seed-runtime.ts b/src/core/seed/seed-runtime.ts index 3cfeb89d..70d9116d 100644 --- a/src/core/seed/seed-runtime.ts +++ b/src/core/seed/seed-runtime.ts @@ -34,6 +34,14 @@ import type { const TAG = "[memory-tdai] [seed]"; +/** + * Flush budget for `pipeline.destroy()` in seed (ms). Unlike the live gateway — + * whose 2s default is bounded by the 3s gateway_stop hook — seed teardown has + * no such race, so allow enough time for the final L2 scene + L3 persona work + * to flush before the store closes. One LLM round-trip is ~10–15s; 30s covers it. + */ +const SEED_DESTROY_TIMEOUT_MS = 30_000; + // ============================ // Seed pipeline options // ============================ @@ -67,9 +75,27 @@ async function createSeedPipeline(opts: SeedRuntimeOptions): Promise<{ pipeline: // Parse config — all values come from pluginConfig (or parseConfig defaults) const cfg = parseConfig(pluginConfig); + // ── Seed-only pacing overrides ─────────────────────────────────────────── + // Seed feeds rounds synchronously and pauses every `everyNConversations` + // rounds to wait for that L1 batch to drain (`waitForL1Idle`). Two live-mode + // defaults sabotage that wait and make seeds ~10x slower than the actual LLM + // work: + // 1. `enableWarmup` doubles the L1 threshold (1→2→4→8…), so a fixed 5-round + // seed batch stops lining up with the threshold — L1 doesn't fire at the + // boundary and `conversation_count` stays > 0. + // 2. `l1IdleTimeoutSeconds` defaults to 600s, so the idle-timer fallback is + // far longer than waitForL1Idle's 120s/300s caps. + // The result: at most boundaries waitForL1Idle never sees idle and burns its + // full cap (~100s) doing nothing. Pin the threshold to everyN (warmup off) so + // L1 fires exactly at each boundary, and shrink the idle timer so the tail / + // any missed boundary still drains in seconds. Both are seed-local — the live + // gateway pipeline is untouched. + cfg.pipeline.enableWarmup = false; + cfg.pipeline.l1IdleTimeoutSeconds = Math.min(cfg.pipeline.l1IdleTimeoutSeconds, 5); + logger.info( `${TAG} Creating seed pipeline: outputDir=${outputDir}, ` + - `everyN=${cfg.pipeline.everyNConversations}, l1Idle=${cfg.pipeline.l1IdleTimeoutSeconds}s, ` + + `everyN=${cfg.pipeline.everyNConversations}, l1Idle=${cfg.pipeline.l1IdleTimeoutSeconds}s, warmup=off, ` + `l2Delay=${cfg.pipeline.l2DelayAfterL1Seconds}s, l2Min=${cfg.pipeline.l2MinIntervalSeconds}s, l2Max=${cfg.pipeline.l2MaxIntervalSeconds}s`, ); @@ -96,13 +122,17 @@ async function createSeedPipeline(opts: SeedRuntimeOptions): Promise<{ pipeline: logger.info(`${TAG} Seed using standalone LLM: model=${cfg.llm.model}`); } - // Use shared factory for everything: store init, L1 runner, persister, destroy + // Use shared factory for everything: store init, L1 runner, persister, destroy. + // Seed has no gateway_stop hook racing it, so give destroy a generous flush + // budget — the live default (2s) is tuned to stay under that hook and would + // cut off the final L2/L3 work (persona, last scene) mid-flight. const pipeline = await createPipeline({ pluginDataDir: outputDir, cfg, openclawConfig, logger, l1LlmRunner, + destroyTimeoutMs: SEED_DESTROY_TIMEOUT_MS, }); // Wire L2 runner via shared factory (same logic as index.ts live runtime) From 79d56b0ae6423e0e022b273d25e1b7e729cda2dd Mon Sep 17 00:00:00 2001 From: Jack <278171810@qq.com> Date: Sat, 27 Jun 2026 11:49:19 +0800 Subject: [PATCH 02/20] fix(llm): add deepseek-v4 no-thinking strategy DeepSeek V4 hybrid models (deepseek-v4-flash) ignore the V3 top-level enable_thinking flag and only suppress reasoning for the object form thinking: { type: "disabled" }. Add a dedicated "deepseek-v4" strategy mapping to that body transform, register it in the valid-strategy list, and document it. Verified against the live endpoint 2026-06. Signed-off-by: Jack <278171810@qq.com> --- src/utils/no-think-fetch.ts | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/utils/no-think-fetch.ts b/src/utils/no-think-fetch.ts index 6f68c2cb..c4307bbd 100644 --- a/src/utils/no-think-fetch.ts +++ b/src/utils/no-think-fetch.ts @@ -8,7 +8,9 @@ * * Strategies: * - `"vllm"`: vLLM / SGLang — `chat_template_kwargs.enable_thinking = false` - * - `"deepseek"`: DeepSeek official API — top-level `enable_thinking: false` + * - `"deepseek"`: DeepSeek V3-series API — top-level `enable_thinking: false` + * - `"deepseek-v4"`: DeepSeek V4 hybrid models (e.g. deepseek-v4-flash) — they + * ignore `enable_thinking`; honor `thinking: { type: "disabled" }` * - `"dashscope"`: Alibaba DashScope (Qwen) — top-level `enable_thinking: false` * - `"openai"`: OpenAI o-series — `reasoning_effort: "low"` (cannot fully disable) * - `"anthropic"`: Anthropic Claude — `thinking: { type: "disabled" }` @@ -22,6 +24,7 @@ export type DisableThinkingStrategy = | false | "vllm" | "deepseek" + | "deepseek-v4" | "dashscope" | "openai" | "anthropic" @@ -29,7 +32,7 @@ export type DisableThinkingStrategy = | "gemini"; export const VALID_DISABLE_THINKING_STRATEGIES: readonly DisableThinkingStrategy[] = [ - false, "vllm", "deepseek", "dashscope", "openai", "anthropic", "kimi", "gemini", + false, "vllm", "deepseek", "deepseek-v4", "dashscope", "openai", "anthropic", "kimi", "gemini", ] as const; /** Check if a value is a valid DisableThinkingStrategy. */ @@ -52,7 +55,7 @@ export function normalizeDisableThinking(raw: boolean | string | undefined): Dis if (isValidDisableThinkingStrategy(raw)) return raw; console.warn( `[memory-tdai] Unknown disableThinking strategy "${raw}", ` + - `valid values: false, true, "vllm", "deepseek", "dashscope", "openai", "anthropic", "kimi", "gemini". ` + + `valid values: false, true, "vllm", "deepseek", "deepseek-v4", "dashscope", "openai", "anthropic", "kimi", "gemini". ` + `Thinking will NOT be disabled.`, ); return false; @@ -72,6 +75,13 @@ function applyDeepSeek(body: Record): void { body.enable_thinking = false; } +// DeepSeek V4 hybrid models (deepseek-v4-flash, …) ignore the top-level +// `enable_thinking` flag the V3 API used; they only suppress reasoning when +// given the object form `thinking: { type: "disabled" }` (verified 2026-06). +function applyDeepSeekV4(body: Record): void { + body.thinking = { type: "disabled" }; +} + function applyDashScope(body: Record): void { body.enable_thinking = false; } @@ -94,6 +104,7 @@ const STRATEGY_TRANSFORMERS: Record< > = { vllm: applyVllm, deepseek: applyDeepSeek, + "deepseek-v4": applyDeepSeekV4, dashscope: applyDashScope, openai: applyOpenAI, anthropic: applyAnthropic, From c3f1a921e648c3a6710e657639803dd938fe463c Mon Sep 17 00:00:00 2001 From: Jack <278171810@qq.com> Date: Sat, 27 Jun 2026 11:49:19 +0800 Subject: [PATCH 03/20] fix(persona): force scene-language headings in generated persona A weak / no-thinking model copied the English template headings verbatim even when the scene content was Chinese, producing English L3 personas over Chinese L0/L1/L2. Tighten the output-language contract and, when the changed scenes are Chinese-dominant, inject the exact mandatory Chinese heading set data-adjacent in the user prompt so the model cannot fall back to the English template. Signed-off-by: Jack <278171810@qq.com> --- src/core/prompts/persona-generation.ts | 56 +++++++++++++++++++++----- 1 file changed, 46 insertions(+), 10 deletions(-) diff --git a/src/core/prompts/persona-generation.ts b/src/core/prompts/persona-generation.ts index 92ec8c6b..2d84d7e8 100644 --- a/src/core/prompts/persona-generation.ts +++ b/src/core/prompts/persona-generation.ts @@ -32,13 +32,22 @@ export interface PersonaPromptResult { const PERSONA_SYSTEM_PROMPT = `# 🧬 Persona Architect - Incremental Evolution Protocol -**Output language contract**: -- Detect the dominant language from the changed scene content. -- \`persona.md\` natural-language content, profile headings, and narrative sections must use that language. -- For English scene content, output English persona headings and English body text. -- For non-Chinese scene content, do not emit Chinese persona headings. -- If the language is ambiguous, default to English. -- Keep Markdown syntax, file name \`persona.md\`, tool names, and structural markers in English. +**Output language contract (STRICT — read first)**: +- Detect the dominant natural language of the changed scene content below. +- EVERY piece of natural-language text in \`persona.md\` — the Archetype sentence, + all chapter/section headings, body paragraphs, bullet points, and trait notes — + MUST be written in that detected language. +- If the scenes are Chinese, the persona MUST be fully Chinese: Chinese headings + AND Chinese body. If the scenes are English, write it in English. Mirror the + user's language; never switch to a different one. +- The output template further below is written in English ONLY to convey its + structure. TRANSLATE its headings (Archetype, Basic Information, the Chapter + titles, etc.) into the detected language — do NOT copy the English words + verbatim when the scenes are not English. +- If the language is genuinely ambiguous, follow the language already used in the + existing \`persona.md\`; if there is none, default to the scene language. +- Keep ONLY the following in English: Markdown structural syntax, the file name + \`persona.md\`, and tool names (write/edit). Everything a human reads is localized. 请你结合已有的 persona.md 和新增/变化的 block 信息深度分析,然后使用文件工具将结果写入 \`persona.md\` 文件。 @@ -91,7 +100,9 @@ const PERSONA_SYSTEM_PROMPT = `# 🧬 Persona Architect - Incremental Evolution ## 📝 输出模板 (The Persona Template) -请参考以下格式,使用 **write** 工具写入最终内容。可以做自主调整(信息不足时可以减少或新增 chapter)(**必须保持 Markdown 格式**): +请参考以下格式,使用 **write** 工具写入最终内容。可以做自主调整(信息不足时可以减少或新增 chapter)(**必须保持 Markdown 格式**)。 + +⚠️ **下面模板中的英文标题(Archetype / Basic Information / Chapter 标题等)仅用于示意结构。请按对话主语言翻译这些标题后再写入——中文对话就用中文标题与中文正文,不要照抄英文。** \`\`\`\`markdown # User Narrative Profile @@ -147,6 +158,29 @@ const PERSONA_SYSTEM_PROMPT = `# 🧬 Persona Architect - Incremental Evolution // User Prompt builder (dynamic data) // ============================ +/** + * Whether the text is predominantly Chinese (more CJK chars than Latin letters). + * Used to inject concrete Chinese headings — a weak / no-thinking model copies + * the English template headings verbatim despite the language contract, so for + * Chinese scenes we hand it the exact headings to use, data-adjacent. + */ +function isChineseDominant(text: string): boolean { + const cjk = (text.match(/[一-鿿]/g) ?? []).length; + const latin = (text.match(/[A-Za-z]/g) ?? []).length; + return cjk > latin; +} + +/** Mandatory Chinese heading set, injected when scenes are Chinese-dominant. */ +const ZH_HEADING_DIRECTIVE = `**【强制使用以下中文标题 — 禁止照抄模板里的英文标题】** +\`persona.md\` 的所有标题与正文必须为中文,结构标题严格使用: +- 一级标题:\`# 用户叙事档案\` +- 概要块标签:\`> **原型**:\` / \`> **基本信息**\` / \`> **长期偏好**\` +- 章节:\`## 📖 第一章:背景与当前状态\` / \`## 🎨 第二章:生活纹理\` / \`## 🤖 第三章:交互与认知协议\` / \`## 🧩 第四章:深层洞察与演化\` +- 三级子标题:\`### 3.1 如何沟通\` / \`### 3.2 如何思考\` +(只有 Markdown 语法、文件名 \`persona.md\`、工具名保持英文。) + +`; + export function buildPersonaPrompt(params: PersonaPromptParams): PersonaPromptResult { const { mode, @@ -176,9 +210,11 @@ export function buildPersonaPrompt(params: PersonaPromptParams): PersonaPromptRe `面对变化场景,自主判断处理方式:强化(佐证已有洞察)/ 补充(新维度)/ 修正(矛盾)/ 重构(结构调整)/ 不改(无有用新增内容)。\n` : ""; - const userPrompt = `**Output language**: \`persona.md\` headings and body text must use the dominant language of the changed scene content below. For English scene content, use English persona headings. + const headingDirective = isChineseDominant(changedScenesContent) ? ZH_HEADING_DIRECTIVE : ""; + + const userPrompt = `**Output language**: Write the ENTIRE \`persona.md\` (headings AND body) in the dominant language of the changed scene content below. Chinese scenes → Chinese headings and Chinese body; do not translate them into English. -**⏰ 更新时间**: ${currentTime} +${headingDirective}**⏰ 更新时间**: ${currentTime} **模式**: ${modeLabel} ${triggerSection} ## 📊 统计 From 1a6b07f46a413444c46e4cdb83740f7cd8453ee9 Mon Sep 17 00:00:00 2001 From: Jack <278171810@qq.com> Date: Sat, 27 Jun 2026 11:51:58 +0800 Subject: [PATCH 04/20] feat(gateway): cap background extraction concurrency across cores The multi-tenant registry runs one TdaiCore per account, each with its own L1/L2/L3 timers and SerialQueue, so background extraction would fan out LLM calls by N accounts. Add a shared ConcurrencyLimiter (async semaphore) that the registry hands to every core via TdaiCoreOptions.extractionLimiter; the pipeline manager/factory acquire it around each extraction run so total concurrent background work stays bounded regardless of tenant count. Signed-off-by: Jack <278171810@qq.com> --- src/core/tdai-core.ts | 13 +- src/utils/async-semaphore.test.ts | 149 ++++++++++++++++++ src/utils/async-semaphore.ts | 126 +++++++++++++++ src/utils/pipeline-factory.ts | 19 ++- .../pipeline-manager.concurrency.test.ts | 112 +++++++++++++ src/utils/pipeline-manager.ts | 57 +++++-- 6 files changed, 462 insertions(+), 14 deletions(-) create mode 100644 src/utils/async-semaphore.test.ts create mode 100644 src/utils/async-semaphore.ts create mode 100644 src/utils/pipeline-manager.concurrency.test.ts diff --git a/src/core/tdai-core.ts b/src/core/tdai-core.ts index 9185e51d..a8b3a34c 100644 --- a/src/core/tdai-core.ts +++ b/src/core/tdai-core.ts @@ -47,6 +47,7 @@ import { createL3Runner, } from "../utils/pipeline-factory.js"; import { MemoryPipelineManager } from "../utils/pipeline-manager.js"; +import type { ConcurrencyLimiter } from "../utils/async-semaphore.js"; import { CheckpointManager } from "../utils/checkpoint.js"; import { SessionFilter } from "../utils/session-filter.js"; import { StandaloneLLMRunnerFactory } from "../adapters/standalone/llm-runner.js"; @@ -66,6 +67,14 @@ export interface TdaiCoreOptions { sessionFilter?: SessionFilter; /** Plugin instance ID for metric reporting. */ instanceId?: string; + /** + * Optional shared limiter gating this core's L1/L2/L3 extraction runners. + * + * The multi-tenant CoreRegistry passes ONE limiter to every core so total + * concurrent background extraction across all accounts stays bounded + * (design §8.4 #5). Omit it for the unbounded single-core default. + */ + extractionLimiter?: ConcurrencyLimiter; } // ============================ @@ -80,6 +89,7 @@ export class TdaiCore { private runnerFactory: LLMRunnerFactory; private sessionFilter: SessionFilter; private instanceId?: string; + private extractionLimiter?: ConcurrencyLimiter; // Lazy-initialized resources private vectorStore?: IMemoryStore; @@ -130,6 +140,7 @@ export class TdaiCore { this.runnerFactory = opts.hostAdapter.getLLMRunnerFactory(); this.sessionFilter = opts.sessionFilter ?? new SessionFilter([]); this.instanceId = opts.instanceId; + this.extractionLimiter = opts.extractionLimiter; } // ============================ @@ -149,7 +160,7 @@ export class TdaiCore { // Create pipeline manager (sync — does not need store) if (this.cfg.extraction.enabled) { - this.scheduler = createPipelineManager(this.cfg, this.logger, this.sessionFilter); + this.scheduler = createPipelineManager(this.cfg, this.logger, this.sessionFilter, this.extractionLimiter); // Wire runners after store is ready (or after store init fails — runners // still work in degraded mode with JSONL fallback and no embedding) this.storeReady diff --git a/src/utils/async-semaphore.test.ts b/src/utils/async-semaphore.test.ts new file mode 100644 index 00000000..260b6e04 --- /dev/null +++ b/src/utils/async-semaphore.test.ts @@ -0,0 +1,149 @@ +import { describe, it, expect } from "vitest"; +import { AsyncSemaphore, PASSTHROUGH_LIMITER } from "./async-semaphore.js"; + +/** Resolve after the given ms (real timer — these tests exercise async timing). */ +const delay = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +/** + * Run `n` tasks through `limiter`, each holding for `holdMs`, and report the + * peak number observed running at once. With a cap of `k`, peak must be ≤ k. + */ +async function peakConcurrency( + limiter: { run(fn: () => Promise): Promise }, + n: number, + holdMs: number, +): Promise { + let active = 0; + let peak = 0; + await Promise.all( + Array.from({ length: n }, () => + limiter.run(async () => { + active++; + peak = Math.max(peak, active); + await delay(holdMs); + active--; + }), + ), + ); + return peak; +} + +describe("AsyncSemaphore", () => { + it("caps concurrency at the configured limit", async () => { + const sem = new AsyncSemaphore(2); + expect(await peakConcurrency(sem, 6, 15)).toBe(2); + // Fully drained afterwards. + expect(sem.active).toBe(0); + expect(sem.waiting).toBe(0); + }); + + it("serializes when the limit is 1", async () => { + const sem = new AsyncSemaphore(1); + expect(await peakConcurrency(sem, 5, 10)).toBe(1); + }); + + it("treats limit <= 0 as unlimited (no gating)", async () => { + for (const lim of [0, -1, Number.NaN]) { + const sem = new AsyncSemaphore(lim); + expect(sem.unlimited).toBe(true); + expect(sem.capacity).toBe(0); + // All 8 run together — peak equals the task count. + expect(await peakConcurrency(sem, 8, 10)).toBe(8); + } + }); + + it("PASSTHROUGH_LIMITER runs everything immediately", async () => { + expect(await peakConcurrency(PASSTHROUGH_LIMITER, 5, 10)).toBe(5); + }); + + it("serves waiters in FIFO order", async () => { + const sem = new AsyncSemaphore(1); + const order: number[] = []; + const release0Holder: Promise<() => void> = sem.acquire(); + const release0 = await release0Holder; // hold the only permit + + // Queue three waiters; they must run in the order they queued. + const waiters = [1, 2, 3].map((i) => + sem.run(async () => { + order.push(i); + await delay(5); + }), + ); + + expect(sem.waiting).toBe(3); + release0(); // let them through one at a time + await Promise.all(waiters); + expect(order).toEqual([1, 2, 3]); + }); + + it("never over-subscribes when an acquire races a release", async () => { + // Regression guard for the classic handoff bug: releasing while a waiter is + // queued must hand the permit over without transiently freeing a slot that + // a synchronously-racing acquire() could grab. + const sem = new AsyncSemaphore(1); + const r1 = await sem.acquire(); // inUse=1 + const w = sem.acquire(); // queued waiter + expect(sem.waiting).toBe(1); + + r1(); // hand permit to the queued waiter + // Synchronously race a third acquire before the waiter's microtask resolves. + let thirdGotPermit = false; + const third = sem.acquire().then((rel) => { + thirdGotPermit = true; + return rel; + }); + + const r2 = await w; // waiter got the permit + expect(sem.active).toBe(1); + expect(thirdGotPermit).toBe(false); // third still blocked — no over-subscription + + r2(); + const r3 = await third; + expect(thirdGotPermit).toBe(true); + r3(); + expect(sem.active).toBe(0); + }); + + it("release is idempotent (double-release does not free extra permits)", async () => { + const sem = new AsyncSemaphore(1); + const rel = await sem.acquire(); + expect(sem.active).toBe(1); + rel(); + rel(); // no-op + expect(sem.active).toBe(0); + + // A fresh acquire still works and the limit is intact. + expect(await peakConcurrency(sem, 3, 5)).toBe(1); + }); + + it("propagates task errors while still releasing the permit", async () => { + const sem = new AsyncSemaphore(1); + await expect( + sem.run(async () => { + throw new Error("boom"); + }), + ).rejects.toThrow("boom"); + // Permit was released despite the throw. + expect(sem.active).toBe(0); + expect(await peakConcurrency(sem, 2, 5)).toBe(1); + }); + + it("reports live active / waiting counts", async () => { + const sem = new AsyncSemaphore(2); + const rels = await Promise.all([sem.acquire(), sem.acquire()]); + expect(sem.active).toBe(2); + expect(sem.available).toBe(0); + + const queued = sem.acquire(); + expect(sem.waiting).toBe(1); + + rels[0]!(); + const rel3 = await queued; + expect(sem.waiting).toBe(0); + expect(sem.active).toBe(2); + + rels[1]!(); + rel3(); + expect(sem.active).toBe(0); + }); +}); diff --git a/src/utils/async-semaphore.ts b/src/utils/async-semaphore.ts new file mode 100644 index 00000000..36e65f16 --- /dev/null +++ b/src/utils/async-semaphore.ts @@ -0,0 +1,126 @@ +/** + * AsyncSemaphore — a small counting semaphore for bounding async concurrency. + * + * ## Why this exists (multi-tenant extraction cap) + * + * In the structural multi-tenant route (see `gateway/core-registry.ts`) every + * account gets its **own** {@link MemoryPipelineManager}, each with its own L1/L2/L3 + * `SerialQueue`s. A single account is therefore naturally capped (one L1, one L2, + * one L3 at a time), but `N` resident accounts fan out to up to `~3N` + * **concurrent background LLM extraction calls** with nothing holding them back — + * the real cost red line called out in design §8.4 #5. + * + * The fix is one semaphore *shared across all cores*: each pipeline manager runs + * its L1/L2/L3 runner through {@link run}, so the total number of in-flight + * extraction runs across every tenant can never exceed the configured limit. + * + * ## Semantics + * + * - `limit <= 0` (or non-finite) means **unlimited** — {@link acquire}/{@link run} + * resolve immediately and impose no ordering. This is the single-tenant default + * (one core can't fan out, so there is nothing to cap) and keeps legacy + * behaviour byte-for-byte. + * - Waiters are served strictly FIFO. + * - A permit is **handed directly** from a releaser to the next waiter without + * ever dropping `active` below the in-use count, so a synchronous `acquire()` + * racing in between a release and the woken waiter cannot over-subscribe the + * limit. (The naive "decrement then resolve" implementation has exactly that + * bug, because resolving a promise only schedules a microtask.) + */ + +/** Minimal interface the pipeline manager depends on (keeps it test-friendly). */ +export interface ConcurrencyLimiter { + /** Run `fn` once a permit is available, releasing the permit when it settles. */ + run(fn: () => Promise): Promise; +} + +/** A limiter that imposes no bound — used when concurrency capping is disabled. */ +export const PASSTHROUGH_LIMITER: ConcurrencyLimiter = { + run: (fn) => fn(), +}; + +const NOOP_RELEASE = (): void => {}; + +export class AsyncSemaphore implements ConcurrencyLimiter { + /** Configured permit count; `<= 0` means unlimited. */ + private readonly limit: number; + /** Permits currently held (acquired but not yet released). */ + private inUse = 0; + /** FIFO queue of resolvers for callers waiting on a permit. */ + private readonly waiters: Array<() => void> = []; + + constructor(limit: number) { + this.limit = Number.isFinite(limit) && limit > 0 ? Math.floor(limit) : 0; + } + + /** Whether this semaphore imposes no bound. */ + get unlimited(): boolean { + return this.limit <= 0; + } + + /** Configured permit count (0 = unlimited). */ + get capacity(): number { + return this.limit; + } + + /** Permits currently held. */ + get active(): number { + return this.inUse; + } + + /** Callers currently blocked waiting for a permit. */ + get waiting(): number { + return this.waiters.length; + } + + /** Free permits right now (`Infinity` when unlimited). */ + get available(): number { + return this.unlimited ? Infinity : Math.max(0, this.limit - this.inUse); + } + + /** + * Acquire a permit, resolving with an idempotent `release` function. Always + * call `release()` exactly once (a `try/finally` is safest — or use + * {@link run}, which does it for you). + */ + async acquire(): Promise<() => void> { + if (this.unlimited) return NOOP_RELEASE; + + if (this.inUse < this.limit) { + this.inUse++; + return this.makeRelease(); + } + + // At capacity: wait for a permit to be handed to us. The handoff in + // makeRelease() leaves `inUse` unchanged, so we must NOT increment here. + await new Promise((resolve) => this.waiters.push(resolve)); + return this.makeRelease(); + } + + /** Acquire a permit, run `fn`, and release the permit when it settles. */ + async run(fn: () => Promise): Promise { + if (this.unlimited) return fn(); + const release = await this.acquire(); + try { + return await fn(); + } finally { + release(); + } + } + + private makeRelease(): () => void { + let released = false; + return () => { + if (released) return; + released = true; + const next = this.waiters.shift(); + if (next) { + // Hand the permit straight to the next waiter: `inUse` stays the same, + // closing the window where a racing acquire() could over-subscribe. + next(); + } else { + this.inUse--; + } + }; + } +} diff --git a/src/utils/pipeline-factory.ts b/src/utils/pipeline-factory.ts index ce87801c..cc6023cb 100644 --- a/src/utils/pipeline-factory.ts +++ b/src/utils/pipeline-factory.ts @@ -70,6 +70,12 @@ export interface PipelineFactoryOptions { l1LlmRunner?: import("../core/types.js").LLMRunner; /** Host-neutral LLM runner for L2/L3 (tool-call enabled, enableTools=true). */ l2l3LlmRunner?: import("../core/types.js").LLMRunner; + /** + * Max time (ms) `pipeline.destroy()` waits to flush pending L1/L2/L3 work. + * Defaults to the manager's live-safe 2s. Seed raises it (no gateway_stop + * hook to race) so the final persona/scene flush isn't cut off. + */ + destroyTimeoutMs?: number; } // ============================ @@ -651,11 +657,18 @@ export function createL3Runner(opts: { /** * Create a MemoryPipelineManager with the standard config mapping. + * + * `extractionLimiter`, when provided, gates L1/L2/L3 runner execution. Pass the + * same instance to every manager (as the multi-tenant CoreRegistry does) to cap + * total concurrent extraction across cores; omit it for the unbounded + * single-core default. */ export function createPipelineManager( cfg: MemoryTdaiConfig, logger: PipelineLogger, sessionFilter?: SessionFilter, + extractionLimiter?: import("./async-semaphore.js").ConcurrencyLimiter, + destroyTimeoutMs?: number, ): MemoryPipelineManager { return new MemoryPipelineManager( { @@ -668,9 +681,11 @@ export function createPipelineManager( maxIntervalSeconds: cfg.pipeline.l2MaxIntervalSeconds, sessionActiveWindowHours: cfg.pipeline.sessionActiveWindowHours, }, + ...(destroyTimeoutMs !== undefined ? { destroyTimeoutMs } : {}), }, logger, sessionFilter ?? new SessionFilter([]), + extractionLimiter, ); } @@ -687,7 +702,7 @@ export function createPipelineManager( * and `createL3Runner()` from this module. */ export async function createPipeline(opts: PipelineFactoryOptions): Promise { - const { pluginDataDir, cfg, openclawConfig, logger, sessionFilter, l1LlmRunner } = opts; + const { pluginDataDir, cfg, openclawConfig, logger, sessionFilter, l1LlmRunner, destroyTimeoutMs } = opts; // Ensure data directories exist initDataDirectories(pluginDataDir); @@ -697,7 +712,7 @@ export async function createPipeline(opts: PipelineFactoryOptions): Promise new Promise((r) => setTimeout(r, ms)); + +/** Minimal pipeline config: warm-up on → the first conversation triggers L1. */ +function testPipelineConfig(): PipelineConfig { + return { + everyNConversations: 1, + enableWarmup: true, + l1: { idleTimeoutSeconds: 999 }, + l2: { + delayAfterL1Seconds: 999, + minIntervalSeconds: 999, + maxIntervalSeconds: 999, + sessionActiveWindowHours: 24, + }, + }; +} + +const msg = (content: string): CapturedMessage => ({ + role: "user", + content, + timestamp: new Date(0).toISOString(), +}); + +/** Poll until every manager's L1 queue is idle (or time out). */ +async function waitForL1Idle(managers: MemoryPipelineManager[], timeoutMs = 5000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (managers.every((m) => m.getQueueSizes().l1Idle)) return; + await delay(10); + } + throw new Error("timed out waiting for L1 queues to drain"); +} + +/** + * Build `count` managers sharing `limiter`, each with an L1 runner that records + * peak overlap into the shared `tracker`. Returns the managers + the tracker. + */ +function buildManagers(count: number, limiter: AsyncSemaphore | undefined, holdMs: number) { + const tracker = { active: 0, peak: 0, runs: 0 }; + const managers = Array.from({ length: count }, () => { + const mgr = new MemoryPipelineManager(testPipelineConfig(), undefined, undefined, limiter); + mgr.setL1Runner(async () => { + tracker.active++; + tracker.runs++; + tracker.peak = Math.max(tracker.peak, tracker.active); + await delay(holdMs); + tracker.active--; + return { processedCount: 1 }; + }); + return mgr; + }); + return { managers, tracker }; +} + +describe("MemoryPipelineManager — shared extraction limiter", () => { + it("caps concurrent L1 across managers at the shared limit", async () => { + const limiter = new AsyncSemaphore(1); + const { managers, tracker } = buildManagers(4, limiter, 25); + + // Fire one conversation per manager (each triggers L1 immediately via warm-up). + await Promise.all(managers.map((m, i) => m.notifyConversation(`acct-${i}`, [msg(`hi ${i}`)]))); + await waitForL1Idle(managers); + + expect(tracker.runs).toBe(4); // every account's L1 ran + expect(tracker.peak).toBe(1); // but never more than one at a time + + await Promise.all(managers.map((m) => m.destroy())); + }); + + it("allows up to the cap to run together (cap = 2)", async () => { + const limiter = new AsyncSemaphore(2); + const { managers, tracker } = buildManagers(6, limiter, 25); + + await Promise.all(managers.map((m, i) => m.notifyConversation(`acct-${i}`, [msg(`hi ${i}`)]))); + await waitForL1Idle(managers); + + expect(tracker.runs).toBe(6); + expect(tracker.peak).toBe(2); + + await Promise.all(managers.map((m) => m.destroy())); + }); + + it("without a shared limiter, managers fan out (peak > 1)", async () => { + // Control: omitting the limiter restores the unbounded per-core behaviour, + // proving the cap above is the limiter's doing and not incidental timing. + const { managers, tracker } = buildManagers(4, undefined, 25); + + await Promise.all(managers.map((m, i) => m.notifyConversation(`acct-${i}`, [msg(`hi ${i}`)]))); + await waitForL1Idle(managers); + + expect(tracker.runs).toBe(4); + expect(tracker.peak).toBeGreaterThan(1); + + await Promise.all(managers.map((m) => m.destroy())); + }); +}); diff --git a/src/utils/pipeline-manager.ts b/src/utils/pipeline-manager.ts index bccf2936..40ef3bc5 100644 --- a/src/utils/pipeline-manager.ts +++ b/src/utils/pipeline-manager.ts @@ -80,6 +80,8 @@ import type { PipelineSessionState } from "./checkpoint.js"; import { SessionFilter } from "./session-filter.js"; import { ManagedTimer } from "./managed-timer.js"; import { SerialQueue } from "./serial-queue.js"; +import { PASSTHROUGH_LIMITER } from "./async-semaphore.js"; +import type { ConcurrencyLimiter } from "./async-semaphore.js"; import { report } from "../core/report/reporter.js"; import type { Logger } from "../core/types.js"; @@ -138,6 +140,14 @@ export interface PipelineConfig { */ sessionActiveWindowHours: number; }; + + /** + * Max time (ms) `destroy()` waits to flush pending L1/L2/L3 work before + * persisting state and closing. Default: 2000 — tuned to stay under the live + * gateway's 3s gateway_stop hook. Offline callers (seed) raise it so the + * final L2/L3 flush isn't cut off mid-run. + */ + destroyTimeoutMs?: number; } /** Result returned by the L1 runner. */ @@ -233,6 +243,14 @@ export class MemoryPipelineManager { // Unified session filter (internal sessions + excludeAgents) private readonly sessionFilter: SessionFilter; + /** + * Gate around L1/L2/L3 runner execution. In multi-tenant mode every core + * shares ONE limiter so total concurrent extraction across all accounts is + * bounded (design §8.4 #5). Defaults to a passthrough (no bound) — correct + * for single-tenant, where one core can't fan out. + */ + private readonly extractionLimiter: ConcurrencyLimiter; + // Lifecycle private destroyed = false; @@ -247,7 +265,12 @@ export class MemoryPipelineManager { /** Counter for GC scheduling. */ private notifyCounter = 0; - constructor(config: PipelineConfig, logger?: Logger, sessionFilter?: SessionFilter) { + constructor( + config: PipelineConfig, + logger?: Logger, + sessionFilter?: SessionFilter, + extractionLimiter?: ConcurrencyLimiter, + ) { this.l1IdleTimeoutMs = config.l1.idleTimeoutSeconds * 1000; this.everyNConversations = config.everyNConversations; this.enableWarmup = config.enableWarmup; @@ -255,8 +278,10 @@ export class MemoryPipelineManager { this.l2MinIntervalMs = config.l2.minIntervalSeconds * 1000; this.l2MaxIntervalMs = config.l2.maxIntervalSeconds * 1000; this.sessionActiveWindowMs = config.l2.sessionActiveWindowHours * 60 * 60 * 1000; + this.DESTROY_TIMEOUT_MS = config.destroyTimeoutMs ?? 2_000; this.logger = logger; this.sessionFilter = sessionFilter ?? new SessionFilter(); + this.extractionLimiter = extractionLimiter ?? PASSTHROUGH_LIMITER; this.logger?.debug?.( `${TAG} Initialized: everyNConversations=${config.everyNConversations}, ` + @@ -500,10 +525,11 @@ export class MemoryPipelineManager { /** * Maximum time (ms) to wait for pipeline flush during destroy. - * Must be shorter than the gateway_stop hook timeout (3 s) to leave - * headroom for VectorStore / EmbeddingService cleanup that runs after. + * Live default (2 s) must stay shorter than the gateway_stop hook timeout + * (3 s) to leave headroom for VectorStore / EmbeddingService cleanup that + * runs after. Offline callers (seed) override via `config.destroyTimeoutMs`. */ - private readonly DESTROY_TIMEOUT_MS = 2_000; + private readonly DESTROY_TIMEOUT_MS: number; /** * Graceful shutdown with timeout protection: @@ -690,12 +716,17 @@ export class MemoryPipelineManager { return; } + const l1Runner = this.l1Runner; try { - await this.l1Runner({ - sessionKey, - msg: buffer, - bg_msg: [], // reserved for future use - }); + // Gate L1 extraction (LLM-bearing) through the shared limiter so that, in + // multi-tenant mode, concurrent L1 runs across all accounts stay capped. + await this.extractionLimiter.run(() => + l1Runner({ + sessionKey, + msg: buffer, + bg_msg: [], // reserved for future use + }), + ); this.logger?.debug?.( `${TAG} [${sessionKey}] L1 complete: processed ${buffer.length} messages`, @@ -882,10 +913,12 @@ export class MemoryPipelineManager { ); const cursor = state.last_extraction_updated_time || undefined; + const l2Runner = this.l2Runner; let result: L2RunnerResult | void; try { - result = await this.l2Runner(sessionKey, cursor); + // Shared-limiter gated (see runL1) — caps L2 fan-out across accounts. + result = await this.extractionLimiter.run(() => l2Runner(sessionKey, cursor)); } catch (err) { this.logger?.error( `${TAG} [${sessionKey}] L2 runner failed: ${err instanceof Error ? err.stack ?? err.message : String(err)}`, @@ -988,9 +1021,11 @@ export class MemoryPipelineManager { return; } + const l3Runner = this.l3Runner; this.logger?.debug?.(`${TAG} L3 running`); try { - await this.l3Runner(); + // Shared-limiter gated (see runL1) — caps L3 fan-out across accounts. + await this.extractionLimiter.run(() => l3Runner()); this.logger?.debug?.(`${TAG} L3 complete`); } catch (err) { this.logger?.error( From 27b3f1a49107463dab6292140a30388ee8175577 Mon Sep 17 00:00:00 2001 From: Jack <278171810@qq.com> Date: Sat, 27 Jun 2026 11:51:58 +0800 Subject: [PATCH 05/20] fix(store): write persona and scene files atomically L3 persona is an unlocked read-modify-write; a crash or concurrent write mid-flush leaves a truncated persona.md. Add atomicWriteFile (temp + fsync + rename) and route the persona generator, profile sync, scene extractor, and scene index through it so readers never observe a partial file. Signed-off-by: Jack <278171810@qq.com> --- src/core/persona/persona-generator.ts | 3 +- src/core/profile/profile-sync.ts | 3 +- src/core/scene/scene-extractor.ts | 3 +- src/core/scene/scene-index.ts | 3 +- src/utils/atomic-write.test.ts | 60 +++++++++++++++++++++++++++ src/utils/atomic-write.ts | 46 ++++++++++++++++++++ 6 files changed, 114 insertions(+), 4 deletions(-) create mode 100644 src/utils/atomic-write.test.ts create mode 100644 src/utils/atomic-write.ts diff --git a/src/core/persona/persona-generator.ts b/src/core/persona/persona-generator.ts index cba05901..978f429c 100644 --- a/src/core/persona/persona-generator.ts +++ b/src/core/persona/persona-generator.ts @@ -5,6 +5,7 @@ import fs from "node:fs/promises"; import path from "node:path"; +import { atomicWriteFile } from "../../utils/atomic-write.js"; import { formatForLLM } from "../../utils/time.js"; import { CleanContextRunner } from "../../utils/clean-context-runner.js"; import { CheckpointManager } from "../../utils/checkpoint.js"; @@ -182,7 +183,7 @@ export class PersonaGenerator { // 11. Append fresh scene navigation and write final content const nav = generateSceneNavigation(index); const finalContent = nav ? `${personaText}\n\n${nav}\n` : personaText; - await fs.writeFile(personaPath, finalContent, "utf-8"); + await atomicWriteFile(personaPath, finalContent); const elapsedMs = Date.now() - startMs; this.logger?.info(`${TAG} Persona written (${finalContent.length} chars) in ${elapsedMs}ms`); diff --git a/src/core/profile/profile-sync.ts b/src/core/profile/profile-sync.ts index 3d931db8..7030230f 100644 --- a/src/core/profile/profile-sync.ts +++ b/src/core/profile/profile-sync.ts @@ -1,6 +1,7 @@ import { createHash } from "node:crypto"; import fs from "node:fs/promises"; import path from "node:path"; +import { atomicWriteFile } from "../../utils/atomic-write.js"; import type { IMemoryStore, ProfileRecord, ProfileSyncRecord } from "../store/types.js"; import { readSceneIndex, syncSceneIndex } from "../scene/scene-index.js"; import { generateSceneNavigation, stripSceneNavigation } from "../scene/scene-navigation.js"; @@ -58,7 +59,7 @@ async function refreshPersonaNavigation(dataDir: string): Promise { const index = await readSceneIndex(dataDir); const nav = generateSceneNavigation(index); const finalContent = nav ? `${body}\n\n${nav}\n` : `${body}\n`; - await fs.writeFile(personaPath, finalContent, "utf-8"); + await atomicWriteFile(personaPath, finalContent); } export async function listLocalProfiles(dataDir: string): Promise { diff --git a/src/core/scene/scene-extractor.ts b/src/core/scene/scene-extractor.ts index 6d8a4b84..2af10674 100644 --- a/src/core/scene/scene-extractor.ts +++ b/src/core/scene/scene-extractor.ts @@ -18,6 +18,7 @@ import fs from "node:fs/promises"; import path from "node:path"; +import { atomicWriteFile } from "../../utils/atomic-write.js"; import { formatForLLM } from "../../utils/time.js"; import { CleanContextRunner } from "../../utils/clean-context-runner.js"; import { CheckpointManager } from "../../utils/checkpoint.js"; @@ -471,7 +472,7 @@ export class SceneExtractor { const updated = nav ? `${stripped}\n\n${nav}\n` : `${stripped}\n`; // persona.md is at dataDir root, no subdir needed - await fs.writeFile(personaPath, updated, "utf-8"); + await atomicWriteFile(personaPath, updated); } } diff --git a/src/core/scene/scene-index.ts b/src/core/scene/scene-index.ts index 84f7d471..ddccc21e 100644 --- a/src/core/scene/scene-index.ts +++ b/src/core/scene/scene-index.ts @@ -4,6 +4,7 @@ import fs from "node:fs/promises"; import path from "node:path"; +import { atomicWriteFile } from "../../utils/atomic-write.js"; import { parseSceneBlock } from "./scene-format.js"; export interface SceneIndexEntry { @@ -57,7 +58,7 @@ export async function writeSceneIndex( ): Promise { const indexPath = path.join(dataDir, ".metadata", "scene_index.json"); await fs.mkdir(path.dirname(indexPath), { recursive: true }); - await fs.writeFile(indexPath, JSON.stringify(entries, null, 2), "utf-8"); + await atomicWriteFile(indexPath, JSON.stringify(entries, null, 2)); } /** diff --git a/src/utils/atomic-write.test.ts b/src/utils/atomic-write.test.ts new file mode 100644 index 00000000..ad1ffc17 --- /dev/null +++ b/src/utils/atomic-write.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { atomicWriteFile } from "./atomic-write.js"; + +describe("atomicWriteFile", () => { + let dir: string; + + beforeEach(async () => { + dir = await fs.mkdtemp(path.join(os.tmpdir(), "atomic-write-test-")); + }); + + afterEach(async () => { + await fs.rm(dir, { recursive: true, force: true }); + }); + + it("writes content to a new file", async () => { + const p = path.join(dir, "persona.md"); + await atomicWriteFile(p, "hello"); + expect(await fs.readFile(p, "utf-8")).toBe("hello"); + }); + + it("overwrites an existing file", async () => { + const p = path.join(dir, "persona.md"); + await fs.writeFile(p, "old", "utf-8"); + await atomicWriteFile(p, "new"); + expect(await fs.readFile(p, "utf-8")).toBe("new"); + }); + + it("leaves no temp files behind on success", async () => { + const p = path.join(dir, "scene_index.json"); + await atomicWriteFile(p, JSON.stringify({ a: 1 })); + const entries = await fs.readdir(dir); + expect(entries).toEqual(["scene_index.json"]); + }); + + it("does not corrupt the existing file when the write throws", async () => { + const p = path.join(dir, "persona.md"); + await atomicWriteFile(p, "original"); + // Target a non-existent nested dir to force a write failure. + const bad = path.join(dir, "no-such-subdir", "persona.md"); + await expect(atomicWriteFile(bad, "doomed")).rejects.toBeTruthy(); + // Original untouched. + expect(await fs.readFile(p, "utf-8")).toBe("original"); + }); + + it("survives concurrent writes to different files without temp collisions", async () => { + await Promise.all( + Array.from({ length: 20 }, (_, i) => + atomicWriteFile(path.join(dir, `f${i}.md`), `content-${i}`), + ), + ); + const entries = (await fs.readdir(dir)).sort(); + expect(entries).toHaveLength(20); + for (let i = 0; i < 20; i++) { + expect(await fs.readFile(path.join(dir, `f${i}.md`), "utf-8")).toBe(`content-${i}`); + } + }); +}); diff --git a/src/utils/atomic-write.ts b/src/utils/atomic-write.ts new file mode 100644 index 00000000..4a0ca546 --- /dev/null +++ b/src/utils/atomic-write.ts @@ -0,0 +1,46 @@ +/** + * Atomic file write — write to a temp file in the same directory, then rename + * over the target. rename(2) is atomic on POSIX within a single filesystem, so + * a reader never observes a half-written file and a crash mid-write leaves the + * previous version intact (or, worst case, an orphan temp file). + * + * Why this matters for TDAI: L3 persona (`persona.md`) and L2 scene index are + * derived state written via a raw `fs.writeFile` read-modify-write, with no + * lock or atomicity. Under the multi-tenant retrofit a single dataDir is still + * owned by one process, but concurrent pipeline stages (or a crash) can corrupt + * a plain write. See design §8.5 red line 3 / issue §2. + * + * Contract reminder: this guards against torn writes within one process/host. + * It does NOT make two processes sharing one dataDir safe — that remains a hard + * "single dataDir, single process" constraint. + */ + +import fs from "node:fs/promises"; +import path from "node:path"; + +/** + * Atomically write `content` to `filePath`. + * + * The temp file is created in the same directory as the target (rename across + * filesystems is not atomic and would throw EXDEV). The temp name embeds the pid + * and a counter so concurrent writers in the same process don't collide. + */ +let counter = 0; +export async function atomicWriteFile( + filePath: string, + content: string | Uint8Array, + encoding: BufferEncoding = "utf-8", +): Promise { + const dir = path.dirname(filePath); + const base = path.basename(filePath); + // eslint-disable-next-line no-plusplus + const tmpPath = path.join(dir, `.${base}.${process.pid}.${counter++}.tmp`); + try { + await fs.writeFile(tmpPath, content, encoding); + await fs.rename(tmpPath, filePath); + } catch (err) { + // Best-effort cleanup of the temp file if rename never happened. + await fs.rm(tmpPath, { force: true }).catch(() => {}); + throw err; + } +} From f9866af8a51a30bb5599c92cc28fedb40cffbe7c Mon Sep 17 00:00:00 2001 From: Jack <278171810@qq.com> Date: Sat, 27 Jun 2026 13:05:17 +0800 Subject: [PATCH 06/20] feat(gateway): per-account multi-tenant isolation via core registry One gateway process must safely serve multiple end-user accounts with hard isolation. Add CoreRegistry, holding a lazy, LRU Map with one dataDir per account (baseDir/{safeAccountDir(key)}), so L1/L2/L3 recall, search, and persona are physically isolated per tenant. Thread session_key through the search/recall request types, return prepend_context from /recall, and add account listing plus a session-scoped namespace wipe for hard-delete. Gated by TDAI_MULTI_TENANT; single-core behavior is unchanged when off. Signed-off-by: Jack <278171810@qq.com> --- src/gateway/config.ts | 72 +++++- src/gateway/core-registry.test.ts | 318 +++++++++++++++++++++++ src/gateway/core-registry.ts | 406 ++++++++++++++++++++++++++++++ src/gateway/server.e2e.test.ts | 179 +++++++++++++ src/gateway/server.ts | 173 ++++++++++--- src/gateway/types.ts | 57 +++++ 6 files changed, 1173 insertions(+), 32 deletions(-) create mode 100644 src/gateway/core-registry.test.ts create mode 100644 src/gateway/core-registry.ts create mode 100644 src/gateway/server.e2e.test.ts diff --git a/src/gateway/config.ts b/src/gateway/config.ts index 4fff5813..9328f58b 100644 --- a/src/gateway/config.ts +++ b/src/gateway/config.ts @@ -62,6 +62,51 @@ export interface GatewayConfig { data: { /** Base directory for TDAI data storage. */ baseDir: string; + /** + * Multi-tenant mode. When `true`, one Gateway process safely serves many + * end-user accounts: each `session_key` is routed to its own `TdaiCore` + * with a dedicated dataDir under `baseDir/{account}` (structural isolation + * — see design §8.4). When `false` (default), the Gateway behaves exactly + * as before: a single shared core rooted at `baseDir`, `session_key` only + * isolates L0/pipeline state. + * + * In multi-tenant mode `session_key` becomes **required** on every routed + * endpoint (`/recall`, `/capture`, `/search/*`, `/session/end`). + * + * env: `TDAI_MULTI_TENANT` + * yaml: `data.multiTenant` + */ + multiTenant: boolean; + /** + * Max concurrent background extraction runs (L1/L2/L3) across ALL per-account + * cores. Structural multi-tenant gives each account its own pipeline, so `N` + * active accounts otherwise fan out to up to `~3N` simultaneous LLM + * extraction calls (design §8.4 #5). One shared limiter caps the total. + * + * - `> 0` — hard cap. + * - `0` — unbounded. + * - unset — multi-tenant defaults to a safe internal cap; single-tenant + * stays unbounded (a lone core can't fan out). + * + * env: `TDAI_MAX_CONCURRENT_EXTRACTIONS` + * yaml: `data.maxConcurrentExtractions` + */ + maxConcurrentExtractions?: number; + /** + * Max number of per-account cores kept resident at once (multi-tenant LRU + * eviction). When a request would exceed this, the least-recently-used idle + * core is flushed + torn down to bound process memory. Must exceed the peak + * number of concurrently active accounts. + * + * - `> 0` — keep at most this many cores warm. + * - `0` / unset — unlimited (legacy: every account stays warm forever). + * + * Ignored in single-tenant mode. + * + * env: `TDAI_MAX_RESIDENT_CORES` + * yaml: `data.maxResidentCores` + */ + maxResidentCores?: number; }; llm: StandaloneLLMConfig; /** Parsed memory-tdai plugin config (recall, capture, extraction, pipeline, etc.). */ @@ -124,6 +169,11 @@ export function loadGatewayConfig(overrides?: Partial): GatewayCo const rawBaseDir = env("TDAI_DATA_DIR") ?? str(dataConfig, "baseDir") ?? resolveDefaultDataDir(); const home = getEnv("HOME") ?? getEnv("USERPROFILE") ?? "/tmp"; const baseDir = rawBaseDir.startsWith("~/") ? path.join(home, rawBaseDir.slice(2)) : rawBaseDir; + const multiTenant = envBool("TDAI_MULTI_TENANT") ?? bool(dataConfig, "multiTenant") ?? false; + const maxConcurrentExtractions = + envInt("TDAI_MAX_CONCURRENT_EXTRACTIONS") ?? num(dataConfig, "maxConcurrentExtractions"); + const maxResidentCores = + envInt("TDAI_MAX_RESIDENT_CORES") ?? num(dataConfig, "maxResidentCores"); // LLM config const llmConfig = obj(fileConfig, "llm"); @@ -144,7 +194,7 @@ export function loadGatewayConfig(overrides?: Partial): GatewayCo const base: GatewayConfig = { server: { port, host, apiKey, corsOrigins }, - data: { baseDir }, + data: { baseDir, multiTenant, maxConcurrentExtractions, maxResidentCores }, llm, memory, }; @@ -237,6 +287,26 @@ function envInt(key: string): number | undefined { return Number.isFinite(n) ? n : undefined; } +/** + * Read a strict boolean env var. Accepts true/1/yes/on and false/0/no/off + * (case-insensitive); anything else (including unset) returns undefined so the + * caller can fall through to the next source / default. + */ +function envBool(key: string): boolean | undefined { + const v = env(key); + if (v === undefined) return undefined; + const s = v.toLowerCase(); + if (s === "true" || s === "1" || s === "yes" || s === "on") return true; + if (s === "false" || s === "0" || s === "no" || s === "off") return false; + return undefined; +} + +/** Read a boolean field from a config object (non-boolean → undefined). */ +function bool(src: Record, key: string): boolean | undefined { + const v = src[key]; + return typeof v === "boolean" ? v : undefined; +} + /** * Read an env var that may be a boolean ("true"/"false"/"1"/"0") * or a plain string (strategy name like "deepseek", "anthropic"). diff --git a/src/gateway/core-registry.test.ts b/src/gateway/core-registry.test.ts new file mode 100644 index 00000000..979ca00c --- /dev/null +++ b/src/gateway/core-registry.test.ts @@ -0,0 +1,318 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { CoreRegistry, safeAccountDir } from "./core-registry.js"; +import { parseConfig } from "../config.js"; +import type { StandaloneLLMConfig } from "../adapters/standalone/llm-runner.js"; +import type { Logger } from "../core/types.js"; + +const silentLogger: Logger = { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, +}; + +const llmConfig: StandaloneLLMConfig = { + baseUrl: "http://127.0.0.1:0", + apiKey: "", + model: "test-model", + maxTokens: 256, + timeoutMs: 1000, + disableThinking: false, +}; + +// Extraction off → no background timers / LLM calls spun up by initialize(). +// provider "none" → no embedding service (keyword/FTS path), no network. +const memory = parseConfig({ extraction: { enabled: false }, embedding: { provider: "none" } }); + +function makeRegistry( + baseDir: string, + multiTenant: boolean, + maxResidentCores?: number, +): CoreRegistry { + return new CoreRegistry({ + baseDir, + llmConfig, + memory, + logger: silentLogger, + multiTenant, + maxResidentCores, + }); +} + +const delay = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +describe("safeAccountDir", () => { + it("is deterministic for the same key", () => { + expect(safeAccountDir("ai4all:alice")).toBe(safeAccountDir("ai4all:alice")); + }); + + it("throws on empty / whitespace keys", () => { + expect(() => safeAccountDir("")).toThrow(); + expect(() => safeAccountDir(" ")).toThrow(); + }); + + it("never emits a path separator, traversal, or hidden segment", () => { + for (const key of ["ai4all:../../etc/passwd", "a/b/c", "..", ".", "../x", "\\\\evil", "..\\..\\x"]) { + const dir = safeAccountDir(key); + expect(dir).not.toContain("/"); + expect(dir).not.toContain("\\"); + expect(dir.startsWith(".")).toBe(false); + expect(dir).not.toBe(".."); + // Stays a single path segment. + expect(path.basename(dir)).toBe(dir); + } + }); + + it("maps slug-colliding keys to DISTINCT dirs (hash guards isolation)", () => { + // 'a/b' and 'a_b' sanitise to the same slug but must not share a directory. + expect(safeAccountDir("a/b")).not.toBe(safeAccountDir("a_b")); + expect(safeAccountDir("ai4all:x")).not.toBe(safeAccountDir("ai4all:y")); + }); +}); + +describe("CoreRegistry extraction cap", () => { + function reg(multiTenant: boolean, maxConcurrentExtractions?: number): CoreRegistry { + return new CoreRegistry({ + baseDir: "/tmp/tdai-cap-unused", // no core is created in these stats-only checks + llmConfig, + memory, + logger: silentLogger, + multiTenant, + maxConcurrentExtractions, + }); + } + + it("multi-tenant defaults to a bounded cap", () => { + expect(reg(true).extractionStats().limit).toBe(4); + }); + + it("single-tenant defaults to unbounded (0)", () => { + expect(reg(false).extractionStats().limit).toBe(0); + }); + + it("an explicit positive cap wins in either mode", () => { + expect(reg(true, 2).extractionStats().limit).toBe(2); + expect(reg(false, 3).extractionStats().limit).toBe(3); + }); + + it("an explicit 0 forces unbounded even in multi-tenant", () => { + expect(reg(true, 0).extractionStats().limit).toBe(0); + }); + + it("starts idle (no active or waiting permits)", () => { + const stats = reg(true).extractionStats(); + expect(stats.active).toBe(0); + expect(stats.waiting).toBe(0); + }); +}); + +describe("CoreRegistry LRU eviction (multi-tenant)", () => { + let baseDir: string; + const registries: CoreRegistry[] = []; + + function reg(maxResidentCores?: number, multiTenant = true): CoreRegistry { + const r = makeRegistry(baseDir, multiTenant, maxResidentCores); + registries.push(r); + return r; + } + + beforeEach(async () => { + baseDir = await fs.mkdtemp(path.join(os.tmpdir(), "tdai-lru-")); + }); + + afterEach(async () => { + await Promise.all(registries.splice(0).map((r) => r.destroyAll())); + await fs.rm(baseDir, { recursive: true, force: true }); + }); + + it("defaults to unlimited resident cores (limit 0)", () => { + expect(reg().residentStats().limit).toBe(0); + }); + + it("single-tenant ignores the resident cap (always unlimited)", () => { + expect(reg(2, false).residentStats().limit).toBe(0); + }); + + it("honours an explicit positive cap", () => { + expect(reg(3).residentStats().limit).toBe(3); + }); + + it("unlimited keeps every account resident", async () => { + const r = reg(); // unlimited + await r.getCore("ai4all:a"); + await r.getCore("ai4all:b"); + await r.getCore("ai4all:c"); + expect(r.size).toBe(3); + expect(r.residentStats().count).toBe(3); + }); + + it("evicts the least-recently-used core past the cap, sparing the active one", async () => { + const r = reg(2); + await r.getCore("ai4all:a"); + await delay(5); + await r.getCore("ai4all:b"); + await delay(5); + await r.getCore("ai4all:a"); // touch a → b becomes the LRU + await delay(5); + await r.getCore("ai4all:c"); // size would be 3 (> 2) → evict b + + expect(r.size).toBe(2); + expect(r.peek("ai4all:b")).toBeUndefined(); + expect(r.peek("ai4all:a")).toBeDefined(); + expect(r.peek("ai4all:c")).toBeDefined(); + }); + + it("eviction flushes + closes the core but KEEPS the dataDir (unlike wipe)", async () => { + const r = reg(1); + const aDir = r.resolveDataDir("ai4all:a"); + await r.getCore("ai4all:a"); + await fs.writeFile(path.join(aDir, "marker.txt"), "x"); + await delay(5); + await r.getCore("ai4all:b"); // evicts a + + expect(r.peek("ai4all:a")).toBeUndefined(); + // dir + its contents survive eviction — eviction is not a hard delete. + expect((await fs.stat(aDir)).isDirectory()).toBe(true); + expect(await fs.readFile(path.join(aDir, "marker.txt"), "utf8")).toBe("x"); + }); + + it("re-requesting an evicted account rebuilds a working core on the same dataDir", async () => { + const r = reg(1); + const first = await r.getCore("ai4all:a"); + const aDir = r.resolveDataDir("ai4all:a"); + await delay(5); + await r.getCore("ai4all:b"); // evicts a (teardown in flight) + + // Re-request a: must wait for the old SQLite handle to close, then reopen. + const again = await r.getCore("ai4all:a"); // evicts b in turn + expect(again).not.toBe(first); // a fresh core, not the destroyed one + expect(r.resolveDataDir("ai4all:a")).toBe(aDir); // same dataDir reused + expect(r.peek("ai4all:a")).toBe(again); + expect(r.peek("ai4all:b")).toBeUndefined(); + expect(r.size).toBe(1); + }); +}); + +describe("CoreRegistry wipe guards", () => { + it("refuses namespace wipe in single-tenant mode", async () => { + const baseDir = await fs.mkdtemp(path.join(os.tmpdir(), "tdai-wipe-st-")); + const registry = makeRegistry(baseDir, false); + try { + await expect(registry.wipe("ai4all:alice")).rejects.toThrow(/multi-tenant/); + } finally { + await registry.destroyAll(); + await fs.rm(baseDir, { recursive: true, force: true }); + } + }); +}); + +describe("CoreRegistry (single-tenant)", () => { + let baseDir: string; + let registry: CoreRegistry; + + beforeEach(async () => { + baseDir = await fs.mkdtemp(path.join(os.tmpdir(), "tdai-registry-st-")); + registry = makeRegistry(baseDir, false); + }); + + afterEach(async () => { + await registry.destroyAll(); + await fs.rm(baseDir, { recursive: true, force: true }); + }); + + it("returns one shared core rooted at baseDir regardless of session_key", async () => { + const a = await registry.getCore("ai4all:alice"); + const b = await registry.getCore("ai4all:bob"); + const c = await registry.getCore(""); + expect(a).toBe(b); + expect(b).toBe(c); + expect(registry.size).toBe(1); + expect(registry.resolveDataDir("anything")).toBe(baseDir); + }); +}); + +describe("CoreRegistry (multi-tenant)", () => { + let baseDir: string; + let registry: CoreRegistry; + + beforeEach(async () => { + baseDir = await fs.mkdtemp(path.join(os.tmpdir(), "tdai-registry-mt-")); + registry = makeRegistry(baseDir, true); + }); + + afterEach(async () => { + await registry.destroyAll(); + await fs.rm(baseDir, { recursive: true, force: true }); + }); + + it("creates a distinct core + dataDir per account, both under baseDir", async () => { + const alice = await registry.getCore("ai4all:alice"); + const bob = await registry.getCore("ai4all:bob"); + + expect(alice).not.toBe(bob); + expect(registry.size).toBe(2); + + const aliceDir = registry.resolveDataDir("ai4all:alice"); + const bobDir = registry.resolveDataDir("ai4all:bob"); + expect(aliceDir).not.toBe(bobDir); + expect(path.dirname(aliceDir)).toBe(baseDir); + expect(path.dirname(bobDir)).toBe(baseDir); + + // initialize() creates the per-account directory tree on disk. + expect((await fs.stat(aliceDir)).isDirectory()).toBe(true); + expect((await fs.stat(bobDir)).isDirectory()).toBe(true); + const entries = (await fs.readdir(baseDir)).sort(); + expect(entries).toContain(path.basename(aliceDir)); + expect(entries).toContain(path.basename(bobDir)); + }); + + it("caches per account: repeated + concurrent gets return the same core", async () => { + const first = await registry.getCore("ai4all:alice"); + const again = await registry.getCore("ai4all:alice"); + expect(again).toBe(first); + + const [c1, c2] = await Promise.all([ + registry.getCore("ai4all:carol"), + registry.getCore("ai4all:carol"), + ]); + expect(c1).toBe(c2); + expect(registry.size).toBe(2); // alice + carol + }); + + it("requires a non-empty session_key", async () => { + await expect(registry.getCore("")).rejects.toThrow(); + expect(() => registry.resolveDataDir("")).toThrow(); + }); + + it("wipe removes the account core + its dataDir, idempotently", async () => { + const dir = registry.resolveDataDir("ai4all:erin"); + await registry.getCore("ai4all:erin"); + await fs.writeFile(path.join(dir, "marker.txt"), "x"); + expect((await fs.stat(dir)).isDirectory()).toBe(true); + + await registry.wipe("ai4all:erin"); + expect(registry.peek("ai4all:erin")).toBeUndefined(); + expect(registry.size).toBe(0); + await expect(fs.stat(dir)).rejects.toBeTruthy(); // dir gone + + // Wiping an already-wiped / never-seen account does not throw. + await expect(registry.wipe("ai4all:erin")).resolves.toBeTruthy(); + await expect(registry.wipe("ai4all:never")).resolves.toBeTruthy(); + }); + + it("peek does not create a core; evict tears one down and frees the slot", async () => { + expect(registry.peek("ai4all:dave")).toBeUndefined(); + const core = await registry.getCore("ai4all:dave"); + expect(registry.peek("ai4all:dave")).toBe(core); + + const dir = await registry.evict("ai4all:dave"); + expect(dir).toBe(registry.resolveDataDir("ai4all:dave")); + expect(registry.peek("ai4all:dave")).toBeUndefined(); + expect(registry.size).toBe(0); + // Evicting an absent account is a no-op. + expect(await registry.evict("ai4all:dave")).toBeUndefined(); + }); +}); diff --git a/src/gateway/core-registry.ts b/src/gateway/core-registry.ts new file mode 100644 index 00000000..40a66af3 --- /dev/null +++ b/src/gateway/core-registry.ts @@ -0,0 +1,406 @@ +/** + * CoreRegistry — routes Gateway requests to a per-account {@link TdaiCore}. + * + * This is the structural multi-tenant route from the design doc (§8.4): instead + * of one shared core + `session_key` filters on every query, each account gets + * its **own** `TdaiCore` rooted at a dedicated dataDir (`baseDir/{account}`). + * Isolation is then physical — distinct SQLite files, distinct L0/L1 tables, + * distinct `persona.md` / `scene_blocks/` — so L1/L2/L3 recall and search are + * isolated for free, without depending on "every SQL remembered its WHERE". + * + * Two modes: + * - **single-tenant (default)**: one shared core rooted at `baseDir`, + * `session_key` is ignored for routing. Behaviour is identical to the + * pre-multi-tenant Gateway. + * - **multi-tenant**: lazy `Map`; `session_key` is + * required and mapped to a collision-free, traversal-safe directory. + * + * The cross-core LLM extraction concurrency cap lives here too: one + * {@link AsyncSemaphore} is shared by every core so total background extraction + * (L1/L2/L3) across all accounts stays bounded (design §8.4 #5). + * + * **LRU eviction.** Resident cores are bounded by `maxResidentCores` (opt-in; + * `0`/unset = unlimited, legacy). When a `getCore` pushes the count over the + * limit, the least-recently-used *other* core is torn down — `TdaiCore.destroy` + * first **flushes** its pipeline queues (no buffered L1/L2/L3 work is lost), + * then releases its SQLite handle. Teardown runs fire-and-forget so it never + * adds drain latency to the triggering request, but is tracked per key in + * {@link closing} so a later `getCore`/`wipe` for an evicted account waits for + * the old handle to close before a fresh core re-opens the same dataDir. + */ + +import fs from "node:fs/promises"; +import path from "node:path"; +import { createHash } from "node:crypto"; +import { TdaiCore } from "../core/tdai-core.js"; +import { StandaloneHostAdapter } from "../adapters/standalone/host-adapter.js"; +import { SessionFilter } from "../utils/session-filter.js"; +import { AsyncSemaphore } from "../utils/async-semaphore.js"; +import type { StandaloneLLMConfig } from "../adapters/standalone/llm-runner.js"; +import type { MemoryTdaiConfig } from "../config.js"; +import type { Logger } from "../core/types.js"; + +/** Map key used for the single shared core when multi-tenant is off. */ +const SINGLE_TENANT_KEY = "__single__"; + +/** + * Default cap on concurrent background extraction runs (L1/L2/L3) across all + * resident cores, applied in multi-tenant mode when no explicit limit is + * configured. Without a cap, `N` active accounts fan out to up to `~3N` + * simultaneous LLM extraction calls (design §8.4 #5). Single-tenant mode keeps + * its legacy unbounded behaviour (one core can't fan out). + */ +const DEFAULT_MULTI_TENANT_EXTRACTION_CAP = 4; + +/** + * Default cap on resident per-account cores in multi-tenant mode when none is + * configured. `0` means **unlimited** — chosen as the default so existing + * deployments keep every warm core (no surprise evictions / re-init latency). + * Operators serving many accounts set `data.maxResidentCores` to bound memory. + */ +const DEFAULT_MAX_RESIDENT_CORES = 0; + +export interface CoreRegistryOptions { + /** Root data directory. Single-tenant: the core's dataDir. Multi-tenant: parent of per-account dirs. */ + baseDir: string; + /** LLM config passed to every core's host adapter. */ + llmConfig: StandaloneLLMConfig; + /** Parsed memory config shared by every core. */ + memory: MemoryTdaiConfig; + /** Logger shared by every core. */ + logger: Logger; + /** When true, route by `session_key` to per-account cores. */ + multiTenant: boolean; + /** Agents excluded from capture (forwarded to each core's SessionFilter). */ + excludeAgents?: string[]; + /** + * Max concurrent background extraction runs (L1/L2/L3) across ALL cores. + * + * - `> 0` — hard cap shared by every core. + * - `0` — unbounded (the single-tenant default — one core can't fan out). + * - `undefined` — multi-tenant falls back to + * {@link DEFAULT_MULTI_TENANT_EXTRACTION_CAP}; single-tenant stays unbounded. + */ + maxConcurrentExtractions?: number; + /** + * Max number of resident per-account cores kept warm at once (multi-tenant + * LRU eviction). When a `getCore` would exceed this, the least-recently-used + * *other* core is flushed + torn down. + * + * - `> 0` — keep at most this many cores resident. + * - `0` / `undefined` — unlimited (legacy: every account stays warm forever). + * + * Must exceed the peak number of *concurrently active* accounts, or a core + * could be evicted mid-request; it bounds idle warm cores, not live ones. + * Ignored in single-tenant mode (there is only ever one core). + */ + maxResidentCores?: number; +} + +interface CoreEntry { + core: TdaiCore; + dataDir: string; + /** Resolves once the core has finished `initialize()`. */ + ready: Promise; + /** Last time this core served a request (epoch ms) — for future LRU eviction. */ + lastUsedMs: number; +} + +/** + * Derive a filesystem-safe, collision-free directory name from a `session_key`. + * + * `session_key` is operator/business-supplied (`"ai4all:{account_id}"`), so it + * must never be trusted as a path segment. We combine: + * - a human-readable **slug** (ascii `[A-Za-z0-9._-]`, leading dots removed, + * length-capped) for debuggability, and + * - a **sha256 prefix** of the *original* key for injectivity. + * + * The hash is what guarantees isolation: two distinct keys that sanitise to the + * same slug (e.g. `a/b` and `a_b`) still get different directories, so they can + * never share a store. Path-traversal (`/`, `\`, `..`, leading `.`) is stripped + * from the slug, and the hash suffix means the result is never `.`/`..`/hidden. + */ +export function safeAccountDir(sessionKey: string): string { + const key = (sessionKey ?? "").trim(); + if (!key) throw new Error("session_key must be a non-empty string"); + + const hash = createHash("sha256").update(key).digest("hex").slice(0, 16); + let slug = key + .replace(/[^A-Za-z0-9._-]/g, "_") // drop path separators & non-ascii + .replace(/^\.+/, "_") // never a hidden/`.`/`..` dir + .slice(0, 48); + if (!slug) slug = "acct"; + + return `${slug}.${hash}`; +} + +export class CoreRegistry { + private readonly opts: CoreRegistryOptions; + private readonly cores = new Map(); + /** + * One limiter shared by every core created here, so background extraction + * (L1/L2/L3) is globally capped instead of fanning out per account. + */ + private readonly extractionLimiter: AsyncSemaphore; + /** Max resident cores (0 = unlimited). Resolved once from options. */ + private readonly maxResidentCores: number; + /** + * In-flight teardowns keyed by account, so a re-request (or wipe) for an + * evicted account waits for its old core to finish closing — releasing the + * SQLite handle — before a fresh core opens on the same dataDir. + */ + private readonly closing = new Map>(); + + constructor(opts: CoreRegistryOptions) { + this.opts = opts; + this.extractionLimiter = new AsyncSemaphore(this.resolveExtractionCap()); + this.maxResidentCores = this.resolveMaxResidentCores(); + } + + /** + * Resolve the effective extraction concurrency cap. An explicit value wins; + * otherwise multi-tenant gets a safe default and single-tenant stays + * unbounded (a lone core is already serialized by its own SerialQueues). + */ + private resolveExtractionCap(): number { + const explicit = this.opts.maxConcurrentExtractions; + if (explicit !== undefined && Number.isFinite(explicit)) { + return explicit > 0 ? Math.floor(explicit) : 0; + } + return this.opts.multiTenant ? DEFAULT_MULTI_TENANT_EXTRACTION_CAP : 0; + } + + /** + * Resolve the resident-core cap. Explicit positive value wins; anything else + * (0, undefined, negative, non-finite) means unlimited. Single-tenant always + * resolves to unlimited — there is only one core, so eviction is moot. + */ + private resolveMaxResidentCores(): number { + if (!this.opts.multiTenant) return 0; + const explicit = this.opts.maxResidentCores; + if (explicit !== undefined && Number.isFinite(explicit) && explicit > 0) { + return Math.floor(explicit); + } + return DEFAULT_MAX_RESIDENT_CORES; + } + + /** Live resident-core stats (for health/metrics). `limit` 0 = unlimited. */ + residentStats(): { count: number; limit: number } { + return { count: this.cores.size, limit: this.maxResidentCores }; + } + + /** Live extraction-limiter stats (for health/metrics). */ + extractionStats(): { limit: number; active: number; waiting: number } { + return { + limit: this.extractionLimiter.capacity, + active: this.extractionLimiter.active, + waiting: this.extractionLimiter.waiting, + }; + } + + /** Whether this registry routes per-account. */ + get multiTenant(): boolean { + return this.opts.multiTenant; + } + + /** Number of currently-instantiated cores (for health/metrics/LRU). */ + get size(): number { + return this.cores.size; + } + + /** + * Resolve the dataDir a `session_key` maps to — **pure**, no side effects. + * Useful for the wipe API and tests. In single-tenant mode the argument is + * ignored and `baseDir` is returned. + */ + resolveDataDir(sessionKey: string): string { + return this.resolve(sessionKey).dataDir; + } + + /** + * Get (lazily creating + initializing) the core for a `session_key`. + * + * Concurrent calls for the same key share a single `initialize()` — the entry + * is inserted synchronously before the await, so a second caller racing in + * finds it and awaits the same `ready` promise. + */ + async getCore(sessionKey: string): Promise { + const { key, dataDir } = this.resolve(sessionKey); + + let entry = this.cores.get(key); + if (!entry) { + // If this account is mid-eviction, gate the new core's `initialize()` + // (which opens SQLite) behind the old core's teardown so the same dataDir + // is never opened twice. The entry is still inserted *synchronously* below + // — the wait lives inside `ready`, not before the map write — so racing + // callers find this entry instead of creating a duplicate core. + const prevClosing = this.closing.get(key); + const core = this.createCore(dataDir); + const ready = prevClosing + ? prevClosing.then(() => core.initialize()) + : core.initialize(); + entry = { core, dataDir, ready, lastUsedMs: Date.now() }; + this.cores.set(key, entry); + this.opts.logger.debug?.( + `[tdai-gateway] [registry] Core created for ${this.multiTenant ? key : "single-tenant"} (dataDir=${dataDir}, active=${this.cores.size})`, + ); + this.evictLruIfNeeded(key); + } + entry.lastUsedMs = Date.now(); + await entry.ready; + return entry.core; + } + + /** + * Return an already-instantiated core without creating one. Used by the + * health probe (single-tenant) so it doesn't spin up a core as a side effect. + */ + peek(sessionKey: string): TdaiCore | undefined { + return this.cores.get(this.resolve(sessionKey).key)?.core; + } + + /** + * Destroy and forget the core for a `session_key` (no-op if not loaded). + * Returns the dataDir it was bound to, so callers (e.g. the wipe API) can + * delete it on disk afterwards. Idempotent. + */ + async evict(sessionKey: string): Promise { + const { key } = this.resolve(sessionKey); + const entry = this.cores.get(key); + if (!entry) { + // Not resident — but it may be mid-eviction from the LRU path. Await that + // teardown so callers (e.g. wipe → rm) see the SQLite handle released. + await this.closing.get(key)?.catch(() => {}); + return undefined; + } + this.cores.delete(key); + await this.beginTeardown(key, entry); + return entry.dataDir; + } + + /** + * Hard-delete an account: tear down its core (closing the SQLite handle) and + * remove its dataDir from disk. This is the structural "namespace wipe" that + * backs the host's account hard-delete (design §8.4 wipe/unbind). + * + * - **Multi-tenant only**: in single-tenant mode there is no per-account + * dataDir to delete, so this throws rather than risk wiping the shared store. + * - **Idempotent**: wiping an account with no resident core (or whose dir was + * already deleted) still succeeds. + * - **Bounded**: refuses to delete anything that is not strictly inside + * `baseDir`, as a defence-in-depth backstop on top of {@link safeAccountDir}. + * + * The core is destroyed *before* the `rm` so the store releases its file + * handle and {@link TdaiCore.destroy} clears the per-dataDir store cache, + * letting a later `getCore` for the same key rebuild a fresh empty store. + */ + async wipe(sessionKey: string): Promise { + if (!this.opts.multiTenant) { + throw new Error("namespace wipe requires multi-tenant mode"); + } + const dataDir = this.resolveDataDir(sessionKey); // throws on empty key + + const root = path.resolve(this.opts.baseDir); + const target = path.resolve(dataDir); + if (target === root || !target.startsWith(root + path.sep)) { + throw new Error(`refusing to wipe path outside baseDir: ${target}`); + } + + await this.evict(sessionKey); // close core + free slot (no-op if absent) + await fs.rm(target, { recursive: true, force: true }); + this.opts.logger.debug?.(`[tdai-gateway] [registry] Wiped account dataDir ${target}`); + return target; + } + + /** Destroy every loaded core. Call on Gateway shutdown. */ + async destroyAll(): Promise { + const entries = [...this.cores.values()]; + this.cores.clear(); + // Include teardowns already in flight from LRU eviction so shutdown waits + // for every core (resident + closing) to release its SQLite handle. + const inFlight = [...this.closing.values()]; + await Promise.allSettled([ + ...inFlight.map((p) => p.catch(() => {})), + ...entries.map(async (e) => { + await e.ready.catch(() => {}); + await e.core.destroy(); + }), + ]); + } + + // ── internals ─────────────────────────────────────────────────────────── + + /** + * Evict least-recently-used cores until the resident count is back within + * {@link maxResidentCores}. `protectKey` (the core just served) is never a + * victim, so the caller's own request always survives. No-op when unlimited. + */ + private evictLruIfNeeded(protectKey: string): void { + if (this.maxResidentCores <= 0) return; // unlimited + while (this.cores.size > this.maxResidentCores) { + let lruKey: string | undefined; + let lru: CoreEntry | undefined; + for (const [k, e] of this.cores) { + if (k === protectKey) continue; + if (!lru || e.lastUsedMs < lru.lastUsedMs) { + lruKey = k; + lru = e; + } + } + if (!lruKey || !lru) break; // only the protected core remains + this.cores.delete(lruKey); + void this.beginTeardown(lruKey, lru); + this.opts.logger.debug?.( + `[tdai-gateway] [registry] LRU-evicted ${lruKey} (active=${this.cores.size}, limit=${this.maxResidentCores})`, + ); + } + } + + /** + * Flush + destroy a core out of band, tracking the teardown in {@link closing} + * so re-creation / wipe of the same key waits for the SQLite handle to close. + * Caller must have already removed the entry from {@link cores}. + */ + private beginTeardown(key: string, entry: CoreEntry): Promise { + const done = (async () => { + await entry.ready.catch(() => {}); + try { + await entry.core.destroy(); // flushes pipeline queues, then closes store + } catch (err) { + this.opts.logger.warn?.( + `[tdai-gateway] [registry] Teardown of ${key} failed: ${String(err)}`, + ); + } + })(); + this.closing.set(key, done); + void done.finally(() => { + // Only clear if this exact teardown is still the tracked one (a newer + // getCore→evict cycle may have replaced it). + if (this.closing.get(key) === done) this.closing.delete(key); + }); + return done; + } + + private resolve(sessionKey: string): { key: string; dataDir: string } { + if (!this.opts.multiTenant) { + return { key: SINGLE_TENANT_KEY, dataDir: this.opts.baseDir }; + } + const dir = safeAccountDir(sessionKey); // throws on empty — enforced upstream + return { key: dir, dataDir: path.join(this.opts.baseDir, dir) }; + } + + private createCore(dataDir: string): TdaiCore { + const adapter = new StandaloneHostAdapter({ + dataDir, + llmConfig: this.opts.llmConfig, + logger: this.opts.logger, + platform: "gateway", + }); + return new TdaiCore({ + hostAdapter: adapter, + config: this.opts.memory, + sessionFilter: new SessionFilter(this.opts.excludeAgents ?? []), + extractionLimiter: this.extractionLimiter, + }); + } +} diff --git a/src/gateway/server.e2e.test.ts b/src/gateway/server.e2e.test.ts new file mode 100644 index 00000000..140a2b2a --- /dev/null +++ b/src/gateway/server.e2e.test.ts @@ -0,0 +1,179 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { TdaiGateway } from "./server.js"; +import type { GatewayConfig } from "./config.js"; + +/** + * HTTP-level end-to-end tests for the Gateway + CoreRegistry multi-tenant + * routing. These boot a real HTTP server backed by per-account SQLite stores, + * so they live in the e2e suite (run via `vitest --config vitest.e2e.config.ts`) + * rather than the default unit run — booting ~a dozen stores in parallel with + * the fast unit files starves the background L0 indexer. No real LLM/embedding: + * extraction off + provider "none" (keyword/FTS path). + */ + +const baseOverrides = (baseDir: string, multiTenant: boolean): Partial => ({ + server: { port: 0, host: "127.0.0.1", apiKey: undefined, corsOrigins: [] }, + data: { baseDir, multiTenant }, +}); + +async function post(url: string, body: unknown): Promise<{ status: number; json: any }> { + const res = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + return { status: res.status, json: await res.json().catch(() => null) }; +} + +async function getJson(url: string): Promise<{ status: number; json: any }> { + const res = await fetch(url); + return { status: res.status, json: await res.json().catch(() => null) }; +} + +/** + * Poll conversation search until the owner's just-captured L0 is FTS-indexed. + * Capture indexes L0 in a fire-and-forget background task, so a search issued + * immediately after /capture can race ahead of indexing — poll the *positive* + * (owner) case so the subsequent cross-tenant negative assertion is meaningful. + */ +async function pollSearchTotal( + origin: string, + sessionKey: string, + query: string, + timeoutMs = 30_000, +): Promise { + const deadline = Date.now() + timeoutMs; + let total = 0; + while (Date.now() < deadline) { + const r = await post(`${origin}/search/conversations`, { session_key: sessionKey, query }); + total = r.json?.total ?? 0; + if (total > 0) return total; + await new Promise((res) => setTimeout(res, 100)); + } + return total; +} + +describe("TdaiGateway HTTP wiring", () => { + let baseDir: string; + let gateway: TdaiGateway; + let origin: string; + + afterEach(async () => { + await gateway?.stop(); + if (baseDir) await fs.rm(baseDir, { recursive: true, force: true }); + }); + + async function boot(multiTenant: boolean): Promise { + baseDir = await fs.mkdtemp(path.join(os.tmpdir(), "tdai-gw-")); + gateway = new TdaiGateway(baseOverrides(baseDir, multiTenant)); + await gateway.start(); + const addr = gateway.address(); + if (!addr) throw new Error("gateway did not bind"); + origin = `http://127.0.0.1:${addr.port}`; + } + + // One multi-tenant boot covers health/routing/capture/isolation/wipe so the + // suite tears down few in-process gateways (each full teardown drains a SQLite + // store + background indexer; many boots in one process starve each other). + it("multi-tenant: end-to-end routing, L0 + persona isolation, and wipe", async () => { + await boot(true); + + // Health: lazy, no cores yet. + const health0 = await getJson(`${origin}/health`); + expect(health0.json.multi_tenant).toBe(true); + expect(health0.json.active_cores).toBe(0); + + // Routed endpoints require session_key. + for (const ep of ["/recall", "/capture", "/search/memories", "/search/conversations"]) { + const { status } = await post(`${origin}${ep}`, { query: "q", user_content: "u", assistant_content: "a" }); + expect(status, `${ep} should 400 without session_key`).toBe(400); + } + + // Capture two accounts. This is the FIRST capture to each freshly-created + // account core, which is exactly the cold-start path: the gateway must stamp + // the turn's messages strictly after the cold-start L0 cursor floor, so BOTH + // the user and assistant message land. A regression here records 0 or 1 (the + // user message silently filtered by a floor sitting in the same millisecond) + // — assert the exact count, not just >0, to catch that. + const capA = await post(`${origin}/capture`, { + session_key: "ai4all:alice", + user_content: "alice fact pineapple", + assistant_content: "ok", + }); + expect(capA.status).toBe(200); + expect(capA.json.l0_recorded).toBe(2); + const capB = await post(`${origin}/capture`, { + session_key: "ai4all:bob", + user_content: "bob fact coffee", + assistant_content: "ok", + }); + expect(capB.status).toBe(200); + expect(capB.json.l0_recorded).toBe(2); + + // Two resident cores; two on-disk account dirs under baseDir. + expect((await getJson(`${origin}/health`)).json.active_cores).toBe(2); + const dirs = (await fs.readdir(baseDir)).filter((d) => !d.startsWith("seed-")); + expect(dirs).toHaveLength(2); + + // Both accounts' L0 indexes and is searchable by its owner. + expect(await pollSearchTotal(origin, "ai4all:alice", "pineapple")).toBeGreaterThan(0); + expect(await pollSearchTotal(origin, "ai4all:bob", "coffee")).toBeGreaterThan(0); + + // Isolation: neither account can see the other's L0. + const bobSeesAlice = await post(`${origin}/search/conversations`, { session_key: "ai4all:bob", query: "pineapple" }); + expect(bobSeesAlice.json.total).toBe(0); + expect(bobSeesAlice.json.results).not.toContain("pineapple"); + const aliceSeesBob = await post(`${origin}/search/conversations`, { session_key: "ai4all:alice", query: "coffee" }); + expect(aliceSeesBob.json.total).toBe(0); + + // ── L3 (persona) isolation via /recall (design §8.4 #5) ────────────────── + // Persona is a per-account file at `/persona.md`; recall reads it + // fresh and injects it into `context` (appendSystemContext, ). + // Seed a distinct persona per account directly on disk (no LLM needed) and + // assert each /recall sees ONLY its own — the structural per-account dataDir + // is what keeps the upper Markdown layers isolated, not a query filter. + const aliceDirEarly = (await fs.readdir(baseDir)).find((d) => d.startsWith("ai4all_alice"))!; + const bobDirEarly = (await fs.readdir(baseDir)).find((d) => d.startsWith("ai4all_bob"))!; + await fs.writeFile(path.join(baseDir, aliceDirEarly, "persona.md"), "Alice is a PINEAPPLE farmer."); + await fs.writeFile(path.join(baseDir, bobDirEarly, "persona.md"), "Bob is a COFFEE roaster."); + + const aliceRecall = await post(`${origin}/recall`, { session_key: "ai4all:alice", query: "hello" }); + expect(aliceRecall.status).toBe(200); + expect(aliceRecall.json.context).toContain(""); + expect(aliceRecall.json.context).toContain("PINEAPPLE farmer"); + expect(aliceRecall.json.context).not.toContain("COFFEE roaster"); // no leak from bob + + const bobRecall = await post(`${origin}/recall`, { session_key: "ai4all:bob", query: "hello" }); + expect(bobRecall.status).toBe(200); + expect(bobRecall.json.context).toContain("COFFEE roaster"); + expect(bobRecall.json.context).not.toContain("PINEAPPLE"); // no leak from alice + + // Wipe alice → alice dir gone, bob intact and still searchable. + const aliceDir = (await fs.readdir(baseDir)).find((d) => d.startsWith("ai4all_alice"))!; + const bobDir = (await fs.readdir(baseDir)).find((d) => d.startsWith("ai4all_bob"))!; + const wipe = await post(`${origin}/namespace/wipe`, { session_key: "ai4all:alice" }); + expect(wipe.status).toBe(200); + expect(wipe.json.wiped).toBe(true); + + const dirsAfter = await fs.readdir(baseDir); + expect(dirsAfter).not.toContain(aliceDir); + expect(dirsAfter).toContain(bobDir); + expect((await post(`${origin}/search/conversations`, { session_key: "ai4all:bob", query: "coffee" })).json.total).toBeGreaterThan(0); + expect((await post(`${origin}/search/conversations`, { session_key: "ai4all:alice", query: "pineapple" })).json.total).toBe(0); + }); + + it("single-tenant: health reports the shared store, and wipe is rejected", async () => { + await boot(false); + + const { status, json } = await getJson(`${origin}/health`); + expect(status).toBe(200); + expect(json.multi_tenant).toBe(false); + expect(json.active_cores).toBeUndefined(); + + const wipe = await post(`${origin}/namespace/wipe`, { session_key: "ai4all:alice" }); + expect(wipe.status).toBe(400); + }); +}); diff --git a/src/gateway/server.ts b/src/gateway/server.ts index 1da5592e..b647c2ca 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -15,14 +15,14 @@ */ import http from "node:http"; +import fs from "node:fs"; import { URL } from "node:url"; import { timingSafeEqual } from "node:crypto"; -import { TdaiCore } from "../core/tdai-core.js"; -import { StandaloneHostAdapter } from "../adapters/standalone/host-adapter.js"; +import { CoreRegistry } from "./core-registry.js"; +import type { TdaiCore } from "../core/tdai-core.js"; import { loadGatewayConfig } from "./config.js"; import type { GatewayConfig } from "./config.js"; import { initDataDirectories } from "../utils/pipeline-factory.js"; -import { SessionFilter } from "../utils/session-filter.js"; import type { HealthResponse, RecallRequest, @@ -35,6 +35,8 @@ import type { ConversationSearchResponse, SessionEndRequest, SessionEndResponse, + WipeRequest, + WipeResponse, SeedRequest, SeedResponse, GatewayErrorResponse, @@ -114,39 +116,61 @@ function safeEqual(a: string, b: string): boolean { export class TdaiGateway { private config: GatewayConfig; private logger: Logger; - private core: TdaiCore; + private registry: CoreRegistry; + private multiTenant: boolean; private server: http.Server | null = null; private startTime = Date.now(); constructor(configOverrides?: Partial) { this.config = loadGatewayConfig(configOverrides); this.logger = createConsoleLogger(); + this.multiTenant = this.config.data.multiTenant; - // Create host adapter - const adapter = new StandaloneHostAdapter({ - dataDir: this.config.data.baseDir, + // Route requests to a per-account TdaiCore (or one shared core in + // single-tenant mode). The registry owns core lifecycle + dataDir binding. + this.registry = new CoreRegistry({ + baseDir: this.config.data.baseDir, llmConfig: this.config.llm, + memory: this.config.memory, logger: this.logger, - platform: "gateway", + multiTenant: this.multiTenant, + excludeAgents: this.config.memory.capture.excludeAgents, + maxConcurrentExtractions: this.config.data.maxConcurrentExtractions, + maxResidentCores: this.config.data.maxResidentCores, }); + } - // Create core - this.core = new TdaiCore({ - hostAdapter: adapter, - config: this.config.memory, - sessionFilter: new SessionFilter(this.config.memory.capture.excludeAgents), - }); + /** + * Resolve the core for a request, enforcing the multi-tenant `session_key` + * contract. Returns `null` (after writing a 400) when multi-tenant mode is on + * but the caller omitted `session_key`, so handlers must short-circuit. + */ + private async coreFor( + sessionKey: string | undefined, + res: http.ServerResponse, + ): Promise { + if (this.multiTenant && !sessionKey) { + sendError(res, 400, "Missing required field in multi-tenant mode: session_key"); + return null; + } + return this.registry.getCore(sessionKey ?? ""); } /** * Start the Gateway HTTP server. */ async start(): Promise { - // Initialize data directories - initDataDirectories(this.config.data.baseDir); - - // Initialize core - await this.core.initialize(); + if (this.multiTenant) { + // baseDir is only the *parent* of per-account dataDirs; each account core + // builds its own subdir layout lazily. Just ensure the parent exists. + fs.mkdirSync(this.config.data.baseDir, { recursive: true }); + } else { + // Single-tenant: baseDir IS the shared core's dataDir. Build the full + // layout and eagerly create + initialize the one shared core so the first + // request (and /health) sees a ready store, matching legacy startup. + initDataDirectories(this.config.data.baseDir); + await this.registry.getCore(""); + } // Create HTTP server this.server = http.createServer((req, res) => this.handleRequest(req, res)); @@ -212,6 +236,15 @@ export class TdaiGateway { } } + /** + * The actual bound address after {@link start}. Returns `null` before the + * server is listening. Primarily for tests / orchestration that bind port 0. + */ + address(): { host: string; port: number } | null { + const a = this.server?.address(); + return a && typeof a === "object" ? { host: a.address, port: a.port } : null; + } + /** * Gracefully stop the Gateway. */ @@ -224,7 +257,7 @@ export class TdaiGateway { }); } - await this.core.destroy(); + await this.registry.destroyAll(); this.logger.info("Gateway stopped"); } @@ -270,6 +303,8 @@ export class TdaiGateway { return await this.handleSearchConversations(req, res); case "POST /session/end": return await this.handleSessionEnd(req, res); + case "POST /namespace/wipe": + return await this.handleWipe(req, res); case "POST /seed": return await this.handleSeed(req, res); default: @@ -356,14 +391,34 @@ export class TdaiGateway { // ============================ private handleHealth(res: http.ServerResponse): void { + if (this.multiTenant) { + // No single shared store to probe — cores are per-account and lazy. + // Report liveness + how many accounts are currently resident. + const response: HealthResponse = { + status: "ok", + version: VERSION, + uptime: Math.floor((Date.now() - this.startTime) / 1000), + stores: { vectorStore: false, embeddingService: false }, + multi_tenant: true, + active_cores: this.registry.size, + extraction: this.registry.extractionStats(), + resident: this.registry.residentStats(), + }; + sendJson(res, 200, response); + return; + } + + // Single-tenant: probe the one shared core (created eagerly in start()). + const core = this.registry.peek(""); const response: HealthResponse = { - status: this.core.getVectorStore() ? "ok" : "degraded", + status: core?.getVectorStore() ? "ok" : "degraded", version: VERSION, uptime: Math.floor((Date.now() - this.startTime) / 1000), stores: { - vectorStore: !!this.core.getVectorStore(), - embeddingService: !!this.core.getEmbeddingService(), + vectorStore: !!core?.getVectorStore(), + embeddingService: !!core?.getEmbeddingService(), }, + multi_tenant: false, }; sendJson(res, 200, response); } @@ -376,14 +431,21 @@ export class TdaiGateway { return; } + const core = await this.coreFor(body.session_key, res); + if (!core) return; + const startMs = Date.now(); - const result = await this.core.handleBeforeRecall(body.query, body.session_key); + const result = await core.handleBeforeRecall(body.query, body.session_key); const elapsed = Date.now() - startMs; - this.logger.info(`Recall completed in ${elapsed}ms: context=${(result.appendSystemContext?.length ?? 0)} chars`); + this.logger.info( + `Recall completed in ${elapsed}ms: context=${(result.appendSystemContext?.length ?? 0)} chars, ` + + `prepend=${(result.prependContext?.length ?? 0)} chars`, + ); const response: RecallResponse = { context: result.appendSystemContext ?? "", + prepend_context: result.prependContext ?? "", strategy: result.recallStrategy, memory_count: result.recalledL1Memories?.length ?? 0, }; @@ -398,16 +460,34 @@ export class TdaiGateway { return; } + // Capture the turn-start timestamp BEFORE resolving the core. On the first + // capture to a brand-new account the core is created lazily here (open DB, + // warm store), which puts real wall-clock distance between startMs and the + // moment messages are stamped during extraction — so startMs is reliably + // earlier than any message timestamp, even for caller-supplied messages + // that lack one. const startMs = Date.now(); - const result = await this.core.handleTurnCommitted({ + + const core = await this.coreFor(body.session_key, res); + if (!core) return; + + // Stamp the synthesized messages explicitly (startMs+1/+2) and pass + // startedAt=startMs as the cold-start L0 cursor floor. The recorder keeps + // messages with timestamp strictly greater than the floor, so the floor + // MUST be below the turn's own messages. Previously no startedAt was passed, + // so the floor fell back to Date.now() inside TdaiCore — landing in the same + // millisecond as the messages and silently dropping the first turn's L0 rows + // on every freshly-created account (see cold-start capture regression test). + const result = await core.handleTurnCommitted({ userText: body.user_content, assistantText: body.assistant_content, messages: body.messages ?? [ - { role: "user", content: body.user_content }, - { role: "assistant", content: body.assistant_content }, + { role: "user", content: body.user_content, timestamp: startMs + 1 }, + { role: "assistant", content: body.assistant_content, timestamp: startMs + 2 }, ], sessionKey: body.session_key, sessionId: body.session_id, + startedAt: startMs, }); const elapsed = Date.now() - startMs; @@ -428,7 +508,10 @@ export class TdaiGateway { return; } - const result = await this.core.searchMemories({ + const core = await this.coreFor(body.session_key, res); + if (!core) return; + + const result = await core.searchMemories({ query: body.query, limit: body.limit, type: body.type, @@ -451,7 +534,10 @@ export class TdaiGateway { return; } - const result = await this.core.searchConversations({ + const core = await this.coreFor(body.session_key, res); + if (!core) return; + + const result = await core.searchConversations({ query: body.query, limit: body.limit, sessionKey: body.session_key, @@ -472,12 +558,37 @@ export class TdaiGateway { return; } - await this.core.handleSessionEnd(body.session_key); + // Only flush a session that already has a resident core; never spin one up + // just to tear its buffers down. Unknown/evicted sessions are a no-op. + const core = this.registry.peek(body.session_key); + if (core) await core.handleSessionEnd(body.session_key); const response: SessionEndResponse = { flushed: true }; sendJson(res, 200, response); } + private async handleWipe(req: http.IncomingMessage, res: http.ServerResponse): Promise { + const body = await parseJsonBody(req); + + if (!body.session_key) { + sendError(res, 400, "Missing required field: session_key"); + return; + } + + if (!this.multiTenant) { + // No per-account dataDir exists in single-tenant mode; refuse rather than + // risk deleting the shared store out from under the process. + sendError(res, 400, "namespace wipe is only supported in multi-tenant mode"); + return; + } + + const dataDir = await this.registry.wipe(body.session_key); + this.logger.info(`Wiped account namespace for ${body.session_key} (${dataDir})`); + + const response: WipeResponse = { wiped: true }; + sendJson(res, 200, response); + } + private async handleSeed(req: http.IncomingMessage, res: http.ServerResponse): Promise { const body = await parseJsonBody(req); diff --git a/src/gateway/types.ts b/src/gateway/types.ts index 50b2ff4c..bdd4b282 100644 --- a/src/gateway/types.ts +++ b/src/gateway/types.ts @@ -23,6 +23,25 @@ export interface HealthResponse { vectorStore: boolean; embeddingService: boolean; }; + /** Whether the Gateway is routing per-account (structural multi-tenant). */ + multi_tenant?: boolean; + /** + * Multi-tenant only: number of per-account cores currently resident in + * memory. Omitted in single-tenant mode (there is exactly one shared core). + */ + active_cores?: number; + /** + * Multi-tenant only: live state of the shared background-extraction limiter + * that caps concurrent L1/L2/L3 runs across all cores (design §8.4 #5). + * `limit` is the configured cap (0 = unbounded), `active` the permits in use, + * `waiting` the runs currently blocked on a permit. + */ + extraction?: { limit: number; active: number; waiting: number }; + /** + * Multi-tenant only: resident-core LRU state. `count` is how many per-account + * cores are warm right now, `limit` the configured cap (0 = unlimited). + */ + resident?: { count: number; limit: number }; } // ============================ @@ -36,7 +55,20 @@ export interface RecallRequest { } export interface RecallResponse { + /** + * Stable recall context for the system prompt end (persona, scene nav, + * tools guide). Mirrors `RecallResult.appendSystemContext`. + */ context: string; + /** + * Query-time L1 relevant memories, meant to be prepended to the user prompt + * (dynamic, per-turn). Mirrors `RecallResult.prependContext`. Empty string + * when no L1 memories were recalled this turn. + * + * Without this, callers get persona/scene but never the query-relevant + * memories, while `memory_count` still reports L1 hits — see design §5.3/§8.4#6. + */ + prepend_context: string; strategy?: string; memory_count?: number; } @@ -68,6 +100,13 @@ export interface MemorySearchRequest { limit?: number; type?: string; scene?: string; + /** + * Account/session to scope the search to. **Required in multi-tenant mode** + * (the Gateway returns 400 without it); ignored in single-tenant mode. In + * the structural multi-tenant route this routes to the account's own core, so + * L1 isolation is physical — see design §8.4 #3. + */ + session_key?: string; } export interface MemorySearchResponse { @@ -104,6 +143,24 @@ export interface SessionEndResponse { flushed: boolean; } +// ============================ +// /namespace/wipe +// ============================ + +/** + * Request body for `POST /namespace/wipe` — account hard-delete. + * + * Multi-tenant only. Removes the account's core and its entire on-disk + * dataDir (L0/L1/L2/L3). Backs the host's `unbind_and_wipe_account()`. + */ +export interface WipeRequest { + session_key: string; +} + +export interface WipeResponse { + wiped: boolean; +} + // ============================ // /seed // ============================ From 0341d7af35a4881a39197cfba6386de413ce571b Mon Sep 17 00:00:00 2001 From: Jack <278171810@qq.com> Date: Sat, 27 Jun 2026 13:05:54 +0800 Subject: [PATCH 07/20] feat(gateway): add dev-console inspector and psydt import script Add a local dev-console (scripts/dev-console) that proxies the gateway and renders the per-account memory pyramid for manual recall/search testing, plus scripts/import-psydt.ts to bulk-seed PsyDTCorpus accounts into the multi-tenant store. Wire both up as npm scripts (dev-console, import-psydt). Signed-off-by: Jack <278171810@qq.com> --- package.json | 2 + scripts/dev-console/README.md | 95 +++++++ scripts/dev-console/inspector.ts | 383 ++++++++++++++++++++++++++ scripts/dev-console/public/index.html | 349 +++++++++++++++++++++++ scripts/dev-console/server.ts | 183 ++++++++++++ scripts/import-psydt.ts | 209 ++++++++++++++ 6 files changed, 1221 insertions(+) create mode 100644 scripts/dev-console/README.md create mode 100644 scripts/dev-console/inspector.ts create mode 100644 scripts/dev-console/public/index.html create mode 100644 scripts/dev-console/server.ts create mode 100644 scripts/import-psydt.ts diff --git a/package.json b/package.json index 09abe4d8..0e0d9806 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,8 @@ "export-tencent-vdb": "node ./bin/export-tencent-vdb.mjs", "build:read-local-memory": "tsc --project scripts/read-local-memory/tsconfig.json", "read-local-memory": "node ./bin/read-local-memory.mjs", + "dev-console": "node --env-file-if-exists=.env --import tsx scripts/dev-console/server.ts", + "import-psydt": "node --env-file-if-exists=.env --import tsx scripts/import-psydt.ts", "test": "vitest run", "test:watch": "vitest", "test:coverage": "vitest run --coverage", diff --git a/scripts/dev-console/README.md b/scripts/dev-console/README.md new file mode 100644 index 00000000..458b0ec8 --- /dev/null +++ b/scripts/dev-console/README.md @@ -0,0 +1,95 @@ +# TDAI dev-console + +A **build-free local console** for exercising the multi-tenant Gateway end to end +and watching the memory pyramid (L0→L1→L2→L3) build per account. Dev tool only — +not bundled, not published, no auth of its own. **Bind to localhost.** + +``` +browser (single-page UI, same-origin) + │ + ▼ +dev-console server (:8421) ──► TDAI Gateway (:8420, the 8 HTTP routes) + │ + └─► reads each account's dataDir read-only ──► the pyramid view +``` + +- **Proxies** the Gateway: the browser only talks to the console (same origin), + so the Gateway needs **no CORS** config and its API key never reaches the + client — the console injects `Authorization: Bearer` server-side. +- **Inspects** the on-disk pyramid the HTTP API can't fully show (L2 scenes, + L1 atoms, persona/raw text), via a read-only SQLite handle + file reads. +- **Zero duplicate config**: it loads the *same* `loadGatewayConfig()` the + Gateway uses, so baseDir / multiTenant / apiKey / port automatically agree. + +## Run + +**1. Start the Gateway.** Multi-tenant, and — to see L1→L3 actually grow — with a +real LLM: + +```bash +# connectivity only (no LLM): L0 + routing/isolation work; L1+ stay empty +TDAI_MULTI_TENANT=true \ +TDAI_DATA_DIR=/tmp/tdai-demo \ +node --import tsx src/gateway/server.ts + +# full effect (real LLM): the pyramid grows L1 → L2 → L3 +TDAI_MULTI_TENANT=true \ +TDAI_DATA_DIR=/tmp/tdai-demo \ +TDAI_LLM_API_KEY=sk-... \ +TDAI_LLM_BASE_URL=https://api.openai.com/v1 \ +TDAI_LLM_MODEL=gpt-4o \ +node --import tsx src/gateway/server.ts +``` + +**2. Start the console** (separate terminal, same env so it resolves the same +baseDir / port / apiKey): + +```bash +TDAI_MULTI_TENANT=true TDAI_DATA_DIR=/tmp/tdai-demo npm run dev-console +``` + +**3. Open** http://127.0.0.1:8421 + +## Env + +| var | default | purpose | +|---|---|---| +| `TDAI_MULTI_TENANT` | `false` | must match the Gateway | +| `TDAI_DATA_DIR` | gateway default | base data dir; must match the Gateway | +| `TDAI_GATEWAY_API_KEY` | unset | injected as Bearer if the Gateway requires it | +| `GATEWAY_URL` | `http://:` from config | override if the Gateway is elsewhere | +| `DEV_CONSOLE_PORT` | `8421` | console listen port | +| `DEV_CONSOLE_HOST` | `127.0.0.1` | console bind host | + +`TDAI_GATEWAY_PORT` / `TDAI_GATEWAY_HOST` (read by `loadGatewayConfig`) set both +the Gateway's bind and the console's default proxy target. + +## Walkthrough + +**Isolation (no LLM needed)** +1. With `ai4all:alice` in the session box, **Send turn** → the L0 bar ticks up. +2. Type `ai4all:bob`, **Inspect** → all bars 0. Alice's data is invisible to Bob. +3. **Search** (L0 conversations) for a word from Alice's turn: hits under + `ai4all:alice`, `total=0` under `ai4all:bob`. +4. **Ops → /health**: `active_cores`, `resident{count,limit}`, + `extraction{limit,active,waiting}`. +5. **Ops → /namespace/wipe** on Alice → her dataDir is gone; Bob untouched. + +**Overall effect (real LLM)** +6. Feed `ai4all:alice` a few informative turns. Toggle **auto 3s**. +7. Watch the pyramid fill: L0 immediately → L1 atoms in seconds → L2 scenes → + L3 persona. +8. **Recall** → `context` carries Alice's `` (highlighted) and + never Bob's. + +## Files + +| file | role | +|---|---| +| `server.ts` | console HTTP server — serves the UI, proxies `/api/gw/*`, `GET /api/inspect` | +| `inspector.ts` | read-only disk readers for L0/L1/L2/L3 (sqlite `query_only` + fs) | +| `public/index.html` | single-page vanilla UI (no build step) | + +Reuses `safeAccountDir` (`src/gateway/core-registry.ts`), `loadGatewayConfig` +(`src/gateway/config.ts`), and the read-only SQLite pattern from +`scripts/read-local-memory/`. diff --git a/scripts/dev-console/inspector.ts b/scripts/dev-console/inspector.ts new file mode 100644 index 00000000..c43a4755 --- /dev/null +++ b/scripts/dev-console/inspector.ts @@ -0,0 +1,383 @@ +/** + * dev-console / inspector — read-only disk readers for the memory pyramid. + * + * Reads a single account's on-disk dataDir and reports the L0/L1/L2/L3 layers + * so the console UI can visualise the pyramid building up after each turn. + * + * This NEVER writes. The SQLite handle is opened with `PRAGMA query_only = ON` + * (the proven pattern from `scripts/read-local-memory/read-local-memory.ts`), + * so it is safe to read `vectors.db` while the Gateway holds it open for writes + * — SQLite allows concurrent readers. Every layer is wrapped in try/catch and + * degrades to empty so a brand-new account (missing db / files) reads as zeros + * rather than erroring. + * + * Layout (verified against source), all relative to the account dataDir: + * L0 conversations/YYYY-MM-DD.jsonl + vectors.db table `l0_conversations` + * L1 vectors.db table `l1_records` + * L2 scene_blocks/*.md + .metadata/scene_index.json + * L3 persona.md + */ + +import { createRequire } from "node:module"; +import type { DatabaseSync } from "node:sqlite"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { safeAccountDir } from "../../src/gateway/core-registry.js"; + +const require = createRequire(import.meta.url); +function requireNodeSqlite(): typeof import("node:sqlite") { + return require("node:sqlite") as typeof import("node:sqlite"); +} + +const SQLITE_DB_NAME = "vectors.db"; +const SCENE_DIR = "scene_blocks"; +const SCENE_INDEX_REL = path.join(".metadata", "scene_index.json"); +const PERSONA_FILE = "persona.md"; +const CONVERSATIONS_DIR = "conversations"; + +const RECENT_LIMIT = 20; + +export interface L1Atom { + content: string; + type: string; + priority: number; + scene_name: string; + updated_time: string; +} + +export interface SceneMeta { + filename: string; + summary: string; + heat: number; + updated: string; +} + +export interface L0Turn { + role: string; + content: string; + recorded_at: string; +} + +export interface AccountSummary { + /** The true session_key (recovered from on-disk data — safeAccountDir is lossy). */ + sessionKey: string; + /** Friendly label: the part after the last ":" (e.g. "User2993"), else the full key. */ + display: string; + /** Namespace prefix before the first ":" (e.g. "psydt"), or "" when unprefixed. */ + namespace: string; + /** On-disk directory name under baseDir. */ + dir: string; + counts: { l0: number; l1: number; l2: number; l3: number }; +} + +export interface InspectResult { + sessionKey: string; + accountDir: string; + /** Whether the account's dataDir exists on disk at all. */ + exists: boolean; + counts: { l0: number; l1: number; l2: number; l3: number }; + l1ByType: { type: string; count: number }[]; + l1Recent: L1Atom[]; + scenes: SceneMeta[]; + persona: { text: string; chars: number } | null; + l0Recent: L0Turn[]; + /** Non-fatal problems encountered while reading (shown as hints in the UI). */ + notes: string[]; +} + +/** + * Map a session_key to its on-disk dataDir. Single-tenant: the shared baseDir. + * Multi-tenant: `baseDir/{safeAccountDir(key)}` — the SAME function the Gateway + * uses, so the console always points at the exact directory the Gateway writes. + */ +export function resolveAccountDir( + sessionKey: string, + baseDir: string, + multiTenant: boolean, +): string { + if (!multiTenant) return baseDir; + return path.join(baseDir, safeAccountDir(sessionKey)); +} + +function openReadonly(dbPath: string): DatabaseSync | null { + if (!fs.existsSync(dbPath)) return null; + const { DatabaseSync: DbSync } = requireNodeSqlite(); + const db = new DbSync(dbPath, { open: false }); + db.open(); + db.exec("PRAGMA query_only = ON"); + return db; +} + +function num(v: unknown): number { + return typeof v === "bigint" ? Number(v) : typeof v === "number" ? v : 0; +} + +/** Read L0 + L1 from vectors.db. Returns zeros if the db is absent. */ +function readSqliteLayers( + dataDir: string, + notes: string[], +): Pick & { + l0Count: number; + l1Count: number; +} { + const empty = { l1ByType: [], l1Recent: [], l0Recent: [], l0Count: 0, l1Count: 0 }; + let db: DatabaseSync | null = null; + try { + db = openReadonly(path.join(dataDir, SQLITE_DB_NAME)); + if (!db) return empty; + + const l0Count = num((db.prepare("SELECT COUNT(*) AS c FROM l0_conversations").get() as any)?.c); + const l1Count = num((db.prepare("SELECT COUNT(*) AS c FROM l1_records").get() as any)?.c); + + const l1ByType = (db + .prepare("SELECT type, COUNT(*) AS c FROM l1_records GROUP BY type ORDER BY c DESC") + .all() as any[]).map((r) => ({ type: String(r.type ?? ""), count: num(r.c) })); + + const l1Recent = (db + .prepare( + "SELECT content, type, priority, scene_name, updated_time FROM l1_records " + + "ORDER BY updated_time DESC LIMIT ?", + ) + .all(RECENT_LIMIT) as any[]).map((r) => ({ + content: String(r.content ?? ""), + type: String(r.type ?? ""), + priority: num(r.priority), + scene_name: String(r.scene_name ?? ""), + updated_time: String(r.updated_time ?? ""), + })); + + const l0Recent = (db + .prepare( + "SELECT role, message_text, recorded_at FROM l0_conversations " + + "ORDER BY timestamp DESC LIMIT ?", + ) + .all(RECENT_LIMIT) as any[]).map((r) => ({ + role: String(r.role ?? ""), + content: String(r.message_text ?? ""), + recorded_at: String(r.recorded_at ?? ""), + })); + + return { l1ByType, l1Recent, l0Recent, l0Count, l1Count }; + } catch (err) { + notes.push(`sqlite read failed: ${String((err as Error)?.message ?? err)}`); + return empty; + } finally { + try { + db?.close(); + } catch { + /* ignore */ + } + } +} + +/** Count L0 turns from the newest JSONL shard — fallback when there is no db. */ +function countL0FromJsonl(dataDir: string): { count: number; recent: L0Turn[] } { + try { + const dir = path.join(dataDir, CONVERSATIONS_DIR); + const files = fs + .readdirSync(dir) + .filter((f) => f.endsWith(".jsonl")) + .sort(); + let count = 0; + let lastLines: string[] = []; + for (const f of files) { + const lines = fs + .readFileSync(path.join(dir, f), "utf8") + .split("\n") + .filter((l) => l.trim().length > 0); + count += lines.length; + lastLines = lines; + } + const recent: L0Turn[] = lastLines + .slice(-RECENT_LIMIT) + .reverse() + .map((l) => { + try { + const o = JSON.parse(l); + return { role: String(o.role ?? ""), content: String(o.content ?? ""), recorded_at: String(o.recordedAt ?? "") }; + } catch { + return { role: "", content: l.slice(0, 200), recorded_at: "" }; + } + }); + return { count, recent }; + } catch { + return { count: 0, recent: [] }; + } +} + +/** Read L2 scenes from the index (preferred) or by scanning scene_blocks/. */ +function readScenes(dataDir: string, notes: string[]): SceneMeta[] { + try { + const indexPath = path.join(dataDir, SCENE_INDEX_REL); + if (fs.existsSync(indexPath)) { + const raw = JSON.parse(fs.readFileSync(indexPath, "utf8")); + if (Array.isArray(raw)) { + return raw.map((e: any) => ({ + filename: String(e.filename ?? ""), + summary: String(e.summary ?? ""), + heat: num(e.heat), + updated: String(e.updated ?? ""), + })); + } + } + // No index yet — list the raw scene block files so the count still shows. + const blocksDir = path.join(dataDir, SCENE_DIR); + if (fs.existsSync(blocksDir)) { + return fs + .readdirSync(blocksDir) + .filter((f) => f.endsWith(".md")) + .map((f) => ({ filename: f, summary: "(no index entry)", heat: 0, updated: "" })); + } + } catch (err) { + notes.push(`scene read failed: ${String((err as Error)?.message ?? err)}`); + } + return []; +} + +function readPersona(dataDir: string, notes: string[]): { text: string; chars: number } | null { + try { + const p = path.join(dataDir, PERSONA_FILE); + if (!fs.existsSync(p)) return null; + const text = fs.readFileSync(p, "utf8"); + if (text.trim().length === 0) return null; + return { text, chars: text.length }; + } catch (err) { + notes.push(`persona read failed: ${String((err as Error)?.message ?? err)}`); + return null; + } +} + +/** + * Inspect one account's on-disk memory pyramid. Pure read-only; safe to call + * repeatedly (e.g. an auto-refresh loop) while the Gateway is live. + */ +export function inspectAccount( + sessionKey: string, + baseDir: string, + multiTenant: boolean, +): InspectResult { + const notes: string[] = []; + const accountDir = resolveAccountDir(sessionKey, baseDir, multiTenant); + const exists = fs.existsSync(accountDir); + + const sql = readSqliteLayers(accountDir, notes); + + // L0: prefer the sqlite index; fall back to JSONL when the db is missing. + let l0Count = sql.l0Count; + let l0Recent = sql.l0Recent; + if (l0Count === 0 && l0Recent.length === 0) { + const jsonl = countL0FromJsonl(accountDir); + l0Count = jsonl.count; + l0Recent = jsonl.recent; + } + + const scenes = readScenes(accountDir, notes); + const persona = readPersona(accountDir, notes); + + return { + sessionKey, + accountDir, + exists, + counts: { + l0: l0Count, + l1: sql.l1Count, + l2: scenes.length, + l3: persona ? 1 : 0, + }, + l1ByType: sql.l1ByType, + l1Recent: sql.l1Recent, + scenes, + persona, + l0Recent, + notes, + }; +} + +/** + * Recover the true session_key for an account dir. `safeAccountDir` is a lossy + * one-way hash (`psydt:User2993` → `psydt_User2993.`), so the dir name + * alone can't be turned back into a key. Both the SQLite `l0_conversations` + * table and the L0 JSONL store the original `session_key` per row — read one. + */ +function recoverSessionKey(accountDir: string): string | null { + // Prefer sqlite (single row, indexed). + let db: DatabaseSync | null = null; + try { + db = openReadonly(path.join(accountDir, SQLITE_DB_NAME)); + if (db) { + const row = db.prepare("SELECT session_key FROM l0_conversations LIMIT 1").get() as any; + const key = row?.session_key ? String(row.session_key) : ""; + if (key) return key; + } + } catch { + /* fall through to JSONL */ + } finally { + try { db?.close(); } catch { /* ignore */ } + } + + // Fallback: first JSONL line carries `sessionKey`. + try { + const dir = path.join(accountDir, CONVERSATIONS_DIR); + const files = fs.readdirSync(dir).filter((f) => f.endsWith(".jsonl")).sort(); + for (const f of files) { + const first = fs.readFileSync(path.join(dir, f), "utf8").split("\n").find((l) => l.trim()); + if (first) { + const key = String(JSON.parse(first).sessionKey ?? ""); + if (key) return key; + } + } + } catch { + /* no recoverable key */ + } + return null; +} + +/** + * List every account with on-disk memory under `baseDir`, recovering each one's + * true session_key so the console can offer a real-account picker (the dir name + * is a lossy hash and cannot be reversed). Single-tenant has no per-account + * dirs, so it returns an empty list (the UI falls back to the free-text key). + * + * `seed-*` snapshot dirs (written by the single-dir /seed pipeline) and hidden + * dirs are skipped. Read-only and cheap — safe to call on an auto-refresh. + */ +export function listAccounts(baseDir: string, multiTenant: boolean): AccountSummary[] { + if (!multiTenant) return []; + + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(baseDir, { withFileTypes: true }); + } catch { + return []; + } + + const out: AccountSummary[] = []; + const seen = new Set(); + for (const ent of entries) { + if (!ent.isDirectory()) continue; + if (ent.name.startsWith(".") || ent.name.startsWith("seed-")) continue; + + const accountDir = path.join(baseDir, ent.name); + const sessionKey = recoverSessionKey(accountDir); + if (!sessionKey || seen.has(sessionKey)) continue; + seen.add(sessionKey); + + const info = inspectAccount(sessionKey, baseDir, multiTenant); + const colon = sessionKey.indexOf(":"); + out.push({ + sessionKey, + display: colon >= 0 ? sessionKey.slice(colon + 1) : sessionKey, + namespace: colon >= 0 ? sessionKey.slice(0, colon) : "", + dir: ent.name, + counts: info.counts, + }); + } + + // Group by namespace, then by display — stable, readable ordering in the picker. + out.sort((a, b) => + a.namespace === b.namespace + ? a.display.localeCompare(b.display, undefined, { numeric: true }) + : a.namespace.localeCompare(b.namespace), + ); + return out; +} diff --git a/scripts/dev-console/public/index.html b/scripts/dev-console/public/index.html new file mode 100644 index 00000000..64d8ec7b --- /dev/null +++ b/scripts/dev-console/public/index.html @@ -0,0 +1,349 @@ + + + + + +TDAI dev-console + + + +
+

TDAI dev-console

+ mode … + + + + + + + + +
+ +
+ +
+
+

① Capture — feed a turn (/capture)

+ + + + +
+ + +
+

L0 records immediately. L1→L3 grow asynchronously and only when the Gateway has a real LLM — Inspect again after a few seconds.

+
+ +
+

② Recall — what gets injected (/recall)

+ + +
+ + +
+
+
+ +
+

③ Search

+
+ + +
+
+ + + +
+
+
+ +
+

④ Ops

+
+ + + +
+
+
+ /seed — paste seed JSON + +
+
+
+
+ + +
+
+

Memory pyramid

+

Press Inspect.

+
+
+
+ + + + diff --git a/scripts/dev-console/server.ts b/scripts/dev-console/server.ts new file mode 100644 index 00000000..d982fbf8 --- /dev/null +++ b/scripts/dev-console/server.ts @@ -0,0 +1,183 @@ +/** + * dev-console / server — a build-free local console for exercising the TDAI + * Gateway end to end and watching the memory pyramid build per account. + * + * browser (single-page UI, same-origin) + * │ + * ▼ + * this console server (default :8421) + * ├─ proxies /api/gw/* → real Gateway (injects Bearer server-side) + * └─ GET /api/inspect → reads account dataDir read-only (the pyramid) + * + * Proxying means the browser only ever talks to this server (same origin), so + * the Gateway needs NO CORS config and its API key never reaches the client. + * + * Config is loaded with the SAME `loadGatewayConfig()` the Gateway uses, so the + * console resolves the identical baseDir / multiTenant / apiKey / port — point + * both at the same env and they automatically agree. + * + * Run: npm run dev-console (or: node --import tsx scripts/dev-console/server.ts) + * Dev tool only — not bundled, not published, no auth of its own. Bind localhost. + */ + +import http from "node:http"; +import fs from "node:fs"; +import { fileURLToPath } from "node:url"; +import { getEnv } from "../../src/utils/env.js"; +import { loadGatewayConfig } from "../../src/gateway/config.js"; +import { inspectAccount, listAccounts } from "./inspector.js"; + +const INDEX_HTML = fileURLToPath(new URL("./public/index.html", import.meta.url)); + +// Gateway routes the console is allowed to proxy to (closed allow-list). +const GW_POST_ROUTES = new Set([ + "/recall", + "/capture", + "/search/memories", + "/search/conversations", + "/session/end", + "/namespace/wipe", + "/seed", +]); + +const cfg = loadGatewayConfig(); +const baseDir = cfg.data.baseDir; +const multiTenant = cfg.data.multiTenant; +const apiKey = cfg.server.apiKey; + +// Where the real Gateway lives. Default to the loopback form of the Gateway's +// own configured host:port; override with GATEWAY_URL for a remote/odd setup. +const gwHost = cfg.server.host === "0.0.0.0" ? "127.0.0.1" : cfg.server.host; +const gatewayUrl = (getEnv("GATEWAY_URL") ?? `http://${gwHost}:${cfg.server.port}`).replace(/\/$/, ""); + +const consolePort = Number(getEnv("DEV_CONSOLE_PORT") ?? "8421"); +const consoleHost = getEnv("DEV_CONSOLE_HOST") ?? "127.0.0.1"; + +function sendJson(res: http.ServerResponse, status: number, body: unknown): void { + const payload = JSON.stringify(body); + res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" }); + res.end(payload); +} + +async function readBody(req: http.IncomingMessage): Promise { + const chunks: Buffer[] = []; + for await (const chunk of req) chunks.push(chunk as Buffer); + return Buffer.concat(chunks).toString("utf8"); +} + +function authHeaders(): Record { + return apiKey ? { Authorization: `Bearer ${apiKey}` } : {}; +} + +/** Forward a console request to the Gateway, returning its status + raw text. */ +async function proxyToGateway( + method: "GET" | "POST", + gwPath: string, + body?: string, +): Promise<{ status: number; text: string; contentType: string }> { + try { + const res = await fetch(`${gatewayUrl}${gwPath}`, { + method, + headers: { + ...(method === "POST" ? { "Content-Type": "application/json" } : {}), + ...authHeaders(), + }, + body: method === "POST" ? (body ?? "{}") : undefined, + }); + const text = await res.text(); + return { status: res.status, text, contentType: res.headers.get("content-type") ?? "application/json" }; + } catch (err) { + return { + status: 502, + text: JSON.stringify({ + error: `Cannot reach Gateway at ${gatewayUrl}${gwPath}: ${String((err as Error)?.message ?? err)}`, + hint: "Is the Gateway running? Set GATEWAY_URL if it is elsewhere.", + }), + contentType: "application/json", + }; + } +} + +const server = http.createServer(async (req, res) => { + try { + const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`); + const pathname = url.pathname; + const method = req.method ?? "GET"; + + // ── UI ──────────────────────────────────────────────────────────────── + if (method === "GET" && (pathname === "/" || pathname === "/index.html")) { + const html = fs.readFileSync(INDEX_HTML, "utf8"); + res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); + res.end(html); + return; + } + + // ── console self-config (UI bootstraps mode/baseDir/gateway from this) ── + if (method === "GET" && pathname === "/api/config") { + sendJson(res, 200, { multiTenant, baseDir, gatewayUrl, hasApiKey: !!apiKey }); + return; + } + + // ── account picker (read-only disk) — real on-disk accounts + counts ──── + if (method === "GET" && pathname === "/api/accounts") { + try { + sendJson(res, 200, { accounts: listAccounts(baseDir, multiTenant), multiTenant }); + } catch (err) { + sendJson(res, 500, { error: String((err as Error)?.message ?? err) }); + } + return; + } + + // ── pyramid inspection (read-only disk) ──────────────────────────────── + if (method === "GET" && pathname === "/api/inspect") { + const sessionKey = url.searchParams.get("session_key") ?? ""; + if (multiTenant && !sessionKey.trim()) { + sendJson(res, 400, { error: "session_key is required in multi-tenant mode" }); + return; + } + try { + sendJson(res, 200, inspectAccount(sessionKey, baseDir, multiTenant)); + } catch (err) { + sendJson(res, 400, { error: String((err as Error)?.message ?? err) }); + } + return; + } + + // ── Gateway proxy ────────────────────────────────────────────────────── + if (pathname === "/api/gw/health" && method === "GET") { + const r = await proxyToGateway("GET", "/health"); + res.writeHead(r.status, { "Content-Type": r.contentType }); + res.end(r.text); + return; + } + + if (pathname.startsWith("/api/gw/") && method === "POST") { + const gwPath = pathname.slice("/api/gw".length); // → "/recall", "/search/memories", ... + if (!GW_POST_ROUTES.has(gwPath)) { + sendJson(res, 404, { error: `Unknown gateway route: ${gwPath}` }); + return; + } + const body = await readBody(req); + const r = await proxyToGateway("POST", gwPath, body); + res.writeHead(r.status, { "Content-Type": r.contentType }); + res.end(r.text); + return; + } + + sendJson(res, 404, { error: `Not found: ${method} ${pathname}` }); + } catch (err) { + sendJson(res, 500, { error: String((err as Error)?.message ?? err) }); + } +}); + +server.listen(consolePort, consoleHost, () => { + // eslint-disable-next-line no-console + console.log( + [ + `TDAI dev-console → http://${consoleHost}:${consolePort}`, + ` gateway : ${gatewayUrl}${apiKey ? " (Bearer auth on)" : ""}`, + ` baseDir : ${baseDir}`, + ` multiTenant: ${multiTenant}`, + ].join("\n"), + ); +}); diff --git a/scripts/import-psydt.ts b/scripts/import-psydt.ts new file mode 100644 index 00000000..da85eeba --- /dev/null +++ b/scripts/import-psydt.ts @@ -0,0 +1,209 @@ +/** + * import-psydt — bulk-import the PsyDTCorpus multi-turn counselling dialogues + * into the multi-tenant TDAI store, one account per conversation. + * + * Each corpus item is `{ id, normalizedTag, messages: [{role, content}] }`. + * We map: + * - account / username → `User{id}` (e.g. id 2993 → "User2993") + * - session_key → `psydt:User{id}` (namespaced, like `ai4all:alice`) + * - rounds → the user/assistant turns, paired; the `system` + * REBT-therapist prompt is dropped (it is a prompt, + * not the user's dialogue, and would pollute memory). + * + * Why a dedicated script and NOT the gateway `/seed` endpoint: + * `/seed` writes ALL sessions into a single `baseDir/seed-/` snapshot dir, + * so its output never lands in the per-account dirs that multi-tenant recall + * and the dev-console read. Here we call `executeSeed` once per account with + * `outputDir = baseDir/safeAccountDir(session_key)` — the EXACT directory the + * gateway's per-account core and the inspector resolve — so each imported user + * shows up as its own pyramid (L0→L1→L2→L3, with DashScope vectors). + * + * Config (LLM + embedding + baseDir) is loaded with the same `loadGatewayConfig` + * the gateway uses, so this imports against the identical DeepSeek + DashScope + * setup. Run it while the gateway is up — these are fresh keys, so no live core + * holds their dirs. + * + * Usage: + * node --env-file=.env --import tsx scripts/import-psydt.ts [--input ] + * [--limit N] [--only User2993[,User1977,...]] [--force] + * + * --limit N import only the first N conversations (cheap smoke test) + * --only ... import only the listed users (by "User" or bare id) + * --force re-import: delete an account's existing dir before seeding + * (default: skip accounts that already have L0 on disk) + */ + +import fs from "node:fs"; +import path from "node:path"; +import { loadGatewayConfig } from "../src/gateway/config.js"; +import { safeAccountDir } from "../src/gateway/core-registry.js"; +import { validateAndNormalizeRaw } from "../src/core/seed/input.js"; +import { executeSeed } from "../src/core/seed/seed-runtime.js"; +import type { Logger } from "../src/core/types.js"; + +const DEFAULT_INPUT = + "/Users/suchong/Data/PsyDTCorpus/PsyDTCorpus_train_mulit_turn_packing_longest20.json"; + +interface RawMsg { role: string; content: string } +interface CorpusItem { id: number | string; normalizedTag?: string; messages: RawMsg[] } + +// ── tiny arg parser ────────────────────────────────────────────────────── +function parseArgs(argv: string[]) { + const out: { input: string; limit?: number; only?: Set; force: boolean } = { + input: DEFAULT_INPUT, + force: false, + }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === "--input") out.input = argv[++i]!; + else if (a === "--limit") out.limit = Number(argv[++i]); + else if (a === "--force") out.force = true; + else if (a === "--only") { + out.only = new Set( + (argv[++i] ?? "") + .split(",") + .map((s) => s.trim()) + .filter(Boolean) + .map((s) => (s.startsWith("User") ? s : `User${s}`)), + ); + } + } + return out; +} + +// ── console logger (PipelineLogger = Logger) ───────────────────────────── +const logger: Logger = { + debug: () => {}, // seed is chatty at debug — mute + info: (m) => console.log(m), + warn: (m) => console.warn(m), + error: (m) => console.error(m), +}; + +/** + * Turn a flat corpus message list into seed "conversations" (a 2-D array of + * rounds). Drops `system`, drops empty content, and starts a new round at each + * `user` turn so each round is `[user, assistant]` (the corpus strictly + * alternates, but the boundary rule is robust to stray ordering). + */ +function toRounds(messages: RawMsg[]): RawMsg[][] { + const dialog = messages.filter( + (m) => (m.role === "user" || m.role === "assistant") && m.content && m.content.trim() !== "", + ); + const rounds: RawMsg[][] = []; + let cur: RawMsg[] = []; + for (const m of dialog) { + if (m.role === "user" && cur.length > 0) { + rounds.push(cur); + cur = []; + } + cur.push({ role: m.role, content: m.content }); + } + if (cur.length) rounds.push(cur); + return rounds; +} + +async function main(): Promise { + const args = parseArgs(process.argv.slice(2)); + const cfg = loadGatewayConfig(); + + if (!cfg.data.multiTenant) { + console.error("Refusing to import: gateway is in single-tenant mode (set TDAI_MULTI_TENANT=true)."); + process.exit(1); + } + if (!cfg.llm.apiKey) { + console.error("No LLM api key (TDAI_LLM_API_KEY) — L1/L2/L3 cannot build. Aborting."); + process.exit(1); + } + + // Mirror the gateway's /seed config assembly: memory config + injected llm. + const pluginConfig: Record = { + ...(cfg.memory as unknown as Record), + llm: { + enabled: true, + baseUrl: cfg.llm.baseUrl, + apiKey: cfg.llm.apiKey, + model: cfg.llm.model, + maxTokens: cfg.llm.maxTokens, + timeoutMs: cfg.llm.timeoutMs, + disableThinking: cfg.llm.disableThinking, + }, + }; + const embeddingOn = (cfg.memory as any)?.embedding?.enabled === true; + + const raw = JSON.parse(fs.readFileSync(args.input, "utf8")) as CorpusItem[]; + let items = Array.isArray(raw) ? raw : []; + if (args.only) items = items.filter((it) => args.only!.has(`User${it.id}`)); + if (args.limit != null) items = items.slice(0, args.limit); + + console.log( + `\n=== PsyDT import ===\n` + + ` input : ${args.input}\n` + + ` baseDir : ${cfg.data.baseDir}\n` + + ` llm : ${cfg.llm.model} @ ${cfg.llm.baseUrl}\n` + + ` embedding : ${embeddingOn ? "ON (vectors)" : "OFF (keyword only)"}\n` + + ` importing : ${items.length} conversation(s)\n` + + ` force : ${args.force}\n`, + ); + + let ok = 0; + let skipped = 0; + let failed = 0; + for (let i = 0; i < items.length; i++) { + const item = items[i]!; + const username = `User${item.id}`; + const sessionKey = `psydt:${username}`; + const outputDir = path.join(cfg.data.baseDir, safeAccountDir(sessionKey)); + const rounds = toRounds(item.messages); + const tag = item.normalizedTag ?? ""; + const header = `[${i + 1}/${items.length}] ${username} (${tag}, ${rounds.length} rounds)`; + + if (rounds.length === 0) { + console.warn(`${header} — no dialogue rounds, skipping`); + skipped++; + continue; + } + + // Idempotency: skip an account that already has L0 unless --force. + const dbPath = path.join(outputDir, "vectors.db"); + if (fs.existsSync(dbPath) && !args.force) { + console.log(`${header} — already imported (dir exists), skipping. Use --force to re-import.`); + skipped++; + continue; + } + if (args.force && fs.existsSync(outputDir)) { + fs.rmSync(outputDir, { recursive: true, force: true }); + } + + const input = validateAndNormalizeRaw([{ sessionKey, conversations: rounds }], { + sessionKey, + autoFillTimestamps: true, + }); + + console.log(`\n${header} → ${outputDir}`); + try { + const t0 = Date.now(); + const summary = await executeSeed(input, { + outputDir, + openclawConfig: {}, + pluginConfig, + logger, + }); + console.log( + `${header} ✓ l0=${summary.l0RecordedCount} rounds=${summary.roundsProcessed} ` + + `in ${((Date.now() - t0) / 1000).toFixed(1)}s`, + ); + ok++; + } catch (err) { + console.error(`${header} ✗ ${err instanceof Error ? err.message : String(err)}`); + failed++; + } + } + + console.log(`\n=== done: ${ok} imported, ${skipped} skipped, ${failed} failed ===`); + process.exit(failed > 0 ? 1 : 0); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); From c801c34b6e08c96040a8049f13eb81a20558031b Mon Sep 17 00:00:00 2001 From: Jack <278171810@qq.com> Date: Sat, 27 Jun 2026 13:05:54 +0800 Subject: [PATCH 08/20] docs: add multi-tenant design notes and gateway config sample Add the multi-tenant design doc and upstream issue under docs/, project guidance in CLAUDE.md, and a tdai-gateway.yaml sample that references ${DASHSCOPE_API_KEY} for embedding config without embedding secrets. Signed-off-by: Jack <278171810@qq.com> --- CLAUDE.md | 104 ++++ docs/tdai_multitenant_design.md | 920 ++++++++++++++++++++++++++++++++ docs/tdai_multitenant_issue.md | 65 +++ tdai-gateway.yaml | 27 + 4 files changed, 1116 insertions(+) create mode 100644 CLAUDE.md create mode 100644 docs/tdai_multitenant_design.md create mode 100644 docs/tdai_multitenant_issue.md create mode 100644 tdai-gateway.yaml diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..dd5bf893 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,104 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What this is + +`@tencentdb-agent-memory/memory-tencentdb` — a memory plugin for the **OpenClaw** agent runtime (and **Hermes** via an HTTP gateway). It does two largely independent jobs: + +1. **Layered long-term memory** (`src/core/`): a semantic pyramid that distills conversations into a user profile. `L0 Conversation` (raw dialogue) → `L1 Atom` (atomic facts) → `L2 Scene` (scene blocks) → `L3 Persona` (user profile). Lower layers are evidence in a database; upper layers are human-readable Markdown. +2. **Symbolic short-term memory / context offload** (`src/offload/`): offloads verbose tool logs to external `refs/*.md` files and keeps only a compact Mermaid "canvas" (with `node_id`s) in context, drilling back down via `node_id` when needed. + +Both jobs are wired into host lifecycle hooks. The two subsystems share a data directory but have separate config, hooks, and code paths — when editing, know which one you're in. + +## Commands + +```bash +npm run build # full build: tsdown bundles index.ts → dist/ + tsc builds the bin/ scripts +npm run build:plugin # just the plugin bundle (tsdown) +npm test # vitest run (unit tests: src/**/*.test.ts) +npm run test:watch # vitest watch +npm run test:coverage # vitest with v8 coverage +npx vitest run path/to/file.test.ts # single test file +npx vitest run -t "substring of test name" # single test by name +npx vitest --config vitest.e2e.config.ts run # e2e tests (**/*.e2e.test.ts), excluded from default run +``` + +**No build is required for local development.** Node ≥ 22.16 strips TypeScript types natively and OpenClaw loads `.ts` source directly. Build only for publishing (`prepack` runs it). To dev against a real OpenClaw: `openclaw plugins install --link .`, then `openclaw gateway restart` after changes. + +`tsdown` treats every declared dependency + `openclaw` + `node:*` as external (see `tsdown.config.ts`) — the bundle is intentionally thin. + +## Architecture + +### Host-neutral core + adapters +The central abstraction is `TdaiCore` (`src/core/tdai-core.ts`) — a host-neutral facade exposing `handleBeforeRecall`, `handleTurnCommitted`, search, and pipeline management. It depends only on the `HostAdapter` / `LLMRunner` interfaces in `src/core/types.ts`, never on a specific runtime. Two adapters implement those interfaces: + +- **`src/adapters/openclaw/`** — in-process. `index.ts` (the plugin entry) is a thin shell: it registers tools/hooks via `api.registerTool` / `api.on(...)`, translates OpenClaw events into `TdaiCore` calls, and manages prompt/recall caches keyed by session. +- **`src/adapters/standalone/`** — used by `src/gateway/server.ts`, a native-`http` server (no Express) that exposes `TdaiCore` over HTTP (`/recall`, `/capture`, `/search/*`, `/session/end`, `/seed`) for the Hermes sidecar. + +When changing memory behavior, put logic in `TdaiCore` / `src/core/`, not in `index.ts` or the gateway — both hosts must get it. + +### Long-term memory pipeline (`src/core/`) +- `conversation/l0-recorder.ts` — appends raw turns to local JSONL (L0). +- `record/` — L1: `l1-extractor.ts` (LLM extracts atomic facts), `l1-dedup.ts` (smart dedup), `l1-writer.ts` / `l1-reader.ts`. +- `scene/` — L2: `scene-extractor.ts` aggregates L1 atoms into Markdown scene blocks; scene index/navigation/formatting helpers alongside. +- `persona/` — L3: `persona-generator.ts` synthesizes the user profile; `persona-trigger.ts` decides when (every N new memories). +- `hooks/auto-recall.ts` + `hooks/auto-capture.ts` — the recall (before prompt) and capture (after agent end) entry points called by both adapters. +- `prompts/` — all LLM prompt templates for L1/L2/L3 and dedup. +- `store/` — pluggable storage via `store/factory.ts`. Two backends behind `IMemoryStore` (`store/types.ts`): `sqlite.ts` (default: SQLite + `sqlite-vec` + FTS5, local) and `tcvdb.ts` (Tencent Cloud VectorDB, server-side embedding + hybrid search). `embedding.ts` + `bm25-local.ts` / `bm25-client.ts` handle vector + lexical retrieval. +- `tools/` — `memory-search.ts` and `conversation-search.ts` are the agent-callable search tools. +- `report/reporter.ts` — metric/health reporting. +- `profile/profile-sync.ts` — keeps L2/L3 Markdown in sync locally. + +### Context offload (`src/offload/`) +Registered separately via `registerOffload(api, offloadConfig)` from `index.ts`, **only when `offload.enabled`**. It registers the OpenClaw `contextEngine` slot and its own hooks (all via `api.on`): +- `hooks/after-tool-call.ts` — captures tool output, writes full text to `refs/*.md`. +- `hooks/before-prompt-build.ts` / `hooks/llm-input-l3.ts` — inject the Mermaid history canvas, compress non-current tool-use blocks, emergency-compress on token overflow. +- `hooks/before-agent-start.ts` — task transition / judgment handling. +- `pipelines/l2-mermaid.ts` — builds the Mermaid canvas and backfills `node_id`s. +- `local-llm/` — optional local LLM (`node-llama-cpp`) path for offload extraction; `backend-client.ts` is the remote alternative. +- `storage.ts` is the on-disk format (`refs/*.md`, `*.mmd`, jsonl); token accounting lives in `context-token-tracker.ts` / `*token*` files. + +### Scripts & bins (`scripts/`, `bin/`) +Standalone migration/inspection tools, each with its own `tsconfig.json` and a built `.mjs` in `bin/`: `migrate-sqlite-to-tcvdb`, `export-tencent-vdb`, `read-local-memory`. Built by `npm run build:scripts`. `scripts/memory-tencentdb-ctl.sh` and `setup-offload.sh` are operator helpers; `openclaw-after-tool-call-messages.patch.sh` patches the OpenClaw install so after-tool-call messages can be offloaded (run once per OpenClaw install, re-run after upgrades). + +## Configuration + +Plugin config is parsed by `parseConfig` in `src/config.ts` into flat groups: `capture` (L0), `extraction` (L1), `persona` (L2/L3), `pipeline`, `recall`, `embedding`, `offload`, and a `tcvdb` block. Zero-config (`{}`) is valid — every field has a default and the SQLite backend is the default. LLM `model` fields use `"provider/model"` and fall back to OpenClaw's default model when omitted. + +The **gateway** (Hermes path) is configured separately in `src/gateway/config.ts`, primarily from env vars: `TDAI_LLM_API_KEY` / `TDAI_LLM_BASE_URL` / `TDAI_LLM_MODEL`, `MEMORY_TENCENTDB_ROOT` / `TDAI_DATA_DIR` for the data dir, and `MEMORY_TENCENTDB_GATEWAY_HOST` / `_PORT`. + +## Active work: multi-tenant retrofit (`custom-multitenant` branch) + +This fork is being adapted so **one Gateway/sidecar process can safely serve multiple end-user accounts** (tenants), for the AI4ALL WeChat companion project. Design doc + upstream issue live in `docs/tdai_multitenant_design.md` and `docs/tdai_multitenant_issue.md`. + +> **Path caveat:** the design doc is written from the *AI4ALL* (weixin_bot) repo's perspective. Paths like `app/turn_service.py`, `scripts/seed_tdai_memory.py`, `docs/tech_design/...` belong to **that** repo, not this one. Only `src/...` paths refer to this TDAI repo. Our scope is the **TDAI side** (`§8.4` / `§8.5` / phase P0.5 of the design, and the standalone issue). + +**The core problem:** the standalone/SQLite store is single-tenant per `dataDir`. `session_key` only isolates L0 (raw dialogue) and pipeline/session state — **L1/L2/L3 recall and search are dataDir-global**, so one sidecar serving multiple accounts would recall across accounts, violating the hard isolation invariant. + +Verified change points (file:line confirmed against current tree, 2026-06): + +| # | Gap | Where | Note | +|---|---|---|---| +| 1 | L1 search has no session filter | `searchL1Vector`/`searchL1Fts`/`searchL1Hybrid?` (`core/store/types.ts`), SQL in `core/store/sqlite.ts` (`l1_vec`/`l1_fts MATCH`) | `L1Record` already carries `session_key` (`store/types.ts:65`) — **no schema change**; add `sessionKey` param + push filter into SQL/vector, thread through `executeMemorySearch → searchMemories → performAutoRecall` | +| 2 | L0 search has no session filter | `searchL0Vector`/`searchL0Fts` (`store/types.ts`); conversation search is **post-filter** (`core/tools/conversation-search.ts:224`) | push down, don't post-filter (post-filter topK can be all other tenants → 0 results after filter) | +| 3 | `/search/memories` has no `session_key` | `MemorySearchRequest` (`gateway/types.ts`), handler `server.ts:~424` doesn't pass it; `MemorySearchParams` (`core/types.ts:229`) lacks it | add field + thread to #1 | +| 4 | `/search/conversations` `session_key` is optional | `gateway/types.ts`, `ConversationSearchParams` has `sessionKey?` | make required in multi-tenant mode | +| 5 | L2/L3 are dataDir-root files | persona reads `pluginDataDir/persona.md` (`auto-recall.ts:148`, writes `persona-generator.ts:185`); scene `readSceneIndex(pluginDataDir)` (`auto-recall.ts:162`) | per-account subdir; change read+write sides together | +| 6 | `/recall` drops `prependContext` (**P0, smallest, do first**) | `server.ts:386` returns only `appendSystemContext` | `RecallResult.prependContext` **already exists** (`core/types.ts:201`) and `handleBeforeRecall` already takes `sessionKey` — just add `prepend_context` to `RecallResponse` and pass it through | +| 7 | reindex/count cross-session | `getAllL1Texts`/`rebuildFtsIndex`/`countL1` (`sqlite.ts`) | structural approach isolates for free; filter approach must decide per-session reindex | + +Background pipeline concerns (as important as isolation): TDAI runs background timers per session (L1 idle, L2 schedule — `utils/pipeline-manager.ts:181`) + a global L3 runner (`pipeline-manager.ts:956`). Only a global `extraction.enabled` switch exists, no per-tenant. L3 persona is an unlocked file read-modify-write (`persona-generator.ts:185`) — needs **atomic write (temp+rename)**; same-account L3 is already serialized by the in-process `SerialQueue` (concurrency=1) only under the single-dataDir-single-process contract. + +Two implementation routes (decision pending — see the design doc §8.4): +- **Structural (AI4ALL's recommendation):** Gateway keeps `Map`, one dataDir per account (`baseDir/{account}`), lazy + LRU. Isolation is physical (covers #1/#2/#5/#7 for free); still needs #3/#6 interface fields. Cost: N cores = N timer sets + N `SerialQueue`s → background LLM extraction fan-out ×N, so it **requires a cross-core global concurrency cap**. +- **Filter:** single core + shared store, `session_key` pushed into every query. Memory-light, single `SerialQueue` caps concurrency naturally, but "miss one WHERE = cross-tenant leak." + +Either way: #6, #5 (L2/L3 per-account), atomic L3 write, and a `session_key`-scoped **namespace wipe** API (for account hard-delete) are all required. + +## Conventions + +- Commits: Conventional Commits with a scope from `{store, hooks, persona, scene, record, conversation, gateway, hermes, offload, llm, embedding}`, e.g. `fix(embedding): ...`. **DCO is enforced** — every commit needs `Signed-off-by` (`git commit -s`). +- Import order: Node builtins → third-party → internal. +- Tests live next to source as `*.test.ts`; e2e as `*.e2e.test.ts` (run only via the e2e config). `vitest` uses the `forks` pool with a 120s timeout. +- The `src/conversation`, `src/record`, etc. paths in CONTRIBUTING.md are stale — the actual layout nests these under `src/core/`. diff --git a/docs/tdai_multitenant_design.md b/docs/tdai_multitenant_design.md new file mode 100644 index 00000000..be87a488 --- /dev/null +++ b/docs/tdai_multitenant_design.md @@ -0,0 +1,920 @@ +# TDAI 多账号记忆接入落地设计 + +> 状态:设计文档,待 PoC。 +> 最后更新:2026-06-26。 +> 适用范围:AI4ALL 微信个人 AI 陪伴项目。 +> 上游参考: +> - `/Users/suchong/workspace/TencentDB-Agent-Memory` +> - `docs/plans/记忆机制_tdai化对齐.md` +> - `docs/tech_design/thick_node_postgres_refactor.md` +> - `docs/tech_design/identity_model_and_wechat_binding.md` + +## 1. 结论 + +Gemini 原方案给出的方向有价值:TDAI 的 L0/L1/L2/L3 分层、query-time recall、异步提纯和多租户隔离,确实适合 AI4ALL。 +但它对现状有几个关键误判,不能直接作为实施方案: + +1. TDAI 当前不是 Python SDK,也没有可直接内嵌到 FastAPI 的 `AsyncMemoryClient`。 +2. TDAI 当前可落地接入面是 Node.js Gateway / OpenClaw 插件能力,HTTP API 包括 `/recall`、`/capture`、`/search/memories`、`/search/conversations`、`/session/end`、`/seed`。 +3. TDAI 当前 store backend 是 `sqlite` 或 `tcvdb`,不是 PostgreSQL / pgvector;如果要统一落到 AI4ALL PostgreSQL,需要新增 TDAI store backend,不能在 AI4ALL 侧单方面配置出来。 +4. AI4ALL 的业务隔离边界是 `ai4all_account_id`,代码和 DB 里历史字段叫 `account_id`;OpenClaw 原始 `session_key` / `channel_account_id` 不是记忆隔离主键。 +5. AI4ALL 已有 `messages`、`account_profile_files`、`memory_writer.py`、`dreaming.py`、`PromptBuilder` 和 PostgreSQL 垫片,接入必须沿这些边界做最小改动,而不是替换整条 turn 链路。 + +因此推荐采用两阶段策略: + +- **近期可落地**:把 TDAI 作为本机 sidecar,通过 HTTP 接入 AI4ALL turn 热路径。TDAI 自己维护本地 SQLite 或 TCVDB 记忆库;AI4ALL 继续以 PostgreSQL/SQLite 抽象作为业务真相。 +- **后续可选**:当 sidecar 效果验证后,再评估是否给 TDAI 增加 `postgres` store backend,把 L0/L1/L2/L3 统一收敛到中心 PG。 + +> **关键修正(2026-06,blocker):`session_key` 不足以隔离多账号。** 经核实 TDAI 当前 standalone/sqlite store 是「单租户 per dataDir」:`session_key` 只隔离了 L0(原始对话)和 pipeline/session 状态,**L1/L2/L3 召回是 dataDir 全局的**——L1 搜索接口(`store/types.ts:269-270` 的 `searchL1Fts`/`searchL1Vector`)签名里没有 session 过滤;persona/scene 是 dataDir 根级文件(`auto-recall.ts:148/162`)。因此「一个 sidecar 靠 `session_key` 服务多账号」会跨账号召回,**违反 AI4ALL「按账号隔离」红线**。已确认方向:在 P1 灰度前对 TDAI 做**多租户改造(路 B,§8.4)**,并补 `/recall` response 丢失的 `prependContext`(§5.3、§8.4)。这两项是 P1 前置,不是可选优化。 + +### 1.1 部署现状(2026-06,影响接入形态) + +- 数据库为 **中心化 PostgreSQL**。线上两节点:一个节点跑 PostgreSQL,另一个节点经内网连接调用。PG 是全局业务真相,`messages` 等表集中存储。 +- 微信接入点的特殊性导致 **用户与节点强绑定**:每个用户的入口只能落到某一台机器节点,该用户的业务主逻辑也全部在这台节点上运行。用户不会在节点间漂移(除非显式迁移)。 + +这两点不推翻 sidecar 方案,反而强化它: + +1. **用户强绑定节点 → 本地 SQLite 不再是妥协,而是天然契合**。同一用户所有轮次都在同一节点处理,本地记忆不会跨节点碎片化(§4.2 旧约束被微信绑定语义自动满足)。 +2. **中心 PG 已持有全量 `messages` → TDAI 记忆可从中心重建**。节点故障/迁移的恢复路径是「新节点起 sidecar + 从中心 PG re-seed」,而非搬运本地 SQLite。源真相在中心、派生记忆在本地且可重建,是稳的混合形态。 +3. **中心 PG 让未来 `postgres` store backend 成为真实可选项(P4)**,但要改 TDAI TypeScript store、依赖 pgvector,并给热路径 recall 增加一次跨节点网络往返;近期不做。 + +### 1.2 接入点完整度(轴 A)≠ provider 抽象(轴 B) + +「完整实现 OpenClaw-like 接入」要区分两个正交的轴: + +- **轴 A — 接入点完整度**:是否把 OpenClaw 暴露的所有 host 点都接上(recall / capture / 模型主动 search 工具 / session-end / shutdown flush)。**目标做满**,详见 §2.3。 +- **轴 B — provider 抽象层**:是否建抽象 `MemoryProvider` 基类 + 多实现(TdaiHttp / NativePg / Noop)热插拔。**推迟到 P3/P4**:在出现第二个实现之前用单一实现去定接口,几乎必然返工;当前 `app/tdai_client.py` 的模块边界已提供「调用不散落、可降级、可灰度」的全部收益。 + +第一版接口面 = `tdai_client.py` 的几个具体函数,不套抽象基类。 + +## 2. 当前系统映射 + +### 2.1 AI4ALL 现有热路径 + +当前用户入站链路是: + +```text +OpenClaw bridge + -> /openclaw/turn + -> app/turn_service.py + -> build_turn_llm_input() + -> PromptBuilder.assemble() + -> generate_reply_with_tools() + -> insert_message / billing / moderation + -> memory_writer.write_memory() + -> dreaming scheduler 或 admin run-once +``` + +现有记忆材料: + +| 层次 | AI4ALL 当前实现 | 说明 | +|---|---|---| +| L0 原始会话 | `messages` 表 | 每轮用户/助手可见文本,按 `account_id` 隔离 | +| L0 daily notes | `app/memory_writer.py` -> `account_profile_files.filename = memory/YYYY-MM-DD.md` | turn 后异步追加,已入 DB-backed profile storage | +| session continuity | `sessions.carryover_summary` | session 轮转后承接上一段对话 | +| 长期上下文 | `SOUL.md` / `IDENTITY.md` / `USER.md` / `MEMORY.md` | 通过 `read_agent_context()` 进入 `Project Context` | +| 整理审计 | `dreaming_runs` / `dreaming_memory_items` / `memory_events` | Dreaming 输出、自动应用和 diff 审计 | + +### 2.2 TDAI 当前能力 + +TDAI 仓库关键入口: + +| 文件 | 作用 | +|---|---| +| `src/core/tdai-core.ts` | Host-neutral facade,提供 recall/capture/search/session end | +| `src/gateway/server.ts` | HTTP Gateway,暴露 `/recall`、`/capture`、`/search/*`、`/session/end`、`/seed` | +| `src/gateway/types.ts` | Gateway 请求/响应类型 | +| `src/config.ts` | TDAI 配置,包含 capture/extraction/persona/pipeline/recall/embedding/storeBackend | +| `src/core/store/factory.ts` | store backend 选择:`sqlite` 或 `tcvdb` | +| `src/core/hooks/auto-recall.ts` | query-time L1 recall + L3 persona + L2 scene navigation 注入 | +| `src/core/hooks/auto-capture.ts` | turn 后 L0 capture + pipeline notify | + +Gateway API 形态: + +```http +GET /health +POST /recall +POST /capture +POST /search/memories +POST /search/conversations +POST /session/end +POST /seed +``` + +其中 `/recall` 返回: + +```json +{ + "context": "......", + "strategy": "hybrid", + "memory_count": 3 +} +``` + +`/capture` 输入: + +```json +{ + "user_content": "用户本轮文本", + "assistant_content": "助手回复", + "session_key": "ai4all:aid_806382741", + "session_id": "123", + "messages": [ + {"role": "user", "content": "..."}, + {"role": "assistant", "content": "..."} + ] +} +``` + +注意:Gateway type 里有 `user_id`,但当前 `src/gateway/server.ts` 并未把它传入 `TdaiCore` 的 recall/capture 主逻辑。当前 TDAI 的隔离和 pipeline state 实际依赖 `session_key`。 + +### 2.3 OpenClaw host 点完整映射(轴 A 的目标范围) + +TDAI 的 `TdaiCore` 是 host-neutral facade,OpenClaw 插件与 Gateway 是它的两个 host adapter;**Gateway 就是 standalone host**,HTTP 端点逐一对应 facade 方法(核对自 TDAI `index.ts` 的 `api.on(...)` 与 `src/core/tdai-core.ts`)。OpenClaw 插件注册的 5 个 host 点映射到 AI4ALL: + +| OpenClaw host 点 | TdaiCore facade | Gateway 端点 | AI4ALL 落点 | 阶段 | +|---|---|---|---|---| +| `before_prompt_build`(被动召回 L1+L3+L2 注入 prompt) | `handleBeforeRecall(userText, sessionKey)` | `POST /recall` | `build_turn_llm_input()` → `extra_blocks` 的 `tdai_recall_memories` + `tdai_recall_persona` | P1 | +| `before_message_write`(剥离注入标签防污染历史) | — | — | **N/A**:recall 作为独立 system block 注入,从不写回 `messages.content`,架构天然满足 | — | +| `agent_end`(捕获 user+assistant 轮,推进 L0→L1→L2→L3) | `handleTurnCommitted(turn)` | `POST /capture` | after-turn 复用 `write_memory` 的 background_loop 派发 | P1 | +| `registerTool`:`tdai_memory_search` / `tdai_conversation_search`(模型主动召回,每轮合计上限 3 次) | `searchMemories` / `searchConversations` | `POST /search/memories`、`/search/conversations` | 加进 `generate_reply_with_tools` 的 tool registry(§5.6) | P1 | +| `gateway_stop`(flush pipeline、关 store) | `handleSessionEnd` + `destroy()` | `POST /session/end` | `session_lifecycle` 轮转 + FastAPI shutdown(§5.5) | P2 | + +要达到「完整 OpenClaw-equivalent」,除已规划的被动 recall + capture 外,还需补两块(旧版本漏列): + +1. **模型主动 search 工具**(§5.6):让模型在生成中途自行查记忆,覆盖被动召回未命中的「我上次说的那个事」类追问。Gateway `/search/*` 现成,只需加 tool 壳并复制「每轮合计 3 次」硬上限。 +2. **session-end / shutdown flush**(§5.5):pipeline 是进程内串行队列,session 轮转与进程退出要给它 flush 机会,否则刚 capture 的 L0 可能未提纯就丢失。 + +`before_message_write` 我们不需要实现——独立 block 注入天然不污染历史。 + +## 3. 关键设计决策 + +### 3.1 接入形态:sidecar 优先,不做 Python 内嵌 + +推荐形态: + +```text +AI4ALL FastAPI 进程 + | + | HTTP localhost + Bearer token + v +TDAI Gateway Node 进程 + | + +-- 本地 SQLite/sqlite-vec 或 Tencent Cloud VectorDB + +-- TDAI pipeline: L0 -> L1 -> L2 -> L3 +``` + +理由: + +- 与 TDAI 真实实现一致,PoC 成本最低。 +- 不需要在 AI4ALL 里引入 Node/TypeScript 运行时代码。 +- 故障边界清晰:TDAI 失败时 AI4ALL 可降级为现有 memory/dreaming。 +- 保留后续替换 store backend 的空间。 + +不建议近期做: + +- 不建议直接改 TDAI 为 Python SDK。 +- 不建议一开始就把 TDAI 全部迁到 AI4ALL PostgreSQL。 +- 不建议让 TDAI OpenClaw 插件直接介入当前微信 bot 的 OpenClaw runtime;AI4ALL 已经自己接管了 prompt、tool、计费、审核和记忆链路,插件式 hook 容易和现有业务链路重叠。 + +注意「完整接入」≠ 现在建 provider 协议:接入点做满(轴 A,§2.3)与抽象接口(轴 B)是两件事,第一版接口面就是 `app/tdai_client.py` 的几个函数(见 §1.2)。 + +### 3.2 多账号隔离:TDAI session_key 必须绑定 AI4ALL account_id + +AI4ALL 隔离主键: + +```text +业务主键:ai4all_account_id +代码/DB 兼容字段:account_id +通道身份:channel_account_id +OpenClaw 会话键:session_key +``` + +TDAI 当前实际按 `session_key` 分 session/pipeline/checkpoint/recall。因此 AI4ALL 调 TDAI 时必须构造稳定 key: + +```python +tdai_session_key = f"ai4all:{account_id}" +tdai_session_id = str(session["id"]) +``` + +不要使用: + +- 不要用 OpenClaw payload 原始 `session_key` 作为 TDAI session key。 +- 不要用 `channel_account_id` 作为 TDAI session key。 +- 不要按 AI4ALL session id 单独隔离长期记忆,否则 L3 persona 和 L1 memories 会按会话碎片化,无法跨 session 生效。 + +`session_id` 可以传 AI4ALL 当前 `sessions.id`,用于 TDAI L0/L1 溯源;但长期隔离仍以 `tdai_session_key = ai4all:{account_id}` 为准。 + +### 3.3 Prompt 防污染 + +TDAI recall 返回的上下文只能进入本轮 prompt,不能写回: + +- `messages.content` +- `memory/YYYY-MM-DD.md` +- 用户原始消息 +- assistant 可见回复之外的材料 + +AI4ALL 当前 `build_turn_llm_input()` 已经先读 history,再构造 system prompt;只要把 TDAI recall block 放进 `PromptBuilder` 的动态 block,且不改落库的 `text/reply`,即可保持存储态干净。 + +建议新增两个 block name(对应 `/recall` 的 prepend/append 两段,§5.3): + +```text +tdai_recall_memories # prepend_context:当前 query 相关 L1 记忆 +tdai_recall_persona # append_context:persona/scene 背景 +``` + +注入内容建议包一层 AI4ALL 自己的说明,避免让 TDAI 原始标签成为回答风格主导: + +```text +【系统召回记忆】 +以下材料来自长期记忆召回,只作为理解用户的参考,不是用户本轮原话。 +如果与用户本轮说法冲突,以用户本轮为准,可轻量确认。 + + +``` + +### 3.4 与现有 dreaming 的关系 + +近期不替换 `dreaming.py`。两套系统并行: + +| 能力 | 近期权威 | +|---|---| +| AI4ALL 业务上下文文件 | 现有 `account_profile_files` + `dreaming.py` | +| TDAI recall/capture 效果评估 | TDAI sidecar | +| billing/moderation/proactive | AI4ALL 现有链路 | +| 用户可见回复 | AI4ALL LLM 链路 | + +并行期的目标不是一次性迁移,而是回答两个问题: + +1. TDAI 的 L1 recall / L3 persona 是否明显改善陪伴感和长期一致性? +2. TDAI pipeline 的成本、延迟、稳定性是否适合微信陪伴业务? + +如果答案稳定为是,再决定是否减少 `MEMORY.md` 的 prompt 权重,或把 `dreaming.py` 改为生成 TDAI seed/records。 + +## 4. 目标架构 + +### 4.1 单机 PoC 架构 + +```text +┌───────────────────────────────┐ +│ AI4ALL FastAPI │ +│ - turn_service.py │ +│ - PromptBuilder │ +│ - messages / billing / audit │ +│ - existing dreaming │ +└───────────────┬───────────────┘ + │ localhost HTTP + │ Authorization: Bearer +┌───────────────▼───────────────┐ +│ TDAI Gateway │ +│ - /recall before LLM │ +│ - /capture after reply │ +│ - /seed for backfill │ +│ - pipeline L0/L1/L2/L3 │ +└───────────────┬───────────────┘ + │ +┌───────────────▼───────────────┐ +│ TDAI store │ +│ Phase 0: local SQLite │ +│ Phase 1 option: TCVDB │ +│ Future: PostgreSQL backend │ +└───────────────────────────────┘ +``` + +### 4.2 线上拓扑与多节点部署 + +线上现状(2026-06):两节点,中心化 PostgreSQL。 + +```text +节点 A + PostgreSQL(中心业务真相:账号/绑定/计费/审核/messages) + + AI4ALL app + TDAI Gateway + OpenClaw + TDAI 本地 store + +节点 B(经内网连节点 A 的 PG) + AI4ALL app + TDAI Gateway + OpenClaw + TDAI 本地 store +``` + +> 已确认(2026-06):两节点都跑 AI4ALL app,节点 A 同时是 PG 节点。因此**每个节点各起一个 TDAI sidecar**,服务本节点的多个账号。 +> +> ⚠️ **多账号共享一个 sidecar 必须先完成 TDAI 多租户改造(路 B,§8.4)**:当前 standalone store 的 L1/L2/L3 是 dataDir 全局的,未改造前一个 sidecar 服务多账号会跨账号召回。改造后该 sidecar 按账号命名空间隔离 L0/L1/L2/L3,对外仍只暴露一个 Gateway。 + +关键:**微信接入点把用户强绑定到某一台节点**,该用户所有轮次都在同一节点处理。因此: + +- TDAI sidecar **与处理该用户的 app 实例同节点**,localhost HTTP 调用;该用户的 L0/L1/L2/L3 都落在这台节点的本地 store,并**按账号命名空间隔离**(§8.4)。 +- 用户不跨节点漂移 → 本地 SQLite 记忆**不会碎片化**。§4.1 的「账号稳定归属同一节点」约束被微信绑定语义自动满足,不再是额外灰度门槛。 + +故障与迁移(DR,第一期): + +- 第一期采用**硬绑定 + 故障暂停**:某节点宕机时,其上用户暂停服务,不做跨节点热备/重绑。 +- 中心 PG 持有全量 `messages`,TDAI 本地记忆是**可重建的派生数据**:节点恢复后 sidecar 重启,本地 SQLite 完好则续用;若本地 store 丢失,从中心 PG re-seed 重建(`scripts/seed_tdai_memory.py`),无需搬运 SQLite 文件。 +- 后续若上热备/重绑,再评估共享 store(TCVDB 或 PostgreSQL backend,§9 P4);那时才需处理「用户在节点间漂移」的记忆一致性。 + +## 5. AI4ALL 代码接入点 + +### 5.1 新增 TDAI client 模块 + +建议新增: + +```text +app/tdai_client.py +tests/test_tdai_client.py +``` + +职责: + +- 读取配置。 +- 封装 `httpx` 调用。 +- 统一超时、鉴权、错误降级和日志。 +- 提供同步接口给当前同步 turn 热路径使用。 + +建议接口: + +```python +def tdai_session_key(account_id: str) -> str: + """Return stable TDAI session key scoped to one AI4ALL account.""" + return f"ai4all:{account_id}" + + +def recall( + *, + account_id: str, + query: str, + timeout_seconds: float | None = None, +) -> dict: + """Recall TDAI memory for one account. Return empty dict on disabled/failure.""" + + +def capture_turn( + *, + account_id: str, + session_id: int, + user_content: str, + assistant_content: str, + messages: list[dict] | None = None, +) -> dict: + """Capture one visible user/assistant turn into TDAI. Best-effort.""" +``` + +### 5.2 配置项 + +新增到 `app/config.py` 和 `.env.example`: + +```python +tdai_enabled: bool = False +tdai_gateway_url: str = "http://127.0.0.1:8420" +tdai_gateway_api_key: str = "" +tdai_recall_enabled: bool = True +tdai_capture_enabled: bool = True +tdai_recall_timeout_seconds: float = 1.5 +tdai_capture_timeout_seconds: float = 2.0 +tdai_recall_max_chars: int = 2500 +tdai_account_allowlist: str = "" +``` + +默认关闭,按账号 allowlist 灰度。 + +### 5.3 recall 注入点 + +位置:`app/turn_service.py` 的 `build_turn_llm_input()`。 + +⚠️ **依赖 TDAI `/recall` 补丁(§8.4 #6,P0 前置)**:TDAI recall 把最核心的「当前 query 相关 L1 记忆」放在 `prependContext`,把 persona/scene/tools 放在 `appendSystemContext`;但当前 Gateway `/recall` **只返回 `appendSystemContext`,丢弃 `prependContext`**(`server.ts:385`),且 `memory_count` 仍报 L1 条数 > 0。未打补丁就接 `/recall`,会拿到 persona/scene 却拿不到 query 相关记忆,PoC 必然误判召回效果。补丁后 response 同时返回两段。 + +建议流程: + +```text +1. 解析 account_id、session、history、agent_context。 +2. 如果 tdai_enabled 且账号命中灰度: + - 调 /recall,query = 当前用户文本。 + - 超时或失败返回空,不阻塞主对话。 + - 对 prepend/append 两段分别做最大字符数截断。 +3. 注入为 PromptBuilder.extra_blocks 中两个 volatile block: + - tdai_recall_memories(prepend_context:当前 query 相关 L1 记忆,每轮变化) + - tdai_recall_persona(append_context:persona/scene,变化慢) +4. metadata 记录 tdai_recall_enabled / status / mem_chars / persona_chars / memory_count / latency_ms。 +``` + +`trim_priority` 建议: + +```python +ContextBlock( + name="tdai_recall_memories", # prepend_context:当前 query 相关 L1 记忆 + text=formatted_tdai_memories, + section="volatile", + char_limit=settings.tdai_recall_max_chars, + trim_priority=25, +) +ContextBlock( + name="tdai_recall_persona", # append_context:persona/scene,相对稳定 + text=formatted_tdai_persona, + section="volatile", + char_limit=settings.tdai_recall_max_chars, + trim_priority=35, +) +``` + +优先级说明: + +- `tdai_recall_memories` 比 `carryover_summary` 稍高或接近,因为它是当前 query 相关材料;persona 块稍低,作为背景。 +- 低于 onboarding/context 基础身份,避免新用户流程被记忆块干扰。 + +### 5.4 capture 调用点 + +位置:主 LLM 回复成功、assistant message 落库后。 + +当前 `turn_service.py` 已调用 `write_memory()`。TDAI capture 可以与 `write_memory()` 同级,best-effort 异步执行: + +```text +1. 用户消息和助手回复都已确定。 +2. 入站内容未被同步审核拦截。 +3. 回复不是 no_reply / error fallback,或按配置允许记录 fallback。 +4. 调 /capture: + - session_key = ai4all:{account_id} + - session_id = 当前 AI4ALL session id + - user_content = 原始用户可见文本 + - assistant_content = 最终发送给用户的文本 +``` + +不要把 system prompt、tool result、TDAI recall context 写入 capture 的 messages,除非后续明确要做短期任务 offload。第一阶段只捕获用户和助手可见文本。 + +### 5.5 session end / seed + +AI4ALL session 生命周期由 `session_lifecycle.py` 控制。**session-end / shutdown flush 属 P2 范围**:pipeline 是进程内串行队列,需在 session 轮转、Dreaming run 后、以及 FastAPI shutdown 时给它 flush 机会,避免刚 capture 的 L0 未提纯丢失。调用: + +```http +POST /session/end +{"session_key": "ai4all:"} +``` + +`/seed` 用于历史回灌。建议先做脚本,不放进主链路: + +```text +scripts/seed_tdai_memory.py +``` + +输入: + +- `--account-id aid_...` +- `--since YYYY-MM-DD` +- `--limit-sessions N` +- `--dry-run` + +数据来源: + +- `messages` 表,按 `account_id` 和 session 分组。 +- 每组映射到 TDAI seed Format A/B。 + +### 5.6 模型主动 search 工具 + +对应 OpenClaw 的 `registerTool`(§2.3),把两个工具加进 `generate_reply_with_tools` 的 tool registry,让模型在生成中途主动召回: + +| 工具 | Gateway 端点 | 用途 | +|---|---|---| +| `tdai_memory_search` | `POST /search/memories` | 召回结构化长期记忆(偏好/事件/指令) | +| `tdai_conversation_search` | `POST /search/conversations` | 召回历史原始消息片段 | + +要求: + +- 两个工具调用都必须带 `session_key = ai4all:{account_id}`,复用 `tdai_client.tdai_session_key()` 唯一构造点。 +- ⚠️ **`/search/memories` 当前无 `session_key` 字段**(`gateway/types.ts:66`),`tdai_memory_search` 直接接会**全库跨账号搜 L1**。必须先完成 §8.4 #3(给 `MemorySearchRequest` 加 `session_key` 并下推过滤)才能开放此工具。`/search/conversations` 已带 `session_key`,我们的工具会强制传入,可先行;但注意它当前是**检索后过滤**(`conversation-search.ts:224`),多租户下召回质量需靠 §8.4 #2 的下推改善。 +- 复制 TDAI 的硬上限:两个工具**每轮合计最多 3 次**,超出直接拒绝,避免模型刷召回拖慢回复。 +- 工程量提示:tool registry 是 schema/handler 单一事实源 + 启动期双向校验(`app/tools/registry.py`),接入需改 `definitions.py`/`registry.py`、新增 handler、给 `TurnContext` 加 per-turn 计数、并按 allowlist/runtime flag gating,不是塞两个 schema 即可。 +- 失败降级:工具返回空结果而非抛错,不打断本轮生成。 +- 与被动 recall(§5.3)并存:被动注入覆盖高频上下文,主动 search 覆盖被动未命中的精确追问。 + +## 6. TDAI Gateway 部署配置 + +### 6.1 本地 Gateway + +Gateway 默认端口是 `8420`,建议绑定 loopback 并开启 Bearer token: + +```bash +export TDAI_GATEWAY_HOST=127.0.0.1 +export TDAI_GATEWAY_PORT=8420 +export TDAI_GATEWAY_API_KEY=change-me +export TDAI_DATA_DIR=/var/lib/ai4all/tdai +export TDAI_LLM_BASE_URL=https://api.deepseek.com/v1 +export TDAI_LLM_API_KEY=... +export TDAI_LLM_MODEL=deepseek-v4-flash +``` + +必须要求: + +- 非 loopback 暴露时必须设置 `TDAI_GATEWAY_API_KEY`。 +- AI4ALL 侧只配置同一个 token,不写死密钥。 +- TDAI data dir 纳入备份策略。 + +### 6.2 TDAI memory 配置建议 + +PoC 阶段建议: + +```yaml +server: + host: 127.0.0.1 + port: 8420 + apiKey: ${TDAI_GATEWAY_API_KEY} + +data: + baseDir: /var/lib/ai4all/tdai + +llm: + baseUrl: ${TDAI_LLM_BASE_URL} + apiKey: ${TDAI_LLM_API_KEY} + model: ${TDAI_LLM_MODEL} + timeoutMs: 120000 + +memory: + timezone: Asia/Shanghai + storeBackend: sqlite + capture: + enabled: true + l0l1RetentionDays: 0 + extraction: + enabled: true + enableDedup: true + maxMemoriesPerSession: 20 + pipeline: + everyNConversations: 5 + enableWarmup: true + l1IdleTimeoutSeconds: 600 + l2DelayAfterL1Seconds: 10 + l2MinIntervalSeconds: 900 + l2MaxIntervalSeconds: 3600 + sessionActiveWindowHours: 24 + recall: + enabled: true + maxResults: 5 + maxTotalRecallChars: 2000 + scoreThreshold: 0.3 + strategy: hybrid + timeoutMs: 1200 + embedding: + enabled: true + provider: dashscope # 非 local/none → 走 OpenAI 兼容远程路径 + baseUrl: https://dashscope.aliyuncs.com/compatible-mode/v1 + apiKey: ${TDAI_EMBEDDING_API_KEY} # 百炼 DashScope key,与 DeepSeek key 是两把不同的 key + model: text-embedding-v3 + dimensions: 1024 # 可降到 768/512 省存储 + sendDimensions: true # v3 支持指定维度;若兼容模式返回 400 改为 false + recallTimeoutMs: 1500 # recall 路径短超时,别拖慢出话 +``` + +说明: + +- **embedding 选型:阿里云百炼 `text-embedding-v3`(远程)**。决定性因素是轻量服务器内存:节点 A 已压着 PG + app + sidecar,<3G 可用,本地 `embeddinggemma-300m`(`node-llama-cpp` 进程内加载)常驻 ~0.5–1G,叠加后有真实 OOM 风险;远程方案在本机内存占用 ≈0,仅一次 HTTP。成本 0.0005 元/1k tokens(免费额度后),陪伴量级可忽略。 +- **LLM 与 embedding 是两把独立的 key**:TDAI 的 extraction(L1)/persona(L3) 复用 AI4ALL 的 DeepSeek `api_key` + `base_url`;embedding 单独用百炼 DashScope key。DeepSeek 无 embedding 端点,二者本就在 config 中分属不同字段,不冲突。 +- **故障可退化(已验证源码 `auto-recall.ts:searchHybrid`)**:hybrid 下 keyword(FTS) 与 embedding 并行、各自独立 try/catch。DashScope 超时/宕机时 embedding 腿返回空,FTS 腿照常出结果,本轮自动降级为关键词召回,不会整轮空;启动期 embedding 配置缺失则策略整体降级为 `keyword`。因此远程 embedding 是「锦上添花、坏了能退」的依赖,不是单点。 +- ⚠️ **`@node-rs/jieba` 必须装上**:它服务 hybrid 里的 FTS 腿(中文分词),与是否用远程 embedding 无关;装不上则中文 FTS 退化为单字切分,召回明显变差。 +- ⚠️ **隐私**:记忆文本会出到阿里云做 embedding;TDAI 写库前 `sanitize` 已剥离密码/验证码/证件号等敏感内容,但仍需业务侧确认可接受第三方云。 +- 上线前一次性验证:百炼兼容模式 `/embeddings` 是否接受请求体 `dimensions` 字段(接受→`sendDimensions:true`,报 400→false)。 +- 建议留一行 embedding token 计数日志/上限:embed 发生在 capture(后台、可批)+ recall(每轮),量级虽小但便于观测成本。 +- TDAI pipeline 内部 L1/L2/L3 队列当前是进程内串行队列;单 sidecar 下已经天然限流。 + +## 7. 数据与一致性策略 + +### 7.1 近期:双系统并行 + +AI4ALL PostgreSQL/SQLite 仍是业务真相;TDAI store 是记忆增强缓存。 + +| 数据 | 权威系统 | +|---|---| +| 账号、绑定、计费、审核、消息 | AI4ALL DB | +| SOUL/IDENTITY/USER/MEMORY | AI4ALL `account_profile_files` | +| TDAI L0/L1/L2/L3 | TDAI store | +| TDAI recall 注入 | 本轮 prompt-only | + +一致性要求: + +- TDAI capture 失败不回滚主 turn。 +- TDAI recall 失败不影响回复。 +- AI4ALL wipe/unbind 需要后续补 TDAI 删除能力;PoC 阶段至少在运行手册中要求删除对应 data dir 或 TCVDB 记录。 + +### 7.2 后续:统一 PostgreSQL 的前提 + +如果要达成 Gemini 原方案里的“所有记忆落 PG”,需要在 TDAI 仓库新增 store backend: + +```text +src/core/store/postgres.ts +``` + +它必须实现 `IMemoryStore`: + +- `upsertL0` / `searchL0Vector` / `searchL0Fts` / `queryL0ForL1` +- `upsertL1` / `searchL1Vector` / `searchL1Fts` / `queryL1` +- `pullProfiles` / `syncProfiles` / `deleteProfiles` +- `getCapabilities` + +AI4ALL 侧不能只靠 `database_url` 替 TDAI 完成这件事,因为 TDAI 进程是 Node.js,当前 store abstraction 与 AI4ALL `app/db/_backend.py` 没有关系。 + +## 8. 安全、隔离与降级 + +### 8.1 隔离红线 + +所有 AI4ALL -> TDAI 调用必须满足: + +```text +tdai_session_key == "ai4all:" + account_id +``` + +⚠️ **这是必要条件,但当前 TDAI 下不充分**:`session_key` 只隔离 L0 和 pipeline 状态,L1/L2/L3 召回仍是 dataDir 全局(§1 结论修正)。真正守住隔离红线还需 §8.4 的 TDAI 多租户改造;在改造完成前,一个 sidecar 只能服务单账号。 + +日志中必须记录: + +- `account_id` +- `tdai_session_key` hash 或原文的安全截断 +- recall/capture status +- latency +- chars / memory_count + +禁止: + +- 禁止跨账号复用 recall 结果缓存。 +- 禁止不带 `account_id` 查询 AI4ALL messages 后 seed。 +- 禁止把 OpenClaw `channel_account_id` 当业务账号。 + +### 8.2 敏感信息 + +AI4ALL 已有 moderation 和 dreaming 敏感过滤。TDAI capture 前仍应保守: + +- 同步审核拦截的入站内容不 capture。 +- 图片理解失败 fallback 不 capture 图片内容猜测。 +- 密码、验证码、token、证件、银行卡、精确地址和联系方式不应进入长期记忆。 + +TDAI 自身 extraction prompt 也有过滤,但 AI4ALL 不能只依赖下游。 + +### 8.3 失败降级 + +| 场景 | 行为 | +|---|---| +| `/health` 失败 | 标记 TDAI unavailable;主对话继续 | +| `/recall` 超时 | 不注入 TDAI block;metadata 记 `timeout` | +| `/capture` 失败 | 记录 warning;不影响用户回复 | +| Gateway 重启 | AI4ALL 自动重试下一轮;不补发本轮 capture,除非后续做 outbox | +| TDAI store 损坏 | 删除/恢复 TDAI data dir 后从 AI4ALL messages seed 重建 | + +第一阶段不建议为 capture 建复杂 outbox;等 PoC 证明有效后再补。 + +### 8.4 TDAI 多租户改造(路 B,P1 前置) + +经源码核实,TDAI standalone/sqlite store 是「单租户 per dataDir」:`session_key` 只隔离 L0 和 pipeline 状态,L1/L2/L3 召回是 dataDir 全局。要让一个 sidecar 安全服务多账号,需对 **TDAI 仓库**做以下改造(这是 TDAI 侧的开发量,AI4ALL 侧只消费结果): + +| # | 改造项 | 证据/落点 | 说明 | +|---|---|---|---| +| 1 | **L1 搜索按 session 过滤** | `searchL1Fts`/`searchL1Vector`/`searchL1Hybrid`(`store/types.ts:269-271`)签名无 session;SQL `sqlite.ts:635-641`(`l1_vec MATCH`)/`796-805`(`l1_fts MATCH`)无 WHERE;`auto-recall.ts:125`、`tdai-core.ts:290` 调用未传 | L1Record 已存 `session_key`(`store/types.ts:65`),**schema 无需改**;给上述接口加 `sessionKey` 参数、把过滤**下推**进 SQL/向量检索(`WHERE session_key = ?`),并沿 `executeMemorySearch → searchMemories → performAutoRecall` 全链路透传 | +| 2 | **L0 搜索按 session 过滤** | `searchL0Vector`/`searchL0Fts`(`store/types.ts:295-296`)签名无 session;conversation search 是**检索后再 filter**(`tools/conversation-search.ts:224-228`),`/search/conversations` 的 `session_key` 还是可选(`gateway/types.ts:83-87`) | 同 #1 下推;**post-filter ≠ pushdown**——先全库取 topK 再 filter,多租户下 topK 可能全是别账号、filter 完剩 0,召回质量塌。必须下推 + 多租户模式 `session_key` 必填 | +| 3 | **`/search/memories` 加 `session_key`** | `MemorySearchRequest`(`gateway/types.ts:66`)无字段;handler `server.ts:431` 未传 | 加字段(强校验)+ 透传到 #1 的过滤;`tdai_memory_search` 工具必带(§5.6) | +| 4 | **L2/L3 per-account** | persona 读 `pluginDataDir/persona.md`(`auto-recall.ts:148`)、scene 读 `readSceneIndex(pluginDataDir)`(`auto-recall.ts:162`),均 dataDir 根级 | 改为按 `session_key` 派生 per-account 子目录;写入侧(persona `persona-generator.ts:185` / scene writer)与读取侧同步改 | +| 5 | **后台 pipeline 多租户限流 + L3 原子写** | 每 session 一组 timer(L1 idle/L2 schedule,`pipeline-manager.ts:181-182,422`)+ 全局 L3 runner(`pipeline-manager.ts:956-1000`);L3 是裸文件 RMW 无锁(`persona-generator.ts:185`);开关只有全局 `extraction.enabled`(`config.ts:488`),无 per-tenant 开关,也无 per-session 手动触发 L2/L3 的 API(只有 `flushSession`=L1) | 结构式下后台 pipeline 会 ×N(见下成本修正)→ 需**跨 core 全局并发上限**;本地 L3/L2 改**原子写(temp+rename)**;明确「单 dataDir 单进程」契约。详见 §8.5 | +| 6 | **`/recall` response 补 `prependContext`** | 仅返回 `appendSystemContext`(`server.ts:385`),丢弃 L1 query-time 记忆(`auto-recall.ts:186-204`) | `RecallResponse` 加 `prepend_context` 字段返回 `result.prependContext`;**P0 前置**(§5.3) | + +实现路线(两种满足方式,推荐结构式): + +- **结构式(推荐)**:Gateway 维护 `Map`,每账号一份独立 dataDir(`baseDir/{account}`),按需 lazy 实例化 + LRU 淘汰空闲账号。隔离是**结构性**的(各账号物理分库分文件),不依赖「每条 SQL 都记得加 WHERE」,天然覆盖 #1/#2/#4/#7(reindex/计数类),最契合「按账号隔离是核心不变量」。仍需做 #3/#6 的接口字段。 + - ⚠️ **成本修正(2026-06 核查)**:先前「单 core ≈ sqlite 句柄 + 小状态,可控」**低估了**。每账号一个 core = **一套后台 timer + 一个独立 `SerialQueue`**,N 个活跃账号可**并发 N 路 LLM 提纯**(各 core 的串行队列互不相干),造成 DeepSeek 并发/成本尖峰 + N 套常驻 timer 内存。结构式必须在 TDAI 侧配一个**跨 core 的全局并发上限/共享工作队列**(改造项 #5),否则活跃账号一多后台 LLM 打满。 +- **过滤式**:单 TdaiCore + 共享 store,靠 #1/#2 的 `session_key` 过滤。内存省、后台 pipeline 被单一 `SerialQueue` 天然限到 concurrency=1,但「漏一个 WHERE 即跨账号泄漏」,与红线相悖,需配套强测试 + Gateway 层强制 `session_key` 放行。 + +无论哪种,#6(recall 补 `prependContext`)都必须做。最终内部实现由 TDAI 侧定,AI4ALL 只要求对外契约:`/recall`、`/search/*` 按 `session_key` 严格隔离且返回 query-time L1。 + +> 改造清单已抽成独立 issue 供 TDAI 团队推进:`docs/tech_design/tdai_multitenant_issue.md`。 + +wipe/unbind:账号硬删除路径 `unbind_and_wipe_account()` 需联动 TDAI namespace 清除——结构式下 = 删除该账号 dataDir(并卸载其 TdaiCore);过滤式下 = 按 `session_key` 删 L1/L2/L3。**进入真实灰度前必须接通**,不能只靠手册删 data dir。 + +### 8.5 三条工程红线(结合 TDAI 源码核查) + +外部评审提出三条工程红线,逐条核查后**全部成立**,但有三处机制判断需按 TDAI 当前实现纠正。 + +#### 红线 1:接管 pipeline 控制权(不放任原生后台) + +- **成立**:TDAI 有后台常驻调度,非纯被动——每 session 一组 timer(L1 idle `l1IdleTimeoutSeconds` 默认 600s、L2 schedule `l2MaxIntervalSeconds` 默认 3600s,`pipeline-manager.ts:181-182,422`)、全局 L3 runner(`pipeline-manager.ts:956-1000`)、offload 5s poll + 24h reclaim(`offload/index.ts:946,1281`)。 +- **纠正点**:开关粒度是**全局** `extraction.enabled`(`config.ts:488`、`tdai-core.ts:151`),关掉则整条 L0→L1→L2→L3 都不跑,**无 per-tenant 开关**;手动触发只暴露 `flushSession(sessionKey)`(=L1 flush,`/session/end`)与 `/capture`(按阈值/idle 触发 L1),**没有按 session 手动触发 L2/L3 的公开 API**。所以「自己调 API 触发该用户的 L1/L3 提纯」当前**只能做到 L1**。 +- **对我们的结论**:结构式路线会把后台 pipeline ×N(§8.4 成本修正),是这条红线在多租户下的具象。优先方案 = **保留后台、但在 TDAI 侧加跨 core 全局并发上限**(改造项 #5);若要完全接管算力(关 `extraction.enabled` + AI4ALL 按「聊满 N 句」自驱),前提是 TDAI 补 per-session L2/L3 触发 API,工程量更大,第一期不做。 + +#### 红线 2:暴力审计 SQL 与向量过滤 + +- **成立且范围更大**:除 L1 `searchL1Vector/Fts` 无过滤外,**L0 `searchL0Vector/Fts` 同样无过滤**(`store/types.ts:295-296`);conversation search 是**检索后再 filter**(`tools/conversation-search.ts:224-228`);`getAllL1Texts`/`rebuildFtsIndex`/`countL1` 等 reindex/统计类全跨 session。 +- **纠正点**:**TDAI store 是 SQLite/tcvdb,不是 PG**,「开 PostgreSQL 慢查询日志审计」用错了对象。审计应落在:(a) 代码层逐条核对 `store/sqlite.ts` 的 SELECT/向量检索是否带 session;(b) SQLite trace / query log;(c) Gateway 层强制每个 search 请求必带 `session_key` 再放行(防御纵深)。 +- **子要点**:**post-filter ≠ pushdown**。现状先全库取 topK 再 filter,多租户下 topK 可能全来自其他账号、filter 完剩 0,召回质量塌。必须把 session 过滤**下推进 SQL/向量检索**(§8.4 #1/#2),不是检索后过滤。 + +#### 红线 3:L3 画像读写一致性 + +- **成立且更脆**:L3 persona 是**磁盘文件 `persona.md`** 的裸 read-modify-write(`persona-generator.ts:185`,`fs.writeFile`,**无原子 rename、无锁、无 version**);version 乐观锁只在 tcvdb 远程 sync(`tcvdb.ts:1117`),不保护本地文件。L2 scene 同样是无锁 `scene_blocks/*.md`。 +- **纠正点**:「用 PostgreSQL 乐观锁 / version 字段」**不适用**——L3 既不在 PG 也不在任何 SQL 表,是文件。正解 = (a) 原子写(temp + rename);(b) 同账号 L3 串行。 +- **对我们的结论**:单进程内 TDAI 已用 `SerialQueue`(concurrency=1)全局串行 L1/L2/L3,故**单 sidecar 单进程下同账号 L3 不会自竞争**。竞争只在 (i) 同一 dataDir 被两个 sidecar 进程打开,或 (ii) P4 多实例扩容时复活。§4.2「用户硬绑定单节点 + 每节点单 sidecar」拓扑**天然规避** (i)(ii)。→ 当前拓扑**风险低**,但落一条硬约束:**禁止两个 sidecar 进程共享同一 dataDir**;P4 若上多实例/共享存储,必须补原子写或分布式锁。结构式额外好处:每账号独立 core+SerialQueue,跨账号 L3 本就串行隔离。 + +## 9. 分阶段实施 + +### P0:Sidecar 连通性 PoC + +目标:不改主链路,确认 Gateway 可运行;**单账号**形态(多租户改造前,一个 sidecar 只服务测试账号)。 + +任务: + +1. 本机启动 TDAI Gateway;装 `@node-rs/jieba`,配百炼 `TDAI_EMBEDDING_API_KEY`、`embedding.provider=dashscope`、`strategy=hybrid`(§6.2)。 +2. 一次性验证:百炼兼容模式 `/embeddings` 是否接受 `dimensions` 字段 → 定 `sendDimensions`。 +3. **TDAI 侧打 `/recall` 补丁(§8.4 #6)**:`RecallResponse` 补 `prepend_context`,否则 recall 拿不到 query-time L1,PoC 误判。 +4. 写最小 `app/tdai_client.py`。 +5. 加 health/recall/capture dry-run 测试。 +6. 用固定 `aid_806382741` 手工 capture 几轮,再 recall。 + +验收: + +- `GET /health` 返回 ok/degraded。 +- `/capture` 返回 `l0_recorded > 0`。 +- 多轮后 `/recall` 同时返回 `prepend_context`(L1 query 相关)和 persona/scene;hybrid 下 embedding 命中 > 0。 +- 断开百炼(错 key)时 recall 自动退回 FTS、不报错整轮空。 +- AI4ALL 不启动 TDAI 时测试仍绿。 + +### P0.5:TDAI 多租户改造(路 B,P1 前置,TDAI 侧) + +目标:让一个 sidecar 能按账号安全隔离 L0/L1/L2/L3,解除「一个 sidecar 只能服务单账号」的约束。详见 §8.4。 + +任务(TDAI 仓库): + +1. 实现隔离(推荐结构式:per-account dataDir + `Map` lazy/LRU;或过滤式:L0/L1 搜索下推 `session_key` 过滤 + L2/L3 per-account 路径)。 +2. `/search/memories` 加 `session_key` 字段并下推过滤(§8.4 #3);`/search/conversations` 多租户模式 `session_key` 必填。 +3. 提供 namespace wipe 能力(按 `session_key` 或删账号 dataDir),供 AI4ALL `unbind_and_wipe_account()` 联动。 +4. 结构式下加**跨 core 全局并发上限**,限制同时进行的 LLM 提纯路数(§8.4 #5、§8.5 红线 1)。 +5. 本地 L3/L2 写改**原子写(temp+rename)**;文档化「单 dataDir 单进程」契约(§8.5 红线 3)。 + +验收: + +- 两个账号交叉 capture 后,A 的 `/recall`、`/search/memories`、`/search/conversations` 不返回 B 的 L0/L1/persona/scene。 +- wipe 账号 A 后,A 的记忆全清,B 不受影响。 +- 多账号同时活跃时后台并发 LLM 提纯有上限、不随账号数线性膨胀。 + +### P1:主对话灰度接入 + +目标:按账号 allowlist 在真实 turn 中启用 recall/capture(**依赖 P0.5 完成**,一个 sidecar 服务多个灰度账号)。 + +任务: + +1. `build_turn_llm_input()` 调 recall 并注入 `tdai_recall_memories` + `tdai_recall_persona` 两个 block(§5.3)。 +2. 回复成功后 best-effort 调 capture。 +3. 在 tool registry 注册 `tdai_memory_search` / `tdai_conversation_search`,带 `ai4all:{account_id}`,每轮合计上限 3 次(§5.6);`tdai_memory_search` 依赖 §8.4 #3。 +4. 接通 `unbind_and_wipe_account()` → TDAI namespace wipe(§8.4)。 +5. debug trace metadata 加 TDAI 字段。 +6. 聚焦测试覆盖: + - recall 成功注入 prompt(memories + persona 两块)。 + - recall 失败不影响 prompt。 + - capture 使用 `ai4all:{account_id}`。 + - search 工具命中正确账号,且每轮 3 次上限生效。 + - **跨账号隔离**:A 灰度账号的 recall/search 不返回 B 的记忆(依赖 P0.5)。 + - TDAI context 不写入 messages/daily notes。 + +验收: + +- allowlist 账号可看到 TDAI recall 生效。 +- 非 allowlist 账号行为逐字稳定。 +- 多账号共享同一 sidecar 下召回严格不跨账号。 +- TDAI Gateway 停止时主对话正常回复。 + +### P2:历史 seed 与质量评估 + +目标:让 TDAI 具备足够历史材料,并评估效果。 + +任务: + +1. 新增 `scripts/seed_tdai_memory.py`。 +2. 支持单账号 dry-run、limit、按日期过滤。 +3. 输出 seed 行数、session 数、失败数。 +4. 接 session-end / FastAPI shutdown flush(§5.5),确保 pipeline 退出前提纯。 +5. 建立人工评估样例: + - 用户偏好召回。 + - 过去事件追问。 + - 用户纠正旧记忆后是否以当前说法为准。 + +验收: + +- 主要测试账号 seed 成功。 +- 召回内容不跨账号。 +- 错误/敏感记忆不过度注入。 + +### P3:与 Dreaming 的职责重划 + +目标:决定长期方向。 + +可选路径: + +1. 保留 Dreaming 写 `USER.md/MEMORY.md`,TDAI 只做动态 recall。 +2. Dreaming 继续做审计,但减少自动写入整文件。 +3. TDAI 效果足够后,AI4ALL 自建 `account_memory_records/account_user_persona`,参考 `docs/plans/记忆机制_tdai化对齐.md`,逐步替代 sidecar。 +4. 给 TDAI 增加 PostgreSQL backend,保留 TDAI pipeline。 + +### P4:共享存储与多节点 + +触发条件: + +- TDAI 已进入核心体验。 +- 多节点账号迁移频繁。 +- 本地 sidecar SQLite 备份/迁移成本不可接受。 + +可选方案: + +| 方案 | 优点 | 风险 | +|---|---|---| +| TCVDB backend | TDAI 已支持,向量能力完整 | 新增云服务和凭证运维 | +| 新增 TDAI PostgreSQL backend | 统一中心 PG,符合 AI4ALL 厚节点方向 | 需要改 TDAI TypeScript store,工作量较大 | +| AI4ALL 自建 L1/L3 | 完全贴合现有 Python/PG 架构 | 放弃一部分 TDAI 现成 pipeline | + +> 中心 PG 已存在,PostgreSQL backend 在存储统一上最契合 AI4ALL 厚节点方向;但硬前置是 (a) 改 TDAI TypeScript store 实现 `IMemoryStore`(已知开发量)、(b) 中心 PG 装 `pgvector`(需实测,§13),并需接受 (c) 非 PG 节点(如节点 B)的 sidecar recall 每次跨内网连节点 A 的 PG——这与本地 SQLite recall 的低延迟相比是真实取舍。 + +## 10. 测试方案 + +### 10.1 文档阶段 + +本次只改文档,不运行代码测试。自审项: + +- 方案不把不存在的 Python SDK 作为实施依赖。 +- 方案不把 TDAI 当前不支持的 PostgreSQL backend 写成已可配置能力。 +- 所有隔离描述都使用 AI4ALL `account_id`。 +- 接入点与当前 `turn_service.py`、`PromptBuilder`、`memory_writer.py` 对齐。 + +### 10.2 实现阶段建议测试 + +聚焦测试: + +```bash +.venv/bin/pytest tests/test_prompt_builder.py -v +.venv/bin/pytest tests/test_turn_service.py -v +``` + +新增测试建议: + +```text +tests/test_tdai_client.py +tests/test_turn_tdai_memory.py +``` + +手工验证: + +```bash +.venv/bin/uvicorn app.main:app --reload --port 8180 +.venv/bin/python scripts/send_mock_turn.py --url http://127.0.0.1:8180 --text "hello" +.venv/bin/python scripts/check_prompt.py --url http://127.0.0.1:8180 --account aid_806382741 +``` + +预期: + +- TDAI disabled 时 prompt 不含 `tdai_recall_memories` / `tdai_recall_persona`。 +- TDAI enabled 且 recall 有内容时 prompt metadata 记录两块 chars。 +- messages 表只保存用户和助手可见文本。 + +## 11. 风险清单 + +| 风险 | 影响 | 缓解 | +|---|---|---| +| **多账号共享 sidecar 跨账号召回(blocker)** | L1/L2/L3 dataDir 全局,`session_key` 隔离不到,违反隔离红线 | P1 前置完成 §8.4 多租户改造(推荐结构式 per-account 物理隔离);改造前一个 sidecar 只服务单账号;P1 测试含跨账号隔离用例 | +| **`/recall` 丢 `prependContext`** | 拿不到 query-time L1,PoC 误判召回无效 | P0 前置打 §8.4 #6 补丁,response 返回两段 | +| **结构式后台 pipeline ×N(成本)** | 活跃账号多时并发 N 路 LLM 提纯,DeepSeek 并发/成本尖峰 + N 套 timer 内存 | TDAI 侧加跨 core 全局并发上限/共享队列(§8.4 #5、§8.5 红线 1);LRU 淘汰空闲 core | +| **L3 persona 文件并发覆盖** | 多进程/多实例下同账号 persona 丢更新 | 单 dataDir 单进程硬约束(§4.2 拓扑天然满足);TDAI 侧改原子写;P4 多实例再上分布式锁(§8.5 红线 3) | +| **conversation/L0 搜索 post-filter 召回塌陷** | topK 全来自他账号、过滤后剩 0,召回质量差 | 把 session 过滤下推进检索而非检索后 filter(§8.4 #2、§8.5 红线 2) | +| TDAI Gateway API 仍在演进 | 接入代码可能跟随调整 | client 层封装,主链路只依赖内部接口 | +| sidecar SQLite 节点故障导致记忆丢失 | 该节点用户长期记忆暂失 | 微信绑定保证用户不漂移、本地记忆不分裂;中心 PG `messages` 可 re-seed 重建;仅频繁自由迁移时再升级 TCVDB/PG | +| 远程 embedding(百炼)超时/宕机 | 该轮丢失语义召回 | hybrid 并行、embedding 腿失败返空,自动退回 FTS 关键词召回(`auto-recall.ts:searchHybrid` 已验证);`recallTimeoutMs` 短超时 | +| `@node-rs/jieba` 装不上 | 中文 FTS 退化为单字切分,召回变差 | 生产机预装并校验;纳入部署检查 | +| recall 延迟增加首 token 时间 | 微信回复变慢 | 1.2-1.5s 硬超时,失败跳过 | +| recall 内容污染长期记忆 | 错误记忆被二次写回 | prompt-only 注入,不写 messages/daily notes | +| TDAI extraction 产生敏感记忆 | 隐私风险 | capture 前过滤 + TDAI prompt + 后续 admin 观测 | +| 双记忆系统冲突 | prompt 里新旧记忆不一致 | 明确用户本轮优先;debug trace 记录来源 | +| 本地 data dir 未备份 | sidecar 记忆丢失 | 纳入备份;可从 AI4ALL messages seed 重建 | + +## 12. 最小实现摘要 + +第一版只需要这些代码改动: + +1. 新增 `app/tdai_client.py`。 +2. `app/config.py` / `.env.example` 新增 TDAI 配置。 +3. `app/turn_service.py`: + - `build_turn_llm_input()` 中调用 recall,并通过 `PromptBuilder.extra_blocks` 注入。 + - 回复成功后 best-effort capture。 +4. 新增 `tests/test_tdai_client.py` 和聚焦 turn 测试。 +5. 可选新增 `scripts/seed_tdai_memory.py`。 + +第一版不改: + +- 不改 `dreaming.py`。 +- 不改 DB schema。 +- 不改 `messages` 存储格式。 +- 不改 OpenClaw bridge。 +- 不引入 TDAI PostgreSQL backend。 + +## 13. 关键确认(2026-06)与遗留项 + +| # | 问题 | 结论 | +|---|---|---| +| 1 | 节点角色 | **已确认**:两节点都跑 AI4ALL app,节点 A 兼 PG。每节点各起一个 TDAI sidecar(§4.2)。 | +| 2 | pgvector 可用性 | **大概率可装,需实测**。仅在「托管 PG 扩展白名单不含 `vector` / 无 `CREATE EXTENSION` 权限 / PG 版本过低(需 11+,HNSW 需 pgvector 0.5+)/ 自建机无法装扩展二进制」时不可装。验证:`SELECT * FROM pg_available_extensions WHERE name='vector';`,或试 `CREATE EXTENSION vector;`。仅 P4 走 PG backend 时硬依赖;第一期 sidecar SQLite 不需要。 | +| 3 | TDAI 后台 LLM / embedding | **均已确认**。LLM 复用 DeepSeek `api_key`(覆盖 extraction/persona);embedding 用阿里云百炼 `text-embedding-v3` 远程,`strategy=hybrid`(理由:轻量服务器 <3G 内存,本地模型有 OOM 风险;远程本机零内存、成本可忽略;hybrid 故障可退 FTS)。两把 key 独立(§6.2)。 | +| 4 | 故障策略 | **已确认(第一期)**:硬绑定 + 故障暂停,不做热备/重绑;DR 靠节点恢复后续用本地 SQLite 或从中心 PG re-seed(§4.2)。后续再评估热备。 | +| 5 | 多账号隔离模型 | **已确认(2026-06)**:走**路 B——改 TDAI 支持多租户**(§8.4),作为 P1 前置(P0.5)。`session_key` 仅隔离 L0/pipeline,L1/L2/L3 需 TDAI 侧改造;实现路线推荐结构式(per-account dataDir 物理隔离),过滤式为备选。改造前一个 sidecar 只服务单账号。 | +| 6 | `/recall` prependContext 补丁 | **已确认**:纳入 **P0 前置**。当前 `/recall` 只返回 `appendSystemContext`、丢弃 L1 query-time 记忆(`server.ts:385`);P0 即在 TDAI 侧补 `prepend_context`,否则 PoC 误判(§5.3、§8.4 #6)。 | + +遗留待定(不阻塞 P0,但阻塞 P1): + +- 路 B 实现路线终选(结构式 per-account dataDir vs 过滤式 session 下推)——由 TDAI 侧定,AI4ALL 只约束对外契约(§8.4)。 + +遗留待定(不阻塞 P0–P2): + +- 百炼兼容模式 `/embeddings` 是否接受请求体 `dimensions` 字段(接受→`sendDimensions:true`,报 400→false);上线前一次性验证。 +- `@node-rs/jieba` native 包在生产机的安装/兼容性(影响 hybrid 的 FTS 腿,装不上中文 FTS 退化为单字切分)。 +- 记忆文本出第三方云(百炼)的隐私签字。 +- pgvector 实测结果(决定 P4 是否走 PostgreSQL backend)。 diff --git a/docs/tdai_multitenant_issue.md b/docs/tdai_multitenant_issue.md new file mode 100644 index 00000000..6a15ad4a --- /dev/null +++ b/docs/tdai_multitenant_issue.md @@ -0,0 +1,65 @@ +# [TDAI] 多租户隔离改造 — 让一个 Gateway/Core 安全服务多账号 + +> 提给:TDAI(TencentDB-Agent-Memory)维护方 +> 提出方:AI4ALL 微信个人 AI 陪伴项目 +> 关联设计:AI4ALL `docs/tech_design/tdai_multitenant_design.md` §8.4 / §8.5 / P0.5 +> 证据基线:核对自 TDAI 仓库当前 `src/`(文件:行号见下) + +## 背景 + +AI4ALL 以 sidecar 形态接 TDAI:一个节点起一个 Gateway,服务该节点上的**多个**最终用户账号,账号隔离主键是 `session_key = "ai4all:{account_id}"`。 + +经源码核实,TDAI 当前 standalone/sqlite store 是「**单租户 per dataDir**」:`session_key` 只隔离 L0(原始对话写入)与 pipeline/session 状态,**L1/L2/L3 召回与搜索是 dataDir 全局的**。因此「一个 Gateway 靠 `session_key` 服务多账号」会跨账号召回,违反我们「按账号隔离」的硬不变量。 + +本 issue 列出让一个 Gateway 安全服务多账号所需的 TDAI 侧改造、验收标准,以及一个待 TDAI 侧拍板的实现路线选择。 + +## 一、隔离缺口清单(按需修复) + +| # | 缺口 | 证据(file:line) | 现状 | 需要 | +|---|---|---|---|---| +| 1 | **L1 搜索无 session 过滤** | `core/store/types.ts:269-270` `searchL1Vector(queryEmbedding, topK?, queryText?)` / `searchL1Fts(ftsQuery, limit?)`;SQL 见 `core/store/sqlite.ts:635-641`(`l1_vec ... WHERE embedding MATCH ?`)、`sqlite.ts:796-805`(`l1_fts MATCH ?`) | 签名与 SQL 都无 session 条件,返回全库 L1 | 加 `sessionKey` 参数;SQL 把 session 过滤**下推**(`WHERE session_key = ?` / 向量预过滤),并沿 `executeMemorySearch → searchMemories → performAutoRecall` 全链路透传 | +| 2 | **L0 搜索无 session 过滤** | `types.ts:295-296` `searchL0Vector` / `searchL0Fts` 签名无 session | conversation search 走的是**检索后过滤**:`core/tools/conversation-search.ts:224-228` `if (sessionFilter) results = results.filter(r => r.session_key === sessionFilter)` | 同 #1:把过滤**下推**进检索,而非取完 topK 再 filter(否则 topK 可能全来自其他账号,过滤后召回质量塌陷) | +| 3 | **`/search/memories` 请求无 `session_key`** | `gateway/types.ts:66-71` `MemorySearchRequest { query; limit?; type?; scene? }`;handler `gateway/server.ts:424-436` 未传 session | 该端点无法按账号约束,任意账号可搜全库 L1 | `MemorySearchRequest` 加 `session_key`(必填/强校验),handler 透传到 #1 的过滤 | +| 4 | **`/search/conversations` 的 `session_key` 是可选** | `gateway/types.ts:83-87` `session_key?`;`server.ts:447-458` 仅在提供时透传 | 缺省即跨账号 | 多租户模式下强制必填(缺省拒绝) | +| 5 | **L2/L3 是 dataDir 根级文件** | persona 读 `pluginDataDir/persona.md`(`core/hooks/auto-recall.ts:148-152`、写 `core/persona/persona-generator.ts:185`);scene 读 `readSceneIndex(pluginDataDir)`(`auto-recall.ts:162-164`、写 `core/scene/*` → `scene_blocks/*.md`) | 多账号共用同一 dataDir 时直接互相覆盖/泄漏 | 按 `session_key` 派生 per-account 子目录;读写两侧同步改 | +| 6 | **`/recall` response 丢 `prependContext`** | `gateway/server.ts:385` 仅返回 `appendSystemContext`;query-time L1 在 `auto-recall.ts:186-204` 的 `prependContext` 被丢弃,但 `memory_count` 仍报 L1 条数 | 接入方拿到 persona/scene 却拿不到「当前 query 相关 L1 记忆」 | `RecallResponse` 加 `prepend_context` 返回 `result.prependContext`(AI4ALL 侧 P0 前置,最优先) | +| 7 | **reindex / 统计类全库跨 session** | `getAllL1Texts`/`getAllL0Texts`(`sqlite.ts:1767-1797`)、`rebuildFtsIndex`(`sqlite.ts:2207-2269`)、`countL1`/`countL0`(`sqlite.ts:1338/1745`) | 重建索引/计数跨所有账号 | 结构式天然隔离;过滤式需评估是否要 per-session 重建 | + +> #6 对 AI4ALL 最优先:未打补丁则 PoC 拿不到 query-time L1,必然误判召回效果。其余按多账号灰度前完成。 + +## 二、后台 pipeline 在多租户下的隐患(与隔离同等重要) + +TDAI 有后台常驻调度,非「纯被动」: + +- 每 session 一组 timer:L1 idle(`l1IdleTimeoutSeconds`,默认 600s)、L2 schedule(`l2MaxIntervalSeconds`,默认 3600s)——`utils/pipeline-manager.ts:181-182,422`。 +- 全局 L3 runner:`pipeline-manager.ts:956-1000`,`SerialQueue` concurrency=1,一次跑一轮但顺序触及所有账号 persona。 +- offload(若 `offload.enabled`):5s poll + 24h reclaim——`offload/index.ts:946,1281`。 +- 开关粒度:只有全局 `extraction.enabled`(`config.ts:488`、`tdai-core.ts:151`),**无 per-tenant 开关**。 +- 手动触发:只暴露 `flushSession(sessionKey)`(=L1 flush,经 `POST /session/end`,`tdai-core.ts:359`)和 `/capture`(按阈值/idle 触发 L1)。**无按 session 手动触发 L2/L3 的公开 API。** + +多租户影响与诉求: + +1. **结构式路线(每账号一 core)会把后台 pipeline 乘以 N**:N 套 timer + N 个独立 SerialQueue → 可并发 N 路 LLM 提纯,造成后端 LLM 并发/成本尖峰。**诉求**:提供一个**跨 core/跨 session 的全局并发上限或共享工作队列**,把同时进行的 L1/L2/L3 提纯限到可控并发。 +2. 若接入方想完全接管算力(关掉后台、自行按「聊满 N 句」触发),**诉求**:提供**按 `session_key` 手动触发 L2/L3 的公开 API**(当前只有 L1 flush)。 +3. L3 一致性:persona 是磁盘文件 `persona.md` 的裸 read-modify-write(`persona-generator.ts:185`,无原子 rename / 无锁 / 无 version;version 乐观锁只在 tcvdb 远程 `tcvdb.ts:1117`,不保护本地文件)。**诉求**:本地 L3/L2 写改为**原子写(temp + rename)**,并明确「同一 dataDir 不可被两个进程同时打开」的契约;结构式下每账号独立 SerialQueue 已串行化同账号 L3。 + +## 三、实现路线(请 TDAI 侧拍板) + +满足上述隔离有两条路,AI4ALL 只约束**对外契约**(`/recall`、`/search/*` 按 `session_key` 严格隔离且返回 query-time L1),内部实现由 TDAI 定: + +- **结构式(AI4ALL 推荐)**:Gateway 维护 `Map`,每账号独立 dataDir(`baseDir/{account}`),lazy 实例化 + LRU 淘汰空闲账号。隔离是**结构性**的(物理分库分文件),天然覆盖 #1/#2/#5/#7,仍需做 #3/#4/#6 的接口字段。代价是 **N 个 core 常驻 + 后台 pipeline ×N**,必须配第二节第 1 条的全局并发上限。 +- **过滤式**:单 core + 共享 store,靠 #1/#2 的 `session_key` 下推过滤 + #5 的 per-account 路径。内存省、后台 pipeline 天然被单一 SerialQueue 限到 concurrency=1,但「漏一个 WHERE 即跨账号泄漏」,需配套强测试与 Gateway 层强制校验。 + +## 四、验收标准 + +1. 两账号交叉 capture 后,A 的 `/recall`、`/search/memories`、`/search/conversations` **不返回** B 的 L1/L0/persona/scene。 +2. `/recall` 同时返回 `prepend_context`(query-time L1)与 `appendSystemContext`(persona/scene)。 +3. 提供按 `session_key`(或删账号 dataDir)的 namespace wipe 能力,供接入方账号硬删除联动。 +4. 后台 pipeline 在多账号活跃时,并发 LLM 提纯有全局上限,不随账号数线性膨胀。 +5. 本地 L3/L2 写为原子写;明确单-dataDir-单-进程契约。 +6. 默认/示例配置文档化:多租户模式下 `/search/*` 的 `session_key` 为必填。 + +## 五、AI4ALL 侧已对齐的前置假设(供 TDAI 侧确认无冲突) + +- 部署:用户硬绑定单节点,每节点单 sidecar 单进程;故障暂停、不热备(第一期)。因此「单-dataDir-单-进程」契约对我们成立,第二节第 3 条的多实例竞争在第一期不触发。 +- 后端:TDAI store 用 SQLite + 远程 embedding(阿里云百炼 `text-embedding-v3`,OpenAI 兼容),`strategy=hybrid`;非 PG/pgvector。审计向量/SQL 请以 SQLite 为对象(SQLite trace / 代码层核对),不要按 PG 慢查询日志思路。 diff --git a/tdai-gateway.yaml b/tdai-gateway.yaml new file mode 100644 index 00000000..b6005209 --- /dev/null +++ b/tdai-gateway.yaml @@ -0,0 +1,27 @@ +# TDAI Gateway config — picked up automatically from the project root (CWD) +# by resolveConfigPath() in src/gateway/config.ts. +# +# Scope: this file ONLY carries what env vars cannot express — the embedding +# block (the gateway has no TDAI_EMBEDDING_* vars). server / data / llm keep +# coming from .env (TDAI_DATA_DIR, TDAI_MULTI_TENANT, TDAI_LLM_*), and env +# always wins over yaml, so there is nothing to keep in sync here. +# +# ${VAR} placeholders are expanded from process.env at load time. Because the +# gateway is launched with `node --env-file=.env ...`, ${DASHSCOPE_API_KEY} +# resolves to the value in .env — the key is never written into this file +# (which, unlike .env, is NOT gitignored). + +memory: + embedding: + enabled: true + # Any non-"none"/"local"/"qclaw" provider is treated as OpenAI-compatible + # and hits ${baseUrl}/embeddings. Aliyun Bailian / DashScope exposes exactly + # that shape under its compatible-mode endpoint. + provider: dashscope + baseUrl: https://dashscope.aliyuncs.com/compatible-mode/v1 + apiKey: ${DASHSCOPE_API_KEY} + model: text-embedding-v3 + dimensions: 1024 # text-embedding-v3 default output dim + sendDimensions: true # compat mode accepts the dimensions param (verified) + recall: + strategy: hybrid # vector + BM25; falls back to keyword if embedding is down From e05b913b83c9bbc267517fa9aa3adb751d261ea1 Mon Sep 17 00:00:00 2001 From: Jack <278171810@qq.com> Date: Sat, 27 Jun 2026 16:39:43 +0800 Subject: [PATCH 09/20] fix(gateway): reject multi-tenant + tcvdb backend at config load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Structural multi-tenant isolation is physical: each account gets its own dataDir + SQLite file. The TCVDB backend ignores dataDir and routes every per-account core to one shared database/collection set, while the structural route does not push session_key into L1/L0 search — so multiTenant=true + storeBackend=tcvdb silently returns other accounts' memories from /recall and /search/memories. loadGatewayConfig now throws on that exact combination so the leak surfaces as a startup failure instead of cross-tenant data exposure. Single-tenant + TCVDB (one shared core, one database) and multi-tenant + SQLite are unaffected. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Jack <278171810@qq.com> --- src/gateway/config.test.ts | 45 ++++++++++++++++++++++++++++++++++++++ src/gateway/config.ts | 39 ++++++++++++++++++++++++++------- 2 files changed, 76 insertions(+), 8 deletions(-) create mode 100644 src/gateway/config.test.ts diff --git a/src/gateway/config.test.ts b/src/gateway/config.test.ts new file mode 100644 index 00000000..893ae987 --- /dev/null +++ b/src/gateway/config.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect } from "vitest"; +import { loadGatewayConfig } from "./config.js"; +import { parseConfig } from "../config.js"; + +/** + * Guard: structural multi-tenant isolation is physical (per-account dataDir + + * its own SQLite). The TCVDB backend ignores dataDir and routes every core to + * ONE shared database/collection set, so the combination would leak L1/L0 + * recall and search across accounts. `loadGatewayConfig` must refuse it at load + * time. See the guard in config.ts and the review item (tcvdb isolation gap). + * + * A full `memory` override is passed so these assertions don't depend on the + * repo-root tdai-gateway.yaml that loadGatewayConfig picks up from CWD. + */ +describe("loadGatewayConfig — multi-tenant store-backend guard", () => { + const tcvdb = () => + parseConfig({ storeBackend: "tcvdb", tcvdb: { url: "https://x", apiKey: "k", database: "db" } }); + + it("throws when multiTenant + storeBackend=tcvdb (would leak across accounts)", () => { + expect(() => + loadGatewayConfig({ + data: { baseDir: "/tmp/tdai-test", multiTenant: true }, + memory: tcvdb(), + }), + ).toThrow(/multi-tenant/i); + }); + + it("allows multiTenant + sqlite (the supported structural-isolation backend)", () => { + expect(() => + loadGatewayConfig({ + data: { baseDir: "/tmp/tdai-test", multiTenant: true }, + memory: parseConfig({}), + }), + ).not.toThrow(); + }); + + it("allows single-tenant + tcvdb (one shared core, one database — no cross-account routing)", () => { + expect(() => + loadGatewayConfig({ + data: { baseDir: "/tmp/tdai-test", multiTenant: false }, + memory: tcvdb(), + }), + ).not.toThrow(); + }); +}); diff --git a/src/gateway/config.ts b/src/gateway/config.ts index 9328f58b..ccf5537e 100644 --- a/src/gateway/config.ts +++ b/src/gateway/config.ts @@ -202,14 +202,37 @@ export function loadGatewayConfig(overrides?: Partial): GatewayCo // Merge overrides one level deep so partial `server`/`data`/`llm` patches // (frequently used by e2e tests) don't accidentally drop sibling fields // such as `corsOrigins` introduced after they were written. - if (!overrides) return base; - return { - ...base, - ...overrides, - server: { ...base.server, ...(overrides.server ?? {}) }, - data: { ...base.data, ...(overrides.data ?? {}) }, - llm: { ...base.llm, ...(overrides.llm ?? {}) }, - }; + const effective: GatewayConfig = overrides + ? { + ...base, + ...overrides, + server: { ...base.server, ...(overrides.server ?? {}) }, + data: { ...base.data, ...(overrides.data ?? {}) }, + llm: { ...base.llm, ...(overrides.llm ?? {}) }, + } + : base; + + // Fail fast on the one config combo that silently breaks tenant isolation. + // + // Multi-tenant isolation in this fork is *structural*: each account gets its + // own dataDir + its own SQLite file (distinct L0/L1 tables, persona.md, …). + // The TCVDB backend ignores dataDir and routes EVERY core to one shared + // `${database}_l1_memories` / `_l0_conversations` / `_profiles` collection + // set (see core/store/factory.ts + tcvdb.ts), while the structural route does + // not push `session_key` into L1/L0 search — so `/recall` and + // `/search/memories` would return other accounts' memories. Refuse at load + // time rather than leak across tenants. (Single-tenant + TCVDB is fine: one + // shared core, one database, no cross-account routing.) + if (effective.data.multiTenant && effective.memory.storeBackend === "tcvdb") { + throw new Error( + "Multi-tenant mode is incompatible with storeBackend=tcvdb: every per-account " + + "core shares one TCVDB database/collection set, so L1/L0 recall and search would " + + "leak across accounts (structural isolation only holds for the per-dataDir SQLite " + + "backend). Use storeBackend=sqlite for multi-tenant, or run single-tenant " + + "(TDAI_MULTI_TENANT=false) with TCVDB.", + ); + } + return effective; } // ============================ From beaa14f9dce2d0abf86e63cdb6ba8de4fed755a1 Mon Sep 17 00:00:00 2001 From: Jack <278171810@qq.com> Date: Sat, 27 Jun 2026 16:40:28 +0800 Subject: [PATCH 10/20] fix(gateway): refuse /seed in multi-tenant mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /seed writes to a shared baseDir/seed- snapshot dir, not the per-account store under baseDir/{account} that recall/search read. In multi-tenant mode a "successful" seed (200, l0_recorded > 0) is therefore invisible to every core — a silent no-op that misleads backfill callers (scripts/import-psydt.ts already documents this and works around it). Return 400 in multi-tenant mode, pointing operators at the per-account seeding path (executeSeed into registry.resolveDataDir(session_key)). Single-tenant /seed is unchanged. Covered by the multi-tenant HTTP e2e test. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Jack <278171810@qq.com> --- src/gateway/server.e2e.test.ts | 9 +++++++++ src/gateway/server.ts | 18 ++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/src/gateway/server.e2e.test.ts b/src/gateway/server.e2e.test.ts index 140a2b2a..0809e2b1 100644 --- a/src/gateway/server.e2e.test.ts +++ b/src/gateway/server.e2e.test.ts @@ -92,6 +92,15 @@ describe("TdaiGateway HTTP wiring", () => { expect(status, `${ep} should 400 without session_key`).toBe(400); } + // /seed is refused in multi-tenant mode: it writes a shared snapshot dir, + // not the per-account store, so a "successful" seed would be invisible to + // recall/search. Even a well-formed body (session_key + data) must 400. + const seedMt = await post(`${origin}/seed`, { + session_key: "ai4all:alice", + data: [{ sessionKey: "ai4all:alice", conversations: [[{ role: "user", content: "x" }, { role: "assistant", content: "y" }]] }], + }); + expect(seedMt.status, "/seed should 400 in multi-tenant mode").toBe(400); + // Capture two accounts. This is the FIRST capture to each freshly-created // account core, which is exactly the cold-start path: the gateway must stamp // the turn's messages strictly after the cold-start L0 cursor floor, so BOTH diff --git a/src/gateway/server.ts b/src/gateway/server.ts index b647c2ca..e2372534 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -592,6 +592,24 @@ export class TdaiGateway { private async handleSeed(req: http.IncomingMessage, res: http.ServerResponse): Promise { const body = await parseJsonBody(req); + if (this.multiTenant) { + // /seed writes to a shared `baseDir/seed-` snapshot dir, NOT the + // per-account store under `baseDir/{account}` that recall/search read — so + // in multi-tenant mode a "successful" seed (200, l0_recorded > 0) is + // invisible to every core. Refuse rather than silently no-op. Backfill an + // account by running `executeSeed` into `registry.resolveDataDir(key)` + // (the exact per-account dir); see scripts/import-psydt.ts for the pattern. + sendError( + res, + 400, + "POST /seed is not supported in multi-tenant mode: it writes a shared snapshot " + + "dir, not the per-account store, so seeded memory is invisible to /recall and " + + "/search. Seed per-account into registry.resolveDataDir(session_key) — see " + + "scripts/import-psydt.ts.", + ); + return; + } + if (!body.data) { sendError(res, 400, "Missing required field: data"); return; From b8b4d236490d9375bb50d86014da791ac65e5909 Mon Sep 17 00:00:00 2001 From: Jack <278171810@qq.com> Date: Sat, 27 Jun 2026 17:12:26 +0800 Subject: [PATCH 11/20] fix(gateway): lease/refcount cores so LRU eviction can't tear down a live request MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The multi-tenant LRU evictor (maxResidentCores) could pick a core that another in-flight request was still using: getCore() returned the core, then a different account's request triggered evictLruIfNeeded → core.destroy(), closing the SQLite handle underneath the first handler. Only triggered when maxResidentCores was set below peak concurrent accounts, but then it surfaced as a capture/recall/search hitting a closed store. Add a lease/refcount: registry.acquire() pins a core (pins++) and returns a release(); request handlers hold the lease for the whole call and release it in a finally. Eviction skips any core with pins > 0 (allowing a transient over-limit rather than killing a live request), and manual evict()/wipe() now defer teardown until in-flight leases drain. release() is idempotent. getCore() stays pin-free for health/eager-warmup/tests. Health reports resident.pinned. Covered by three new registry tests (no eviction under lease, wipe defers to drain, idempotent double-release); existing LRU + e2e suites still pass. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Jack <278171810@qq.com> --- src/gateway/core-registry.test.ts | 73 +++++++++++++ src/gateway/core-registry.ts | 119 +++++++++++++++++++-- src/gateway/server.ts | 171 +++++++++++++++++------------- src/gateway/types.ts | 7 +- 4 files changed, 282 insertions(+), 88 deletions(-) diff --git a/src/gateway/core-registry.test.ts b/src/gateway/core-registry.test.ts index 979ca00c..6c9865a4 100644 --- a/src/gateway/core-registry.test.ts +++ b/src/gateway/core-registry.test.ts @@ -196,6 +196,79 @@ describe("CoreRegistry LRU eviction (multi-tenant)", () => { }); }); +describe("CoreRegistry lease / refcount (LRU eviction safety)", () => { + let baseDir: string; + const registries: CoreRegistry[] = []; + + function reg(maxResidentCores?: number): CoreRegistry { + const r = makeRegistry(baseDir, true, maxResidentCores); + registries.push(r); + return r; + } + + beforeEach(async () => { + baseDir = await fs.mkdtemp(path.join(os.tmpdir(), "tdai-lease-")); + }); + + afterEach(async () => { + await Promise.all(registries.splice(0).map((r) => r.destroyAll())); + await fs.rm(baseDir, { recursive: true, force: true }); + }); + + it("a leased core is NOT LRU-evicted even past the cap, and is evictable once released", async () => { + const r = reg(1); // cap of 1 — any second resident core would normally evict the first + const leaseA = await r.acquire("ai4all:a"); + // Touch a second account while a is still leased. With a refcount, a cannot + // be torn down mid-request, so the registry holds BOTH (transient over-limit) + // rather than destroy a's store underneath the live lease. + await r.getCore("ai4all:b"); + expect(r.size).toBe(2); + expect(r.peek("ai4all:a")).toBeDefined(); + expect(r.residentStats().pinned).toBe(1); + + // The leased core's store is still open: it can serve work. + const conv = await leaseA.core.searchConversations({ query: "x", limit: 1, sessionKey: "ai4all:a" }); + expect(conv.total).toBe(0); // empty store, but a LIVE one (no closed-handle throw) + + // Release a → it is now an eligible victim; the next get past the cap evicts it. + leaseA.release(); + expect(r.residentStats().pinned).toBe(0); + await delay(5); + await r.getCore("ai4all:c"); // size 3 > 1 → evict the now-unpinned LRU + expect(r.peek("ai4all:a")).toBeUndefined(); + }); + + it("wipe of a leased account defers teardown until the lease is released", async () => { + const r = reg(); + const dir = r.resolveDataDir("ai4all:erin"); + const lease = await r.acquire("ai4all:erin"); + + // Kick off wipe while the lease is held; it must not resolve before release. + let wiped = false; + const wipePromise = r.wipe("ai4all:erin").then((d) => { wiped = true; return d; }); + await delay(20); + expect(wiped).toBe(false); // teardown is parked on the in-flight lease + + lease.release(); + await wipePromise; + expect(wiped).toBe(true); + expect(r.peek("ai4all:erin")).toBeUndefined(); + await expect(fs.stat(dir)).rejects.toBeTruthy(); // dir gone only after drain + }); + + it("double-release is idempotent (does not under-count pins)", async () => { + const r = reg(); + const lease = await r.acquire("ai4all:a"); + lease.release(); + lease.release(); // second call must be a no-op + expect(r.residentStats().pinned).toBe(0); + // A fresh acquire still works and is correctly counted. + const again = await r.acquire("ai4all:a"); + expect(r.residentStats().pinned).toBe(1); + again.release(); + }); +}); + describe("CoreRegistry wipe guards", () => { it("refuses namespace wipe in single-tenant mode", async () => { const baseDir = await fs.mkdtemp(path.join(os.tmpdir(), "tdai-wipe-st-")); diff --git a/src/gateway/core-registry.ts b/src/gateway/core-registry.ts index 40a66af3..75e39051 100644 --- a/src/gateway/core-registry.ts +++ b/src/gateway/core-registry.ts @@ -102,8 +102,29 @@ interface CoreEntry { dataDir: string; /** Resolves once the core has finished `initialize()`. */ ready: Promise; - /** Last time this core served a request (epoch ms) — for future LRU eviction. */ + /** Last time this core served a request (epoch ms) — for LRU eviction. */ lastUsedMs: number; + /** + * Active leases: in-flight requests currently holding this core (see + * {@link CoreRegistry.acquire}). A core with `pins > 0` is **never** an LRU + * eviction victim, and manual evict/wipe defers its teardown until pins drain + * — so a request mid-`capture`/`recall`/`search` can never have its SQLite + * handle closed underneath it. + */ + pins: number; + /** Set while a teardown is waiting for {@link pins} to reach 0; fired by the last release. */ + onIdle?: () => void; +} + +/** + * A held reference to an account's core. The core is guaranteed alive (its store + * open) until {@link release} is called. Always release exactly once — a + * `try/finally` around the handler body is the intended usage. Release is + * idempotent, so a double-release is harmless. + */ +export interface CoreLease { + core: TdaiCore; + release: () => void; } /** @@ -184,9 +205,16 @@ export class CoreRegistry { return DEFAULT_MAX_RESIDENT_CORES; } - /** Live resident-core stats (for health/metrics). `limit` 0 = unlimited. */ - residentStats(): { count: number; limit: number } { - return { count: this.cores.size, limit: this.maxResidentCores }; + /** + * Live resident-core stats (for health/metrics). `limit` 0 = unlimited; + * `pinned` is how many resident cores currently have in-flight leases (a + * `pinned` near `count` while `count > limit` means the limiter is holding + * cores past the cap because they are all busy — transient, expected). + */ + residentStats(): { count: number; limit: number; pinned: number } { + let pinned = 0; + for (const e of this.cores.values()) if (e.pins > 0) pinned++; + return { count: this.cores.size, limit: this.maxResidentCores, pinned }; } /** Live extraction-limiter stats (for health/metrics). */ @@ -218,13 +246,14 @@ export class CoreRegistry { } /** - * Get (lazily creating + initializing) the core for a `session_key`. + * Ensure an entry exists for `session_key` (lazily creating + scheduling its + * `initialize()`), returning it **synchronously** without awaiting `ready`. * - * Concurrent calls for the same key share a single `initialize()` — the entry - * is inserted synchronously before the await, so a second caller racing in - * finds it and awaits the same `ready` promise. + * Synchronous on purpose: callers increment `pins` in the same tick before + * the first `await`, so a concurrent `evictLruIfNeeded` (which only runs from + * this method's own create branch) can never select a just-acquired core. */ - async getCore(sessionKey: string): Promise { + private ensureEntry(sessionKey: string): CoreEntry { const { key, dataDir } = this.resolve(sessionKey); let entry = this.cores.get(key); @@ -239,7 +268,7 @@ export class CoreRegistry { const ready = prevClosing ? prevClosing.then(() => core.initialize()) : core.initialize(); - entry = { core, dataDir, ready, lastUsedMs: Date.now() }; + entry = { core, dataDir, ready, lastUsedMs: Date.now(), pins: 0 }; this.cores.set(key, entry); this.opts.logger.debug?.( `[tdai-gateway] [registry] Core created for ${this.multiTenant ? key : "single-tenant"} (dataDir=${dataDir}, active=${this.cores.size})`, @@ -247,10 +276,65 @@ export class CoreRegistry { this.evictLruIfNeeded(key); } entry.lastUsedMs = Date.now(); + return entry; + } + + /** + * Get (lazily creating + initializing) the core for a `session_key`. + * + * Concurrent calls for the same key share a single `initialize()`. **Does not + * pin** — the returned core may be LRU-evicted at any time, so this is only + * safe for callers that use it synchronously or tolerate eviction (health, + * single-tenant eager warm-up, tests). Request handlers must use + * {@link acquire} so the core can't be torn down mid-request. + */ + async getCore(sessionKey: string): Promise { + const entry = this.ensureEntry(sessionKey); await entry.ready; return entry.core; } + /** + * Acquire a **lease** on the core for a `session_key`: like {@link getCore}, + * but pins the core so LRU eviction and manual teardown cannot close its store + * until the lease is released. This closes the race where one account's + * request triggers eviction of another account's core while a request is still + * using it (`maxResidentCores` below peak concurrency). Always release in a + * `finally`. + */ + async acquire(sessionKey: string): Promise { + const entry = this.ensureEntry(sessionKey); + entry.pins++; // synchronous, same tick as ensureEntry — no eviction can interleave + + let released = false; + const release = (): void => { + if (released) return; // idempotent: only the first call counts + released = true; + this.unpin(entry); + }; + + try { + await entry.ready; + } catch (err) { + release(); // init failed — don't leak the pin + throw err; + } + return { core: entry.core, release }; + } + + /** + * Drop one lease. When the last lease for a core that is awaiting teardown is + * released, the deferred teardown (set up in {@link beginTeardown}) is woken. + */ + private unpin(entry: CoreEntry): void { + if (entry.pins > 0) entry.pins--; + if (entry.pins === 0 && entry.onIdle) { + const wake = entry.onIdle; + entry.onIdle = undefined; + wake(); + } + } + /** * Return an already-instantiated core without creating one. Used by the * health probe (single-tenant) so it doesn't spin up a core as a side effect. @@ -342,12 +426,16 @@ export class CoreRegistry { let lru: CoreEntry | undefined; for (const [k, e] of this.cores) { if (k === protectKey) continue; + if (e.pins > 0) continue; // never evict a core with in-flight leases if (!lru || e.lastUsedMs < lru.lastUsedMs) { lruKey = k; lru = e; } } - if (!lruKey || !lru) break; // only the protected core remains + // No evictable (unpinned) victim: every other core is busy. Stop and allow + // a transient over-limit rather than tear down a live request — the cap + // bounds *idle* warm cores, and correctness wins over the memory bound. + if (!lruKey || !lru) break; this.cores.delete(lruKey); void this.beginTeardown(lruKey, lru); this.opts.logger.debug?.( @@ -363,6 +451,15 @@ export class CoreRegistry { */ private beginTeardown(key: string, entry: CoreEntry): Promise { const done = (async () => { + // Wait for in-flight leases to release before closing the store, so a + // request holding this core never hits a closed SQLite handle. LRU + // eviction only ever tears down UNPINNED cores, so this await is a no-op + // there; it matters for manual evict()/wipe() of a still-busy account. + if (entry.pins > 0) { + await new Promise((resolve) => { + entry.onIdle = resolve; + }); + } await entry.ready.catch(() => {}); try { await entry.core.destroy(); // flushes pipeline queues, then closes store diff --git a/src/gateway/server.ts b/src/gateway/server.ts index e2372534..af91f3e9 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -19,7 +19,7 @@ import fs from "node:fs"; import { URL } from "node:url"; import { timingSafeEqual } from "node:crypto"; import { CoreRegistry } from "./core-registry.js"; -import type { TdaiCore } from "../core/tdai-core.js"; +import type { CoreLease } from "./core-registry.js"; import { loadGatewayConfig } from "./config.js"; import type { GatewayConfig } from "./config.js"; import { initDataDirectories } from "../utils/pipeline-factory.js"; @@ -141,19 +141,24 @@ export class TdaiGateway { } /** - * Resolve the core for a request, enforcing the multi-tenant `session_key` + * Lease the core for a request, enforcing the multi-tenant `session_key` * contract. Returns `null` (after writing a 400) when multi-tenant mode is on * but the caller omitted `session_key`, so handlers must short-circuit. + * + * The returned lease pins the core for the duration of the request — handlers + * MUST `release()` it in a `finally` so it becomes eligible for LRU eviction + * again. The pin is what prevents another account's request from evicting + + * destroying this core while we're mid-`capture`/`recall`/`search`. */ - private async coreFor( + private async leaseFor( sessionKey: string | undefined, res: http.ServerResponse, - ): Promise { + ): Promise { if (this.multiTenant && !sessionKey) { sendError(res, 400, "Missing required field in multi-tenant mode: session_key"); return null; } - return this.registry.getCore(sessionKey ?? ""); + return this.registry.acquire(sessionKey ?? ""); } /** @@ -431,25 +436,29 @@ export class TdaiGateway { return; } - const core = await this.coreFor(body.session_key, res); - if (!core) return; + const lease = await this.leaseFor(body.session_key, res); + if (!lease) return; - const startMs = Date.now(); - const result = await core.handleBeforeRecall(body.query, body.session_key); - const elapsed = Date.now() - startMs; + try { + const startMs = Date.now(); + const result = await lease.core.handleBeforeRecall(body.query, body.session_key); + const elapsed = Date.now() - startMs; - this.logger.info( - `Recall completed in ${elapsed}ms: context=${(result.appendSystemContext?.length ?? 0)} chars, ` + - `prepend=${(result.prependContext?.length ?? 0)} chars`, - ); + this.logger.info( + `Recall completed in ${elapsed}ms: context=${(result.appendSystemContext?.length ?? 0)} chars, ` + + `prepend=${(result.prependContext?.length ?? 0)} chars`, + ); - const response: RecallResponse = { - context: result.appendSystemContext ?? "", - prepend_context: result.prependContext ?? "", - strategy: result.recallStrategy, - memory_count: result.recalledL1Memories?.length ?? 0, - }; - sendJson(res, 200, response); + const response: RecallResponse = { + context: result.appendSystemContext ?? "", + prepend_context: result.prependContext ?? "", + strategy: result.recallStrategy, + memory_count: result.recalledL1Memories?.length ?? 0, + }; + sendJson(res, 200, response); + } finally { + lease.release(); + } } private async handleCapture(req: http.IncomingMessage, res: http.ServerResponse): Promise { @@ -468,36 +477,40 @@ export class TdaiGateway { // that lack one. const startMs = Date.now(); - const core = await this.coreFor(body.session_key, res); - if (!core) return; - - // Stamp the synthesized messages explicitly (startMs+1/+2) and pass - // startedAt=startMs as the cold-start L0 cursor floor. The recorder keeps - // messages with timestamp strictly greater than the floor, so the floor - // MUST be below the turn's own messages. Previously no startedAt was passed, - // so the floor fell back to Date.now() inside TdaiCore — landing in the same - // millisecond as the messages and silently dropping the first turn's L0 rows - // on every freshly-created account (see cold-start capture regression test). - const result = await core.handleTurnCommitted({ - userText: body.user_content, - assistantText: body.assistant_content, - messages: body.messages ?? [ - { role: "user", content: body.user_content, timestamp: startMs + 1 }, - { role: "assistant", content: body.assistant_content, timestamp: startMs + 2 }, - ], - sessionKey: body.session_key, - sessionId: body.session_id, - startedAt: startMs, - }); - const elapsed = Date.now() - startMs; + const lease = await this.leaseFor(body.session_key, res); + if (!lease) return; - this.logger.info(`Capture completed in ${elapsed}ms: l0=${result.l0RecordedCount}`); + try { + // Stamp the synthesized messages explicitly (startMs+1/+2) and pass + // startedAt=startMs as the cold-start L0 cursor floor. The recorder keeps + // messages with timestamp strictly greater than the floor, so the floor + // MUST be below the turn's own messages. Previously no startedAt was passed, + // so the floor fell back to Date.now() inside TdaiCore — landing in the same + // millisecond as the messages and silently dropping the first turn's L0 rows + // on every freshly-created account (see cold-start capture regression test). + const result = await lease.core.handleTurnCommitted({ + userText: body.user_content, + assistantText: body.assistant_content, + messages: body.messages ?? [ + { role: "user", content: body.user_content, timestamp: startMs + 1 }, + { role: "assistant", content: body.assistant_content, timestamp: startMs + 2 }, + ], + sessionKey: body.session_key, + sessionId: body.session_id, + startedAt: startMs, + }); + const elapsed = Date.now() - startMs; - const response: CaptureResponse = { - l0_recorded: result.l0RecordedCount, - scheduler_notified: result.schedulerNotified, - }; - sendJson(res, 200, response); + this.logger.info(`Capture completed in ${elapsed}ms: l0=${result.l0RecordedCount}`); + + const response: CaptureResponse = { + l0_recorded: result.l0RecordedCount, + scheduler_notified: result.schedulerNotified, + }; + sendJson(res, 200, response); + } finally { + lease.release(); + } } private async handleSearchMemories(req: http.IncomingMessage, res: http.ServerResponse): Promise { @@ -508,22 +521,26 @@ export class TdaiGateway { return; } - const core = await this.coreFor(body.session_key, res); - if (!core) return; + const lease = await this.leaseFor(body.session_key, res); + if (!lease) return; - const result = await core.searchMemories({ - query: body.query, - limit: body.limit, - type: body.type, - scene: body.scene, - }); + try { + const result = await lease.core.searchMemories({ + query: body.query, + limit: body.limit, + type: body.type, + scene: body.scene, + }); - const response: MemorySearchResponse = { - results: result.text, - total: result.total, - strategy: result.strategy, - }; - sendJson(res, 200, response); + const response: MemorySearchResponse = { + results: result.text, + total: result.total, + strategy: result.strategy, + }; + sendJson(res, 200, response); + } finally { + lease.release(); + } } private async handleSearchConversations(req: http.IncomingMessage, res: http.ServerResponse): Promise { @@ -534,20 +551,24 @@ export class TdaiGateway { return; } - const core = await this.coreFor(body.session_key, res); - if (!core) return; + const lease = await this.leaseFor(body.session_key, res); + if (!lease) return; - const result = await core.searchConversations({ - query: body.query, - limit: body.limit, - sessionKey: body.session_key, - }); + try { + const result = await lease.core.searchConversations({ + query: body.query, + limit: body.limit, + sessionKey: body.session_key, + }); - const response: ConversationSearchResponse = { - results: result.text, - total: result.total, - }; - sendJson(res, 200, response); + const response: ConversationSearchResponse = { + results: result.text, + total: result.total, + }; + sendJson(res, 200, response); + } finally { + lease.release(); + } } private async handleSessionEnd(req: http.IncomingMessage, res: http.ServerResponse): Promise { diff --git a/src/gateway/types.ts b/src/gateway/types.ts index bdd4b282..78160c67 100644 --- a/src/gateway/types.ts +++ b/src/gateway/types.ts @@ -39,9 +39,12 @@ export interface HealthResponse { extraction?: { limit: number; active: number; waiting: number }; /** * Multi-tenant only: resident-core LRU state. `count` is how many per-account - * cores are warm right now, `limit` the configured cap (0 = unlimited). + * cores are warm right now, `limit` the configured cap (0 = unlimited), and + * `pinned` how many are currently serving a request (held by a lease). A + * `count` above `limit` with a matching `pinned` means the cap is being held + * past its bound because every candidate core is busy — transient and safe. */ - resident?: { count: number; limit: number }; + resident?: { count: number; limit: number; pinned: number }; } // ============================ From 65a64cad680c66a130134232b58636fc7738ae39 Mon Sep 17 00:00:00 2001 From: Jack <278171810@qq.com> Date: Sat, 27 Jun 2026 17:21:34 +0800 Subject: [PATCH 12/20] test(gateway): make server e2e hermetic by pinning memory config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit baseOverrides only set server/data, so loadGatewayConfig() still loaded the repo-root tdai-gateway.yaml from CWD — enabling DashScope embedding. On a machine with DASHSCOPE_API_KEY set the suite would silently exercise real embedding/network instead of the keyword/FTS path it asserts on (the comment already claimed "provider none" but nothing enforced it). Pass memory: parseConfig({ extraction:{enabled:false}, embedding:{provider: "none"}, recall:{strategy:"keyword"} }) so the tests are self-contained. Also drops e2e wall-time (no embedding init attempts). Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Jack <278171810@qq.com> --- src/gateway/server.e2e.test.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/gateway/server.e2e.test.ts b/src/gateway/server.e2e.test.ts index 0809e2b1..1f8fbfa0 100644 --- a/src/gateway/server.e2e.test.ts +++ b/src/gateway/server.e2e.test.ts @@ -4,6 +4,7 @@ import os from "node:os"; import path from "node:path"; import { TdaiGateway } from "./server.js"; import type { GatewayConfig } from "./config.js"; +import { parseConfig } from "../config.js"; /** * HTTP-level end-to-end tests for the Gateway + CoreRegistry multi-tenant @@ -14,9 +15,19 @@ import type { GatewayConfig } from "./config.js"; * extraction off + provider "none" (keyword/FTS path). */ +// Pin the memory config explicitly so these tests are hermetic: without it, +// loadGatewayConfig() picks up the repo-root tdai-gateway.yaml from CWD, which +// enables DashScope embedding — so a machine with DASHSCOPE_API_KEY set would +// silently exercise real embedding/network instead of the keyword/FTS path +// these assertions assume. extraction off → no background LLM timers. const baseOverrides = (baseDir: string, multiTenant: boolean): Partial => ({ server: { port: 0, host: "127.0.0.1", apiKey: undefined, corsOrigins: [] }, data: { baseDir, multiTenant }, + memory: parseConfig({ + extraction: { enabled: false }, + embedding: { provider: "none" }, + recall: { strategy: "keyword" }, + }), }); async function post(url: string, body: unknown): Promise<{ status: number; json: any }> { From 57f0494035c6d4cb3b64afcbbc44ef599c3224a4 Mon Sep 17 00:00:00 2001 From: Jack <278171810@qq.com> Date: Sat, 27 Jun 2026 17:36:21 +0800 Subject: [PATCH 13/20] feat(gateway): report embedding config intent in /health MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Multi-tenant /health hardcoded stores.{vectorStore,embeddingService}=false (cores are lazy/per-account, so there's no single store to probe), leaving operators no way to tell whether vector recall is even configured — the exact blind spot that made "is embedding wired?" un-answerable from the API. Add an `embedding` block to /health (both modes) derived from config, not a network probe (health must stay a cheap liveness check): `configured` is true only when embedding is enabled, the provider isn't the "none" sentinel, and the config has no error, plus provider/model/dimensions and the recall strategy. The live "did vectors actually fire" signal remains the `strategy` field on /search/memories. Also surfaces resident.pinned (active leases). e2e asserts the field under the hermetic provider=none/keyword boot; integration guide updated. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Jack <278171810@qq.com> --- docs/tdai_gateway_integration.md | 310 +++++++++++++++++++++++++++++++ src/gateway/server.e2e.test.ts | 5 + src/gateway/server.ts | 26 ++- src/gateway/types.ts | 20 ++ 4 files changed, 359 insertions(+), 2 deletions(-) create mode 100644 docs/tdai_gateway_integration.md diff --git a/docs/tdai_gateway_integration.md b/docs/tdai_gateway_integration.md new file mode 100644 index 00000000..7c9870a7 --- /dev/null +++ b/docs/tdai_gateway_integration.md @@ -0,0 +1,310 @@ +# TDAI Memory Gateway — Integration Guide + +> Audience: application teams (AI4ALL `weixin_bot`, and any similar chat/agent +> product) who want to bolt a **layered long-term memory** onto their bot by +> talking HTTP to one TDAI Gateway process. You do **not** need to embed the +> plugin, run OpenClaw, or understand the internals — you POST turns in and GET +> memory context back. + +--- + +## 1. What you get + +TDAI distils a user's conversation history into a **semantic pyramid** and hands +back a compact, ready-to-inject context block at recall time: + +``` +L0 raw dialogue turns (everything the user said) +L1 atomic facts ("studies guitar", "evening practice 30min") +L2 scene blocks (Markdown) (themed summaries of related atoms) +L3 persona profile (Markdown) (one human-readable user portrait) +``` + +You feed it turns (`/capture`); a background pipeline grows L1→L2→L3 with an LLM. +Before each model call you ask for context (`/recall`) and prepend/append what +you get to your own prompt. That's the whole contract. + +**One process, many users — safely.** A single Gateway can serve thousands of +end-user accounts with **hard isolation**: account A's memory can never surface +in account B's recall or search. Isolation is *structural* — each account gets +its own data directory, SQLite file, and pyramid — not a `WHERE session_key=…` +filter that one missed query could leak past. + +--- + +## 2. Architecture model + +``` + your app (weixin_bot turn_service) + │ HTTP (JSON, Bearer auth) + ▼ + ┌─────────────────────────────────────────┐ + │ TDAI Gateway (node, native http :8420) │ + │ │ + │ CoreRegistry Map │ + │ ├─ ai4all:alice → TdaiCore → baseDir/alice./ (own SQLite, persona.md…) + │ ├─ ai4all:bob → TdaiCore → baseDir/bob./ + │ └─ … (lazy create, LRU evict) │ + │ │ + │ one shared extraction semaphore │ ← caps total background LLM fan-out + └─────────────────────────────────────────┘ + │ + ▼ per-account dirs on disk (the isolation boundary) +``` + +- **`session_key` is the tenant identity.** You choose the scheme; the convention + is `"{namespace}:{account_id}"`, e.g. `ai4all:alice`, `psydt:User2993`. It is + business-supplied and **never trusted as a path** — the Gateway derives a + collision-free, traversal-safe directory name from it (slug + sha256 prefix). +- **Lazy + LRU.** A core is created on first request for an account and can be + evicted when idle (bounded by `maxResidentCores`) — its pending L1/L2/L3 work + is flushed before teardown, never dropped. +- **Bounded background cost.** N active accounts would otherwise fan out to ~3N + concurrent extraction LLM calls. One shared semaphore caps the global total. + +--- + +## 3. Deploy & configure + +Run it directly (no build needed; Node ≥ 22.16 strips TS types): + +```bash +node --env-file=.env --import tsx src/gateway/server.ts +``` + +### Configuration sources & precedence + +`env var` **>** `tdai-gateway.yaml` (CWD or `/`) **>** built-in default. + +| Concern | env | yaml | default | +|---|---|---|---| +| Bind port / host | `TDAI_GATEWAY_PORT` / `_HOST` | `server.port`/`.host` | `8420` / `127.0.0.1` | +| **Multi-tenant** | `TDAI_MULTI_TENANT` | `data.multiTenant` | `false` | +| Data root | `TDAI_DATA_DIR` (or `MEMORY_TENCENTDB_ROOT`) | `data.baseDir` | `~/.memory-tencentdb/memory-tdai` | +| API key (auth) | `TDAI_GATEWAY_API_KEY` | `server.apiKey` | unset → **auth off** | +| CORS allow-list | `TDAI_CORS_ORIGINS` | `server.corsOrigins` | `[]` → no CORS headers | +| Max resident cores (LRU) | `TDAI_MAX_RESIDENT_CORES` | `data.maxResidentCores` | `0` = unlimited | +| Global extraction cap | `TDAI_MAX_CONCURRENT_EXTRACTIONS` | `data.maxConcurrentExtractions` | multi-tenant `4`, single `∞` | +| LLM (chat/extraction) | `TDAI_LLM_API_KEY` / `_BASE_URL` / `_MODEL` | `llm.*` | OpenAI defaults | + +> **Embedding is yaml-only.** There are **no** `TDAI_EMBEDDING_*` env vars. Vector +> recall is configured under `memory.embedding.*` in `tdai-gateway.yaml`. The +> `TDAI_LLM_*` vars configure *chat/extraction only* — setting them does **not** +> turn on vector retrieval. See `tdai-gateway.yaml` in the repo root for a working +> DashScope/Bailian (`text-embedding-v3`, 1024-dim) example, and set +> `memory.recall.strategy: hybrid` for vector + BM25 fusion. + +Minimal multi-tenant `.env`: + +```bash +TDAI_MULTI_TENANT=true +TDAI_DATA_DIR=/var/lib/tdai +TDAI_GATEWAY_API_KEY= +TDAI_LLM_API_KEY=sk-... +TDAI_LLM_BASE_URL=https://api.deepseek.com/v1 +TDAI_LLM_MODEL=deepseek-chat +DASHSCOPE_API_KEY=sk-... # referenced by ${DASHSCOPE_API_KEY} in the yaml +``` + +--- + +## 4. HTTP API reference + +All bodies are JSON. When `TDAI_GATEWAY_API_KEY` is set, send +`Authorization: Bearer ` on every route except `GET /health`. In +**multi-tenant mode `session_key` is required** on every routed endpoint (omit → +`400`). + +### `GET /health` +Liveness; never requires auth. Multi-tenant response reports routing state, not +store internals: +```json +{ "status":"ok","version":"0.1.0","uptime":1234, + "multi_tenant":true,"active_cores":12, + "extraction":{"limit":4,"active":1,"waiting":0}, + "resident":{"count":12,"limit":200,"pinned":3}, + "embedding":{"configured":true,"provider":"dashscope","model":"text-embedding-v3", + "dimensions":1024,"recallStrategy":"hybrid"} } +``` +- `resident.pinned` — cores currently serving a request (held by a lease). If + `count > limit` with `pinned` close behind, the LRU cap is being held past its + bound because every core is busy — transient and safe. +- `embedding` — **configuration intent**, not a live probe: `configured:true` + means embedding is enabled with a real provider and no config error. It does + **not** prove the embedding endpoint is reachable (health stays a cheap + liveness check). For the live signal that vectors actually fired, read the + `strategy` field from `/search/memories` (§6). +> In multi-tenant mode `stores.vectorStore`/`embeddingService` stay `false` by +> design (cores are lazy/per-account — there is no single store to probe); the +> `embedding` block is the embedding signal health can give there. + +### `POST /recall` — get memory context for a turn +Call this *before* your LLM call. +```jsonc +// request +{ "query": "what am I practicing lately?", "session_key": "ai4all:alice" } +// response +{ "context": "\n…", // append to system prompt + "prepend_context": "- studies guitar; practices 30min nightly", // prepend to user turn + "strategy": "hybrid", "memory_count": 3 } +``` +- `context` — stable system-prompt material (persona, scene navigation). +- `prepend_context` — query-relevant L1 memories for *this* turn. **Inject both.** + (A client that uses only `context` gets the persona but never the + query-specific memories.) + +### `POST /capture` — record a completed turn +Call this *after* the assistant replies. L0 is written synchronously; L1→L3 grow +in the background. +```jsonc +// request +{ "session_key":"ai4all:alice", + "user_content":"I started learning guitar.", + "assistant_content":"That's great — daily practice is key.", + "session_id":"optional-conversation-id" } +// response +{ "l0_recorded": 2, "scheduler_notified": true } +``` + +### `POST /search/memories` — L1 fact search +```jsonc +{ "query":"guitar", "session_key":"ai4all:alice", "limit":5 } +// → { "results":"…formatted…", "total":4, "strategy":"hybrid" } +``` +`strategy` tells you which retrieval path fired: `hybrid` (vector+BM25), +`embedding` (vector only), `fts` (keyword only), `none`. Use it to verify vector +recall is actually live (§6). + +### `POST /search/conversations` — L0 raw-dialogue search +```jsonc +{ "query":"guitar", "session_key":"ai4all:alice", "limit":5 } +// → { "results":"…", "total":2 } (no strategy field) +``` + +### `POST /session/end` — flush a session +Flushes buffered pipeline work for a session. No-op if the account has no +resident core (never spins one up just to tear it down). +```jsonc +{ "session_key":"ai4all:alice" } // → { "flushed": true } +``` + +### `POST /namespace/wipe` — account hard-delete *(multi-tenant only)* +Tears down the account's core and removes its entire dataDir from disk. +Idempotent; refuses any path outside `baseDir`. Backs your "delete my data" flow. +```jsonc +{ "session_key":"ai4all:alice" } // → { "wiped": true } +``` + +### `POST /seed` — bulk import historical dialogue +Validates + runs the L0→L1 pipeline over batches. +> **Caveat (multi-tenant):** `/seed` writes to a snapshot dir `baseDir/seed-/`, +> **not** the per-account dirs that recall/search read. To pre-load *per-account* +> memory in multi-tenant mode, drive `executeSeed` per account into +> `baseDir/safeAccountDir(session_key)` — see `scripts/import-psydt.ts` for the +> reference pattern. Treat the HTTP `/seed` route as single-tenant / snapshot use. + +--- + +## 5. Integration lifecycle (the turn flow) + +This is the whole loop your `turn_service` runs per user message: + +```python +# pseudo-code — one user turn +ctx = POST("/recall", {"session_key": sk, "query": user_text}) + +system_prompt = base_system + "\n" + ctx["context"] +user_prompt = (ctx["prepend_context"] + "\n\n" if ctx["prepend_context"] else "") + user_text + +reply = your_llm(system_prompt, user_prompt) + +# fire-and-forget; do not block the user's reply on this +POST("/capture", { + "session_key": sk, "session_id": conversation_id, + "user_content": user_text, "assistant_content": reply, +}) +return reply +``` + +- **`/recall` is on the hot path** — keep its timeout tight; it returns whatever + memory exists (degrades gracefully to keyword-only if embedding is down). +- **`/capture` is off the hot path** — fire it asynchronously after you've replied. + L1→L3 synthesis happens later in the background. +- Call **`/session/end`** when a conversation goes idle (or on a timer) to flush + the last turn's pipeline work promptly. +- Call **`/namespace/wipe`** from your account-deletion / GDPR path. + +--- + +## 6. Verifying vector recall is live + +Hybrid search returns hits regardless of which path fired, and `/health` only +reports embedding *config intent* (`embedding.configured`), not whether a vector +query actually succeeded — so test deliberately: + +1. Capture a turn with a distinctive fact (e.g. "我喜欢户外登山"). +2. Wait for L1 to form (seconds, with a real LLM). +3. `/search/memories` with a **paraphrase that shares no keywords** (e.g. + "徒步运动"). If you get a hit with `strategy: hybrid` or `embedding`, vector + recall works. If paraphrases only ever return `fts`/`none`, embedding isn't + contributing — check the `memory.embedding.*` yaml block and the DashScope key. + +The bundled **dev-console** (`scripts/dev-console`, `:8421`) drives all of this +through a UI and shows the `strategy` tag and the live pyramid per account. + +--- + +## 7. Operational knobs + +| Knob | Effect | Guidance | +|---|---|---| +| `maxResidentCores` | warm cores kept in RAM (LRU evicts idle ones) | **Must exceed peak concurrently-active accounts**, or a core can be evicted mid-request. Bounds memory, not liveness. | +| `maxConcurrentExtractions` | global cap on background L1/L2/L3 LLM calls | Raise for more throughput, lower to protect your LLM quota. Watch `/health.extraction.waiting`. | +| `strategy: hybrid` | vector + BM25 fusion | Falls back to keyword automatically if embedding is unavailable. | + +Cold-start cost: the first request for an (evicted or new) account opens SQLite +and warms the store — a few hundred ms. Size `maxResidentCores` so your active +set stays warm; pre-warm with a cheap `/recall` if first-turn latency matters. + +--- + +## 8. Security posture + +- **Auth is opt-in and off by default.** With no `TDAI_GATEWAY_API_KEY`, every + route except `/health` is open. The Gateway logs a loud WARN at startup if it + is bound to a non-loopback host without a key. **Always set a key before + exposing the port.** Token check is constant-time. +- **Bind localhost** unless you've set auth + a CORS allow-list. CORS defaults to + sending no headers (browsers block cross-origin); `["*"]` is dev-only. +- The dev-console (`:8421`) has **no auth of its own** — it injects the Bearer + server-side and must stay bound to localhost. Never expose it. + +--- + +## 9. Invariants & limitations (read before you scale) + +1. **One process per dataDir.** Structural isolation makes accounts independent, + but a single account's dataDir must be owned by exactly one process. Do not + run two Gateways over the same `baseDir`, and don't run the import script + against an account whose core is live. Atomic writes guard against torn files + *within* one process, not against two writers. +2. **`session_key` is the whole security boundary.** If your app reuses one + `session_key` across two real users, their memory merges. Make it stable and + unique per end-user. +3. **`maxResidentCores` is a memory bound, not a request guard.** It must be set + above your peak active-account count (see §7). +4. **`/seed` does not populate per-account dirs in multi-tenant mode** (§4). +5. **No cross-account aggregation API.** By design — there is no "search all + users" endpoint, because that would defeat isolation. + +--- + +## 10. Quickstart checklist + +- [ ] `.env` with `TDAI_MULTI_TENANT=true`, `TDAI_DATA_DIR`, `TDAI_GATEWAY_API_KEY`, `TDAI_LLM_*`. +- [ ] `tdai-gateway.yaml` with `memory.embedding.*` + `memory.recall.strategy: hybrid`. +- [ ] Start the Gateway; confirm `GET /health` → `multi_tenant:true`. +- [ ] Wire `turn_service`: `/recall` before the LLM, async `/capture` after. +- [ ] Pick a `session_key` scheme (`{namespace}:{account_id}`). +- [ ] Verify vector recall with a paraphrase search (§6). +- [ ] Wire `/namespace/wipe` into account deletion. diff --git a/src/gateway/server.e2e.test.ts b/src/gateway/server.e2e.test.ts index 1f8fbfa0..4c9e9434 100644 --- a/src/gateway/server.e2e.test.ts +++ b/src/gateway/server.e2e.test.ts @@ -96,6 +96,11 @@ describe("TdaiGateway HTTP wiring", () => { const health0 = await getJson(`${origin}/health`); expect(health0.json.multi_tenant).toBe(true); expect(health0.json.active_cores).toBe(0); + // Embedding *config intent* is reported even with no cores resident. This + // boot forces provider "none" + keyword recall, so vector recall is off. + expect(health0.json.embedding.configured).toBe(false); + expect(health0.json.embedding.provider).toBe("none"); + expect(health0.json.embedding.recallStrategy).toBe("keyword"); // Routed endpoints require session_key. for (const ep of ["/recall", "/capture", "/search/memories", "/search/conversations"]) { diff --git a/src/gateway/server.ts b/src/gateway/server.ts index af91f3e9..5ebb2b5f 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -395,10 +395,30 @@ export class TdaiGateway { // Route handlers // ============================ + /** + * Summarise embedding *configuration intent* for /health — config-only, no + * network probe (health is a hot liveness path). `configured` is true only + * when embedding is enabled, the provider is not the "none" disable sentinel, + * and the config carries no error. The live signal stays the `strategy` field + * on /search/memories responses. + */ + private embeddingSummary(): NonNullable { + const emb = this.config.memory.embedding; + return { + configured: emb.enabled && emb.provider !== "none" && !emb.configError, + provider: emb.provider, + model: emb.model || undefined, + dimensions: emb.dimensions || undefined, + recallStrategy: this.config.memory.recall.strategy, + }; + } + private handleHealth(res: http.ServerResponse): void { if (this.multiTenant) { - // No single shared store to probe — cores are per-account and lazy. - // Report liveness + how many accounts are currently resident. + // No single shared store to probe — cores are per-account and lazy. Report + // liveness, resident-core stats, and embedding *config intent* (the only + // embedding signal possible without spinning up a core), since + // stores.embeddingService cannot reflect per-account lazy services here. const response: HealthResponse = { status: "ok", version: VERSION, @@ -408,6 +428,7 @@ export class TdaiGateway { active_cores: this.registry.size, extraction: this.registry.extractionStats(), resident: this.registry.residentStats(), + embedding: this.embeddingSummary(), }; sendJson(res, 200, response); return; @@ -424,6 +445,7 @@ export class TdaiGateway { embeddingService: !!core?.getEmbeddingService(), }, multi_tenant: false, + embedding: this.embeddingSummary(), }; sendJson(res, 200, response); } diff --git a/src/gateway/types.ts b/src/gateway/types.ts index 78160c67..861697db 100644 --- a/src/gateway/types.ts +++ b/src/gateway/types.ts @@ -45,6 +45,26 @@ export interface HealthResponse { * past its bound because every candidate core is busy — transient and safe. */ resident?: { count: number; limit: number; pinned: number }; + /** + * Embedding **configuration intent** — answers "is vector recall supposed to + * be on?" without a network probe (health must stay a cheap liveness check). + * + * `configured` is `true` only when embedding is enabled, the provider is not + * the `"none"` disable sentinel, and the config carries no error. It does NOT + * confirm the embedding endpoint is reachable — for the live runtime signal, + * read the `strategy` field returned by `POST /search/memories` (`hybrid` / + * `embedding` mean vectors actually fired). In multi-tenant mode this is the + * embedding signal health can give, since cores (and their embedding services) + * are lazy and per-account — `stores.embeddingService` stays `false` there. + */ + embedding?: { + configured: boolean; + provider: string; + model?: string; + dimensions?: number; + /** Configured recall strategy: "hybrid" | "embedding" | "keyword". */ + recallStrategy: string; + }; } // ============================ From 8d5cd9bcf424a7492776635a0c64a6425fc77b69 Mon Sep 17 00:00:00 2001 From: Jack <278171810@qq.com> Date: Sat, 27 Jun 2026 19:49:59 +0800 Subject: [PATCH 14/20] feat(gateway): idle-TTL eviction for resident cores MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit maxResidentCores is a count bound: a long tail of accounts that each go quiet still linger until an LRU push. Add coreIdleTtlMs (env TDAI_CORE_IDLE_TTL_MS / yaml data.coreIdleTtlMs) — a periodic sweep evicts any unpinned core idle longer than the TTL, reclaiming memory during quiet periods. Complements the count bound with a time bound. Default 0 = disabled (no behaviour change); multi-tenant only. The sweep timer is unref()'d so it never holds the process open, and is cleared on destroyAll. Pinned (in-flight) cores are always spared, consistent with the lease refcount. Covered by four registry tests (disabled no-op, idle evicted / fresh spared, pinned spared then reclaimed, background timer reclaims). Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Jack <278171810@qq.com> --- docs/tdai_gateway_integration.md | 4 +- src/gateway/config.ts | 18 ++++++- src/gateway/core-registry.test.ts | 82 +++++++++++++++++++++++++++++++ src/gateway/core-registry.ts | 68 +++++++++++++++++++++++++ src/gateway/server.ts | 1 + 5 files changed, 171 insertions(+), 2 deletions(-) diff --git a/docs/tdai_gateway_integration.md b/docs/tdai_gateway_integration.md index 7c9870a7..6ee4654b 100644 --- a/docs/tdai_gateway_integration.md +++ b/docs/tdai_gateway_integration.md @@ -84,6 +84,7 @@ node --env-file=.env --import tsx src/gateway/server.ts | API key (auth) | `TDAI_GATEWAY_API_KEY` | `server.apiKey` | unset → **auth off** | | CORS allow-list | `TDAI_CORS_ORIGINS` | `server.corsOrigins` | `[]` → no CORS headers | | Max resident cores (LRU) | `TDAI_MAX_RESIDENT_CORES` | `data.maxResidentCores` | `0` = unlimited | +| Idle core TTL (ms) | `TDAI_CORE_IDLE_TTL_MS` | `data.coreIdleTtlMs` | `0` = disabled | | Global extraction cap | `TDAI_MAX_CONCURRENT_EXTRACTIONS` | `data.maxConcurrentExtractions` | multi-tenant `4`, single `∞` | | LLM (chat/extraction) | `TDAI_LLM_API_KEY` / `_BASE_URL` / `_MODEL` | `llm.*` | OpenAI defaults | @@ -258,7 +259,8 @@ through a UI and shows the `strategy` tag and the live pyramid per account. | Knob | Effect | Guidance | |---|---|---| -| `maxResidentCores` | warm cores kept in RAM (LRU evicts idle ones) | **Must exceed peak concurrently-active accounts**, or a core can be evicted mid-request. Bounds memory, not liveness. | +| `maxResidentCores` | warm cores kept in RAM (LRU evicts idle ones) | A *count* bound. In-flight requests are pinned and never evicted, so a too-low value causes a transient over-limit, not a mid-request teardown. Still size it above your peak active-account count. | +| `coreIdleTtlMs` | reclaim cores idle longer than this | A *time* bound, complementing the count bound. Good for long-tail traffic (many accounts, each briefly active) — frees memory during quiet periods instead of waiting for an LRU push. Pinned cores are spared. | | `maxConcurrentExtractions` | global cap on background L1/L2/L3 LLM calls | Raise for more throughput, lower to protect your LLM quota. Watch `/health.extraction.waiting`. | | `strategy: hybrid` | vector + BM25 fusion | Falls back to keyword automatically if embedding is unavailable. | diff --git a/src/gateway/config.ts b/src/gateway/config.ts index ccf5537e..6f16b09d 100644 --- a/src/gateway/config.ts +++ b/src/gateway/config.ts @@ -107,6 +107,20 @@ export interface GatewayConfig { * yaml: `data.maxResidentCores` */ maxResidentCores?: number; + /** + * Idle time-to-live (ms) for resident per-account cores (multi-tenant). A + * periodic sweep evicts any idle (unpinned) core whose last request was + * longer ago than this, reclaiming long-tail accounts during quiet periods + * instead of waiting for an LRU push. Complements `maxResidentCores` (a count + * bound) with a time bound. + * + * - `> 0` — evict cores idle longer than this. + * - `0` / unset — disabled (default). Ignored in single-tenant mode. + * + * env: `TDAI_CORE_IDLE_TTL_MS` + * yaml: `data.coreIdleTtlMs` + */ + coreIdleTtlMs?: number; }; llm: StandaloneLLMConfig; /** Parsed memory-tdai plugin config (recall, capture, extraction, pipeline, etc.). */ @@ -174,6 +188,8 @@ export function loadGatewayConfig(overrides?: Partial): GatewayCo envInt("TDAI_MAX_CONCURRENT_EXTRACTIONS") ?? num(dataConfig, "maxConcurrentExtractions"); const maxResidentCores = envInt("TDAI_MAX_RESIDENT_CORES") ?? num(dataConfig, "maxResidentCores"); + const coreIdleTtlMs = + envInt("TDAI_CORE_IDLE_TTL_MS") ?? num(dataConfig, "coreIdleTtlMs"); // LLM config const llmConfig = obj(fileConfig, "llm"); @@ -194,7 +210,7 @@ export function loadGatewayConfig(overrides?: Partial): GatewayCo const base: GatewayConfig = { server: { port, host, apiKey, corsOrigins }, - data: { baseDir, multiTenant, maxConcurrentExtractions, maxResidentCores }, + data: { baseDir, multiTenant, maxConcurrentExtractions, maxResidentCores, coreIdleTtlMs }, llm, memory, }; diff --git a/src/gateway/core-registry.test.ts b/src/gateway/core-registry.test.ts index 6c9865a4..55f718d2 100644 --- a/src/gateway/core-registry.test.ts +++ b/src/gateway/core-registry.test.ts @@ -269,6 +269,88 @@ describe("CoreRegistry lease / refcount (LRU eviction safety)", () => { }); }); +describe("CoreRegistry idle-TTL eviction (multi-tenant)", () => { + let baseDir: string; + const registries: CoreRegistry[] = []; + + function reg(coreIdleTtlMs?: number, multiTenant = true): CoreRegistry { + const r = new CoreRegistry({ + baseDir, + llmConfig, + memory, + logger: silentLogger, + multiTenant, + coreIdleTtlMs, + }); + registries.push(r); + return r; + } + + beforeEach(async () => { + baseDir = await fs.mkdtemp(path.join(os.tmpdir(), "tdai-ttl-")); + }); + + afterEach(async () => { + await Promise.all(registries.splice(0).map((r) => r.destroyAll())); + await fs.rm(baseDir, { recursive: true, force: true }); + }); + + // NB: a configured TTL also starts the background sweep timer, so tests assert + // observable state (peek/size), not exact sweepIdleCores() return counts — + // except where the outcome is deterministic regardless of the timer (a pinned + // core is always skipped; a disabled registry never sweeps). + + it("is disabled by default and in single-tenant mode (sweep is a no-op, no timer)", async () => { + const off = reg(undefined); // ttl resolves to 0 → no timer + await off.getCore("ai4all:a"); + await delay(10); + expect(off.sweepIdleCores()).toBe(0); + expect(off.size).toBe(1); + + const st = reg(20, false); // single-tenant ignores TTL → no timer + await st.getCore("ai4all:a"); + await delay(40); + expect(st.sweepIdleCores()).toBe(0); + expect(st.size).toBe(1); + }); + + it("evicts a core idle past the TTL, spares a recently-used one", async () => { + const r = reg(40); + await r.getCore("ai4all:idle"); + await delay(60); // idle core is now past its 40ms TTL + await r.getCore("ai4all:fresh"); // fresh: touched just now (within TTL) + r.sweepIdleCores(); // synchronous, runs before the next timer tick + expect(r.peek("ai4all:idle")).toBeUndefined(); + expect(r.peek("ai4all:fresh")).toBeDefined(); + }); + + it("never idle-evicts a pinned (in-flight) core; reclaims it after release", async () => { + const r = reg(40); + const lease = await r.acquire("ai4all:busy"); + await delay(70); // well past TTL, but a request still holds it + // Deterministic: pinned cores are skipped by both manual sweep and the timer. + expect(r.sweepIdleCores()).toBe(0); + expect(r.peek("ai4all:busy")).toBeDefined(); + + lease.release(); // now reclaimable — by a manual sweep or the background timer + const deadline = Date.now() + 1000; + while (Date.now() < deadline && r.peek("ai4all:busy")) { + r.sweepIdleCores(); + await delay(20); + } + expect(r.peek("ai4all:busy")).toBeUndefined(); + }); + + it("the background timer reclaims idle cores without a manual sweep", async () => { + const r = reg(40); + await r.getCore("ai4all:a"); + // sweepMs = max(50, min(40, 60000)) = 50ms; poll up to ~1s for the timer. + const deadline = Date.now() + 1000; + while (Date.now() < deadline && r.size > 0) await delay(25); + expect(r.size).toBe(0); + }); +}); + describe("CoreRegistry wipe guards", () => { it("refuses namespace wipe in single-tenant mode", async () => { const baseDir = await fs.mkdtemp(path.join(os.tmpdir(), "tdai-wipe-st-")); diff --git a/src/gateway/core-registry.ts b/src/gateway/core-registry.ts index 75e39051..12c0a77e 100644 --- a/src/gateway/core-registry.ts +++ b/src/gateway/core-registry.ts @@ -95,6 +95,19 @@ export interface CoreRegistryOptions { * Ignored in single-tenant mode (there is only ever one core). */ maxResidentCores?: number; + /** + * Idle time-to-live (ms) for resident cores (multi-tenant). A periodic sweep + * evicts any *unpinned* core whose last request was longer ago than this, so a + * long-tail of accounts that went quiet are reclaimed during idle periods + * instead of lingering until an LRU push. Complements {@link maxResidentCores} + * (count bound): TTL is a time bound. + * + * - `> 0` — evict cores idle longer than this. + * - `0` / `undefined` — disabled (no time-based reclamation; the default). + * + * Ignored in single-tenant mode. + */ + coreIdleTtlMs?: number; } interface CoreEntry { @@ -165,6 +178,10 @@ export class CoreRegistry { private readonly extractionLimiter: AsyncSemaphore; /** Max resident cores (0 = unlimited). Resolved once from options. */ private readonly maxResidentCores: number; + /** Idle TTL (ms) for resident cores; 0 = disabled. Resolved once from options. */ + private readonly idleTtlMs: number; + /** Background idle-sweep timer (multi-tenant + idleTtlMs > 0 only). */ + private idleTimer?: ReturnType; /** * In-flight teardowns keyed by account, so a re-request (or wipe) for an * evicted account waits for its old core to finish closing — releasing the @@ -176,6 +193,53 @@ export class CoreRegistry { this.opts = opts; this.extractionLimiter = new AsyncSemaphore(this.resolveExtractionCap()); this.maxResidentCores = this.resolveMaxResidentCores(); + this.idleTtlMs = this.resolveIdleTtl(); + this.startIdleSweep(); + } + + /** Resolve the idle TTL. Multi-tenant + explicit positive value; else 0 (off). */ + private resolveIdleTtl(): number { + if (!this.opts.multiTenant) return 0; + const explicit = this.opts.coreIdleTtlMs; + return explicit !== undefined && Number.isFinite(explicit) && explicit > 0 + ? Math.floor(explicit) + : 0; + } + + /** + * Start the background idle sweep when an idle TTL is configured. The interval + * checks at least every `ttl` (capped at 60s so long TTLs still reclaim within + * a minute of going idle, floored so tiny test TTLs sweep promptly). `unref()` + * so the timer never keeps the process alive on its own. + */ + private startIdleSweep(): void { + if (this.idleTtlMs <= 0) return; + const sweepMs = Math.max(50, Math.min(this.idleTtlMs, 60_000)); + this.idleTimer = setInterval(() => this.sweepIdleCores(), sweepMs); + this.idleTimer.unref?.(); + } + + /** + * Evict every *unpinned* resident core idle longer than {@link idleTtlMs}. + * Pinned cores (in-flight requests) are spared — they'll be reclaimed on a + * later sweep once idle. Returns the number evicted (for tests/metrics). Safe + * to call directly; the sweep timer just invokes it on a schedule. + */ + sweepIdleCores(): number { + if (this.idleTtlMs <= 0) return 0; + const cutoff = Date.now() - this.idleTtlMs; + let evicted = 0; + for (const [key, entry] of [...this.cores]) { + if (entry.pins > 0) continue; // never evict a live core + if (entry.lastUsedMs > cutoff) continue; // still within TTL + this.cores.delete(key); + void this.beginTeardown(key, entry); + evicted++; + this.opts.logger.debug?.( + `[tdai-gateway] [registry] Idle-evicted ${key} (idle>${this.idleTtlMs}ms, active=${this.cores.size})`, + ); + } + return evicted; } /** @@ -398,6 +462,10 @@ export class CoreRegistry { /** Destroy every loaded core. Call on Gateway shutdown. */ async destroyAll(): Promise { + if (this.idleTimer) { + clearInterval(this.idleTimer); + this.idleTimer = undefined; + } const entries = [...this.cores.values()]; this.cores.clear(); // Include teardowns already in flight from LRU eviction so shutdown waits diff --git a/src/gateway/server.ts b/src/gateway/server.ts index 5ebb2b5f..29e71aad 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -137,6 +137,7 @@ export class TdaiGateway { excludeAgents: this.config.memory.capture.excludeAgents, maxConcurrentExtractions: this.config.data.maxConcurrentExtractions, maxResidentCores: this.config.data.maxResidentCores, + coreIdleTtlMs: this.config.data.coreIdleTtlMs, }); } From 8482296b1e963d4e19a4337c764d99c35fc81e59 Mon Sep 17 00:00:00 2001 From: Jack <278171810@qq.com> Date: Sat, 27 Jun 2026 19:52:10 +0800 Subject: [PATCH 15/20] docs: document persona.md lost-update race and L0 post-filter caveat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two known-issue annotations, no behaviour change: - persona.md is written by multiple stages on different SerialQueues (L3 persona-generator, L2 scene-nav, tcvdb profile-sync). atomicWriteFile guards torn reads but NOT lost updates, and the L3 path mutates the file via a ~180s LLM run, so a correct fix needs LLM-to-staging + a per-account lock — too big for a one-line guard. Annotate both local write sites so the next change knows the shape of the proper fix; tcvdb+multiTenant (the extra writer) is already rejected at config load. - conversation-search applies session_key as a POST-filter over topK. Clarify that it's a structural no-op in multi-tenant (each core's store is single account) but REQUIRED in single-tenant shared-store mode, where topK dilution can drop results — the real fix being session pushdown into the L0 query. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Jack <278171810@qq.com> --- src/core/persona/persona-generator.ts | 13 +++++++++++++ src/core/scene/scene-extractor.ts | 7 ++++++- src/core/tools/conversation-search.ts | 12 ++++++++++++ 3 files changed, 31 insertions(+), 1 deletion(-) diff --git a/src/core/persona/persona-generator.ts b/src/core/persona/persona-generator.ts index 978f429c..6bfbfad7 100644 --- a/src/core/persona/persona-generator.ts +++ b/src/core/persona/persona-generator.ts @@ -181,6 +181,19 @@ export class PersonaGenerator { } // 11. Append fresh scene navigation and write final content + // + // KNOWN ISSUE — lost-update race (tracked for a dedicated fix): persona.md is + // written by multiple stages on DIFFERENT SerialQueues — this L3 generator, + // the L2 scene-extractor's updateSceneNavigation, and (on the tcvdb path) + // profile-sync. atomicWriteFile() guards against a TORN READ but NOT a lost + // update: an L2 nav write interleaving with this L3 regen can clobber the + // freshly generated body, or vice-versa. It is not fixable with a short lock + // *here* because the LLM (step 8 above) writes persona.md directly during a + // ~180s run, so a correct critical section would span that whole run. The + // proper fix routes the LLM to a staging path and finalizes persona.md under + // a per-account lock shared by all writers. Mitigations today: the window is + // small and self-heals on the next L2/L3 pass, and tcvdb+multiTenant (which + // adds the profile-sync writer) is rejected at config load. const nav = generateSceneNavigation(index); const finalContent = nav ? `${personaText}\n\n${nav}\n` : personaText; await atomicWriteFile(personaPath, finalContent); diff --git a/src/core/scene/scene-extractor.ts b/src/core/scene/scene-extractor.ts index 2af10674..291b9468 100644 --- a/src/core/scene/scene-extractor.ts +++ b/src/core/scene/scene-extractor.ts @@ -471,7 +471,12 @@ export class SceneExtractor { const updated = nav ? `${stripped}\n\n${nav}\n` : `${stripped}\n`; - // persona.md is at dataDir root, no subdir needed + // persona.md is at dataDir root, no subdir needed. + // KNOWN ISSUE — lost-update race: this L2 nav write and the L3 persona regen + // run on different SerialQueues and both read-modify-write persona.md. + // atomicWriteFile avoids torn reads, NOT lost updates — see the detailed + // INVARIANT note in persona-generator.ts (step 11). Single-writer fix tracked + // separately. await atomicWriteFile(personaPath, updated); } } diff --git a/src/core/tools/conversation-search.ts b/src/core/tools/conversation-search.ts index d25ae178..cdd62410 100644 --- a/src/core/tools/conversation-search.ts +++ b/src/core/tools/conversation-search.ts @@ -221,6 +221,18 @@ export async function executeConversationSearch(params: { } // ── Apply session key filter ── + // This is a *post-filter* over the topK that the L0 vector/FTS search already + // returned — those store methods have no session pushdown. Two regimes: + // - structural multi-tenant: each account's core has its OWN store holding + // only that account's L0, so every row already matches and this is a no-op. + // Isolation there is physical (per-dataDir), NOT this filter. + // - single-tenant shared store: many sessions live in one store, so the + // filter is REQUIRED. Caveat: because it runs AFTER topK, a query whose + // topK is dominated by other sessions can yield few/zero rows post-filter + // (design gap #2). The real fix is to push session_key INTO the L0 + // SQL/vector query; deferred because the multi-tenant product gets + // isolation structurally, making the pushdown a single-tenant-only quality + // improvement. if (sessionFilter) { const preFilterCount = results.length; results = results.filter((r) => r.session_key === sessionFilter); From a759336b989aac189fee368661c68998cdcb843f Mon Sep 17 00:00:00 2001 From: Jack <278171810@qq.com> Date: Sat, 27 Jun 2026 20:35:41 +0800 Subject: [PATCH 16/20] docs: clarify DashScope+SQLite is the supported stack; tcvdb is out of scope Spell out in the integration guide that embedding (DashScope text-embedding-v3) and storage (local SQLite + sqlite-vec + FTS5) are independent layers and the only supported multi-tenant stack. Note that the tcvdb backend is a separate design with its own server-side embedding and shared cloud collections, that multiTenant + tcvdb is rejected at startup by design, and therefore the "tcvdb breaks isolation" issue does not apply to a DashScope+SQLite deployment. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Jack <278171810@qq.com> --- docs/tdai_gateway_integration.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/docs/tdai_gateway_integration.md b/docs/tdai_gateway_integration.md index 6ee4654b..865d85d2 100644 --- a/docs/tdai_gateway_integration.md +++ b/docs/tdai_gateway_integration.md @@ -95,6 +95,30 @@ node --env-file=.env --import tsx src/gateway/server.ts > DashScope/Bailian (`text-embedding-v3`, 1024-dim) example, and set > `memory.recall.strategy: hybrid` for vector + BM25 fusion. +### Storage backend & embedding — the supported stack + +These are two **independent** layers; this deployment fixes both: + +| Layer | Job | What we use | +|---|---|---| +| **Embedding** | text → vector (semantic encoding) | **Alibaba DashScope** `text-embedding-v3` (1024-dim), via `memory.embedding.*` | +| **Vector store** | store vectors + nearest-neighbour & keyword search | **local SQLite** (`sqlite-vec` for vectors + FTS5/BM25 for keyword), one DB file per account | + +This is the **only supported multi-tenant stack.** The code also contains a +**Tencent Cloud VectorDB (`tcvdb`) backend**, but it is a fully separate design — +tcvdb does its *own* server-side embedding (it would replace DashScope, not work +alongside it) and keeps all data in shared cloud collections. Because shared +collections defeat the per-account physical isolation this Gateway relies on, +**`multiTenant=true` + `storeBackend=tcvdb` is rejected at startup by design.** + +> If you only ever run DashScope + SQLite (the default — `storeBackend` is +> `sqlite` unless you set it), the tcvdb path is never exercised and the +> "tcvdb breaks multi-tenant isolation" issue **does not apply to you**. It is a +> guard against an unsupported combination, not a bug to fix in this deployment. +> Switching to tcvdb would mean re-embedding all history with Tencent's model and +> doing dedicated isolation work first — only worth it if local SQLite outgrows +> your scale/ops needs. + Minimal multi-tenant `.env`: ```bash From 2c4c9be444328c78b273fd74604977a8694b819b Mon Sep 17 00:00:00 2001 From: Jack <278171810@qq.com> Date: Sat, 27 Jun 2026 23:46:59 +0800 Subject: [PATCH 17/20] fix(persona): serialize persona.md writers with a per-path mutex MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit persona.md is written by two background stages on separate SerialQueues — L3 PersonaGenerator.generateLocalPersona (read → ~180s LLM tool-write → final write) and L2 SceneExtractor.updateSceneNavigation (read → strip → append → write). Both are read-modify-write; atomicWriteFile prevents a torn read but not a lost update, so an interleaving L2 nav write can clobber a freshly regenerated L3 body (or vice-versa). This race is live in the supported DashScope+SQLite multi-tenant stack (the profile-sync writer is tcvdb-only, and tcvdb+multiTenant is rejected at config load). Add KeyedAsyncMutex (per-key FIFO async mutex, error-isolated, in-process) and route both writers' whole RMW through the shared fileWriteMutex keyed by persona.md's absolute path. Method B: the L3 critical section spans the full LLM run because the LLM writes the file mid-run. Per-account isolation is automatic (distinct paths never contend); recall reads take no lock, so the read hot path is unaffected. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Jack <278171810@qq.com> --- src/core/persona/persona-generator.ts | 39 ++++++--- src/core/scene/scene-extractor.ts | 59 ++++++------- src/utils/keyed-mutex.test.ts | 117 ++++++++++++++++++++++++++ src/utils/keyed-mutex.ts | 80 ++++++++++++++++++ 4 files changed, 252 insertions(+), 43 deletions(-) create mode 100644 src/utils/keyed-mutex.test.ts create mode 100644 src/utils/keyed-mutex.ts diff --git a/src/core/persona/persona-generator.ts b/src/core/persona/persona-generator.ts index 6bfbfad7..3caadbd6 100644 --- a/src/core/persona/persona-generator.ts +++ b/src/core/persona/persona-generator.ts @@ -6,6 +6,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { atomicWriteFile } from "../../utils/atomic-write.js"; +import { fileWriteMutex } from "../../utils/keyed-mutex.js"; import { formatForLLM } from "../../utils/time.js"; import { CleanContextRunner } from "../../utils/clean-context-runner.js"; import { CheckpointManager } from "../../utils/checkpoint.js"; @@ -57,8 +58,23 @@ export class PersonaGenerator { /** * Execute local persona generation without advancing checkpoint. + * + * The entire read → LLM-write → read-back → final-write critical section runs + * under a per-file mutex (keyed by persona.md's absolute path) shared with the + * L2 scene-nav writer, so the two cannot lost-update each other. The lock is + * held for the full ~180s LLM run by design — see {@link fileWriteMutex}. */ async generateLocalPersona(triggerReason?: string): Promise { + const personaPath = path.join(this.dataDir, "persona.md"); + return fileWriteMutex.run(personaPath, () => + this.runGenerationLocked(triggerReason, personaPath), + ); + } + + private async runGenerationLocked( + triggerReason: string | undefined, + personaPath: string, + ): Promise { const startMs = Date.now(); this.logger?.debug?.(`${TAG} Starting generation: reason="${triggerReason ?? "none"}"`); @@ -66,8 +82,6 @@ export class PersonaGenerator { const cp = await cpManager.read(); this.logger?.debug?.(`${TAG} Checkpoint: total_processed=${cp.total_processed}, last_persona_at=${cp.last_persona_at}`); - const personaPath = path.join(this.dataDir, "persona.md"); - // 1. Read existing persona (strip navigation) let existingPersona: string | undefined; try { @@ -182,18 +196,15 @@ export class PersonaGenerator { // 11. Append fresh scene navigation and write final content // - // KNOWN ISSUE — lost-update race (tracked for a dedicated fix): persona.md is - // written by multiple stages on DIFFERENT SerialQueues — this L3 generator, - // the L2 scene-extractor's updateSceneNavigation, and (on the tcvdb path) - // profile-sync. atomicWriteFile() guards against a TORN READ but NOT a lost - // update: an L2 nav write interleaving with this L3 regen can clobber the - // freshly generated body, or vice-versa. It is not fixable with a short lock - // *here* because the LLM (step 8 above) writes persona.md directly during a - // ~180s run, so a correct critical section would span that whole run. The - // proper fix routes the LLM to a staging path and finalizes persona.md under - // a per-account lock shared by all writers. Mitigations today: the window is - // small and self-heals on the next L2/L3 pass, and tcvdb+multiTenant (which - // adds the profile-sync writer) is rejected at config load. + // Lost-update safety: persona.md is written by two stages on DIFFERENT + // SerialQueues — this L3 generator and the L2 scene-extractor's + // updateSceneNavigation. atomicWriteFile() alone guards a TORN READ but NOT + // a lost update, so both writers' full read-modify-write now runs under the + // shared per-path fileWriteMutex (acquired in generateLocalPersona above, + // spanning the ~180s LLM run). That makes this regen and an interleaving L2 + // nav write mutually exclusive — neither can clobber the other. + // (profile-sync is a third writer but only on the tcvdb path, and + // tcvdb+multiTenant is rejected at config load.) const nav = generateSceneNavigation(index); const finalContent = nav ? `${personaText}\n\n${nav}\n` : personaText; await atomicWriteFile(personaPath, finalContent); diff --git a/src/core/scene/scene-extractor.ts b/src/core/scene/scene-extractor.ts index 291b9468..9986ead2 100644 --- a/src/core/scene/scene-extractor.ts +++ b/src/core/scene/scene-extractor.ts @@ -19,6 +19,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { atomicWriteFile } from "../../utils/atomic-write.js"; +import { fileWriteMutex } from "../../utils/keyed-mutex.js"; import { formatForLLM } from "../../utils/time.js"; import { CleanContextRunner } from "../../utils/clean-context-runner.js"; import { CheckpointManager } from "../../utils/checkpoint.js"; @@ -445,39 +446,39 @@ export class SceneExtractor { */ private async updateSceneNavigation(): Promise { const personaPath = path.join(this.dataDir, "persona.md"); - const index = await readSceneIndex(this.dataDir); - const nav = generateSceneNavigation(index); - - let existing = ""; - try { - existing = await fs.readFile(personaPath, "utf-8"); - } catch { - // No persona file yet — PersonaGenerator will create it with navigation. - // Don't write a navigation-only file. - this.logger?.debug?.(`${TAG} updateSceneNavigation() skipped: no persona file yet, waiting for PersonaGenerator`); - return; - } - - if (!existing.trim() && !nav) return; + // The whole read-modify-write runs under the shared per-path mutex so this + // L2 nav write and the L3 persona regen (which take the same lock, keyed by + // persona.md's absolute path) cannot lost-update each other — they run on + // different SerialQueues and would otherwise interleave. atomicWriteFile + // alone only prevents a torn read. See fileWriteMutex / persona-generator.ts. + await fileWriteMutex.run(personaPath, async () => { + const index = await readSceneIndex(this.dataDir); + const nav = generateSceneNavigation(index); + + let existing = ""; + try { + existing = await fs.readFile(personaPath, "utf-8"); + } catch { + // No persona file yet — PersonaGenerator will create it with navigation. + // Don't write a navigation-only file. + this.logger?.debug?.(`${TAG} updateSceneNavigation() skipped: no persona file yet, waiting for PersonaGenerator`); + return; + } - const stripped = stripSceneNavigation(existing).trimEnd(); + if (!existing.trim() && !nav) return; - // If the persona body is empty (only navigation existed), don't overwrite - // with a navigation-only file. Let PersonaGenerator handle full generation. - if (!stripped) { - this.logger?.debug?.(`${TAG} updateSceneNavigation() skipped: persona body is empty, waiting for PersonaGenerator`); - return; - } + const stripped = stripSceneNavigation(existing).trimEnd(); - const updated = nav ? `${stripped}\n\n${nav}\n` : `${stripped}\n`; + // If the persona body is empty (only navigation existed), don't overwrite + // with a navigation-only file. Let PersonaGenerator handle full generation. + if (!stripped) { + this.logger?.debug?.(`${TAG} updateSceneNavigation() skipped: persona body is empty, waiting for PersonaGenerator`); + return; + } - // persona.md is at dataDir root, no subdir needed. - // KNOWN ISSUE — lost-update race: this L2 nav write and the L3 persona regen - // run on different SerialQueues and both read-modify-write persona.md. - // atomicWriteFile avoids torn reads, NOT lost updates — see the detailed - // INVARIANT note in persona-generator.ts (step 11). Single-writer fix tracked - // separately. - await atomicWriteFile(personaPath, updated); + const updated = nav ? `${stripped}\n\n${nav}\n` : `${stripped}\n`; + await atomicWriteFile(personaPath, updated); + }); } } diff --git a/src/utils/keyed-mutex.test.ts b/src/utils/keyed-mutex.test.ts new file mode 100644 index 00000000..3fc7ff37 --- /dev/null +++ b/src/utils/keyed-mutex.test.ts @@ -0,0 +1,117 @@ +import { describe, it, expect } from "vitest"; +import { KeyedAsyncMutex } from "./keyed-mutex.js"; + +const tick = () => new Promise((r) => setTimeout(r, 0)); + +describe("KeyedAsyncMutex", () => { + it("serializes critical sections sharing a key (no interleave)", async () => { + const m = new KeyedAsyncMutex(); + const trace: string[] = []; + + const section = (id: string) => + m.run("k", async () => { + trace.push(`${id}:start`); + await tick(); + await tick(); + trace.push(`${id}:end`); + }); + + await Promise.all([section("a"), section("b"), section("c")]); + + // Each section's start is immediately followed by its own end — never + // another section's start in between. + expect(trace).toEqual([ + "a:start", "a:end", + "b:start", "b:end", + "c:start", "c:end", + ]); + }); + + it("runs in strict FIFO (call) order for the same key", async () => { + const m = new KeyedAsyncMutex(); + const order: number[] = []; + const tasks = [0, 1, 2, 3, 4].map((i) => + m.run("k", async () => { + await tick(); + order.push(i); + }), + ); + await Promise.all(tasks); + expect(order).toEqual([0, 1, 2, 3, 4]); + }); + + it("lets different keys proceed concurrently", async () => { + const m = new KeyedAsyncMutex(); + let aInside = false; + let bSawAInside = false; + + const a = m.run("a", async () => { + aInside = true; + await tick(); + await tick(); + aInside = false; + }); + // Give "a" a chance to enter its section first. + await tick(); + const b = m.run("b", async () => { + bSawAInside = aInside; // a different key must not block on a's lock + }); + + await Promise.all([a, b]); + expect(bSawAInside).toBe(true); + }); + + it("returns the critical section's value to the caller", async () => { + const m = new KeyedAsyncMutex(); + await expect(m.run("k", async () => 42)).resolves.toBe(42); + }); + + it("a throwing section rejects only that caller and does not poison the key", async () => { + const m = new KeyedAsyncMutex(); + const ran: string[] = []; + + const bad = m.run("k", async () => { + ran.push("bad"); + throw new Error("boom"); + }); + const good = m.run("k", async () => { + ran.push("good"); + return "ok"; + }); + + await expect(bad).rejects.toThrow("boom"); + await expect(good).resolves.toBe("ok"); // next waiter still runs + expect(ran).toEqual(["bad", "good"]); + }); + + it("releases the key when idle (map does not grow unbounded)", async () => { + const m = new KeyedAsyncMutex(); + expect(m.activeKeys).toBe(0); + + const p = m.run("k", async () => { + // While held, the key is tracked. + expect(m.activeKeys).toBe(1); + }); + await p; + // Once the last holder releases, the key is dropped. + expect(m.activeKeys).toBe(0); + + // A fresh acquire on the same key still works after cleanup. + await expect(m.run("k", async () => "again")).resolves.toBe("again"); + expect(m.activeKeys).toBe(0); + }); + + it("keeps the key while waiters remain, drops it after the last drains", async () => { + const m = new KeyedAsyncMutex(); + const p1 = m.run("k", async () => { + await tick(); + }); + const p2 = m.run("k", async () => { + await tick(); + }); + // Two holders queued on one key → exactly one key tracked. + expect(m.activeKeys).toBe(1); + await Promise.all([p1, p2]); + expect(m.activeKeys).toBe(0); + }); +}); diff --git a/src/utils/keyed-mutex.ts b/src/utils/keyed-mutex.ts new file mode 100644 index 00000000..cc286de4 --- /dev/null +++ b/src/utils/keyed-mutex.ts @@ -0,0 +1,80 @@ +/** + * KeyedAsyncMutex — a per-key async mutex (one critical section at a time per key). + * + * ## Why this exists (persona.md single-writer) + * + * `persona.md` is written by two background stages that run on **separate** + * `SerialQueue`s (`utils/pipeline-manager.ts`), so they are NOT serialized + * against each other: + * + * - **L3** `PersonaGenerator.generateLocalPersona` — a read → ~180s LLM + * tool-write → read-back → final write cycle. + * - **L2** `SceneExtractor.updateSceneNavigation` — a read → strip-nav → + * append-nav → write cycle. + * + * Both are read-modify-write. `atomicWriteFile` (temp+rename) prevents a *torn + * read* but NOT a *lost update*: if the two interleave, whichever writes last + * overwrites the other's body with content built from a now-stale read (e.g. an + * L2 nav write clobbering a freshly regenerated L3 body). Routing both writers + * through one mutex keyed by the file's absolute path makes each whole RMW + * mutually exclusive, so neither can clobber the other. + * + * The L3 critical section spans the full LLM run by design ("Method B"): the LLM + * writes `persona.md` directly mid-run, so a correct critical section must cover + * it. The cost is that a same-account L2 nav update can wait behind an in-flight + * persona regen — acceptable because regen is infrequent and nav is cheap and + * deferrable. Recall *reads* `persona.md` without taking this lock, so the read + * hot path is unaffected (atomic writes keep reads whole). + * + * ## Semantics + * + * - Keys are independent: different keys run concurrently, same key serializes. + * - Critical sections sharing a key run strictly in call (FIFO) order. + * - A failing critical section does NOT poison the key — the next waiter still + * runs, and `run` rejects only to the caller whose `fn` threw. + * - In-process only. This relies on the "one process per dataDir" invariant + * (see the integration guide §9.1); it does not guard against two processes + * writing the same file. + */ +export class KeyedAsyncMutex { + /** Tail of the release-chain per key. Absence === currently unlocked. */ + private readonly tails = new Map>(); + + /** Critical sections sharing `key` run one at a time, in call order. */ + async run(key: string, fn: () => Promise): Promise { + const prev = this.tails.get(key) ?? Promise.resolve(); + + // Our gate resolves when WE release; the next caller for this key waits on + // it (chained after every earlier holder). The chain only ever resolves + // (never rejects), so one failing section cannot block the queue. + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + const tail = prev.then(() => gate); + this.tails.set(key, tail); + + await prev; // our turn, once all earlier holders have released + try { + return await fn(); + } finally { + release(); + // Drop the key once we are the last in line, so the map does not grow + // unbounded across many one-shot keys. + if (this.tails.get(key) === tail) this.tails.delete(key); + } + } + + /** Number of keys with an active or queued holder (for tests/diagnostics). */ + get activeKeys(): number { + return this.tails.size; + } +} + +/** + * Process-wide mutex for files written by more than one pipeline stage, keyed by + * the file's **absolute path**. Per-account isolation is automatic because each + * account's `persona.md` lives at a distinct path, so different accounts never + * contend. Shared by {@link PersonaGenerator} (L3) and {@link SceneExtractor} (L2). + */ +export const fileWriteMutex = new KeyedAsyncMutex(); From 6b0a573b6803e3c86aa397a6bc600e3a5f5d9e89 Mon Sep 17 00:00:00 2001 From: Jack <278171810@qq.com> Date: Sun, 28 Jun 2026 00:26:18 +0800 Subject: [PATCH 18/20] =?UTF-8?q?test(gateway):=20add=20=C2=A76=20one-clic?= =?UTF-8?q?k=20vector-recall=20smoke=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scripts/smoke-recall.mjs validates a running gateway end-to-end against its real embedding provider (DashScope): GET /health (gates fast on embedding.configured=false), POST /capture a distinctive fact, poll POST /search/memories with a no-shared-keyword paraphrase until an L1 atom is recalled, and assert strategy is hybrid/embedding (vectors fired) rather than fts/none. Cleans up via /namespace/wipe in multi-tenant mode. Dependency-free Node ESM (global fetch) — no build/tsx needed. Exit 0 PASS / 1 FAIL / 2 setup-error, with actionable diagnostics that distinguish "L1 never formed" from "formed but embedding not contributing". Probe text overridable via SMOKE_* env for non-Chinese or domain-specific deployments. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Jack <278171810@qq.com> --- scripts/smoke-recall.mjs | 230 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 230 insertions(+) create mode 100755 scripts/smoke-recall.mjs diff --git a/scripts/smoke-recall.mjs b/scripts/smoke-recall.mjs new file mode 100755 index 00000000..26096108 --- /dev/null +++ b/scripts/smoke-recall.mjs @@ -0,0 +1,230 @@ +#!/usr/bin/env node +/** + * smoke-recall.mjs — §6 "is vector recall live?" one-click smoke test. + * + * Validates a REAL running TDAI Gateway end-to-end against its REAL configured + * embedding provider (e.g. Alibaba DashScope). It does exactly the manual §6 + * check from docs/tdai_gateway_integration.md, but repeatable and asserted: + * + * 1. GET /health — gateway up? embedding configured at all? + * 2. POST /capture — record a distinctive fact for a throwaway account + * 3. POST /search/memories — poll with a PARAPHRASE that shares NO keywords + * with the fact, until an L1 atom is recalled + * 4. assert strategy — PASS only if `hybrid`/`embedding` fired (vectors + * actually contributed); `fts`/`none` => FAIL + * 5. POST /namespace/wipe — clean up the throwaway account (multi-tenant) + * + * Why a paraphrase with no shared tokens: keyword (FTS/BM25) can't bridge it, so + * a hit can only come from vector similarity. That isolates the embedding path. + * + * No build, no deps — needs only Node >= 18 (global fetch). Run against a gateway + * that is already started with your real .env (DashScope key, TDAI_LLM_*, etc.): + * + * node scripts/smoke-recall.mjs + * TDAI_GATEWAY_URL=http://127.0.0.1:8420 \ + * TDAI_GATEWAY_API_KEY=*** node scripts/smoke-recall.mjs --timeout 120 + * + * Exit codes: 0 = PASS, 1 = FAIL (vectors not contributing / no recall), + * 2 = setup error (gateway unreachable, bad config, capture failed). + * + * Override the probe text for non-Chinese / domain-specific deployments via env: + * SMOKE_FACT_USER, SMOKE_FACT_ASSISTANT, SMOKE_KEYWORD, SMOKE_PARAPHRASE + * (keep KEYWORD a substring of the fact, and PARAPHRASE sharing no tokens with it). + */ + +// ── config ──────────────────────────────────────────────────────────────── +const argv = process.argv.slice(2); +const hasFlag = (name) => argv.includes(name); +const flagVal = (name) => { + const i = argv.indexOf(name); + return i >= 0 && i + 1 < argv.length ? argv[i + 1] : undefined; +}; + +const BASE = (process.env.TDAI_GATEWAY_URL || flagVal("--url") || "http://127.0.0.1:8420").replace(/\/$/, ""); +const API_KEY = process.env.TDAI_GATEWAY_API_KEY || flagVal("--api-key") || ""; +const TIMEOUT_MS = Number(flagVal("--timeout") ?? process.env.SMOKE_TIMEOUT_SEC ?? 90) * 1000; +const POLL_MS = Number(flagVal("--poll") ?? 4) * 1000; +const KEEP = hasFlag("--keep") || hasFlag("--no-wipe"); + +// A unique throwaway account so the test is isolated and cleanly wipeable. +const SESSION = + flagVal("--session") || + process.env.SMOKE_SESSION || + `smoke:recall:${Date.now()}:${Math.floor(Math.random() * 1e6)}`; + +// Distinctive fact + a paraphrase that shares NO surface tokens with it. +const FACT_USER = process.env.SMOKE_FACT_USER || "我每个周末都会去山里露营和钓鱼,这是我最喜欢的放松方式。"; +const FACT_ASSISTANT = process.env.SMOKE_FACT_ASSISTANT || "听起来很惬意,亲近大自然确实能让人充电。"; +const KEYWORD = process.env.SMOKE_KEYWORD || "露营"; // appears in the fact → formation probe +const PARAPHRASE = process.env.SMOKE_PARAPHRASE || "户外探险活动"; // no shared tokens, near in meaning + +const VECTOR_STRATEGIES = new Set(["hybrid", "embedding"]); + +// ── tiny http helper ──────────────────────────────────────────────────────── +const headers = { "content-type": "application/json", ...(API_KEY ? { authorization: `Bearer ${API_KEY}` } : {}) }; + +async function req(method, path, body) { + const ac = new AbortController(); + const timer = setTimeout(() => ac.abort(), 30_000); + try { + const res = await fetch(`${BASE}${path}`, { + method, + headers, + body: body === undefined ? undefined : JSON.stringify(body), + signal: ac.signal, + }); + const text = await res.text(); + let json; + try { + json = text ? JSON.parse(text) : {}; + } catch { + json = { _raw: text }; + } + return { status: res.status, json }; + } finally { + clearTimeout(timer); + } +} + +const log = (...a) => console.log("[smoke]", ...a); +const fail = (msg) => { + console.error("\n[smoke] ❌ FAIL —", msg); + process.exit(1); +}; +const setupError = (msg) => { + console.error("\n[smoke] ⚠️ SETUP ERROR —", msg); + process.exit(2); +}; +const delay = (ms) => new Promise((r) => setTimeout(r, ms)); + +// ── flow ───────────────────────────────────────────────────────────────────── +async function main() { + log(`gateway : ${BASE}`); + log(`auth : ${API_KEY ? "Bearer (set)" : "none"}`); + log(`session : ${SESSION}`); + log(`fact : "${FACT_USER}"`); + log(`paraphrase: "${PARAPHRASE}" (shares no keywords with the fact)`); + log(""); + + // 1. Health + embedding-intent gate ----------------------------------------- + let health; + try { + health = await req("GET", "/health"); + } catch (err) { + setupError(`cannot reach gateway at ${BASE} (${err?.cause?.code || err?.name || err}). Is it running?`); + } + if (health.status === 401 || health.status === 403) { + setupError(`/health returned ${health.status} — unexpected (health should be unauthenticated). Check the URL.`); + } + if (health.status !== 200 || health.json.status !== "ok") { + setupError(`/health not ok: status=${health.status} body=${JSON.stringify(health.json)}`); + } + const multiTenant = health.json.multi_tenant === true; + const emb = health.json.embedding; + log(`health : ok version=${health.json.version} multi_tenant=${multiTenant}`); + if (emb) { + log(`embedding : configured=${emb.configured} provider=${emb.provider} model=${emb.model ?? "?"} dims=${emb.dimensions ?? "?"} recall=${emb.recallStrategy}`); + } else { + log(`embedding : (no embedding block in /health — older gateway build)`); + } + // Fail fast on the single most common cause, with no waiting. + if (emb && emb.configured === false) { + fail( + `embedding is NOT configured (provider=${emb.provider}). Vector recall cannot work.\n` + + ` Fix the memory.embedding.* block in tdai-gateway.yaml (DashScope text-embedding-v3, the key),\n` + + ` set memory.recall.strategy: hybrid, and restart the gateway. (Embedding is yaml-only — there are no TDAI_EMBEDDING_* envs.)`, + ); + } + + // 2. Capture a distinctive fact ---------------------------------------------- + const cap = await req("POST", "/capture", { + session_key: SESSION, + session_id: `smoke-${Date.now()}`, + user_content: FACT_USER, + assistant_content: FACT_ASSISTANT, + }); + if (cap.status !== 200) { + setupError(`/capture failed: status=${cap.status} body=${JSON.stringify(cap.json)}`); + } + log(`capture : ok l0_recorded=${cap.json.l0_recorded} scheduler_notified=${cap.json.scheduler_notified}`); + log(""); + log(`polling /search/memories every ${POLL_MS / 1000}s for up to ${TIMEOUT_MS / 1000}s (waiting for L1 to form)…`); + + // 3. Poll the paraphrase search until a vector hit or timeout ----------------- + const search = (query) => req("POST", "/search/memories", { query, session_key: SESSION, limit: 5 }); + + const deadline = Date.now() + TIMEOUT_MS; + let lastStrategy = "none"; + let lastTotal = 0; + let sawVector = false; + let formationConfirmed = false; // keyword probe found the atom at all + + while (Date.now() < deadline) { + const para = await search(PARAPHRASE); + if (para.status !== 200) { + setupError(`/search/memories failed: status=${para.status} body=${JSON.stringify(para.json)}`); + } + lastStrategy = para.json.strategy ?? "none"; + lastTotal = para.json.total ?? 0; + + if (lastTotal > 0 && VECTOR_STRATEGIES.has(lastStrategy)) { + sawVector = true; + break; // PASS condition met — vectors fired on a no-keyword query + } + + // Diagnostic-only keyword probe: did L1 form at all yet? + if (!formationConfirmed) { + const probe = await search(KEYWORD); + if (probe.status === 200 && (probe.json.total ?? 0) > 0) formationConfirmed = true; + } + + const elapsed = Math.round((TIMEOUT_MS - (deadline - Date.now())) / 1000); + log(` …${elapsed}s paraphrase: total=${lastTotal} strategy=${lastStrategy} | L1 formed=${formationConfirmed}`); + await delay(POLL_MS); + } + + // 4. Verdict ------------------------------------------------------------------ + log(""); + const cleanup = async () => { + if (KEEP) { + log(`cleanup : skipped (--keep). Throwaway account left as "${SESSION}".`); + return; + } + if (!multiTenant) { + log(`cleanup : single-tenant mode — /namespace/wipe is refused; smoke data stays in the shared store.`); + log(` (Run a multi-tenant gateway, or use a disposable dataDir, to auto-clean.)`); + return; + } + const w = await req("POST", "/namespace/wipe", { session_key: SESSION }); + log(w.status === 200 && w.json.wiped ? `cleanup : wiped throwaway account ✓` : `cleanup : wipe returned status=${w.status} body=${JSON.stringify(w.json)}`); + }; + + if (sawVector) { + await cleanup(); + log(""); + log(`✅ PASS — vector recall is LIVE. A no-keyword paraphrase recalled the fact via strategy="${lastStrategy}".`); + process.exit(0); + } + + // Failed — distinguish the two root causes for an actionable message. + await cleanup(); + if (formationConfirmed) { + fail( + `L1 formed (the fact is searchable by keyword), but the paraphrase only ever returned ` + + `total=${lastTotal} strategy="${lastStrategy}" — vectors are NOT contributing.\n` + + ` => Embedding is enabled but not actually producing hits. Check the DashScope key/endpoint,\n` + + ` the model/dimensions in memory.embedding.*, and that memory.recall.strategy is hybrid/embedding.\n` + + ` (Watch the gateway logs for embedding errors during the search.)`, + ); + } else { + fail( + `could not confirm L1 formation within ${TIMEOUT_MS / 1000}s (keyword "${KEYWORD}" never matched, ` + + `paraphrase strategy="${lastStrategy}").\n` + + ` => Either extraction is slow/off (check TDAI_LLM_* and the gateway logs for L1 activity),\n` + + ` or the run needs longer — retry with --timeout 180. The fact may also have been\n` + + ` extracted with different wording; try SMOKE_KEYWORD/SMOKE_PARAPHRASE overrides.`, + ); + } +} + +main().catch((err) => setupError(err?.stack || String(err))); From 1fbe2346ce361ddb85b19857da6c4518bd3ff95d Mon Sep 17 00:00:00 2001 From: Jack <278171810@qq.com> Date: Sun, 28 Jun 2026 09:15:31 +0800 Subject: [PATCH 19/20] =?UTF-8?q?docs:=20reference=20the=20=C2=A76=20smoke?= =?UTF-8?q?-recall=20script=20in=20the=20integration=20guide?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Point integration teams at scripts/smoke-recall.mjs (commit 6b0a573) as the first post-deploy check: it automates and asserts the §6 vector-recall test and is wired into the §10 quickstart checklist. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Jack <278171810@qq.com> --- docs/tdai_gateway_integration.md | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/docs/tdai_gateway_integration.md b/docs/tdai_gateway_integration.md index 865d85d2..6fa3f881 100644 --- a/docs/tdai_gateway_integration.md +++ b/docs/tdai_gateway_integration.md @@ -274,6 +274,29 @@ query actually succeeded — so test deliberately: recall works. If paraphrases only ever return `fts`/`none`, embedding isn't contributing — check the `memory.embedding.*` yaml block and the DashScope key. +### One-click smoke test + +`scripts/smoke-recall.mjs` automates exactly the steps above and **asserts** the +result — run it against a Gateway started with your real `.env` (DashScope key, +`TDAI_LLM_*`) as the first thing you do after deploy: + +```bash +node scripts/smoke-recall.mjs +# against a non-default address / with auth: +TDAI_GATEWAY_URL=http://127.0.0.1:8420 \ +TDAI_GATEWAY_API_KEY= node scripts/smoke-recall.mjs --timeout 120 +``` + +It captures a distinctive fact for a throwaway account, polls a no-keyword +paraphrase until L1 forms, and passes **only** when `strategy` is `hybrid` or +`embedding`. Exit codes: `0` PASS, `1` FAIL (vectors not contributing — the +message distinguishes "L1 never formed" from "formed but embedding silent"), `2` +setup error (gateway unreachable / bad config). It fails fast (~1s) if +`/health` reports `embedding.configured:false`, and cleans up via +`/namespace/wipe` in multi-tenant mode (`--keep` to retain the probe data). No +build, no deps — needs only Node ≥ 18. Override the probe text for non-Chinese +deployments via `SMOKE_FACT_USER` / `SMOKE_KEYWORD` / `SMOKE_PARAPHRASE`. + The bundled **dev-console** (`scripts/dev-console`, `:8421`) drives all of this through a UI and shows the `strategy` tag and the live pyramid per account. @@ -332,5 +355,5 @@ set stays warm; pre-warm with a cheap `/recall` if first-turn latency matters. - [ ] Start the Gateway; confirm `GET /health` → `multi_tenant:true`. - [ ] Wire `turn_service`: `/recall` before the LLM, async `/capture` after. - [ ] Pick a `session_key` scheme (`{namespace}:{account_id}`). -- [ ] Verify vector recall with a paraphrase search (§6). +- [ ] Verify vector recall: run `node scripts/smoke-recall.mjs` → `✅ PASS` (§6). - [ ] Wire `/namespace/wipe` into account deletion. From 16105043dc6cfa2ddaac8a233402df384af2f571 Mon Sep 17 00:00:00 2001 From: Jack <278171810@qq.com> Date: Mon, 29 Jun 2026 10:00:32 +0800 Subject: [PATCH 20/20] chore(release): default multiTenant=true + ignore throwaway scripts Lock the multi-tenant fork's deploy state for AI4ALL production: - config.ts: default `multiTenant` to true so the fork is multi-tenant by default even if TDAI_MULTI_TENANT is unset (prod still sets it explicitly). - dev-console: surface recall `strategy` in search results (hybrid/embedding/ fts/none) to distinguish vector recall from keyword at a glance. - .gitignore: ignore throwaway `scripts/tmp_*.py` analysis scripts. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 3 +++ scripts/dev-console/public/index.html | 8 +++++++- src/gateway/config.ts | 2 +- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 919d772b..f4937c68 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,9 @@ __pycache__/ *.pyo *.pyd +# Throwaway dev/analysis scripts (not part of the product) +scripts/tmp_*.py + # Migration build output scripts/export-tencent-vdb/dist/ scripts/migrate-sqlite-to-tcvdb/dist/ diff --git a/scripts/dev-console/public/index.html b/scripts/dev-console/public/index.html index 64d8ec7b..b9046681 100644 --- a/scripts/dev-console/public/index.html +++ b/scripts/dev-console/public/index.html @@ -214,8 +214,14 @@

Memory pyramid

const route = SEARCH_KIND === "memories" ? "search/memories" : "search/conversations"; const r = await gw(route, { session_key: sk(), query: $("searchQuery").value, limit: Number($("searchLimit").value) || 5 }); if (r.status !== 200) { $("searchOut").innerHTML = '' + esc((r.json && r.json.error) || ("HTTP " + r.status)) + ''; return; } + // `strategy` is only returned by /search/memories — show it when present so you + // can tell vector recall apart from keyword: hybrid=vector+BM25, embedding=vector + // only, fts=keyword only, none=nothing retrieved. + const strat = r.json.strategy + ? ' · strategy=' + esc(r.json.strategy) + '' + : ''; $("searchOut").innerHTML = - '
total=' + r.json.total + ' (' + SEARCH_KIND + ')
' + esc(r.json.results || "(no results)") + '
'; + '
total=' + r.json.total + ' (' + SEARCH_KIND + ')' + strat + '
' + esc(r.json.results || "(no results)") + '
'; }; // ── ops ─────────────────────────────────────────────────────────── diff --git a/src/gateway/config.ts b/src/gateway/config.ts index 6f16b09d..41b535c5 100644 --- a/src/gateway/config.ts +++ b/src/gateway/config.ts @@ -183,7 +183,7 @@ export function loadGatewayConfig(overrides?: Partial): GatewayCo const rawBaseDir = env("TDAI_DATA_DIR") ?? str(dataConfig, "baseDir") ?? resolveDefaultDataDir(); const home = getEnv("HOME") ?? getEnv("USERPROFILE") ?? "/tmp"; const baseDir = rawBaseDir.startsWith("~/") ? path.join(home, rawBaseDir.slice(2)) : rawBaseDir; - const multiTenant = envBool("TDAI_MULTI_TENANT") ?? bool(dataConfig, "multiTenant") ?? false; + const multiTenant = envBool("TDAI_MULTI_TENANT") ?? bool(dataConfig, "multiTenant") ?? true; const maxConcurrentExtractions = envInt("TDAI_MAX_CONCURRENT_EXTRACTIONS") ?? num(dataConfig, "maxConcurrentExtractions"); const maxResidentCores =