Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ Mnemuron stores reusable memories with their sources and revisions, so authorize

The central service stores data in SQLite. Adapters connect agent lifecycle events to the service and keep a local outbox when it is unavailable. There is no required cloud memory service or external vector database.

> **Status: experimental.** The project targets a single user's self-hosted workspace. APIs, schemas, and host integrations may change. `production_ready` remains `false`; adapter availability is not a claim that every host version or deployment is supported.
> **Status: experimental.** Single-owner self-hosting remains the default. An opt-in account-isolated console is available for local evaluation; deployment and recovery policies require separate review. APIs, schemas, and host integrations may change. `production_ready` remains `false`; adapter availability is not a claim that every host version or deployment is supported.

## Why Mnemuron?

Expand Down Expand Up @@ -75,6 +75,7 @@ These are source integrations, not a universal installer. Host plugin loading, h

- [Getting started](docs/getting-started.md) — a local, authenticated API walkthrough.
- [Documentation index](docs/README.md) — concepts, protocol contracts, adapters, and operations.
- [Account console preview](docs/integrated-console-v0.3.md) — invitation-based registration, local TOTP, account-bound reads, migration and deliberately blocked operations.
- [Core specification](docs/core-spec-v0.1.md) — the data model and continuity boundaries.
- [Deployment guide](docs/pve-lxc-deployment-v0.1.md) — an optional Linux/LXC deployment example; Proxmox is not required by the core API.
- [Core optimization notes](docs/core-optimization-v0.2/release-notes.md) and [retrieval/sync review](docs/core-review-v0.3/README.md) — implementation changes and compatibility notes.
Expand All @@ -95,14 +96,15 @@ server/ HTTP API, SQLite storage, administration, and tests
plugins/mnemuron/ ChatGPT / Codex plugin
adapters/ OpenClaw, Hermes, and optional read-only HTTP MCP integrations
services/oauth/ Optional password + TOTP authorization service
web/console/ Same-origin desktop console, themes and bilingual catalogue
shared/ Shared OAuth/gateway boundary helpers
scripts/ Benchmarks, regression runners, and publication checks
docs/ Guides, specifications, and test plans
```

## Current boundaries

- Built for single-user self-hosting, not a managed multi-tenant service.
- Single-owner by default, with opt-in account isolation and a desktop console. This is not a managed multi-tenant service or a production certification.
- Lexical/FTS retrieval works without models. Optional, operator-configured embedding and Qdrant modules provide hybrid/semantic retrieval; query egress approval and budgets remain required. Hybrid fallback is marked; unavailable semantic search is an error, not a fabricated success.
- Derived summaries preserve source revisions and coverage. Read-only summary retrieval never schedules a model; see [Memory First](docs/memory-first-v0.1/README.md).
- Automatic summaries can omit context. Source records and explicit task state remain distinct.
Expand Down
6 changes: 4 additions & 2 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ Mnemuron 保留记忆的来源和版本,让授权的 Agent 跨会话、跨设

中心服务使用 SQLite 存储数据。适配器将宿主生命周期事件接入服务,并在服务暂时不可用时保留本地待发送队列。不依赖云端记忆服务或外部向量数据库。

> **当前状态:实验阶段。** 项目面向单用户自托管工作空间,API、数据结构和宿主集成仍可能变化。`production_ready` 保持 `false`;提供适配器源码不等于承诺兼容所有宿主版本或部署环境。
> **当前状态:实验阶段。** 默认仍为单 owner 自托管,另提供需显式启用的账户隔离控制台供本地评估;部署及恢复策略需单独审查。API、数据结构和宿主集成仍可能变化。`production_ready` 保持 `false`;提供适配器源码不等于承诺兼容所有宿主版本或部署环境。

## 能解决什么问题?

Expand Down Expand Up @@ -75,6 +75,7 @@ curl --fail http://127.0.0.1:47831/readyz

- [快速开始](docs/getting-started.md):带身份认证的本地 API 示例。
- [文档索引](docs/README.md):概念、协议、适配器与运维文档。
- [账户控制台预览](docs/integrated-console-v0.3.md):注册码注册、本地 TOTP、逐账户读取、迁移,以及仍关闭的高风险操作。
- [核心规格](docs/core-spec-v0.1.md):数据模型与任务连续性边界。
- [部署指南](docs/pve-lxc-deployment-v0.1.md):可选的 Linux/LXC 部署示例;核心 API 不要求使用 Proxmox。
- [核心优化说明](docs/core-optimization-v0.2/release-notes.md)与[检索及同步修订](docs/core-review-v0.3/README.md):实现变更和兼容性说明。
Expand All @@ -95,14 +96,15 @@ server/ HTTP API、SQLite 存储、管理工具与测试
plugins/mnemuron/ ChatGPT / Codex 插件
adapters/ OpenClaw、Hermes 与可选只读 HTTP MCP 集成
services/oauth/ 可选密码 + TOTP 授权服务
web/console/ 同域桌面控制台、颜色主题与双语字典
shared/ OAuth 与网关共用的边界检查
scripts/ 性能基准、回归运行器与发布内容检查
docs/ 指南、规格与测试计划
```

## 当前边界

- 面向单用户自托管,不是托管式多租户服务。
- 默认单 owner,逐账户隔离与桌面控制台需显式启用;不是托管式多租户服务,也不是生产认证。
- 词法/全文检索无需模型;可选的 Embedding 与 Qdrant 模块支持混合和语义检索,但必须配置查询外发许可与预算。混合降级会明确标记,语义搜索不可用时返回错误。
- 派生摘要保留来源版本和覆盖范围;只读摘要查询不会启动模型任务,详见 [Memory First](docs/memory-first-v0.1/README.md)。
- 自动摘要可能遗漏上下文,来源记录与明确的任务状态始终分开保留。
Expand Down
12 changes: 6 additions & 6 deletions adapters/chatgpt-web/src/authorization.mjs
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { BoundaryError, readSecret, seconds, secretHash } from "../../../shared/oauth-common.mjs";
import { loadIdentityMap } from "./config.mjs";
import { loadIdentityMappings } from "./config.mjs";
import { fetchAuthorizationJson } from "./auth-transport.mjs";

export class GatewayAuthorization {
constructor(config) {
this.config = config;
this.secret = readSecret(config.introspection.client_secret_file);
loadIdentityMap(config);
loadIdentityMappings(config);
}
async metadata() {
const c = this.config;
Expand Down Expand Up @@ -46,10 +46,10 @@ export class GatewayAuthorization {
|| data.client_id !== c.introspection.expected_oauth_client_id || data.token_kind !== "access_token"
|| data.token_type !== "Bearer") throw new BoundaryError(401, "INVALID_TOKEN");
let mapping;
try { mapping = loadIdentityMap(c); } catch { throw new BoundaryError(503, "IDENTITY_CONFIGURATION_UNAVAILABLE"); }
if (!mapping.enabled || data.sub !== mapping.subject) throw new BoundaryError(403, "SUBJECT_DENIED");
return { mapping, scopes: new Set(data.scope.split(" ").filter(Boolean)),
connection_id:secretHash(JSON.stringify([c.issuer,data.client_id,data.sub])) };
try { mapping = loadIdentityMappings(c).find(item=>item.subject===data.sub && item.issuer===c.issuer); } catch { throw new BoundaryError(503, "IDENTITY_CONFIGURATION_UNAVAILABLE"); }
if (!mapping?.enabled || (c.identity_mode==='multi_account_v1' && (data.account_id!==mapping.account_id || data.security_version!==mapping.security_version))) throw new BoundaryError(403, "SUBJECT_DENIED");
return Object.freeze({ mapping, scopes: new Set(data.scope.split(" ").filter(Boolean)),
connection_id:secretHash(JSON.stringify([c.issuer,data.client_id,data.sub])) });
}
}

Expand Down
26 changes: 23 additions & 3 deletions adapters/chatgpt-web/src/config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ export function validateGatewayConfig(input, { isolated = false } = {}) {
requireConfig(c.config_version === "mnemuron-web-gateway-config-v1", "config_version");
requireConfig(["oauth", "bootstrap_metadata_only"].includes(c.mode), "mode");
requireConfig(["auth_only", "readonly"].includes(c.tool_profile), "tool_profile");
c.identity_mode ??= 'legacy_owner';
requireConfig(['legacy_owner','multi_account_v1'].includes(c.identity_mode),'identity mode');
canonicalUrl(c.issuer, { isolated, pathname: "/" });
canonicalUrl(c.resource, { isolated, pathname: "/mcp" });
c.public_origin_mode = publicOriginMode(c);
Expand Down Expand Up @@ -69,10 +71,17 @@ export function loadGatewayConfig(file, options) {
}

export function loadIdentityMap(config) {
const mappings=loadIdentityMappings(config);
requireConfig(mappings.length===1,'single mapping required by legacy operator command');
return mappings[0];
}

export function loadIdentityMappings(config) {
const data = readPrivate(config.identity_map_file, { json: true });
requireConfig(data.unknown_subject_policy === "deny" && Array.isArray(data.mappings)
&& data.mappings.length === 1, "single-owner identity map");
const mapping = data.mappings[0];
&& (config.identity_mode==='multi_account_v1' || data.mappings.length===1), "identity map");
const subjects=new Set(),users=new Set(),credentials=new Set();
for(const mapping of data.mappings) {
requireConfig(mapping.issuer === config.issuer && typeof mapping.subject === "string"
&& /^[A-Za-z0-9_-]{16,128}$/.test(mapping.subject) && !mapping.subject.includes("__REQUIRED")
&& typeof mapping.enabled === "boolean", "immutable mapped subject");
Expand All @@ -82,5 +91,16 @@ export function loadIdentityMap(config) {
&& !mapping[key].includes("__REQUIRED"), `mapped ${key}`);
}
}
return mapping;
requireConfig(!subjects.has(mapping.subject),'duplicate subject');subjects.add(mapping.subject);
if(config.identity_mode==='multi_account_v1') {
requireConfig(typeof mapping.account_id==='string' && Number.isSafeInteger(mapping.security_version) && mapping.security_version>0,'account mapping version');
requireConfig(!users.has(mapping.mnemuron_user_id),'duplicate owner binding');users.add(mapping.mnemuron_user_id);
if(config.tool_profile==='readonly') {
requireConfig(typeof mapping.credential_file==='string' && mapping.credential_file.startsWith('/') && typeof mapping.credential_id==='string','per-account credential');
const credential=readPrivate(mapping.credential_file);
requireConfig(!credentials.has(credential),'shared credential forbidden');credentials.add(credential);
}
}
}
return data.mappings.map(mapping=>Object.freeze(mapping));
}
10 changes: 9 additions & 1 deletion adapters/chatgpt-web/src/core-client.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export class ReadonlyCoreClient {
const result = await this.request("/v1/identity");
const identity = result.identity;
if (!identity || identity.user_id !== mapping.mnemuron_user_id
|| (mapping.credential_id && identity.credential_id!==mapping.credential_id)
|| identity.agent_instance_id !== mapping.agent_instance_id || identity.identity_status !== "server_verified"
|| identity.agent_id!=='chatgpt-web' || identity.web_read_policy!=='web-memory-visibility-v1'
|| !Array.isArray(result.scopes) || result.scopes.length !== CORE_SCOPES.length
Expand All @@ -52,7 +53,14 @@ export class ReadonlyCoreClient {
async call(name, args, mapping) {
await this.checkIdentity(mapping);
switch (name) {
case "mnemuron_search_memories": return this.request("/v1/memories/query", args);
case "mnemuron_search_memories": {
// A new account does not inherit the legacy owner's paid model allocation.
if(this.config.identity_mode!=='multi_account_v1')return this.request('/v1/memories/query',args);
if(args.mode==='semantic')throw Object.assign(new BoundaryError(503,'SEMANTIC_UNAVAILABLE'),{degradation_code:'NOT_CONFIGURED'});
const result=await this.request('/v1/memories/query',{...args,mode:'lexical'});
if(args.mode==='hybrid')result.retrieval={...result.retrieval,mode:'hybrid',requested_mode:'hybrid',effective_mode:'lexical',degraded:true,fallback:'lexical',degradation_code:'NOT_CONFIGURED'};
return result;
}
case "mnemuron_get_summary": return this.request("/v1/memory-summaries/query", args);
case "mnemuron_get_memory": {
const { memory_id, ...options } = args;
Expand Down
19 changes: 13 additions & 6 deletions adapters/chatgpt-web/src/server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { randomUUID } from "node:crypto";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { SUPPORTED_PROTOCOL_VERSIONS } from "@modelcontextprotocol/sdk/types.js";
import { BoundaryError, WindowLimit, RESOURCE_SCOPES, OAUTH_SCOPES, requestBoundary, sendJson, readBody, secretHash, requireConfig } from "../../../shared/oauth-common.mjs";
import { validateGatewayConfig, loadGatewayConfig, loadIdentityMap } from "./config.mjs";
import { validateGatewayConfig, loadGatewayConfig, loadIdentityMappings } from "./config.mjs";
import { GatewayAuthorization } from "./authorization.mjs";
import { ReadonlyCoreClient } from "./core-client.mjs";
import { createMcpServer, toolDefinitions, enabledTools } from "./tools.mjs";
Expand All @@ -14,7 +14,14 @@ import {readObservation} from './read-audit.mjs';
export function createGateway(input, { isolated = false, logger = () => {} } = {}) {
const config = validateGatewayConfig(input, { isolated });
const authorization = config.mode === "oauth" ? new GatewayAuthorization(config) : null;
const core = config.mode === "oauth" && config.tool_profile === "readonly" ? new ReadonlyCoreClient(config) : null;
const multi=config.identity_mode==='multi_account_v1';
const core = config.mode === "oauth" && config.tool_profile === "readonly" && !multi ? new ReadonlyCoreClient(config) : null;
const requestCore = mapping => {
if(config.tool_profile!=='readonly')return null;
const client=multi?new ReadonlyCoreClient({...config,core:{...config.core,credential_file:mapping.credential_file}}):core;
requireConfig(client.token!==authorization.secret,'separate introspection and core credentials');
return client;
};
requireConfig(!core || core.token !== authorization.secret, "separate introspection and core credentials");
const origin = new URL(config.resource);
const metadataUrl = `${origin.origin}${config.protected_resource_metadata_path}`;
Expand Down Expand Up @@ -60,9 +67,9 @@ export function createGateway(input, { isolated = false, logger = () => {} } = {
let ready = false;
if (url.pathname === "/readyz" && authorization) {
await authorization.metadata();
const mapping = loadIdentityMap(config);
if (!mapping.enabled) throw new BoundaryError(503, "SUBJECT_DISABLED");
if (core) await core.ready(mapping);
const mappings=loadIdentityMappings(config);
if(!multi && !mappings[0].enabled)throw new BoundaryError(503,'SUBJECT_DISABLED');
for(const mapping of mappings.filter(m=>m.enabled))await requestCore(mapping)?.ready(mapping);
ready = true;
}
return sendJson(response, url.pathname === "/livez" || ready ? 200 : 503,
Expand Down Expand Up @@ -98,7 +105,7 @@ export function createGateway(input, { isolated = false, logger = () => {} } = {
if (enabledTools(config).includes(body.params.name)) tool = body.params.name;
if(tool)requireScope(auth,toolDefinitions[tool].scope);
}
const mcp = createMcpServer({ config, auth, core, id:body.id,
const mcp = createMcpServer({ config, auth, core:requestCore(auth.mapping), id:body.id,
onError:code=>{errorCode=code;readOutcome='tool_error';},
onResult:(name,result)=>{read=readObservation(name,result);readOutcome='success';} });
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true });
Expand Down
2 changes: 1 addition & 1 deletion adapters/chatgpt-web/test/fixture.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ export async function gatewayFixture(t, { profile = "auth_only", coreFixture, mu
await listen(gateway.server, f.ports.gatewayPort);
t.after(async () => { await close(gateway.server); });
if (sharedOrigin) {
f.ingress = testIngress(config.issuer, f.ports);
f.ingress = testIngress(config.issuer, f.ports,{consoleEnabled:f.config.identity_mode==='multi_account_v1'});
await listen(f.ingress, ingressPort);
t.after(() => close(f.ingress));
}
Expand Down
Loading
Loading