Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db6fd1b8e3 | ||
|
|
5176683937 | ||
|
|
6150ceb9cc | ||
|
|
6054f9921a | ||
|
|
0c59413ad8 | ||
|
|
625f877b43 |
No files matched your search
+16
-4
@@ -1,8 +1,20 @@
|
||||
# MemHub / One Memory
|
||||
# MemHub
|
||||
|
||||
MemHub 是一个本地优先的共享记忆产品:把用户授权的多个 AI 产品中的历史对话和新增内容
|
||||
汇总到同一份记忆,并让这些 AI 在独立授权后复用它。Hy Memory 是 MemHub 使用的 Python
|
||||
SDK 和记忆引擎。One Memory / `one-memory` 是历史模块、数据目录、服务和兼容命令名称。
|
||||
MemHub是一个本地优先的共享记忆产品。它把用户明确授权的AI对话和编码记录汇总到一份本地记忆中,再由用户分别授权给支持的AI工具调用。你可以继续使用熟悉的客户端,同时保留跨工具延续的上下文。Hy Memory是MemHub使用的Python SDK和记忆引擎。
|
||||
|
||||
# 💡❤️ MemHub 刚刚开源,欢迎一起优化共建! ❤️💡
|
||||
|
||||
**# 💭 MemHub处于preview阶段,当前公开版本可以下载和体验,部分平台制品、安装路径与宿主体验仍在持续优化验证。安装前请先查看Release里的实际制品和版本说明。**
|
||||
|
||||
> 🙋 如果你遇到问题/有建议:欢迎提交Issue,并附上MemHub版本、操作系统与芯片架构、复现步骤、预期结果、实际结果和经过脱敏的日志。请不要上传APIKey、真实对话、个人路径或其他敏感信息。
|
||||
|
||||
> 我们会公开跟进可复现的问题,持续发布修复,并根据真实反馈完善安装、同步和召回体验。
|
||||
|
||||
**# 感谢每一条反馈,期待你和我们一起打磨MemHub的体验 :)**
|
||||
|
||||
> 💗 MemHub has just been open-sourced and is under active development. The current public release is available for download andevaluation. Some platform packages, installation paths, and host integrations are still being completed and verified. Check the assets
|
||||
and release notes before installing.
|
||||
If you run into a problem, please open an Issue with the version, platform,reproduction steps, and sanitized logs, We wilreviewreports, publish fixes, and keep the support matrix up to date.
|
||||
|
||||
## 发行与平台合同
|
||||
|
||||
|
||||
@@ -17,16 +17,23 @@ import {
|
||||
import * as api from "./api";
|
||||
import { saveConnectionPreferences } from "./connection-preferences";
|
||||
import { resetConnectionReconcile } from "./connection-reconcile";
|
||||
import { queryClient } from "./query-client";
|
||||
import { queryClient, resetClient } from "./query-client";
|
||||
|
||||
const { summary, processing, history, liveSync, recentConversations } =
|
||||
vi.hoisted(() => ({
|
||||
summary: vi.fn(),
|
||||
processing: vi.fn(),
|
||||
history: vi.fn(),
|
||||
liveSync: vi.fn(),
|
||||
recentConversations: vi.fn(),
|
||||
}));
|
||||
const {
|
||||
summary,
|
||||
processing,
|
||||
sedimentation,
|
||||
history,
|
||||
liveSync,
|
||||
recentConversations,
|
||||
} = vi.hoisted(() => ({
|
||||
summary: vi.fn(),
|
||||
processing: vi.fn(),
|
||||
sedimentation: vi.fn(),
|
||||
history: vi.fn(),
|
||||
liveSync: vi.fn(),
|
||||
recentConversations: vi.fn(),
|
||||
}));
|
||||
vi.mock("./call-history-client", async (original) => ({
|
||||
...(await original<typeof import("./call-history-client")>()),
|
||||
getCallHistory: history,
|
||||
@@ -35,6 +42,7 @@ vi.mock("./api", () => ({
|
||||
getHomeSummary: summary,
|
||||
getLiveSyncHealth: liveSync,
|
||||
getMemoryProcessing: processing,
|
||||
getSedimentation: sedimentation,
|
||||
getRecentConversations: recentConversations,
|
||||
getSources: vi.fn().mockResolvedValue([]),
|
||||
}));
|
||||
@@ -74,6 +82,11 @@ beforeEach(() => {
|
||||
observed_at: "2026-09-19T00:00:00Z",
|
||||
});
|
||||
processing.mockReset().mockResolvedValue({ running: 0, pending: 0 });
|
||||
sedimentation.mockReset().mockResolvedValue({
|
||||
enabled: true,
|
||||
pending_retained: true,
|
||||
imports_unaffected: true,
|
||||
});
|
||||
liveSync.mockReset().mockResolvedValue([]);
|
||||
recentConversations.mockReset().mockResolvedValue({
|
||||
items: [],
|
||||
@@ -538,6 +551,7 @@ it.each([
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
resetClient();
|
||||
resetConnectionReconcile();
|
||||
window.localStorage.clear();
|
||||
vi.restoreAllMocks();
|
||||
@@ -616,7 +630,7 @@ it("keeps authoritative home and processing data when only the source snapshot b
|
||||
expect(screen.getByText("601")).toBeTruthy();
|
||||
expect(screen.getByText("后台运行中")).toBeTruthy();
|
||||
expect(screen.getByText("2 个应用已接入")).toBeTruthy();
|
||||
expect(screen.getAllByText("一切就绪")).toHaveLength(2);
|
||||
expect(screen.getAllByText("整理中")).toHaveLength(2);
|
||||
expect(screen.queryByText("状态待核对")).toBeNull();
|
||||
});
|
||||
it("does not turn a failed home summary into zero or expose a private error", async () => {
|
||||
@@ -647,11 +661,11 @@ it("never calls a recall event a success when only stored memories are available
|
||||
});
|
||||
|
||||
it.each([
|
||||
[{ running: 0, pending: 0 }, "一切就绪"],
|
||||
[{ running: 0, pending: 0, waiting_for_model: 1 }, "等待模型"],
|
||||
[{ running: 0, pending: 0, blocked_stages: [{ count: 1 }] }, "排队处理中"],
|
||||
[{ running: 0, pending: 0, failed: 1 }, "需要处理"],
|
||||
])("does not confuse task state with readiness: %j", async (value, label) => {
|
||||
{ running: 0, pending: 0 },
|
||||
{ running: 0, pending: 0, waiting_for_model: 1 },
|
||||
{ running: 0, pending: 0, blocked_stages: [{ count: 1 }] },
|
||||
{ running: 0, pending: 0, failed: 1 },
|
||||
])("shows the enabled switch instead of task counters: %j", async (value) => {
|
||||
processing.mockResolvedValue(value);
|
||||
render(
|
||||
<ApplicationsHome
|
||||
@@ -661,8 +675,29 @@ it.each([
|
||||
onSettings={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(await screen.findAllByText(label)).toHaveLength(2);
|
||||
expect(screen.queryByText("已就绪")).toBeNull();
|
||||
expect(await screen.findAllByText("整理中")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("shows a paused switch immediately even when task rows still say running", async () => {
|
||||
processing.mockResolvedValue({ running: 3, pending: 10 });
|
||||
sedimentation.mockResolvedValue({
|
||||
enabled: false,
|
||||
pending_retained: true,
|
||||
imports_unaffected: true,
|
||||
});
|
||||
|
||||
render(
|
||||
<ApplicationsHome
|
||||
imports={imports}
|
||||
onManage={() => {}}
|
||||
onMemories={() => {}}
|
||||
onSettings={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(await screen.findAllByText("已暂停")).toHaveLength(2);
|
||||
expect(screen.getByText("已停止生成新记忆")).toBeTruthy();
|
||||
expect(screen.queryByText("新的对话正在整理")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the import-in-progress empty state stable without a progress link", async () => {
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
import { subscribePoll } from "./poll-scheduler";
|
||||
import { R6Button } from "./R6Controls";
|
||||
import { prefetchDataRuntime } from "./runtime-state";
|
||||
import { refreshSedimentation, useSedimentation } from "./sedimentation-state";
|
||||
import type { useImportSources } from "./use-import-sources";
|
||||
import "./applications-home.css";
|
||||
|
||||
@@ -192,6 +193,7 @@ export function ApplicationsHome({
|
||||
// A failed poll keeps the last answer on screen; the page must say it is old.
|
||||
const hostStale = hostQuery.isError && hostQuery.data !== undefined;
|
||||
const auto = useAutoConnect();
|
||||
const sedimentation = useSedimentation();
|
||||
const [summary, setSummary] = useState<HomeSummary | null>(null);
|
||||
const [processing, setProcessing] = useState<MemoryProcessingSummary | null>(
|
||||
null,
|
||||
@@ -554,6 +556,7 @@ export function ApplicationsHome({
|
||||
getMemoryProcessing(),
|
||||
getLiveSyncHealth(),
|
||||
getRecentConversations({ limit: 1, recentActivity: true }),
|
||||
refreshSedimentation(),
|
||||
]);
|
||||
busy = false;
|
||||
if (stopped) return;
|
||||
@@ -651,19 +654,18 @@ export function ApplicationsHome({
|
||||
const apps = imports.sources.filter(
|
||||
(s) => s.authorized || s.detected === true || s.location_state === "found",
|
||||
);
|
||||
const stateLabel = processing?.running
|
||||
? "整理中"
|
||||
: processing?.blocked_stages?.some((stage) => stage.count > 0)
|
||||
? "排队处理中"
|
||||
: processing?.waiting_for_model
|
||||
? "等待模型"
|
||||
: processing?.failed
|
||||
? "需要处理"
|
||||
: processing?.pending
|
||||
? "准备中"
|
||||
: processing
|
||||
? "一切就绪"
|
||||
: "待核对";
|
||||
const stateLabel =
|
||||
sedimentation?.enabled === true
|
||||
? "整理中"
|
||||
: sedimentation?.enabled === false
|
||||
? "已暂停"
|
||||
: "待核对";
|
||||
const stateDetail =
|
||||
sedimentation?.enabled === false
|
||||
? "已停止生成新记忆"
|
||||
: sedimentation?.enabled === true
|
||||
? "新的对话正在整理"
|
||||
: "正在核对整理状态";
|
||||
const latestClock = latest ? clockParts(latest.updated_at) : null;
|
||||
const time = latestClock?.clock ?? (latest ? "时间未知" : null);
|
||||
const checked = visible ? clockParts(visible.observed_at) : null;
|
||||
@@ -835,13 +837,7 @@ export function ApplicationsHome({
|
||||
<div>
|
||||
<p>沉淀状态</p>
|
||||
<strong className="r6-home-word">{stateLabel}</strong>
|
||||
<small>
|
||||
{processing?.running
|
||||
? "新的对话正在整理"
|
||||
: processing?.pending
|
||||
? "等待队列开始处理"
|
||||
: "以当前处理状态为准"}
|
||||
</small>
|
||||
<small>{stateDetail}</small>
|
||||
</div>
|
||||
<div>
|
||||
<p>最近更新</p>
|
||||
|
||||
@@ -5,6 +5,7 @@ import userEvent from "@testing-library/user-event";
|
||||
import type { Ref } from "react";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { getModelProfiles, getSedimentation, setSedimentation } from "./api";
|
||||
import { queryClient, resetClient } from "./query-client";
|
||||
import { R6Settings } from "./R6Settings";
|
||||
|
||||
vi.mock("./api", () => ({
|
||||
@@ -54,6 +55,7 @@ vi.mock("./diagnostics-export", () => ({
|
||||
}));
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
resetClient();
|
||||
vi.clearAllMocks();
|
||||
window.oneMemory = undefined;
|
||||
});
|
||||
@@ -94,6 +96,11 @@ it("stops model sedimentation on one switch flip without touching the model conf
|
||||
await user.click(toggle);
|
||||
|
||||
expect(setSedimentation).toHaveBeenCalledExactlyOnceWith(false);
|
||||
expect(queryClient.getQueryData(["sedimentation"])).toEqual({
|
||||
enabled: false,
|
||||
pending_retained: true,
|
||||
imports_unaffected: true,
|
||||
});
|
||||
await screen.findByText(/停止后不再调用模型/);
|
||||
});
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { getModelProfiles, getSedimentation, setSedimentation } from "./api";
|
||||
import { getModelProfiles, setSedimentation } from "./api";
|
||||
import { ConnectionFallback } from "./ConnectionFallback";
|
||||
import { exportDiagnosticsFile } from "./diagnostics-export";
|
||||
import { ModelSettings, type ModelSettingsActions } from "./ModelSettings";
|
||||
@@ -18,6 +18,11 @@ import {
|
||||
setLoginPreference,
|
||||
useLoginPreference,
|
||||
} from "./runtime-state";
|
||||
import {
|
||||
commitSedimentation,
|
||||
refreshSedimentation,
|
||||
useSedimentation,
|
||||
} from "./sedimentation-state";
|
||||
import "./r6-settings.css";
|
||||
|
||||
type Group = "model" | "connections" | "help";
|
||||
@@ -72,7 +77,8 @@ export function R6Settings({
|
||||
commit: "unknown",
|
||||
},
|
||||
);
|
||||
const [sedimentation, setSedimentationState] = useState<boolean | null>(null);
|
||||
const sedimentationState = useSedimentation();
|
||||
const sedimentation = sedimentationState?.enabled ?? null;
|
||||
const [sedimentationPending, setSedimentationPending] = useState(false);
|
||||
const [sedimentationError, setSedimentationError] = useState("");
|
||||
const [sedimentationUnavailable, setSedimentationUnavailable] =
|
||||
@@ -119,13 +125,11 @@ export function R6Settings({
|
||||
|
||||
const readSedimentation = useCallback(async () => {
|
||||
try {
|
||||
const state = await getSedimentation();
|
||||
setSedimentationState(state.enabled === true);
|
||||
await refreshSedimentation();
|
||||
setSedimentationUnavailable(false);
|
||||
} catch {
|
||||
// Left unknown rather than assumed on: showing "已停止" when the switch
|
||||
// could not be read would hide a running model.
|
||||
setSedimentationState(null);
|
||||
// Keep the last confirmed state visible, but disable writes until the
|
||||
// server can confirm it again.
|
||||
setSedimentationUnavailable(true);
|
||||
}
|
||||
}, []);
|
||||
@@ -141,7 +145,7 @@ export function R6Settings({
|
||||
setSedimentationError("");
|
||||
try {
|
||||
const state = await setSedimentation(enabled);
|
||||
setSedimentationState(state.enabled === true);
|
||||
commitSedimentation(state);
|
||||
} catch {
|
||||
setSedimentationError(
|
||||
enabled
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { queryClient, resetClient } from "./query-client";
|
||||
import {
|
||||
commitSedimentation,
|
||||
refreshSedimentation,
|
||||
} from "./sedimentation-state";
|
||||
|
||||
const { getSedimentation } = vi.hoisted(() => ({
|
||||
getSedimentation: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./api", () => ({ getSedimentation }));
|
||||
|
||||
const enabled = {
|
||||
enabled: true,
|
||||
pending_retained: true,
|
||||
imports_unaffected: true,
|
||||
};
|
||||
const paused = { ...enabled, enabled: false };
|
||||
|
||||
afterEach(() => {
|
||||
resetClient();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("keeps a newer switch receipt when an older read lands later", async () => {
|
||||
let finishRead: ((value: typeof enabled) => void) | undefined;
|
||||
getSedimentation.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<typeof enabled>((resolve) => {
|
||||
finishRead = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
const olderRead = refreshSedimentation();
|
||||
commitSedimentation(paused);
|
||||
finishRead?.(enabled);
|
||||
|
||||
expect(await olderRead).toEqual(paused);
|
||||
expect(queryClient.getQueryData(["sedimentation"])).toEqual(paused);
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { getSedimentation, type SedimentationState } from "./api";
|
||||
import { onClientReset, queryClient } from "./query-client";
|
||||
|
||||
const SEDIMENTATION_KEY = ["sedimentation"] as const;
|
||||
const FRESH_MS = 5_000;
|
||||
|
||||
queryClient.setQueryDefaults(SEDIMENTATION_KEY, {
|
||||
gcTime: Number.POSITIVE_INFINITY,
|
||||
staleTime: FRESH_MS,
|
||||
});
|
||||
|
||||
let revision = 0;
|
||||
onClientReset(() => {
|
||||
revision = 0;
|
||||
});
|
||||
|
||||
/**
|
||||
* Reads the server switch without allowing an older request to overwrite a
|
||||
* newer PUT receipt committed by the settings page.
|
||||
*/
|
||||
export async function refreshSedimentation(): Promise<SedimentationState> {
|
||||
const startedAtRevision = revision;
|
||||
const next = await getSedimentation();
|
||||
const current =
|
||||
queryClient.getQueryData<SedimentationState>(SEDIMENTATION_KEY);
|
||||
if (startedAtRevision !== revision && current) return current;
|
||||
queryClient.setQueryData(SEDIMENTATION_KEY, next);
|
||||
return next;
|
||||
}
|
||||
|
||||
/** The PUT response is the latest confirmed server state. */
|
||||
export function commitSedimentation(value: SedimentationState) {
|
||||
revision += 1;
|
||||
queryClient.setQueryData(SEDIMENTATION_KEY, value);
|
||||
}
|
||||
|
||||
export function useSedimentation() {
|
||||
return useQuery(
|
||||
{
|
||||
queryKey: SEDIMENTATION_KEY,
|
||||
queryFn: refreshSedimentation,
|
||||
enabled: false,
|
||||
},
|
||||
queryClient,
|
||||
).data;
|
||||
}
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"name": "one-memory",
|
||||
"version": "1.0.4",
|
||||
"release": {
|
||||
"build_number": 12,
|
||||
"build_number": 13,
|
||||
"macos_min_version": "13.0",
|
||||
"app_update": "manual",
|
||||
"runtime_manifest_schema": 1,
|
||||
|
||||
@@ -52,4 +52,21 @@ describe("domain errors", () => {
|
||||
expect(isDomainCode("")).toBe(false);
|
||||
expect(isDomainCode(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps only validated repo-relative stack locations", () => {
|
||||
const error = new DomainError("grant_required");
|
||||
error.stack = [
|
||||
"DomainError: private message",
|
||||
" at private (/Users/someone/private.ts:1:2)",
|
||||
" at safe (packages/host-bridge/src/domain-error.ts:88:10)",
|
||||
` at noisy (${`${"apps/".repeat(2_000)}bad`})`,
|
||||
].join("\n");
|
||||
|
||||
const diagnostic = diagnosticDomainError(error);
|
||||
|
||||
expect(diagnostic.safe_stack).toEqual([
|
||||
"packages/host-bridge/src/domain-error.ts:88:10",
|
||||
]);
|
||||
expect(JSON.stringify(diagnostic)).not.toContain("/Users/someone");
|
||||
});
|
||||
});
|
||||
@@ -66,6 +66,48 @@ export function domainCodeOf(error: unknown): DomainCode | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
const SAFE_STACK_PREFIXES = ["apps/", "packages/", "scripts/"] as const;
|
||||
|
||||
function isSafeStackCharacter(character: string): boolean {
|
||||
const code = character.charCodeAt(0);
|
||||
return (
|
||||
(code >= 48 && code <= 57) ||
|
||||
(code >= 65 && code <= 90) ||
|
||||
(code >= 97 && code <= 122) ||
|
||||
"_./-:".includes(character)
|
||||
);
|
||||
}
|
||||
|
||||
function isAsciiDigits(value: string): boolean {
|
||||
if (!value.length) return false;
|
||||
for (const character of value) {
|
||||
const code = character.charCodeAt(0);
|
||||
if (code < 48 || code > 57) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Extract one repo-relative `path:line:column` in linear time. */
|
||||
function safeStackLocation(line: string): string | null {
|
||||
for (const prefix of SAFE_STACK_PREFIXES) {
|
||||
const start = line.indexOf(prefix);
|
||||
if (start < 0) continue;
|
||||
let end = start;
|
||||
while (end < line.length && isSafeStackCharacter(line[end] ?? "")) end += 1;
|
||||
const candidate = line.slice(start, end);
|
||||
const columnSeparator = candidate.lastIndexOf(":");
|
||||
const lineSeparator = candidate.lastIndexOf(":", columnSeparator - 1);
|
||||
if (
|
||||
lineSeparator > prefix.length &&
|
||||
columnSeparator > lineSeparator + 1 &&
|
||||
isAsciiDigits(candidate.slice(lineSeparator + 1, columnSeparator)) &&
|
||||
isAsciiDigits(candidate.slice(columnSeparator + 1))
|
||||
)
|
||||
return candidate;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The diagnostic fields for a failure, keeping the code when there is one.
|
||||
*
|
||||
* Only `error_type` and a repo-relative stack survive. `Error.message` is
|
||||
@@ -85,10 +127,8 @@ export function diagnosticDomainError(error: unknown): {
|
||||
const stack = (error.stack ?? "")
|
||||
.split("\n")
|
||||
.flatMap((line) => {
|
||||
const match = line.match(
|
||||
/((?:apps|packages|scripts)\/[A-Za-z0-9_./-]+:\d+:\d+)/,
|
||||
);
|
||||
return match?.[1] ? [match[1]] : [];
|
||||
const location = safeStackLocation(line);
|
||||
return location ? [location] : [];
|
||||
})
|
||||
.slice(0, 16);
|
||||
return {
|
||||
|
||||
Reference in new issue
Block a user