In the output, find password reset token: a1b2c3d4e5f6... (plain line or inside JSON). Copy the 32-character hex string after the colon.
+
+
+
+
3
+
+
Paste & reset
+
Paste the token below and set your new password.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ OpenClaw Memory
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
Memories
+
-
Sessions
+
-
Embeddings
+
-
Days
+
+
+
Sessions
+
+
+
+
+
+
+
+ \u{1F50D}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-Total Tasks
+
-Active
+
-Completed
+
-Skipped
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Related Memories
+
+
+
+
+
+
+
+
+
-Total Skills
+
-Active
+
-Draft
+
-Installed
+
-Public
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Skill Files
+
+
SKILL.md Content
+
+
Version History
+
+
Related Tasks
+
+
+
+
+
+
+ Range
+
+
+
+
+
+
+
-
Total Memories
+
-
Writes Today
+
-
Sessions
+
-
Embeddings
+
+
+
\u{1F4CA}Memory Writes per Day
+
+
+
+
+
+
\u26A1Tool Response Time(per minute avg)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\u{1F464}By Role
+
+
+
+
\u{1F4DD}By Kind
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Model Configuration
+
+
\u{1F4E1}Embedding Model
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\u{1F9E0}Summarizer Model
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\u{1F527}Skill Evolution
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Skill Dedicated Model
+
If not configured, the main Summarizer Model above will be used for skill generation. Configure a dedicated model here for higher quality skill output.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\u{1F4CA}Telemetry
+
+
+
+
+
+
+
Anonymous usage analytics to help improve the plugin. Only sends tool names, latencies, and version info. No memory content, queries, or personal data is ever sent.
+
+
+
+
+
+
\u{1F4BE}General
+
+
+
+
+
Requires restart to take effect
+
+
+
+
+
+ \u2713 Saved
+
+
+
+
Some changes require restarting the OpenClaw gateway to take effect.
+
+
+
+
+
+
\u{1F4E5}Import OpenClaw Memory
+
Migrate your existing OpenClaw built-in memories and conversation history into this plugin. The import process uses smart deduplication to avoid duplicates.
+
+
+
Three ways to use:
+
+
\u2460 Import memories only (fast) — Click "Start Import" to quickly migrate all memory chunks and conversations. No task/skill generation. Suitable when you just need the raw data.
+
\u2461 Import + generate tasks & skills (slow, serial) — After importing memories, enable "Generate Tasks" and/or "Trigger Skill Evolution" below to analyze conversations one by one. This takes longer as each session is processed by LLM sequentially.
+
\u2462 Import first, generate later (flexible) — Import memories now, then come back anytime to start task/skill generation. You can pause the generation at any point and resume later — it will pick up where you left off, only processing sessions that haven't been handled yet.
+
+
+
+
+
\u26A0 Configuration Required
+
Please configure both Embedding Model and Summarizer Model in Settings before importing. These are required for processing memories.
+
+
+
+
+
+
Memory Index (SQLite)
+
0
+
+
+
+
Conversation History
+
0
+
+
+
+
+
+
+
+
+
+ Concurrent agents
+
+
+
+
+
+ \u26A0 Increasing concurrency raises LLM API call frequency, which may trigger rate limits and cause failures.
+
+
+
+
+
+
\u{1F9E0} Optional: Generate Tasks & Skills
+
This step is completely optional. The import above has already stored raw memory data. Here you can further analyze imported conversations to generate structured task summaries and evolve reusable skills. Processing is serial (one session at a time) and may take a while. You can stop at any time and resume later — it will only process sessions not yet handled.
+
+
+
+
+
+
+
+
+ Concurrent agents
+
+
+
+
+
+ \u26A0 Increasing concurrency raises LLM API call frequency, which may trigger rate limits and cause failures.
+
+
+
+
+
+
+
+
+
+
+
+
+ Tasks
+ 0
+
+
+
+ Skills
+ 0
+
+
+
+ Errors
+ 0
+
+
+
+ Skipped
+ 0
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Stored
+ 0
+
+
+
+ Skipped
+ 0
+
+
+
+ Merged
+ 0
+
+
+
+ Errors
+ 0
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
New Memory
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Memory Detail
+
+
+
+
+
+
+
+`;
diff --git a/apps/memos-local-openclaw/src/viewer/server.ts b/apps/memos-local-openclaw/src/viewer/server.ts
new file mode 100644
index 000000000..cbd76d35c
--- /dev/null
+++ b/apps/memos-local-openclaw/src/viewer/server.ts
@@ -0,0 +1,1927 @@
+import http from "node:http";
+import crypto from "node:crypto";
+import { execSync } from "node:child_process";
+import fs from "node:fs";
+import path from "node:path";
+import readline from "node:readline";
+import type { SqliteStore } from "../storage/sqlite";
+import type { Embedder } from "../embedding";
+import { Summarizer } from "../ingest/providers";
+import { findTopSimilar } from "../ingest/dedup";
+import { stripInboundMetadata } from "../capture";
+import { vectorSearch } from "../storage/vector";
+import { TaskProcessor } from "../ingest/task-processor";
+import { RecallEngine } from "../recall/engine";
+import { SkillEvolver } from "../skill/evolver";
+import type { Logger, Chunk, PluginContext } from "../types";
+import { viewerHTML } from "./html";
+import { v4 as uuid } from "uuid";
+
+export interface ViewerServerOptions {
+ store: SqliteStore;
+ embedder: Embedder;
+ port: number;
+ log: Logger;
+ dataDir: string;
+ ctx?: PluginContext;
+}
+
+interface AuthState {
+ passwordHash: string | null;
+ sessions: Map;
+}
+
+export class ViewerServer {
+ private server: http.Server | null = null;
+ private readonly store: SqliteStore;
+ private readonly embedder: Embedder;
+ private readonly port: number;
+ private readonly log: Logger;
+ private readonly dataDir: string;
+ private readonly authFile: string;
+ private readonly auth: AuthState;
+ private readonly ctx?: PluginContext;
+
+ private static readonly SESSION_TTL = 24 * 60 * 60 * 1000;
+ private resetToken: string;
+ private migrationRunning = false;
+ private migrationAbort = false;
+ private migrationState: {
+ phase: string;
+ stored: number;
+ skipped: number;
+ merged: number;
+ errors: number;
+ processed: number;
+ total: number;
+ lastItem: any;
+ done: boolean;
+ stopped: boolean;
+ } = { phase: "", stored: 0, skipped: 0, merged: 0, errors: 0, processed: 0, total: 0, lastItem: null, done: false, stopped: false };
+ private migrationSSEClients: http.ServerResponse[] = [];
+
+ private ppRunning = false;
+ private ppAbort = false;
+ private ppState: { running: boolean; done: boolean; stopped: boolean; processed: number; total: number; tasksCreated: number; skillsCreated: number; errors: number } =
+ { running: false, done: false, stopped: false, processed: 0, total: 0, tasksCreated: 0, skillsCreated: 0, errors: 0 };
+ private ppSSEClients: http.ServerResponse[] = [];
+
+ constructor(opts: ViewerServerOptions) {
+ this.store = opts.store;
+ this.embedder = opts.embedder;
+ this.port = opts.port;
+ this.log = opts.log;
+ this.dataDir = opts.dataDir;
+ this.ctx = opts.ctx;
+ this.authFile = path.join(opts.dataDir, "viewer-auth.json");
+ this.auth = { passwordHash: null, sessions: new Map() };
+ this.resetToken = crypto.randomBytes(16).toString("hex");
+ this.loadAuth();
+ }
+
+ start(): Promise {
+ return new Promise((resolve, reject) => {
+ this.server = http.createServer((req, res) => this.handleRequest(req, res));
+ this.server.on("error", (err: NodeJS.ErrnoException) => {
+ if (err.code === "EADDRINUSE") {
+ this.log.warn(`Viewer port ${this.port} in use, trying ${this.port + 1}`);
+ this.server!.listen(this.port + 1, "127.0.0.1");
+ } else {
+ reject(err);
+ }
+ });
+ this.server.listen(this.port, "127.0.0.1", () => {
+ const addr = this.server!.address();
+ const actualPort = typeof addr === "object" && addr ? addr.port : this.port;
+ resolve(`http://127.0.0.1:${actualPort}`);
+ });
+ });
+ }
+
+ stop(): void {
+ this.server?.close();
+ this.server = null;
+ }
+
+ getResetToken(): string {
+ return this.resetToken;
+ }
+
+ // ─── Auth helpers ───
+
+ private loadAuth(): void {
+ try {
+ if (fs.existsSync(this.authFile)) {
+ const data = JSON.parse(fs.readFileSync(this.authFile, "utf-8"));
+ this.auth.passwordHash = data.passwordHash ?? null;
+ }
+ } catch {
+ this.log.warn("Failed to load viewer auth file, starting fresh");
+ }
+ }
+
+ private saveAuth(): void {
+ try {
+ fs.mkdirSync(path.dirname(this.authFile), { recursive: true });
+ fs.writeFileSync(this.authFile, JSON.stringify({ passwordHash: this.auth.passwordHash }));
+ } catch (e) {
+ this.log.warn(`Failed to save viewer auth: ${e}`);
+ }
+ }
+
+ private hashPassword(pw: string): string {
+ return crypto.createHash("sha256").update(pw + "memos-lite-salt-2026").digest("hex");
+ }
+
+ private createSession(): string {
+ const token = crypto.randomBytes(32).toString("hex");
+ this.auth.sessions.set(token, Date.now() + ViewerServer.SESSION_TTL);
+ return token;
+ }
+
+ private isValidSession(req: http.IncomingMessage): boolean {
+ const cookie = req.headers.cookie ?? "";
+ const match = cookie.match(/memos_token=([a-f0-9]+)/);
+ if (!match) return false;
+ const expiry = this.auth.sessions.get(match[1]);
+ if (!expiry) return false;
+ if (Date.now() > expiry) { this.auth.sessions.delete(match[1]); return false; }
+ return true;
+ }
+
+ private get needsSetup(): boolean {
+ return this.auth.passwordHash === null;
+ }
+
+ // ─── Request routing ───
+
+ private handleRequest(req: http.IncomingMessage, res: http.ServerResponse): void {
+ const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
+ const p = url.pathname;
+
+ res.setHeader("Access-Control-Allow-Origin", "*");
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type");
+
+ if (req.method === "OPTIONS") { res.writeHead(204); res.end(); return; }
+
+ try {
+ if (p === "/api/auth/status") {
+ return this.jsonResponse(res, { needsSetup: this.needsSetup, loggedIn: this.isValidSession(req) });
+ }
+ if (p === "/api/auth/setup" && req.method === "POST") {
+ return this.handleSetup(req, res);
+ }
+ if (p === "/api/auth/login" && req.method === "POST") {
+ return this.handleLogin(req, res);
+ }
+ if (p === "/api/auth/reset" && req.method === "POST") {
+ return this.handlePasswordReset(req, res);
+ }
+ if (p === "/" || p === "/viewer") {
+ return this.serveViewer(res);
+ }
+
+ if (!this.isValidSession(req)) {
+ res.writeHead(401, { "Content-Type": "application/json" });
+ res.end(JSON.stringify({ error: "unauthorized" }));
+ return;
+ }
+
+ if (p === "/api/memories" && req.method === "GET") this.serveMemories(res, url);
+ else if (p === "/api/stats") this.serveStats(res);
+ else if (p === "/api/metrics") this.serveMetrics(res, url);
+ else if (p === "/api/tool-metrics") this.serveToolMetrics(res, url);
+ else if (p === "/api/search") this.serveSearch(req, res, url);
+ else if (p === "/api/tasks" && req.method === "GET") this.serveTasks(res, url);
+ else if (p.match(/^\/api\/task\/[^/]+\/retry-skill$/) && req.method === "POST") this.handleTaskRetrySkill(req, res, p);
+ else if (p.startsWith("/api/task/") && req.method === "DELETE") this.handleTaskDelete(res, p);
+ else if (p.startsWith("/api/task/") && req.method === "PUT") this.handleTaskUpdate(req, res, p);
+ else if (p.startsWith("/api/task/") && req.method === "GET") this.serveTaskDetail(res, p);
+ else if (p === "/api/skills" && req.method === "GET") this.serveSkills(res, url);
+ else if (p.match(/^\/api\/skill\/[^/]+\/download$/) && req.method === "GET") this.serveSkillDownload(res, p);
+ else if (p.match(/^\/api\/skill\/[^/]+\/files$/) && req.method === "GET") this.serveSkillFiles(res, p);
+ else if (p.match(/^\/api\/skill\/[^/]+\/visibility$/) && req.method === "PUT") this.handleSkillVisibility(req, res, p);
+ else if (p.startsWith("/api/skill/") && req.method === "DELETE") this.handleSkillDelete(res, p);
+ else if (p.startsWith("/api/skill/") && req.method === "PUT") this.handleSkillUpdate(req, res, p);
+ else if (p.startsWith("/api/skill/") && req.method === "GET") this.serveSkillDetail(res, p);
+ else if (p === "/api/memory" && req.method === "POST") this.handleCreate(req, res);
+ else if (p.startsWith("/api/memory/") && req.method === "GET") this.serveMemoryDetail(res, p);
+ else if (p.startsWith("/api/memory/") && req.method === "PUT") this.handleUpdate(req, res, p);
+ else if (p.startsWith("/api/memory/") && req.method === "DELETE") this.handleDelete(res, p);
+ else if (p === "/api/session" && req.method === "DELETE") this.handleDeleteSession(res, url);
+ else if (p === "/api/memories" && req.method === "DELETE") this.handleDeleteAll(res);
+ else if (p === "/api/logs" && req.method === "GET") this.serveLogs(res, url);
+ else if (p === "/api/log-tools" && req.method === "GET") this.serveLogTools(res);
+ else if (p === "/api/config" && req.method === "GET") this.serveConfig(res);
+ else if (p === "/api/config" && req.method === "PUT") this.handleSaveConfig(req, res);
+ else if (p === "/api/auth/logout" && req.method === "POST") this.handleLogout(req, res);
+ else if (p === "/api/migrate/scan" && req.method === "GET") this.handleMigrateScan(res);
+ else if (p === "/api/migrate/start" && req.method === "POST") this.handleMigrateStart(req, res);
+ else if (p === "/api/migrate/status" && req.method === "GET") this.handleMigrateStatus(res);
+ else if (p === "/api/migrate/stream" && req.method === "GET") this.handleMigrateStream(res);
+ else if (p === "/api/migrate/stop" && req.method === "POST") this.handleMigrateStop(res);
+ else if (p === "/api/migrate/postprocess" && req.method === "POST") this.handlePostprocess(req, res);
+ else if (p === "/api/migrate/postprocess/stream" && req.method === "GET") this.handlePostprocessStream(res);
+ else if (p === "/api/migrate/postprocess/stop" && req.method === "POST") this.handlePostprocessStop(res);
+ else if (p === "/api/migrate/postprocess/status" && req.method === "GET") this.handlePostprocessStatus(res);
+ else {
+ res.writeHead(404, { "Content-Type": "application/json" });
+ res.end(JSON.stringify({ error: "not found" }));
+ }
+ } catch (err) {
+ this.log.error(`Viewer request error: ${err}`);
+ res.writeHead(500, { "Content-Type": "application/json" });
+ res.end(JSON.stringify({ error: String(err) }));
+ }
+ }
+
+ // ─── Auth endpoints ───
+
+ private handleSetup(req: http.IncomingMessage, res: http.ServerResponse): void {
+ if (!this.needsSetup) {
+ res.writeHead(400, { "Content-Type": "application/json" });
+ res.end(JSON.stringify({ error: "Password already set" }));
+ return;
+ }
+ this.readBody(req, (body) => {
+ try {
+ const { password } = JSON.parse(body);
+ if (!password || password.length < 4) {
+ res.writeHead(400, { "Content-Type": "application/json" });
+ res.end(JSON.stringify({ error: "Password must be at least 4 characters" }));
+ return;
+ }
+ this.auth.passwordHash = this.hashPassword(password);
+ this.saveAuth();
+ const token = this.createSession();
+ res.writeHead(200, {
+ "Content-Type": "application/json",
+ "Set-Cookie": `memos_token=${token}; Path=/; HttpOnly; SameSite=Strict; Max-Age=86400`,
+ });
+ res.end(JSON.stringify({ ok: true, message: "Password set successfully" }));
+ } catch (err) {
+ res.writeHead(400, { "Content-Type": "application/json" });
+ res.end(JSON.stringify({ error: String(err) }));
+ }
+ });
+ }
+
+ private handleLogin(req: http.IncomingMessage, res: http.ServerResponse): void {
+ this.readBody(req, (body) => {
+ try {
+ const { password } = JSON.parse(body);
+ if (this.needsSetup || this.hashPassword(password) !== this.auth.passwordHash) {
+ res.writeHead(401, { "Content-Type": "application/json" });
+ res.end(JSON.stringify({ error: "Invalid password" }));
+ return;
+ }
+ const token = this.createSession();
+ res.writeHead(200, {
+ "Content-Type": "application/json",
+ "Set-Cookie": `memos_token=${token}; Path=/; HttpOnly; SameSite=Strict; Max-Age=86400`,
+ });
+ res.end(JSON.stringify({ ok: true }));
+ } catch (err) {
+ res.writeHead(401, { "Content-Type": "application/json" });
+ res.end(JSON.stringify({ error: String(err) }));
+ }
+ });
+ }
+
+ private handleLogout(req: http.IncomingMessage, res: http.ServerResponse): void {
+ const cookie = req.headers.cookie ?? "";
+ const match = cookie.match(/memos_token=([a-f0-9]+)/);
+ if (match) this.auth.sessions.delete(match[1]);
+ res.writeHead(200, {
+ "Content-Type": "application/json",
+ "Set-Cookie": "memos_token=; Path=/; HttpOnly; Max-Age=0",
+ });
+ res.end(JSON.stringify({ ok: true }));
+ }
+
+ private handlePasswordReset(req: http.IncomingMessage, res: http.ServerResponse): void {
+ this.readBody(req, (body) => {
+ try {
+ const { token, newPassword } = JSON.parse(body);
+ if (token !== this.resetToken) {
+ res.writeHead(403, { "Content-Type": "application/json" });
+ res.end(JSON.stringify({ error: "Invalid reset token" }));
+ return;
+ }
+ if (!newPassword || newPassword.length < 4) {
+ res.writeHead(400, { "Content-Type": "application/json" });
+ res.end(JSON.stringify({ error: "Password must be at least 4 characters" }));
+ return;
+ }
+ this.auth.passwordHash = this.hashPassword(newPassword);
+ this.auth.sessions.clear();
+ this.saveAuth();
+ this.resetToken = crypto.randomBytes(16).toString("hex");
+ this.log.info(`memos-local: password has been reset. New reset token: ${this.resetToken}`);
+ const sessionToken = this.createSession();
+ res.writeHead(200, {
+ "Content-Type": "application/json",
+ "Set-Cookie": `memos_token=${sessionToken}; Path=/; HttpOnly; SameSite=Strict; Max-Age=86400`,
+ });
+ res.end(JSON.stringify({ ok: true, message: "Password reset successfully" }));
+ } catch (err) {
+ res.writeHead(400, { "Content-Type": "application/json" });
+ res.end(JSON.stringify({ error: String(err) }));
+ }
+ });
+ }
+
+ // ─── Pages ───
+
+ private serveViewer(res: http.ServerResponse): void {
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store, no-cache, must-revalidate, max-age=0", "Pragma": "no-cache", "Expires": "0" });
+ res.end(viewerHTML);
+ }
+
+ // ─── Data APIs ───
+
+ private serveMemories(res: http.ServerResponse, url: URL): void {
+ const limit = Math.min(Number(url.searchParams.get("limit")) || 40, 200);
+ const page = Math.max(1, Number(url.searchParams.get("page")) || 1);
+ const offset = (page - 1) * limit;
+ const session = url.searchParams.get("session") ?? undefined;
+ const role = url.searchParams.get("role") ?? undefined;
+ const kind = url.searchParams.get("kind") ?? undefined;
+ const dateFrom = url.searchParams.get("dateFrom") ?? undefined;
+ const dateTo = url.searchParams.get("dateTo") ?? undefined;
+ const owner = url.searchParams.get("owner") ?? undefined;
+ const sortBy = url.searchParams.get("sort") === "oldest" ? "ASC" : "DESC";
+
+ const db = (this.store as any).db;
+ const conditions: string[] = [];
+ const params: any[] = [];
+ if (session) { conditions.push("session_key = ?"); params.push(session); }
+ if (role) { conditions.push("role = ?"); params.push(role); }
+ if (kind) { conditions.push("kind = ?"); params.push(kind); }
+ if (owner) { conditions.push("owner = ?"); params.push(owner); }
+ if (dateFrom) { conditions.push("created_at >= ?"); params.push(new Date(dateFrom).getTime()); }
+ if (dateTo) { conditions.push("created_at <= ?"); params.push(new Date(dateTo).getTime()); }
+
+ const where = conditions.length > 0 ? " WHERE " + conditions.join(" AND ") : "";
+ const totalRow = db.prepare("SELECT COUNT(*) as count FROM chunks" + where).get(...params) as any;
+ const rawMemories = db.prepare("SELECT * FROM chunks" + where + ` ORDER BY created_at ${sortBy} LIMIT ? OFFSET ?`).all(...params, limit, offset);
+ const memories = rawMemories.map((m: any) => {
+ if (m.role === "user" && m.content) {
+ return { ...m, content: stripInboundMetadata(m.content) };
+ }
+ return m;
+ });
+
+ this.store.recordViewerEvent("list");
+ this.jsonResponse(res, {
+ memories, page, limit, total: totalRow.count,
+ totalPages: Math.ceil(totalRow.count / limit),
+ });
+ }
+
+ private serveMetrics(res: http.ServerResponse, url: URL): void {
+ const days = Math.min(90, Math.max(7, Number(url.searchParams.get("days")) || 30));
+ const data = this.store.getMetrics(days);
+ this.jsonResponse(res, data);
+ }
+
+ private serveToolMetrics(res: http.ServerResponse, url: URL): void {
+ const minutes = Math.min(1440, Math.max(10, Number(url.searchParams.get("minutes")) || 60));
+ const data = this.store.getToolMetrics(minutes);
+ this.jsonResponse(res, data);
+ }
+
+ private serveTasks(res: http.ServerResponse, url: URL): void {
+ this.store.recordViewerEvent("tasks_list");
+ const status = url.searchParams.get("status") ?? undefined;
+ const limit = Math.min(100, Math.max(1, Number(url.searchParams.get("limit")) || 50));
+ const offset = Math.max(0, Number(url.searchParams.get("offset")) || 0);
+ const { tasks, total } = this.store.listTasks({ status, limit, offset });
+
+ const db = (this.store as any).db;
+ const items = tasks.map((t) => {
+ const meta = db.prepare("SELECT skill_status FROM tasks WHERE id = ?").get(t.id) as { skill_status: string | null } | undefined;
+ return {
+ id: t.id,
+ sessionKey: t.sessionKey,
+ title: t.title,
+ summary: t.summary ? (t.summary.length > 300 ? t.summary.slice(0, 297) + "..." : t.summary) : "",
+ status: t.status,
+ startedAt: t.startedAt,
+ endedAt: t.endedAt,
+ chunkCount: this.store.countChunksByTask(t.id),
+ skillStatus: meta?.skill_status ?? null,
+ };
+ });
+
+ this.jsonResponse(res, { tasks: items, total, limit, offset });
+ }
+
+ private serveTaskDetail(res: http.ServerResponse, urlPath: string): void {
+ const taskId = urlPath.replace("/api/task/", "");
+ const task = this.store.getTask(taskId);
+ if (!task) {
+ res.writeHead(404, { "Content-Type": "application/json" });
+ res.end(JSON.stringify({ error: "Task not found" }));
+ return;
+ }
+
+ const chunks = this.store.getChunksByTask(taskId);
+ const chunkItems = chunks.map((c) => {
+ let text = c.role === "user" ? stripInboundMetadata(c.content) : c.content;
+ if (text.length > 500) text = text.slice(0, 497) + "...";
+ return { id: c.id, role: c.role, content: text, summary: c.summary, createdAt: c.createdAt };
+ });
+
+ const relatedSkills = this.store.getSkillsByTask(taskId);
+ const skillLinks = relatedSkills.map((rs) => ({
+ skillId: rs.skill.id,
+ skillName: rs.skill.name,
+ relation: rs.relation,
+ versionAt: rs.versionAt,
+ status: rs.skill.status,
+ qualityScore: rs.skill.qualityScore,
+ }));
+
+ const db = (this.store as any).db;
+ const meta = db.prepare("SELECT skill_status, skill_reason FROM tasks WHERE id = ?").get(taskId) as
+ { skill_status: string | null; skill_reason: string | null } | undefined;
+
+ this.jsonResponse(res, {
+ id: task.id,
+ sessionKey: task.sessionKey,
+ title: task.title,
+ summary: task.summary,
+ status: task.status,
+ startedAt: task.startedAt,
+ endedAt: task.endedAt,
+ chunks: chunkItems,
+ skillStatus: meta?.skill_status ?? null,
+ skillReason: meta?.skill_reason ?? null,
+ skillLinks,
+ });
+ }
+
+ private serveStats(res: http.ServerResponse): void {
+ const emptyStats = {
+ totalMemories: 0, totalSessions: 0, totalEmbeddings: 0, totalSkills: 0,
+ embeddingProvider: this.embedder?.provider ?? "none",
+ roleBreakdown: {}, kindBreakdown: {}, dedupBreakdown: {},
+ timeRange: { earliest: null, latest: null },
+ sessions: [],
+ };
+
+ if (!this.store || !(this.store as any).db) {
+ this.jsonResponse(res, emptyStats);
+ return;
+ }
+
+ try {
+ const db = (this.store as any).db;
+ const total = db.prepare("SELECT COUNT(*) as count FROM chunks").get() as any;
+ const sessions = db.prepare("SELECT COUNT(DISTINCT session_key) as count FROM chunks").get() as any;
+ const roles = db.prepare("SELECT role, COUNT(*) as count FROM chunks GROUP BY role").all() as any[];
+ const timeRange = db.prepare("SELECT MIN(created_at) as earliest, MAX(created_at) as latest FROM chunks").get() as any;
+ let embCount = 0;
+ try { embCount = (db.prepare("SELECT COUNT(*) as count FROM embeddings").get() as any).count; } catch { /* table may not exist */ }
+ const kinds = db.prepare("SELECT kind, COUNT(*) as count FROM chunks GROUP BY kind").all() as any[];
+ const sessionList = db.prepare(
+ "SELECT session_key, COUNT(*) as count, MIN(created_at) as earliest, MAX(created_at) as latest FROM chunks GROUP BY session_key ORDER BY latest DESC",
+ ).all() as any[];
+
+ let skillCount = 0;
+ try { skillCount = (db.prepare("SELECT COUNT(*) as count FROM skills").get() as any).count; } catch { /* table may not exist yet */ }
+
+ let dedupBreakdown: Record = {};
+ try {
+ const dedupRows = db.prepare("SELECT dedup_status, COUNT(*) as count FROM chunks GROUP BY dedup_status").all() as any[];
+ dedupBreakdown = Object.fromEntries(dedupRows.map((d: any) => [d.dedup_status ?? "active", d.count]));
+ } catch { /* column may not exist yet */ }
+
+ let owners: string[] = [];
+ try {
+ const ownerRows = db.prepare("SELECT DISTINCT owner FROM chunks WHERE owner IS NOT NULL ORDER BY owner").all() as any[];
+ owners = ownerRows.map((o: any) => o.owner);
+ } catch { /* column may not exist yet */ }
+
+ this.jsonResponse(res, {
+ totalMemories: total.count, totalSessions: sessions.count, totalEmbeddings: embCount,
+ totalSkills: skillCount,
+ embeddingProvider: this.embedder.provider,
+ roleBreakdown: Object.fromEntries(roles.map((r: any) => [r.role, r.count])),
+ kindBreakdown: Object.fromEntries(kinds.map((k: any) => [k.kind, k.count])),
+ dedupBreakdown,
+ timeRange: { earliest: timeRange.earliest, latest: timeRange.latest },
+ sessions: sessionList,
+ owners,
+ });
+ } catch (e) {
+ this.log.warn(`stats error: ${e}`);
+ this.jsonResponse(res, emptyStats);
+ }
+ }
+
+ private async serveSearch(_req: http.IncomingMessage, res: http.ServerResponse, url: URL): Promise {
+ const q = url.searchParams.get("q") ?? "";
+ if (!q.trim()) { this.jsonResponse(res, { results: [], query: q }); return; }
+
+ const role = url.searchParams.get("role") ?? undefined;
+ const kind = url.searchParams.get("kind") ?? undefined;
+ const dateFrom = url.searchParams.get("dateFrom") ?? undefined;
+ const dateTo = url.searchParams.get("dateTo") ?? undefined;
+
+ const passesFilter = (r: any): boolean => {
+ if (role && r.role !== role) return false;
+ if (kind && r.kind !== kind) return false;
+ if (dateFrom && r.created_at < new Date(dateFrom).getTime()) return false;
+ if (dateTo && r.created_at > new Date(dateTo).getTime()) return false;
+ return true;
+ };
+
+ const db = (this.store as any).db;
+ let ftsResults: any[] = [];
+ try {
+ ftsResults = db.prepare(
+ "SELECT c.* FROM chunks_fts f JOIN chunks c ON f.rowid = c.rowid WHERE chunks_fts MATCH ? ORDER BY rank LIMIT 100",
+ ).all(q).filter(passesFilter);
+ } catch {
+ ftsResults = db.prepare(
+ "SELECT * FROM chunks WHERE content LIKE ? OR summary LIKE ? ORDER BY created_at DESC LIMIT 100",
+ ).all(`%${q}%`, `%${q}%`).filter(passesFilter);
+ }
+
+ const SEMANTIC_THRESHOLD = 0.64;
+ let vectorResults: any[] = [];
+ let scoreMap = new Map();
+ try {
+ const queryVec = await this.embedder.embedQuery(q);
+ const hits = vectorSearch(this.store, queryVec, 40);
+ scoreMap = new Map(hits.map(h => [h.chunkId, h.score]));
+ const hitIds = new Set(hits.filter(h => h.score >= SEMANTIC_THRESHOLD).map(h => h.chunkId));
+ if (hitIds.size > 0) {
+ const placeholders = [...hitIds].map(() => "?").join(",");
+ const rows = db.prepare(`SELECT * FROM chunks WHERE id IN (${placeholders})`).all(...hitIds).filter(passesFilter);
+ rows.forEach((r: any) => { r._vscore = scoreMap.get(r.id) ?? 0; });
+ rows.sort((a: any, b: any) => (b._vscore ?? 0) - (a._vscore ?? 0));
+ vectorResults = rows;
+ }
+ } catch (err) {
+ this.log.warn(`Vector search failed (falling back to FTS only): ${err}`);
+ }
+
+ const seenIds = new Set();
+ const merged: any[] = [];
+ for (const r of vectorResults) {
+ if (!seenIds.has(r.id)) { seenIds.add(r.id); merged.push(r); }
+ }
+ for (const r of ftsResults) {
+ if (seenIds.has(r.id)) continue;
+ const vscore = scoreMap.get(r.id);
+ if (vscore !== undefined && vscore < SEMANTIC_THRESHOLD) continue;
+ seenIds.add(r.id); merged.push(r);
+ }
+
+ const fallback = merged.length === 0 && ftsResults.length > 0;
+ const results = fallback ? ftsResults.slice(0, 20) : merged;
+
+ this.store.recordViewerEvent("search");
+ this.jsonResponse(res, {
+ results,
+ query: q,
+ vectorCount: vectorResults.length,
+ ftsCount: ftsResults.length,
+ total: results.length,
+ fallbackFts: fallback,
+ });
+ }
+
+ // ─── Skills API ───
+
+ private serveSkills(res: http.ServerResponse, url: URL): void {
+ const status = url.searchParams.get("status") ?? undefined;
+ const visibility = url.searchParams.get("visibility") ?? undefined;
+ let skills = this.store.listSkills({ status });
+ if (visibility) {
+ skills = skills.filter(s => s.visibility === visibility);
+ }
+ this.jsonResponse(res, { skills });
+ }
+
+ private serveSkillDetail(res: http.ServerResponse, urlPath: string): void {
+ const skillId = urlPath.replace("/api/skill/", "");
+ const skill = this.store.getSkill(skillId);
+ if (!skill) {
+ res.writeHead(404, { "Content-Type": "application/json" });
+ res.end(JSON.stringify({ error: "Skill not found" }));
+ return;
+ }
+
+ const versions = this.store.getSkillVersions(skillId);
+ const relatedTasks = this.store.getTasksBySkill(skillId);
+ const files = fs.existsSync(skill.dirPath) ? this.walkDir(skill.dirPath, skill.dirPath) : [];
+
+ this.jsonResponse(res, {
+ skill,
+ versions: versions.map(v => ({
+ id: v.id,
+ version: v.version,
+ content: v.content,
+ changelog: v.changelog,
+ changeSummary: v.changeSummary,
+ upgradeType: v.upgradeType,
+ sourceTaskId: v.sourceTaskId,
+ metrics: v.metrics,
+ qualityScore: v.qualityScore,
+ createdAt: v.createdAt,
+ })),
+ relatedTasks: relatedTasks.map(rt => ({
+ task: {
+ id: rt.task.id,
+ title: rt.task.title,
+ status: rt.task.status,
+ startedAt: rt.task.startedAt,
+ },
+ relation: rt.relation,
+ })),
+ files,
+ });
+ }
+
+ private serveSkillFiles(res: http.ServerResponse, urlPath: string): void {
+ const skillId = urlPath.replace("/api/skill/", "").replace("/files", "");
+ const skill = this.store.getSkill(skillId);
+ if (!skill) {
+ res.writeHead(404, { "Content-Type": "application/json" });
+ res.end(JSON.stringify({ error: "Skill not found" }));
+ return;
+ }
+
+ if (!fs.existsSync(skill.dirPath)) {
+ this.jsonResponse(res, { files: [], error: "Skill directory not found" });
+ return;
+ }
+
+ const files = this.walkDir(skill.dirPath, skill.dirPath);
+ this.jsonResponse(res, { files });
+ }
+
+ private walkDir(dir: string, root: string): Array<{ path: string; type: string; size: number }> {
+ const results: Array<{ path: string; type: string; size: number }> = [];
+ try {
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
+ for (const entry of entries) {
+ const fullPath = path.join(dir, entry.name);
+ const relPath = path.relative(root, fullPath);
+ if (entry.isDirectory()) {
+ results.push(...this.walkDir(fullPath, root));
+ } else {
+ const stat = fs.statSync(fullPath);
+ const ext = path.extname(entry.name).toLowerCase();
+ let type = "file";
+ if (entry.name === "SKILL.md") type = "skill";
+ else if ([".sh", ".py", ".ts", ".js"].includes(ext)) type = "script";
+ else if ([".md", ".txt", ".json"].includes(ext)) type = "reference";
+ results.push({ path: relPath, type, size: stat.size });
+ }
+ }
+ } catch { /* directory may not exist */ }
+ return results;
+ }
+
+ private serveSkillDownload(res: http.ServerResponse, urlPath: string): void {
+ const skillId = urlPath.replace("/api/skill/", "").replace("/download", "");
+ const skill = this.store.getSkill(skillId);
+ if (!skill) {
+ res.writeHead(404, { "Content-Type": "application/json" });
+ res.end(JSON.stringify({ error: "Skill not found" }));
+ return;
+ }
+
+ if (!fs.existsSync(skill.dirPath)) {
+ res.writeHead(404, { "Content-Type": "application/json" });
+ res.end(JSON.stringify({ error: "Skill directory not found" }));
+ return;
+ }
+
+ const zipName = `${skill.name}-v${skill.version}.zip`;
+ const tmpPath = path.join(require("os").tmpdir(), zipName);
+
+ try {
+ try { fs.unlinkSync(tmpPath); } catch { /* no-op */ }
+ execSync(
+ `cd "${path.dirname(skill.dirPath)}" && zip -r "${tmpPath}" "${path.basename(skill.dirPath)}"`,
+ { timeout: 15_000 },
+ );
+
+ const data = fs.readFileSync(tmpPath);
+ res.writeHead(200, {
+ "Content-Type": "application/zip",
+ "Content-Disposition": `attachment; filename="${zipName}"`,
+ "Content-Length": String(data.length),
+ });
+ res.end(data);
+
+ try { fs.unlinkSync(tmpPath); } catch { /* cleanup */ }
+ } catch (err) {
+ this.log.error(`Skill download zip failed: ${err}`);
+ res.writeHead(500, { "Content-Type": "application/json" });
+ res.end(JSON.stringify({ error: `Failed to create zip: ${err}` }));
+ }
+ }
+
+ private handleSkillVisibility(req: http.IncomingMessage, res: http.ServerResponse, urlPath: string): void {
+ const segments = urlPath.split("/");
+ const skillId = segments[segments.length - 2];
+ this.readBody(req, (body) => {
+ try {
+ const parsed = JSON.parse(body);
+ const visibility = parsed.visibility;
+ if (visibility !== "public" && visibility !== "private") {
+ res.writeHead(400, { "Content-Type": "application/json" });
+ res.end(JSON.stringify({ error: `visibility must be 'public' or 'private', got: '${visibility}'` }));
+ return;
+ }
+ const skill = this.store.getSkill(skillId);
+ if (!skill) {
+ res.writeHead(404, { "Content-Type": "application/json" });
+ res.end(JSON.stringify({ error: `Skill not found: ${skillId}` }));
+ return;
+ }
+ this.store.setSkillVisibility(skillId, visibility);
+ this.jsonResponse(res, { ok: true, skillId, visibility });
+ } catch (err) {
+ this.log.error(`handleSkillVisibility error: skillId=${skillId}, body=${body}, err=${err}`);
+ res.writeHead(400, { "Content-Type": "application/json" });
+ res.end(JSON.stringify({ error: String(err) }));
+ }
+ });
+ }
+
+ // ─── Task/Skill management ───
+
+ private handleTaskRetrySkill(_req: http.IncomingMessage, res: http.ServerResponse, urlPath: string): void {
+ const taskId = urlPath.replace("/api/task/", "").replace("/retry-skill", "");
+ const task = this.store.getTask(taskId);
+ if (!task) { res.writeHead(404, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "Task not found" })); return; }
+ if (task.status !== "completed") { res.writeHead(400, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "Only completed tasks can retry skill generation" })); return; }
+ if (!this.ctx) { res.writeHead(500, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "Plugin context not available" })); return; }
+
+ // Clean up stale task_skills references (e.g., skill was manually deleted)
+ const db = (this.store as any).db;
+ db.prepare("DELETE FROM task_skills WHERE task_id = ? AND skill_id NOT IN (SELECT id FROM skills)").run(taskId);
+
+ this.store.setTaskSkillMeta(taskId, { skillStatus: "queued", skillReason: "手动重试中..." });
+ this.jsonResponse(res, { ok: true, taskId, status: "queued" });
+
+ const ctx = this.ctx;
+ const recallEngine = new RecallEngine(this.store, this.embedder, ctx);
+ const evolver = new SkillEvolver(this.store, recallEngine, ctx, this.embedder);
+ evolver.onTaskCompleted(task).then(() => {
+ this.log.info(`Retry skill generation completed for task ${taskId}`);
+ }).catch((err) => {
+ this.log.error(`Retry skill generation failed for task ${taskId}: ${err}`);
+ this.store.setTaskSkillMeta(taskId, { skillStatus: "skipped", skillReason: `error: ${err}` });
+ });
+ }
+
+ private handleTaskDelete(res: http.ServerResponse, urlPath: string): void {
+ const taskId = urlPath.replace("/api/task/", "");
+ const deleted = this.store.deleteTask(taskId);
+ if (!deleted) { res.writeHead(404, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "Task not found" })); return; }
+ this.jsonResponse(res, { ok: true, taskId });
+ }
+
+ private handleTaskUpdate(req: http.IncomingMessage, res: http.ServerResponse, urlPath: string): void {
+ const taskId = urlPath.replace("/api/task/", "");
+ this.readBody(req, (body) => {
+ try {
+ const data = JSON.parse(body);
+ const task = this.store.getTask(taskId);
+ if (!task) { res.writeHead(404, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "Task not found" })); return; }
+ this.store.updateTask(taskId, {
+ title: data.title ?? task.title,
+ summary: data.summary ?? task.summary,
+ status: data.status ?? task.status,
+ endedAt: task.endedAt ?? undefined,
+ });
+ this.jsonResponse(res, { ok: true, taskId });
+ } catch (err) {
+ res.writeHead(400, { "Content-Type": "application/json" });
+ res.end(JSON.stringify({ error: String(err) }));
+ }
+ });
+ }
+
+ private handleSkillDelete(res: http.ServerResponse, urlPath: string): void {
+ const skillId = urlPath.replace("/api/skill/", "");
+ const skill = this.store.getSkill(skillId);
+ if (!skill) { res.writeHead(404, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "Skill not found" })); return; }
+ // Remove skill directory from disk
+ try {
+ if (skill.dirPath && fs.existsSync(skill.dirPath)) {
+ fs.rmSync(skill.dirPath, { recursive: true, force: true });
+ }
+ } catch (err) {
+ this.log.warn(`Failed to remove skill directory ${skill.dirPath}: ${err}`);
+ }
+ this.store.deleteSkill(skillId);
+ this.jsonResponse(res, { ok: true, skillId });
+ }
+
+ private handleSkillUpdate(req: http.IncomingMessage, res: http.ServerResponse, urlPath: string): void {
+ const skillId = urlPath.replace("/api/skill/", "");
+ this.readBody(req, (body) => {
+ try {
+ const data = JSON.parse(body);
+ const skill = this.store.getSkill(skillId);
+ if (!skill) { res.writeHead(404, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "Skill not found" })); return; }
+ this.store.updateSkill(skillId, {
+ description: data.description ?? skill.description,
+ version: skill.version,
+ status: data.status ?? skill.status,
+ installed: skill.installed,
+ qualityScore: skill.qualityScore,
+ });
+ this.jsonResponse(res, { ok: true, skillId });
+ } catch (err) {
+ res.writeHead(400, { "Content-Type": "application/json" });
+ res.end(JSON.stringify({ error: String(err) }));
+ }
+ });
+ }
+
+ // ─── CRUD ───
+
+ private handleCreate(req: http.IncomingMessage, res: http.ServerResponse): void {
+ this.readBody(req, (body) => {
+ try {
+ const data = JSON.parse(body);
+ if (!data.content || typeof data.content !== "string" || !data.content.trim()) {
+ res.writeHead(400, { "Content-Type": "application/json" });
+ res.end(JSON.stringify({ error: "content is required and must be a non-empty string" }));
+ return;
+ }
+ const { v4: uuidv4 } = require("uuid");
+ const id = uuidv4();
+ const now = Date.now();
+ this.store.insertChunk({
+ id, sessionKey: data.session_key || "manual", turnId: `manual-${now}`, seq: 0,
+ role: data.role || "user", content: data.content, kind: data.kind || "paragraph",
+ summary: data.summary || data.content.slice(0, 100),
+ taskId: null, skillId: null, owner: data.owner || "agent:main",
+ dedupStatus: "active", dedupTarget: null, dedupReason: null,
+ mergeCount: 0, lastHitAt: null, mergeHistory: "[]",
+ createdAt: now, updatedAt: now, embedding: null,
+ });
+ this.jsonResponse(res, { ok: true, id, message: "Memory created" });
+ } catch (err) {
+ res.writeHead(400, { "Content-Type": "application/json" });
+ res.end(JSON.stringify({ error: String(err) }));
+ }
+ });
+ }
+
+ private serveMemoryDetail(res: http.ServerResponse, urlPath: string): void {
+ const chunkId = urlPath.replace("/api/memory/", "");
+ const chunk = this.store.getChunk(chunkId);
+ if (!chunk) {
+ res.writeHead(404, { "Content-Type": "application/json" });
+ res.end(JSON.stringify({ error: "Not found" }));
+ return;
+ }
+ const cleaned = chunk.role === "user" && chunk.content
+ ? { ...chunk, content: stripInboundMetadata(chunk.content) }
+ : chunk;
+ this.jsonResponse(res, { memory: cleaned });
+ }
+
+ private handleUpdate(req: http.IncomingMessage, res: http.ServerResponse, urlPath: string): void {
+ const chunkId = urlPath.replace("/api/memory/", "");
+ this.readBody(req, (body) => {
+ try {
+ const data = JSON.parse(body);
+ if (data.content !== undefined && (typeof data.content !== "string" || !data.content.trim())) {
+ res.writeHead(400, { "Content-Type": "application/json" });
+ res.end(JSON.stringify({ error: "content must be a non-empty string" }));
+ return;
+ }
+ const ok = this.store.updateChunk(chunkId, { summary: data.summary, content: data.content, role: data.role, kind: data.kind, owner: data.owner });
+ if (ok) this.jsonResponse(res, { ok: true, message: "Memory updated" });
+ else { res.writeHead(404, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "Not found" })); }
+ } catch (err) {
+ res.writeHead(400, { "Content-Type": "application/json" });
+ res.end(JSON.stringify({ error: String(err) }));
+ }
+ });
+ }
+
+ private handleDelete(res: http.ServerResponse, urlPath: string): void {
+ const chunkId = urlPath.replace("/api/memory/", "");
+ if (this.store.deleteChunk(chunkId)) this.jsonResponse(res, { ok: true });
+ else { res.writeHead(404, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "Not found" })); }
+ }
+
+ private handleDeleteSession(res: http.ServerResponse, url: URL): void {
+ const key = url.searchParams.get("key");
+ if (!key) { res.writeHead(400, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "Missing key" })); return; }
+ const count = this.store.deleteSession(key);
+ this.jsonResponse(res, { ok: true, deleted: count });
+ }
+
+ private handleDeleteAll(res: http.ServerResponse): void {
+ const result = this.store.deleteAll();
+ // Clean up skills-store directory
+ const skillsStoreDir = path.join(this.dataDir, "skills-store");
+ try {
+ if (fs.existsSync(skillsStoreDir)) {
+ fs.rmSync(skillsStoreDir, { recursive: true });
+ fs.mkdirSync(skillsStoreDir, { recursive: true });
+ this.log.info("Cleared skills-store directory");
+ }
+ } catch (err) {
+ this.log.warn(`Failed to clear skills-store: ${err}`);
+ }
+ this.jsonResponse(res, { ok: true, deleted: result });
+ }
+
+ // ─── Helpers ───
+
+ // ─── Config API ───
+
+ private getOpenClawConfigPath(): string {
+ const home = process.env.HOME || process.env.USERPROFILE || "";
+ return path.join(home, ".openclaw", "openclaw.json");
+ }
+
+ private serveConfig(res: http.ServerResponse): void {
+ try {
+ const cfgPath = this.getOpenClawConfigPath();
+ if (!fs.existsSync(cfgPath)) {
+ this.jsonResponse(res, {});
+ return;
+ }
+ const raw = JSON.parse(fs.readFileSync(cfgPath, "utf-8"));
+ const entries = raw?.plugins?.entries ?? {};
+ const pluginEntry = entries["memos-local-openclaw-plugin"]?.config
+ ?? entries["memos-lite-openclaw-plugin"]?.config
+ ?? entries["memos-lite"]?.config
+ ?? {};
+ const result: Record = { ...pluginEntry };
+ const topEntry = entries["memos-local-openclaw-plugin"]
+ ?? entries["memos-lite-openclaw-plugin"]
+ ?? entries["memos-lite"]
+ ?? {};
+ if (pluginEntry.viewerPort == null && topEntry.viewerPort) {
+ result.viewerPort = topEntry.viewerPort;
+ }
+ this.jsonResponse(res, result);
+ } catch (e) {
+ this.log.warn(`serveConfig error: ${e}`);
+ this.jsonResponse(res, {});
+ }
+ }
+
+ private handleSaveConfig(req: http.IncomingMessage, res: http.ServerResponse): void {
+ this.readBody(req, (body) => {
+ try {
+ const newCfg = JSON.parse(body);
+ const cfgPath = this.getOpenClawConfigPath();
+ let raw: Record = {};
+ if (fs.existsSync(cfgPath)) {
+ raw = JSON.parse(fs.readFileSync(cfgPath, "utf-8"));
+ }
+
+ if (!raw.plugins) raw.plugins = {};
+ const plugins = raw.plugins as Record;
+ if (!plugins.entries) plugins.entries = {};
+ const entries = plugins.entries as Record;
+ const entryKey = entries["memos-local-openclaw-plugin"] ? "memos-local-openclaw-plugin"
+ : entries["memos-lite-openclaw-plugin"] ? "memos-lite-openclaw-plugin"
+ : entries["memos-lite"] ? "memos-lite"
+ : "memos-local-openclaw-plugin";
+ if (!entries[entryKey]) entries[entryKey] = { enabled: true };
+ const entry = entries[entryKey] as Record;
+ if (!entry.config) entry.config = {};
+ const config = entry.config as Record;
+
+ if (newCfg.embedding) config.embedding = newCfg.embedding;
+ if (newCfg.summarizer) config.summarizer = newCfg.summarizer;
+ if (newCfg.skillEvolution) config.skillEvolution = newCfg.skillEvolution;
+ if (newCfg.viewerPort) config.viewerPort = newCfg.viewerPort;
+ if (newCfg.telemetry !== undefined) config.telemetry = newCfg.telemetry;
+
+ fs.mkdirSync(path.dirname(cfgPath), { recursive: true });
+ fs.writeFileSync(cfgPath, JSON.stringify(raw, null, 2), "utf-8");
+ this.log.info("Plugin config updated via Viewer");
+ this.jsonResponse(res, { ok: true });
+ } catch (e) {
+ this.log.warn(`handleSaveConfig error: ${e}`);
+ res.writeHead(500, { "Content-Type": "application/json" });
+ res.end(JSON.stringify({ error: String(e) }));
+ }
+ });
+ }
+
+ private serveLogs(res: http.ServerResponse, url: URL): void {
+ const limit = Math.min(Number(url.searchParams.get("limit") ?? 20), 200);
+ const offset = Math.max(0, Number(url.searchParams.get("offset") ?? 0));
+ const tool = url.searchParams.get("tool") || undefined;
+ const { logs, total } = this.store.getApiLogs(limit, offset, tool);
+ const page = Math.floor(offset / limit) + 1;
+ const totalPages = Math.ceil(total / limit);
+ this.jsonResponse(res, { logs, total, page, totalPages, limit, offset });
+ }
+
+ private serveLogTools(res: http.ServerResponse): void {
+ const tools = this.store.getApiLogToolNames();
+ this.jsonResponse(res, { tools });
+ }
+
+ // ─── Migration: scan OpenClaw built-in memory ───
+
+ private getOpenClawHome(): string {
+ const home = process.env.HOME || process.env.USERPROFILE || "";
+ return path.join(home, ".openclaw");
+ }
+
+ private handleMigrateScan(res: http.ServerResponse): void {
+ try {
+ const ocHome = this.getOpenClawHome();
+ const memoryDir = path.join(ocHome, "memory");
+ const sessionsDir = path.join(ocHome, "agents", "main", "sessions");
+
+ const sqliteFiles: Array<{ file: string; chunks: number }> = [];
+ if (fs.existsSync(memoryDir)) {
+ for (const f of fs.readdirSync(memoryDir)) {
+ if (f.endsWith(".sqlite")) {
+ try {
+ const Database = require("better-sqlite3");
+ const db = new Database(path.join(memoryDir, f), { readonly: true });
+ const row = db.prepare("SELECT COUNT(*) as cnt FROM chunks").get() as { cnt: number };
+ sqliteFiles.push({ file: f, chunks: row.cnt });
+ db.close();
+ } catch { /* skip unreadable */ }
+ }
+ }
+ }
+
+ let sessionCount = 0;
+ let messageCount = 0;
+ if (fs.existsSync(sessionsDir)) {
+ const jsonlFiles = fs.readdirSync(sessionsDir).filter(f => f.includes(".jsonl"));
+ sessionCount = jsonlFiles.length;
+ for (const f of jsonlFiles) {
+ try {
+ const content = fs.readFileSync(path.join(sessionsDir, f), "utf-8");
+ const lines = content.split("\n").filter(l => l.trim());
+ for (const line of lines) {
+ try {
+ const obj = JSON.parse(line);
+ if (obj.type === "message") {
+ const role = obj.message?.role ?? obj.role;
+ if (role === "user" || role === "assistant") {
+ const mc = obj.message?.content ?? obj.content;
+ let txt = "";
+ if (typeof mc === "string") txt = mc;
+ else if (Array.isArray(mc)) txt = mc.filter((p: any) => p.type === "text" && p.text).map((p: any) => p.text).join("\n");
+ else txt = JSON.stringify(mc);
+ if (role === "user") txt = stripInboundMetadata(txt);
+ if (txt && txt.length >= 10) messageCount++;
+ }
+ }
+ } catch { /* skip bad lines */ }
+ }
+ } catch { /* skip unreadable */ }
+ }
+ }
+
+ const cfgPath = this.getOpenClawConfigPath();
+ let hasEmbedding = false;
+ let hasSummarizer = false;
+ if (fs.existsSync(cfgPath)) {
+ try {
+ const raw = JSON.parse(fs.readFileSync(cfgPath, "utf-8"));
+ const pluginCfg = raw?.plugins?.entries?.["memos-local-openclaw-plugin"]?.config ??
+ raw?.plugins?.entries?.["memos-lite"]?.config ??
+ raw?.plugins?.entries?.["memos-lite-openclaw-plugin"]?.config ?? {};
+ const emb = pluginCfg.embedding;
+ hasEmbedding = !!(emb && emb.provider);
+ const sum = pluginCfg.summarizer;
+ hasSummarizer = !!(sum && sum.provider);
+ } catch { /* ignore */ }
+ }
+
+ let importedSessions: string[] = [];
+ try {
+ if (this.store) {
+ importedSessions = this.store.getDistinctSessionKeys()
+ .filter((sk: string) => sk.startsWith("openclaw-import-") || sk.startsWith("openclaw-session-"));
+ }
+ } catch (storeErr) {
+ this.log.warn(`migrate/scan: store query failed: ${storeErr}`);
+ }
+
+ this.jsonResponse(res, {
+ sqliteFiles,
+ sessions: { count: sessionCount, messages: messageCount },
+ totalItems: sqliteFiles.reduce((s, f) => s + f.chunks, 0) + messageCount,
+ configReady: hasEmbedding && hasSummarizer,
+ hasEmbedding,
+ hasSummarizer,
+ hasImportedData: importedSessions.length > 0,
+ importedSessionCount: importedSessions.length,
+ });
+ } catch (e) {
+ this.log.warn(`migrate/scan error: ${e}`);
+ this.jsonResponse(res, {
+ sqliteFiles: [],
+ sessions: { count: 0, messages: 0 },
+ totalItems: 0,
+ configReady: false,
+ hasEmbedding: false,
+ hasSummarizer: false,
+ hasImportedData: false,
+ importedSessionCount: 0,
+ error: String(e),
+ });
+ }
+ }
+
+ // ─── Migration: start import with SSE progress ───
+
+ private broadcastSSE(event: string, data: unknown): void {
+ const msg = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
+ this.migrationSSEClients = this.migrationSSEClients.filter(c => {
+ try { c.write(msg); return true; } catch { return false; }
+ });
+ }
+
+ private handleMigrateStatus(res: http.ServerResponse): void {
+ this.jsonResponse(res, {
+ running: this.migrationRunning,
+ ...this.migrationState,
+ });
+ }
+
+ private handleMigrateStop(res: http.ServerResponse): void {
+ if (!this.migrationRunning) {
+ this.jsonResponse(res, { ok: false, error: "not_running" });
+ return;
+ }
+ this.migrationAbort = true;
+ this.jsonResponse(res, { ok: true });
+ }
+
+ private handleMigrateStream(res: http.ServerResponse): void {
+ res.writeHead(200, {
+ "Content-Type": "text/event-stream",
+ "Cache-Control": "no-cache",
+ "Connection": "keep-alive",
+ "X-Accel-Buffering": "no",
+ });
+
+ if (this.migrationRunning) {
+ res.write(`event: state\ndata: ${JSON.stringify(this.migrationState)}\n\n`);
+ this.migrationSSEClients.push(res);
+ res.on("close", () => {
+ this.migrationSSEClients = this.migrationSSEClients.filter(c => c !== res);
+ });
+ } else if (this.migrationState.done) {
+ const evtName = this.migrationState.stopped ? "stopped" : "done";
+ res.write(`event: state\ndata: ${JSON.stringify(this.migrationState)}\n\n`);
+ res.write(`event: ${evtName}\ndata: ${JSON.stringify({ ok: true })}\n\n`);
+ res.end();
+ } else {
+ res.end();
+ }
+ }
+
+ private handleMigrateStart(req: http.IncomingMessage, res: http.ServerResponse): void {
+ if (this.migrationRunning) {
+ res.writeHead(200, {
+ "Content-Type": "text/event-stream",
+ "Cache-Control": "no-cache",
+ "Connection": "keep-alive",
+ "X-Accel-Buffering": "no",
+ });
+ res.write(`event: state\ndata: ${JSON.stringify(this.migrationState)}\n\n`);
+ this.migrationSSEClients.push(res);
+ res.on("close", () => {
+ this.migrationSSEClients = this.migrationSSEClients.filter(c => c !== res);
+ });
+ return;
+ }
+
+ this.readBody(req, (body) => {
+ let opts: { sources?: string[]; concurrency?: number } = {};
+ try { opts = JSON.parse(body); } catch { /* defaults */ }
+
+ const concurrency = Math.max(1, Math.min(opts.concurrency ?? 1, 8));
+
+ res.writeHead(200, {
+ "Content-Type": "text/event-stream",
+ "Cache-Control": "no-cache",
+ "Connection": "keep-alive",
+ "X-Accel-Buffering": "no",
+ });
+
+ this.migrationSSEClients.push(res);
+ res.on("close", () => {
+ this.migrationSSEClients = this.migrationSSEClients.filter(c => c !== res);
+ });
+
+ this.migrationAbort = false;
+ this.migrationState = { phase: "", stored: 0, skipped: 0, merged: 0, errors: 0, processed: 0, total: 0, lastItem: null, done: false, stopped: false };
+
+ const send = (event: string, data: unknown) => {
+ if (event === "item") {
+ const d = data as any;
+ if (d.status === "stored") this.migrationState.stored++;
+ else if (d.status === "skipped" || d.status === "duplicate") this.migrationState.skipped++;
+ else if (d.status === "merged") this.migrationState.merged++;
+ else if (d.status === "error") this.migrationState.errors++;
+ this.migrationState.processed = d.index ?? this.migrationState.processed + 1;
+ this.migrationState.total = d.total ?? this.migrationState.total;
+ this.migrationState.lastItem = d;
+ } else if (event === "phase") {
+ this.migrationState.phase = (data as any).phase;
+ } else if (event === "progress") {
+ this.migrationState.total = (data as any).total ?? this.migrationState.total;
+ }
+ this.broadcastSSE(event, data);
+ };
+
+ this.migrationRunning = true;
+ this.runMigration(send, opts.sources, concurrency).finally(() => {
+ this.migrationRunning = false;
+ this.migrationState.done = true;
+ if (this.migrationAbort) {
+ this.migrationState.stopped = true;
+ this.broadcastSSE("stopped", { ok: true, ...this.migrationState });
+ } else {
+ this.broadcastSSE("done", { ok: true });
+ }
+ for (const c of this.migrationSSEClients) {
+ try { c.end(); } catch { /* ignore */ }
+ }
+ this.migrationSSEClients = [];
+ this.migrationAbort = false;
+ });
+ });
+ }
+
+ private async runMigration(
+ send: (event: string, data: unknown) => void,
+ sources?: string[],
+ concurrency: number = 1,
+ ): Promise {
+ const ocHome = this.getOpenClawHome();
+ const importSqlite = !sources || sources.includes("sqlite");
+ const importSessions = !sources || sources.includes("sessions");
+
+ let totalProcessed = 0;
+ let totalStored = 0;
+ let totalSkipped = 0;
+ let totalErrors = 0;
+
+ const cfgPath = this.getOpenClawConfigPath();
+ let summarizerCfg: any;
+ let strongCfg: any;
+ try {
+ const raw = JSON.parse(fs.readFileSync(cfgPath, "utf-8"));
+ const pluginCfg = raw?.plugins?.entries?.["memos-local-openclaw-plugin"]?.config ??
+ raw?.plugins?.entries?.["memos-lite"]?.config ??
+ raw?.plugins?.entries?.["memos-lite-openclaw-plugin"]?.config ?? {};
+ summarizerCfg = pluginCfg.summarizer;
+ strongCfg = pluginCfg.skillEvolution?.summarizer;
+ } catch { /* no config */ }
+
+ const summarizer = new Summarizer(summarizerCfg, this.log, strongCfg);
+
+ // Phase 1: Import SQLite memory chunks
+ if (importSqlite) {
+ const memoryDir = path.join(ocHome, "memory");
+ if (fs.existsSync(memoryDir)) {
+ const files = fs.readdirSync(memoryDir).filter(f => f.endsWith(".sqlite"));
+ for (const file of files) {
+ if (this.migrationAbort) break;
+ send("phase", { phase: "sqlite", file });
+ try {
+ const Database = require("better-sqlite3");
+ const db = new Database(path.join(memoryDir, file), { readonly: true });
+ const rows = db.prepare("SELECT id, path, text, updated_at FROM chunks ORDER BY updated_at ASC").all() as Array<{
+ id: string; path: string; text: string; updated_at: number;
+ }>;
+ db.close();
+
+ const agentId = file.replace(".sqlite", "");
+ send("progress", { total: rows.length, processed: 0, phase: "sqlite", file });
+
+ for (let i = 0; i < rows.length; i++) {
+ if (this.migrationAbort) break;
+ const row = rows[i];
+ totalProcessed++;
+
+ const contentHash = crypto.createHash("sha256").update(row.text).digest("hex");
+ if (this.store.chunkExistsByContent(`openclaw-import-${agentId}`, "assistant", row.text)) {
+ totalSkipped++;
+ send("item", {
+ index: i + 1,
+ total: rows.length,
+ status: "skipped",
+ preview: row.text.slice(0, 120),
+ source: file,
+ reason: "duplicate",
+ });
+ continue;
+ }
+
+ const importOwner = `agent:${agentId}`;
+
+ // Exact hash dedup within same agent
+ const existingByHash = this.store.findActiveChunkByHash(row.text, importOwner);
+ if (existingByHash) {
+ totalSkipped++;
+ send("item", {
+ index: i + 1,
+ total: rows.length,
+ status: "skipped",
+ preview: row.text.slice(0, 120),
+ source: file,
+ reason: "exact duplicate within agent",
+ });
+ continue;
+ }
+
+ try {
+ const summary = await summarizer.summarize(row.text);
+ let embedding: number[] | null = null;
+ try {
+ [embedding] = await this.embedder.embed([summary]);
+ } catch (err) {
+ this.log.warn(`Migration embed failed: ${err}`);
+ }
+
+ let dedupStatus: "active" | "duplicate" | "merged" = "active";
+ let dedupTarget: string | null = null;
+ let dedupReason: string | null = null;
+
+ if (embedding) {
+ const importThreshold = this.ctx?.config?.dedup?.similarityThreshold ?? 0.60;
+ const dedupOwnerFilter = [importOwner];
+ const topSimilar = findTopSimilar(this.store, embedding, importThreshold, 5, this.log, dedupOwnerFilter);
+ if (topSimilar.length > 0) {
+ const candidates = topSimilar.map((s, idx) => {
+ const chunk = this.store.getChunk(s.chunkId);
+ return { index: idx + 1, summary: chunk?.summary ?? "", chunkId: s.chunkId };
+ }).filter(c => c.summary);
+
+ if (candidates.length > 0) {
+ const dedupResult = await summarizer.judgeDedup(summary, candidates);
+ if (dedupResult?.action === "DUPLICATE" && dedupResult.targetIndex) {
+ const targetId = candidates[dedupResult.targetIndex - 1]?.chunkId;
+ if (targetId) {
+ dedupStatus = "duplicate";
+ dedupTarget = targetId;
+ dedupReason = dedupResult.reason;
+ }
+ } else if (dedupResult?.action === "UPDATE" && dedupResult.targetIndex && dedupResult.mergedSummary) {
+ const targetId = candidates[dedupResult.targetIndex - 1]?.chunkId;
+ if (targetId) {
+ this.store.updateChunkSummaryAndContent(targetId, dedupResult.mergedSummary, row.text);
+ try {
+ const [newEmb] = await this.embedder.embed([dedupResult.mergedSummary]);
+ if (newEmb) this.store.upsertEmbedding(targetId, newEmb);
+ } catch { /* best-effort */ }
+ dedupStatus = "merged";
+ dedupTarget = targetId;
+ dedupReason = dedupResult.reason;
+ }
+ }
+ }
+ }
+ }
+
+ const chunkId = uuid();
+ const chunk: Chunk = {
+ id: chunkId,
+ sessionKey: `openclaw-import-${agentId}`,
+ turnId: `import-${row.id}`,
+ seq: 0,
+ role: "assistant",
+ content: row.text,
+ kind: "paragraph",
+ summary,
+ embedding: null,
+ taskId: null,
+ skillId: null,
+ owner: `agent:${agentId}`,
+ dedupStatus,
+ dedupTarget,
+ dedupReason,
+ mergeCount: 0,
+ lastHitAt: null,
+ mergeHistory: "[]",
+ createdAt: row.updated_at * 1000,
+ updatedAt: row.updated_at * 1000,
+ };
+
+ this.store.insertChunk(chunk);
+ if (embedding && dedupStatus === "active") {
+ this.store.upsertEmbedding(chunkId, embedding);
+ }
+
+ totalStored++;
+ send("item", {
+ index: i + 1,
+ total: rows.length,
+ status: dedupStatus === "active" ? "stored" : dedupStatus,
+ preview: row.text.slice(0, 120),
+ summary: summary.slice(0, 80),
+ source: file,
+ });
+ } catch (err) {
+ totalErrors++;
+ send("item", {
+ index: i + 1,
+ total: rows.length,
+ status: "error",
+ preview: row.text.slice(0, 120),
+ source: file,
+ error: String(err).slice(0, 200),
+ });
+ }
+ }
+ } catch (err) {
+ send("error", { file, error: String(err) });
+ totalErrors++;
+ }
+ }
+ }
+ }
+
+ // Phase 2: Import session JSONL files from ALL agents (supports parallel by agent)
+ if (importSessions) {
+ const agentsDir = path.join(ocHome, "agents");
+ const agentGroups: Map> = new Map();
+ if (fs.existsSync(agentsDir)) {
+ for (const entry of fs.readdirSync(agentsDir, { withFileTypes: true })) {
+ if (entry.isDirectory()) {
+ const sessDir = path.join(agentsDir, entry.name, "sessions");
+ if (fs.existsSync(sessDir)) {
+ const jsonlFiles = fs.readdirSync(sessDir).filter(f => f.includes(".jsonl")).sort();
+ if (jsonlFiles.length > 0) {
+ agentGroups.set(entry.name, jsonlFiles.map(f => ({ file: f, filePath: path.join(sessDir, f) })));
+ }
+ }
+ }
+ }
+ }
+
+ const agentIds = Array.from(agentGroups.keys());
+ const allFileCount = Array.from(agentGroups.values()).reduce((s, g) => s + g.length, 0);
+ send("phase", { phase: "sessions", files: allFileCount, agents: agentIds, concurrency });
+
+ // Count total messages across all agents
+ let totalMsgs = 0;
+ for (const files of agentGroups.values()) {
+ for (const { filePath } of files) {
+ try {
+ const raw = fs.readFileSync(filePath, "utf-8");
+ for (const line of raw.split("\n")) {
+ if (!line.trim()) continue;
+ try {
+ const obj = JSON.parse(line);
+ if (obj.type === "message") {
+ const role = obj.message?.role ?? obj.role;
+ if (role === "user" || role === "assistant") {
+ const mc = obj.message?.content ?? obj.content;
+ let txt = "";
+ if (typeof mc === "string") txt = mc;
+ else if (Array.isArray(mc)) txt = mc.filter((p: any) => p.type === "text" && p.text).map((p: any) => p.text).join("\n");
+ else txt = JSON.stringify(mc);
+ if (role === "user") txt = stripInboundMetadata(txt);
+ if (txt && txt.length >= 10) totalMsgs++;
+ }
+ }
+ } catch { /* skip */ }
+ }
+ } catch { /* skip */ }
+ }
+ }
+
+ // Thread-safe counters for parallel execution
+ let globalMsgIdx = 0;
+ const incIdx = () => ++globalMsgIdx;
+
+ // Import one agent's sessions sequentially
+ const importAgent = async (agentId: string, files: Array<{ file: string; filePath: string }>) => {
+ const agentOwner = `agent:${agentId}`;
+ for (const { file, filePath } of files) {
+ if (this.migrationAbort) break;
+ const sessionId = file.replace(/\.jsonl.*$/, "");
+
+ try {
+ const fileStream = fs.createReadStream(filePath, { encoding: "utf-8" });
+ const rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity });
+
+ for await (const line of rl) {
+ if (this.migrationAbort) break;
+ if (!line.trim()) continue;
+ let obj: any;
+ try { obj = JSON.parse(line); } catch { continue; }
+ if (obj.type !== "message") continue;
+ const msgRole = obj.message?.role ?? obj.role;
+ if (msgRole !== "user" && msgRole !== "assistant") continue;
+
+ const msgContent = obj.message?.content ?? obj.content;
+ let content: string;
+ if (typeof msgContent === "string") {
+ content = msgContent;
+ } else if (Array.isArray(msgContent)) {
+ content = msgContent
+ .filter((p: any) => p.type === "text" && p.text)
+ .map((p: any) => p.text)
+ .join("\n");
+ } else {
+ content = JSON.stringify(msgContent);
+ }
+ if (msgRole === "user") {
+ content = stripInboundMetadata(content);
+ }
+ if (!content || content.length < 10) continue;
+
+ const idx = incIdx();
+ totalProcessed++;
+
+ const sessionKey = `openclaw-session-${sessionId}`;
+ if (this.store.chunkExistsByContent(sessionKey, msgRole, content)) {
+ totalSkipped++;
+ send("item", { index: idx, total: totalMsgs, status: "skipped", preview: content.slice(0, 120), source: file, agent: agentId, role: msgRole, reason: "duplicate" });
+ continue;
+ }
+
+ const existingByHash = this.store.findActiveChunkByHash(content, agentOwner);
+ if (existingByHash) {
+ totalSkipped++;
+ send("item", { index: idx, total: totalMsgs, status: "skipped", preview: content.slice(0, 120), source: file, agent: agentId, role: msgRole, reason: "exact duplicate within agent" });
+ continue;
+ }
+
+ try {
+ const summary = await summarizer.summarize(content);
+ let embedding: number[] | null = null;
+ try {
+ [embedding] = await this.embedder.embed([summary]);
+ } catch (err) {
+ this.log.warn(`Migration embed failed: ${err}`);
+ }
+
+ let dedupStatus: "active" | "duplicate" | "merged" = "active";
+ let dedupTarget: string | null = null;
+ let dedupReason: string | null = null;
+
+ if (embedding) {
+ const importThreshold = this.ctx?.config?.dedup?.similarityThreshold ?? 0.60;
+ const dedupOwnerFilter = [agentOwner];
+ const topSimilar = findTopSimilar(this.store, embedding, importThreshold, 5, this.log, dedupOwnerFilter);
+ if (topSimilar.length > 0) {
+ const candidates = topSimilar.map((s, i) => {
+ const chunk = this.store.getChunk(s.chunkId);
+ return { index: i + 1, summary: chunk?.summary ?? "", chunkId: s.chunkId };
+ }).filter(c => c.summary);
+
+ if (candidates.length > 0) {
+ const dedupResult = await summarizer.judgeDedup(summary, candidates);
+ if (dedupResult?.action === "DUPLICATE" && dedupResult.targetIndex) {
+ const targetId = candidates[dedupResult.targetIndex - 1]?.chunkId;
+ if (targetId) { dedupStatus = "duplicate"; dedupTarget = targetId; dedupReason = dedupResult.reason; }
+ } else if (dedupResult?.action === "UPDATE" && dedupResult.targetIndex && dedupResult.mergedSummary) {
+ const targetId = candidates[dedupResult.targetIndex - 1]?.chunkId;
+ if (targetId) {
+ this.store.updateChunkSummaryAndContent(targetId, dedupResult.mergedSummary, content);
+ try { const [newEmb] = await this.embedder.embed([dedupResult.mergedSummary]); if (newEmb) this.store.upsertEmbedding(targetId, newEmb); } catch { /* best-effort */ }
+ dedupStatus = "merged"; dedupTarget = targetId; dedupReason = dedupResult.reason;
+ }
+ }
+ }
+ }
+ }
+
+ const chunkId = uuid();
+ const msgTs = obj.message?.timestamp ?? obj.timestamp;
+ const ts = msgTs ? new Date(msgTs).getTime() : Date.now();
+ const chunk: Chunk = {
+ id: chunkId, sessionKey, turnId: `import-${agentId}-${sessionId}-${idx}`, seq: 0,
+ role: msgRole as any, content, kind: "paragraph", summary, embedding: null,
+ taskId: null, skillId: null, owner: agentOwner, dedupStatus, dedupTarget, dedupReason,
+ mergeCount: 0, lastHitAt: null, mergeHistory: "[]", createdAt: ts, updatedAt: ts,
+ };
+
+ this.store.insertChunk(chunk);
+ if (embedding && dedupStatus === "active") this.store.upsertEmbedding(chunkId, embedding);
+
+ totalStored++;
+ send("item", { index: idx, total: totalMsgs, status: dedupStatus === "active" ? "stored" : dedupStatus, preview: content.slice(0, 120), summary: summary.slice(0, 80), source: file, agent: agentId, role: msgRole });
+ } catch (err) {
+ totalErrors++;
+ send("item", { index: idx, total: totalMsgs, status: "error", preview: content.slice(0, 120), source: file, agent: agentId, error: String(err).slice(0, 200) });
+ }
+ }
+ } catch (err) {
+ send("error", { file, agent: agentId, error: String(err) });
+ totalErrors++;
+ }
+ }
+ };
+
+ // Execute agents with concurrency control
+ const agentEntries = Array.from(agentGroups.entries());
+ if (concurrency <= 1 || agentEntries.length <= 1) {
+ for (const [agentId, files] of agentEntries) {
+ if (this.migrationAbort) break;
+ send("progress", { total: totalMsgs, processed: globalMsgIdx, phase: "sessions", agent: agentId });
+ await importAgent(agentId, files);
+ }
+ } else {
+ // Parallel: run up to `concurrency` agents at once
+ let cursor = 0;
+ const runBatch = async () => {
+ while (cursor < agentEntries.length && !this.migrationAbort) {
+ const batch: Promise[] = [];
+ const batchStart = cursor;
+ while (batch.length < concurrency && cursor < agentEntries.length) {
+ const [agentId, files] = agentEntries[cursor++];
+ send("progress", { total: totalMsgs, processed: globalMsgIdx, phase: "sessions", agent: agentId, parallel: true });
+ batch.push(importAgent(agentId, files));
+ }
+ await Promise.all(batch);
+ }
+ };
+ await runBatch();
+ }
+ }
+
+ send("progress", { total: totalProcessed, processed: totalProcessed, phase: "done" });
+ send("summary", { totalProcessed, totalStored, totalSkipped, totalErrors });
+ }
+
+ // ─── Post-processing: independent task/skill generation ───
+
+ private handlePostprocess(req: http.IncomingMessage, res: http.ServerResponse): void {
+ if (this.ppRunning) {
+ res.writeHead(409, { "Content-Type": "application/json" });
+ res.end(JSON.stringify({ error: "postprocess already running" }));
+ return;
+ }
+ if (!this.ctx) {
+ res.writeHead(500, { "Content-Type": "application/json" });
+ res.end(JSON.stringify({ error: "plugin context not available — please restart the gateway" }));
+ return;
+ }
+
+ this.readBody(req, (body) => {
+ let opts: { enableTasks?: boolean; enableSkills?: boolean; concurrency?: number } = {};
+ try { opts = JSON.parse(body); } catch { /* defaults */ }
+
+ const concurrency = Math.max(1, Math.min(opts.concurrency ?? 1, 8));
+
+ res.writeHead(200, {
+ "Content-Type": "text/event-stream",
+ "Cache-Control": "no-cache",
+ "Connection": "keep-alive",
+ "X-Accel-Buffering": "no",
+ });
+
+ this.ppSSEClients.push(res);
+ res.on("close", () => { this.ppSSEClients = this.ppSSEClients.filter(c => c !== res); });
+
+ this.ppAbort = false;
+ this.ppState = { running: true, done: false, stopped: false, processed: 0, total: 0, tasksCreated: 0, skillsCreated: 0, errors: 0 };
+
+ const send = (event: string, data: unknown) => {
+ this.broadcastPPSSE(event, data);
+ };
+
+ this.ppRunning = true;
+ this.runPostprocess(send, !!opts.enableTasks, !!opts.enableSkills, concurrency).finally(() => {
+ this.ppRunning = false;
+ this.ppState.running = false;
+ this.ppState.done = true;
+ if (this.ppAbort) {
+ this.ppState.stopped = true;
+ this.broadcastPPSSE("stopped", { ...this.ppState });
+ } else {
+ this.broadcastPPSSE("done", { ...this.ppState });
+ }
+ for (const c of this.ppSSEClients) { try { c.end(); } catch { /* */ } }
+ this.ppSSEClients = [];
+ this.ppAbort = false;
+ });
+ });
+ }
+
+ private handlePostprocessStream(res: http.ServerResponse): void {
+ res.writeHead(200, {
+ "Content-Type": "text/event-stream",
+ "Cache-Control": "no-cache",
+ "Connection": "keep-alive",
+ "X-Accel-Buffering": "no",
+ });
+
+ if (this.ppRunning) {
+ res.write(`event: state\ndata: ${JSON.stringify(this.ppState)}\n\n`);
+ this.ppSSEClients.push(res);
+ res.on("close", () => { this.ppSSEClients = this.ppSSEClients.filter(c => c !== res); });
+ } else if (this.ppState.done) {
+ const evt = this.ppState.stopped ? "stopped" : "done";
+ res.write(`event: ${evt}\ndata: ${JSON.stringify(this.ppState)}\n\n`);
+ res.end();
+ } else {
+ res.end();
+ }
+ }
+
+ private handlePostprocessStop(res: http.ServerResponse): void {
+ this.ppAbort = true;
+ this.jsonResponse(res, { ok: true });
+ }
+
+ private handlePostprocessStatus(res: http.ServerResponse): void {
+ this.jsonResponse(res, this.ppState);
+ }
+
+ private broadcastPPSSE(event: string, data: unknown): void {
+ const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
+ for (const c of this.ppSSEClients) {
+ try { c.write(payload); } catch { /* */ }
+ }
+ }
+
+ private async runPostprocess(
+ send: (event: string, data: unknown) => void,
+ enableTasks: boolean,
+ enableSkills: boolean,
+ concurrency: number = 1,
+ ): Promise {
+ const ctx = this.ctx!;
+
+ const importSessions = this.store.getDistinctSessionKeys()
+ .filter((sk: string) => sk.startsWith("openclaw-import-") || sk.startsWith("openclaw-session-"));
+
+ type PendingItem = { sessionKey: string; action: "full" | "skill-only"; owner: string };
+ const pendingItems: PendingItem[] = [];
+ let skippedCount = 0;
+
+ const ownerMap = this.store.getSessionOwnerMap(importSessions);
+
+ for (const sk of importSessions) {
+ const hasTask = this.store.hasTaskForSession(sk);
+ const hasSkill = this.store.hasSkillForSessionTask(sk);
+ const owner = ownerMap.get(sk) ?? "agent:main";
+
+ if (enableTasks && !hasTask) {
+ pendingItems.push({ sessionKey: sk, action: "full", owner });
+ } else if (enableSkills && hasTask && !hasSkill) {
+ pendingItems.push({ sessionKey: sk, action: "skill-only", owner });
+ } else {
+ skippedCount++;
+ }
+ }
+
+ // Group pending items by agent (owner)
+ const agentGroups = new Map();
+ for (const item of pendingItems) {
+ const group = agentGroups.get(item.owner) ?? [];
+ group.push(item);
+ agentGroups.set(item.owner, group);
+ }
+
+ this.ppState.total = pendingItems.length;
+ send("info", {
+ totalSessions: importSessions.length,
+ alreadyProcessed: skippedCount,
+ pending: pendingItems.length,
+ agents: Array.from(agentGroups.keys()),
+ concurrency,
+ });
+ send("progress", { processed: 0, total: pendingItems.length });
+
+ let globalIdx = 0;
+ const incIdx = () => ++globalIdx;
+
+ // Process one agent's sessions sequentially
+ const processAgent = async (agentOwner: string, items: PendingItem[]) => {
+ const taskProcessor = new TaskProcessor(this.store, ctx);
+ let skillEvolver: SkillEvolver | null = null;
+
+ if (enableSkills) {
+ const recallEngine = new RecallEngine(this.store, this.embedder, ctx);
+ skillEvolver = new SkillEvolver(this.store, recallEngine, ctx);
+ taskProcessor.onTaskCompleted(async (task) => {
+ try {
+ await skillEvolver!.onTaskCompleted(task);
+ this.ppState.skillsCreated++;
+ send("skill", { taskId: task.id, title: task.title, agent: agentOwner });
+ } catch (err) {
+ this.log.warn(`Postprocess skill evolution error (${agentOwner}): ${err}`);
+ }
+ });
+ }
+
+ for (const { sessionKey, action } of items) {
+ if (this.ppAbort) break;
+ const idx = incIdx();
+ this.ppState.processed = globalIdx;
+
+ send("item", {
+ index: idx,
+ total: pendingItems.length,
+ session: sessionKey,
+ agent: agentOwner,
+ step: "processing",
+ action,
+ });
+
+ try {
+ if (action === "full") {
+ await taskProcessor.onChunksIngested(sessionKey, Date.now());
+ const activeTask = this.store.getActiveTask(sessionKey);
+ if (activeTask) {
+ await taskProcessor.finalizeTask(activeTask);
+ const finalized = this.store.getTask(activeTask.id);
+ this.ppState.tasksCreated++;
+ send("item", {
+ index: idx, total: pendingItems.length, session: sessionKey, agent: agentOwner,
+ step: "done", taskTitle: finalized?.title || "", taskStatus: finalized?.status || "",
+ });
+ } else {
+ send("item", {
+ index: idx, total: pendingItems.length, session: sessionKey, agent: agentOwner,
+ step: "done", taskTitle: "(no chunks)",
+ });
+ }
+ } else if (action === "skill-only" && skillEvolver) {
+ const completedTasks = this.store.getCompletedTasksForSession(sessionKey);
+ let skillGenerated = false;
+ for (const task of completedTasks) {
+ if (this.ppAbort) break;
+ try {
+ await skillEvolver.onTaskCompleted(task);
+ this.ppState.skillsCreated++;
+ skillGenerated = true;
+ send("skill", { taskId: task.id, title: task.title, agent: agentOwner });
+ } catch (err) {
+ this.log.warn(`Skill evolution error (${agentOwner}) task=${task.id}: ${err}`);
+ }
+ }
+ send("item", {
+ index: idx, total: pendingItems.length, session: sessionKey, agent: agentOwner,
+ step: "done", taskTitle: completedTasks[0]?.title || sessionKey, action: "skill-only", skillGenerated,
+ });
+ }
+ } catch (err) {
+ this.ppState.errors++;
+ this.log.warn(`Postprocess error (${agentOwner}) ${sessionKey}: ${err}`);
+ send("item", {
+ index: idx, total: pendingItems.length, session: sessionKey, agent: agentOwner,
+ step: "error", error: String(err).slice(0, 200),
+ });
+ }
+
+ send("progress", { processed: globalIdx, total: pendingItems.length });
+ }
+ };
+
+ // Execute agents with concurrency control
+ const agentEntries = Array.from(agentGroups.entries());
+ if (concurrency <= 1 || agentEntries.length <= 1) {
+ for (const [agentOwner, items] of agentEntries) {
+ if (this.ppAbort) break;
+ await processAgent(agentOwner, items);
+ }
+ } else {
+ let cursor = 0;
+ while (cursor < agentEntries.length && !this.ppAbort) {
+ const batch: Promise[] = [];
+ while (batch.length < concurrency && cursor < agentEntries.length) {
+ const [agentOwner, items] = agentEntries[cursor++];
+ batch.push(processAgent(agentOwner, items));
+ }
+ await Promise.all(batch);
+ }
+ }
+ }
+
+ private readBody(req: http.IncomingMessage, cb: (body: string) => void): void {
+ let body = "";
+ req.on("data", (chunk: Buffer) => { body += chunk.toString(); });
+ req.on("end", () => cb(body));
+ }
+
+ private jsonResponse(res: http.ServerResponse, data: unknown): void {
+ res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
+ res.end(JSON.stringify(data));
+ }
+}
diff --git a/apps/memos-local-openclaw/tests/capture.test.ts b/apps/memos-local-openclaw/tests/capture.test.ts
new file mode 100644
index 000000000..97ee5c5a7
--- /dev/null
+++ b/apps/memos-local-openclaw/tests/capture.test.ts
@@ -0,0 +1,151 @@
+import { describe, it, expect } from "vitest";
+import { captureMessages } from "../src/capture";
+import type { Logger } from "../src/types";
+
+const noopLog: Logger = {
+ debug: () => {},
+ info: () => {},
+ warn: () => {},
+ error: () => {},
+};
+
+describe("captureMessages", () => {
+ it("should keep user and assistant messages as-is", () => {
+ const msgs = [
+ { role: "user", content: "Hello world" },
+ { role: "assistant", content: "Hi there" },
+ ];
+ const result = captureMessages(msgs, "s1", "t1", "STORED_MEMORY", noopLog);
+ expect(result).toHaveLength(2);
+ expect(result[0].role).toBe("user");
+ expect(result[0].content).toBe("Hello world");
+ expect(result[1].role).toBe("assistant");
+ expect(result[1].content).toBe("Hi there");
+ });
+
+ it("should filter system messages and self-tool results", () => {
+ const msgs = [
+ { role: "system", content: "You are a helpful assistant" },
+ { role: "tool", content: '{"hits":[]}', toolName: "memory_search" },
+ { role: "user", content: "Hello" },
+ ];
+ const result = captureMessages(msgs, "s1", "t1", "STORED_MEMORY", noopLog);
+ expect(result).toHaveLength(1);
+ expect(result[0].role).toBe("user");
+ });
+
+ it("should keep non-self tool messages with original content", () => {
+ const msgs = [
+ { role: "tool", content: '{"result": "ok"}', toolName: "web_search" },
+ { role: "user", content: "Hello" },
+ ];
+ const result = captureMessages(msgs, "s1", "t1", "STORED_MEMORY", noopLog);
+ expect(result).toHaveLength(2);
+ expect(result[0].role).toBe("tool");
+ expect(result[0].content).toBe('{"result": "ok"}');
+ expect(result[0].toolName).toBe("web_search");
+ });
+
+ it("should strip explicit evidence wrapper blocks from assistant messages", () => {
+ const msgs = [
+ {
+ role: "assistant",
+ content: "Based on memory: [STORED_MEMORY]some evidence[/STORED_MEMORY] the answer is 42.",
+ },
+ ];
+ const result = captureMessages(msgs, "s1", "t1", "STORED_MEMORY", noopLog);
+ expect(result).toHaveLength(1);
+ expect(result[0].content).toBe("Based on memory: the answer is 42.");
+ });
+
+ it("should not strip ordinary mentions of the evidence tag", () => {
+ const msgs = [
+ {
+ role: "assistant",
+ content: "The literal token STORED_MEMORY appears in this docs note.",
+ },
+ ];
+
+ const result = captureMessages(msgs, "s1", "t1", "STORED_MEMORY", noopLog);
+
+ expect(result).toHaveLength(1);
+ expect(result[0].content).toBe("The literal token STORED_MEMORY appears in this docs note.");
+ });
+
+ it("should skip empty messages", () => {
+ const msgs = [
+ { role: "user", content: "" },
+ { role: "assistant", content: " " },
+ { role: "user", content: "Real message" },
+ ];
+ const result = captureMessages(msgs, "s1", "t1", "STORED_MEMORY", noopLog);
+ expect(result).toHaveLength(1);
+ expect(result[0].content).toBe("Real message");
+ });
+
+ it("should skip all memory tool variants", () => {
+ const msgs = [
+ { role: "tool", content: "search results", toolName: "memory_search" },
+ { role: "tool", content: "timeline data", toolName: "memory_timeline" },
+ { role: "tool", content: "chunk data", toolName: "memory_get" },
+ { role: "tool", content: "viewer url", toolName: "memory_viewer" },
+ { role: "tool", content: "other tool result", toolName: "bash" },
+ ];
+ const result = captureMessages(msgs, "s1", "t1", "STORED_MEMORY", noopLog);
+ expect(result).toHaveLength(1);
+ expect(result[0].toolName).toBe("bash");
+ });
+
+ it("should strip OpenClaw inbound metadata from user messages", () => {
+ const rawContent = [
+ "Sender (untrusted metadata):",
+ "```json",
+ "{",
+ ' "label": "openclaw-control-ui",',
+ ' "id": "openclaw-control-ui"',
+ "}",
+ "```",
+ "",
+ " [Tue 2026-03-03 21:58 GMT+8] 我的职业是啥",
+ ].join("\n");
+
+ const msgs = [{ role: "user", content: rawContent }];
+ const result = captureMessages(msgs, "s1", "t1", "STORED_MEMORY", noopLog);
+ expect(result).toHaveLength(1);
+ expect(result[0].content).toBe("我的职业是啥");
+ });
+
+ it("should strip multiple metadata blocks", () => {
+ const rawContent = [
+ "Conversation info (untrusted metadata):",
+ "```json",
+ '{ "channel": "webchat" }',
+ "```",
+ "Sender (untrusted metadata):",
+ "```json",
+ '{ "label": "user1", "id": "u1" }',
+ "```",
+ "",
+ "[Mon 2026-03-03 20:00 GMT+8] 你好",
+ ].join("\n");
+
+ const msgs = [{ role: "user", content: rawContent }];
+ const result = captureMessages(msgs, "s1", "t1", "STORED_MEMORY", noopLog);
+ expect(result).toHaveLength(1);
+ expect(result[0].content).toBe("你好");
+ });
+
+ it("should not strip from assistant or tool messages", () => {
+ const msgs = [
+ { role: "assistant", content: "Sender (untrusted metadata):\nsome text" },
+ ];
+ const result = captureMessages(msgs, "s1", "t1", "STORED_MEMORY", noopLog);
+ expect(result[0].content).toBe("Sender (untrusted metadata):\nsome text");
+ });
+
+ it("should handle user message without metadata prefix", () => {
+ const msgs = [{ role: "user", content: "普通的用户消息" }];
+ const result = captureMessages(msgs, "s1", "t1", "STORED_MEMORY", noopLog);
+ expect(result[0].content).toBe("普通的用户消息");
+ });
+});
diff --git a/apps/memos-local-openclaw/tests/chunker.test.ts b/apps/memos-local-openclaw/tests/chunker.test.ts
new file mode 100644
index 000000000..a6724c2aa
--- /dev/null
+++ b/apps/memos-local-openclaw/tests/chunker.test.ts
@@ -0,0 +1,68 @@
+import { describe, it, expect } from "vitest";
+import { chunkText } from "../src/ingest/chunker";
+
+describe("chunkText", () => {
+ it("should extract code blocks as standalone chunks", () => {
+ const text = `Here is some context.
+
+\`\`\`python
+def hello():
+ print("world")
+\`\`\`
+
+And more text after the code block that is long enough to be its own chunk.`;
+
+ const chunks = chunkText(text);
+ const codeChunk = chunks.find((c) => c.kind === "code_block");
+ expect(codeChunk).toBeDefined();
+ expect(codeChunk!.content).toContain("def hello()");
+ });
+
+ it("should extract error stacks as standalone chunks", () => {
+ const text = `Something went wrong.
+
+Error: Connection refused
+ at Socket.connect (net.js:1141:16)
+ at TCPConnectWrap.afterConnect (net.js:1152:14)
+
+Then we continued.`;
+
+ const chunks = chunkText(text);
+ const errorChunk = chunks.find((c) => c.kind === "error_stack");
+ expect(errorChunk).toBeDefined();
+ expect(errorChunk!.content).toContain("Connection refused");
+ });
+
+ it("should split long paragraphs by sentence when over MAX_CHUNK_CHARS", () => {
+ // Total length > 3000 so splitOversized will split at sentence boundaries
+ const longPara =
+ "First sentence here. " +
+ "A".repeat(1500) +
+ ". " +
+ "B".repeat(1500) +
+ ". " +
+ "Last sentence.";
+ const chunks = chunkText(longPara);
+ expect(chunks.length).toBeGreaterThan(1);
+ });
+
+ it("should return at least one chunk for non-empty input", () => {
+ const chunks = chunkText("Short text but still meaningful enough to chunk.");
+ expect(chunks.length).toBeGreaterThanOrEqual(1);
+ });
+
+ it("should extract list blocks", () => {
+ const text = `Here are some items:
+
+- First item in the list
+- Second item in the list
+- Third item in the list
+
+End of text with enough padding to be a real chunk on its own line.`;
+
+ const chunks = chunkText(text);
+ const listChunk = chunks.find((c) => c.kind === "list");
+ expect(listChunk).toBeDefined();
+ expect(listChunk!.content).toContain("First item");
+ });
+});
diff --git a/apps/memos-local-openclaw/tests/integration.test.ts b/apps/memos-local-openclaw/tests/integration.test.ts
new file mode 100644
index 000000000..6cd6b0b93
--- /dev/null
+++ b/apps/memos-local-openclaw/tests/integration.test.ts
@@ -0,0 +1,245 @@
+import { describe, it, expect, beforeAll, afterAll } from "vitest";
+import * as fs from "fs";
+import * as path from "path";
+import * as os from "os";
+import { initPlugin, type MemosLocalPlugin } from "../src/index";
+
+let plugin: MemosLocalPlugin;
+let tmpDir: string;
+
+beforeAll(async () => {
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "memos-integration-"));
+ plugin = initPlugin({
+ stateDir: tmpDir,
+ config: {
+ // No summarizer → rule-based fallback
+ // No embedding → local MiniLM fallback
+ },
+ });
+
+ // Simulate a conversation: user talks about deploying a service
+ plugin.onConversationTurn([
+ { role: "user", content: "I'm deploying our API service to port 8443 using Docker. The command is: `docker compose -f docker-compose.prod.yml up -d`. The Postgres password is configured via POSTGRES_PASSWORD env var." },
+ { role: "assistant", content: "Got it. I'll help you deploy. Make sure the firewall allows port 8443 and that POSTGRES_PASSWORD is set in your .env file. The docker-compose.prod.yml should have health checks configured." },
+ ], "session-deploy");
+
+ // Second turn about a different topic
+ plugin.onConversationTurn([
+ { role: "user", content: "Now let's discuss the React frontend. We're using Next.js 14 with App Router. The main page component is at app/page.tsx and it fetches data from /api/dashboard." },
+ { role: "assistant", content: "For the Next.js 14 App Router setup, your app/page.tsx should use server components by default. The /api/dashboard route handler should be in app/api/dashboard/route.ts." },
+ ], "session-frontend");
+
+ // Third turn with an error stack
+ plugin.onConversationTurn([
+ { role: "user", content: `The build is failing with this error:
+Error: Module not found: Can't resolve '@/components/Chart'
+ at ModuleNotFoundError (webpack/lib/ModuleNotFoundError.js:28:12)
+ at factorize (webpack/lib/Compilation.js:2045:24)
+ at resolve (webpack/lib/NormalModuleFactory.js:439:20)
+
+I think the path alias is wrong in the tsconfig configuration.` },
+ { role: "assistant", content: "The error shows a missing path alias for @/components/Chart. Check your tsconfig.json paths configuration - it should have: \"@/*\": [\"./src/*\"] or similar mapping." },
+ ], "session-frontend");
+
+ plugin.onConversationTurn([
+ { role: "user", content: "alpha private marker only alpha should see this rollout note" },
+ { role: "assistant", content: "Recorded alpha private marker deployment note." },
+ ], "session-alpha-private", "agent:alpha");
+
+ plugin.onConversationTurn([
+ { role: "user", content: "beta private marker only beta should see this rollback note" },
+ { role: "assistant", content: "Recorded beta private marker rollback note." },
+ ], "session-beta-private", "agent:beta");
+
+ plugin.onConversationTurn([
+ { role: "user", content: "shared public marker all agents can use this shared convention" },
+ { role: "assistant", content: "Recorded shared public marker convention." },
+ ], "session-public", "public");
+
+ // Wait for all async ingest to complete
+ await plugin.flush();
+}, 120_000);
+
+afterAll(() => {
+ plugin.shutdown();
+ fs.rmSync(tmpDir, { recursive: true, force: true });
+});
+
+describe("Integration: memory_search", () => {
+ it("should find docker deployment details", async () => {
+ const searchTool = plugin.tools.find((t) => t.name === "memory_search")!;
+ const result = (await searchTool.handler({ query: "docker deploy port 8443" })) as any;
+
+ expect(result.hits.length).toBeGreaterThan(0);
+ expect(result.meta.usedMinScore).toBe(0.45);
+ expect(result.meta.usedMaxResults).toBe(6);
+
+ const hit = result.hits[0];
+ expect(hit.summary).toBeTruthy();
+ expect(hit.original_excerpt).toBeTruthy();
+ expect(hit.ref).toBeDefined();
+ expect(hit.ref.sessionKey).toBeTruthy();
+ expect(hit.ref.chunkId).toBeTruthy();
+ expect(hit.score).toBeGreaterThanOrEqual(0);
+ expect(hit.score).toBeLessThanOrEqual(1);
+ expect(hit.source.ts).toBeGreaterThan(0);
+ });
+
+ it("should find Next.js frontend details", async () => {
+ const searchTool = plugin.tools.find((t) => t.name === "memory_search")!;
+ const result = (await searchTool.handler({ query: "Next.js App Router page.tsx" })) as any;
+
+ expect(result.hits.length).toBeGreaterThan(0);
+ });
+
+ it("should find error stack information", async () => {
+ const searchTool = plugin.tools.find((t) => t.name === "memory_search")!;
+ const result = (await searchTool.handler({ query: "Module not found Chart component" })) as any;
+
+ expect(result.hits.length).toBeGreaterThan(0);
+ });
+
+ it("should respect maxResults parameter", async () => {
+ const searchTool = plugin.tools.find((t) => t.name === "memory_search")!;
+ const result = (await searchTool.handler({ query: "deploy", maxResults: 2 })) as any;
+
+ expect(result.hits.length).toBeLessThanOrEqual(2);
+ expect(result.meta.usedMaxResults).toBe(2);
+ });
+
+ it("should produce note on repeated identical query", async () => {
+ const searchTool = plugin.tools.find((t) => t.name === "memory_search")!;
+
+ await searchTool.handler({ query: "unique test query xyz", maxResults: 6, minScore: 0.45 });
+ const result2 = (await searchTool.handler({ query: "unique test query xyz", maxResults: 6, minScore: 0.45 })) as any;
+
+ expect(result2.meta.note).toBeDefined();
+ expect(result2.meta.note).toContain("already");
+ });
+});
+
+describe("Integration: memory_timeline", () => {
+ it("should return neighboring context around a hit", async () => {
+ const searchTool = plugin.tools.find((t) => t.name === "memory_search")!;
+ const timelineTool = plugin.tools.find((t) => t.name === "memory_timeline")!;
+
+ const searchResult = (await searchTool.handler({ query: "docker compose" })) as any;
+ if (searchResult.hits.length === 0) return; // skip if no hits
+
+ const ref = searchResult.hits[0].ref;
+ const timelineResult = (await timelineTool.handler({ ref, window: 2 })) as any;
+
+ expect(timelineResult.entries).toBeDefined();
+ expect(timelineResult.entries.length).toBeGreaterThan(0);
+ expect(timelineResult.anchorRef).toEqual(ref);
+
+ const entry = timelineResult.entries[0];
+ expect(entry.excerpt).toBeTruthy();
+ expect(entry.ref).toBeDefined();
+ expect(["before", "current", "after"]).toContain(entry.relation);
+ });
+});
+
+describe("Integration: memory_get", () => {
+ it("should return full original text of a chunk", async () => {
+ const searchTool = plugin.tools.find((t) => t.name === "memory_search")!;
+ const getTool = plugin.tools.find((t) => t.name === "memory_get")!;
+
+ const searchResult = (await searchTool.handler({ query: "docker compose" })) as any;
+ if (searchResult.hits.length === 0) return;
+
+ const ref = searchResult.hits[0].ref;
+ const getResult = (await getTool.handler({ ref })) as any;
+
+ expect(getResult.content).toBeTruthy();
+ expect(getResult.ref).toBeDefined();
+ expect(getResult.source).toBeDefined();
+ expect(getResult.source.ts).toBeGreaterThan(0);
+ });
+
+ it("should respect maxChars parameter", async () => {
+ const searchTool = plugin.tools.find((t) => t.name === "memory_search")!;
+ const getTool = plugin.tools.find((t) => t.name === "memory_get")!;
+
+ const searchResult = (await searchTool.handler({ query: "docker" })) as any;
+ if (searchResult.hits.length === 0) return;
+
+ const ref = searchResult.hits[0].ref;
+ const getResult = (await getTool.handler({ ref, maxChars: 50 })) as any;
+
+ expect(getResult.content.length).toBeLessThanOrEqual(52); // 50 + "…"
+ });
+});
+
+describe("Integration: owner isolation for initPlugin tools", () => {
+ it("memory_search should respect owner on initPlugin path", async () => {
+ const searchTool = plugin.tools.find((t) => t.name === "memory_search")!;
+
+ const betaSearch = (await searchTool.handler({
+ query: "alpha private marker",
+ owner: "agent:beta",
+ })) as any;
+
+ expect(betaSearch.hits).toHaveLength(0);
+
+ const publicSearch = (await searchTool.handler({
+ query: "shared public marker",
+ owner: "agent:beta",
+ })) as any;
+
+ expect(publicSearch.hits.length).toBeGreaterThan(0);
+ expect(publicSearch.hits.some((hit: any) => hit.ref.sessionKey === "session-public")).toBe(true);
+ });
+
+ it("memory_timeline should not expose another owner's chunks on initPlugin path", async () => {
+ const searchTool = plugin.tools.find((t) => t.name === "memory_search")!;
+ const timelineTool = plugin.tools.find((t) => t.name === "memory_timeline")!;
+
+ const alphaSearch = (await searchTool.handler({
+ query: "alpha private marker",
+ owner: "agent:alpha",
+ })) as any;
+
+ expect(alphaSearch.hits.length).toBeGreaterThan(0);
+
+ const ref = alphaSearch.hits[0].ref;
+ const leaked = (await timelineTool.handler({ ref, owner: "agent:beta", window: 2 })) as any;
+
+ expect(leaked.entries).toEqual([]);
+ });
+
+ it("memory_get should not expose another owner's chunk on initPlugin path", async () => {
+ const searchTool = plugin.tools.find((t) => t.name === "memory_search")!;
+ const getTool = plugin.tools.find((t) => t.name === "memory_get")!;
+
+ const alphaSearch = (await searchTool.handler({
+ query: "alpha private marker",
+ owner: "agent:alpha",
+ })) as any;
+
+ expect(alphaSearch.hits.length).toBeGreaterThan(0);
+
+ const ref = alphaSearch.hits[0].ref;
+ const leaked = (await getTool.handler({ ref, owner: "agent:beta" })) as any;
+
+ expect(leaked.error).toContain(ref.chunkId);
+ });
+});
+
+describe("Integration: evidence anti-writeback", () => {
+ it("should not store evidence wrapper blocks in memory", async () => {
+ plugin.onConversationTurn([
+ { role: "assistant", content: "Based on [STORED_MEMORY]old data about port 3000[/STORED_MEMORY] the answer is port 8443." },
+ ], "session-test");
+
+ await new Promise((resolve) => setTimeout(resolve, 3000));
+
+ const searchTool = plugin.tools.find((t) => t.name === "memory_search")!;
+ const result = (await searchTool.handler({ query: "old data port 3000" })) as any;
+
+ for (const hit of result.hits) {
+ expect(hit.original_excerpt).not.toContain("[STORED_MEMORY]");
+ expect(hit.original_excerpt).not.toContain("old data about port 3000");
+ }
+ });
+});
diff --git a/apps/memos-local-openclaw/tests/multi-agent.test.ts b/apps/memos-local-openclaw/tests/multi-agent.test.ts
new file mode 100644
index 000000000..99af3bc19
--- /dev/null
+++ b/apps/memos-local-openclaw/tests/multi-agent.test.ts
@@ -0,0 +1,348 @@
+import { describe, it, expect, beforeEach, afterEach } from "vitest";
+import * as fs from "fs";
+import * as path from "path";
+import * as os from "os";
+import { SqliteStore } from "../src/storage/sqlite";
+import { cosineSimilarity, vectorSearch } from "../src/storage/vector";
+import type { Chunk, Skill, Logger } from "../src/types";
+
+const noopLog: Logger = {
+ debug: () => {},
+ info: () => {},
+ warn: () => {},
+ error: () => {},
+};
+
+let store: SqliteStore;
+let tmpDir: string;
+
+beforeEach(() => {
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "memos-multi-agent-"));
+ store = new SqliteStore(path.join(tmpDir, "test.db"), noopLog);
+});
+
+afterEach(() => {
+ store.close();
+ fs.rmSync(tmpDir, { recursive: true, force: true });
+});
+
+function makeChunk(overrides: Partial = {}): Chunk {
+ return {
+ id: overrides.id ?? "chunk-1",
+ sessionKey: "session-1",
+ turnId: "turn-1",
+ seq: 0,
+ role: "user",
+ content: "Hello world",
+ kind: "paragraph",
+ summary: "Greeting message",
+ embedding: null,
+ taskId: null,
+ skillId: null,
+ owner: "agent:main",
+ dedupStatus: "active",
+ dedupTarget: null,
+ dedupReason: null,
+ mergeCount: 0,
+ lastHitAt: null,
+ mergeHistory: "[]",
+ createdAt: Date.now(),
+ updatedAt: Date.now(),
+ ...overrides,
+ };
+}
+
+describe("Multi-Agent Memory Isolation", () => {
+ it("should store and retrieve chunks with owner", () => {
+ store.insertChunk(makeChunk({ id: "c1", owner: "agent:alpha", content: "Alpha memory" }));
+ store.insertChunk(makeChunk({ id: "c2", owner: "agent:beta", content: "Beta memory" }));
+ store.insertChunk(makeChunk({ id: "c3", owner: "public", content: "Public memory" }));
+
+ const c1 = store.getChunk("c1");
+ expect(c1!.owner).toBe("agent:alpha");
+ const c2 = store.getChunk("c2");
+ expect(c2!.owner).toBe("agent:beta");
+ const c3 = store.getChunk("c3");
+ expect(c3!.owner).toBe("public");
+ });
+
+ it("FTS search should filter by owner", () => {
+ store.insertChunk(makeChunk({
+ id: "c1", owner: "agent:alpha",
+ content: "TypeScript deployment guide",
+ summary: "TypeScript deployment guide",
+ }));
+ store.insertChunk(makeChunk({
+ id: "c2", owner: "agent:beta",
+ content: "TypeScript testing patterns",
+ summary: "TypeScript testing patterns",
+ }));
+ store.insertChunk(makeChunk({
+ id: "c3", owner: "public",
+ content: "TypeScript best practices shared",
+ summary: "TypeScript best practices shared",
+ }));
+
+ // Alpha sees own + public
+ const alphaResults = store.ftsSearch("TypeScript", 10, ["agent:alpha", "public"]);
+ const alphaIds = alphaResults.map(r => r.chunkId);
+ expect(alphaIds).toContain("c1");
+ expect(alphaIds).toContain("c3");
+ expect(alphaIds).not.toContain("c2");
+
+ // Beta sees own + public
+ const betaResults = store.ftsSearch("TypeScript", 10, ["agent:beta", "public"]);
+ const betaIds = betaResults.map(r => r.chunkId);
+ expect(betaIds).toContain("c2");
+ expect(betaIds).toContain("c3");
+ expect(betaIds).not.toContain("c1");
+
+ // No filter sees all
+ const allResults = store.ftsSearch("TypeScript", 10);
+ expect(allResults.length).toBe(3);
+ });
+
+ it("vector search should filter by owner", () => {
+ const vec1 = [0.1, 0.2, 0.3, 0.4, 0.5];
+ const vec2 = [0.15, 0.25, 0.35, 0.45, 0.55];
+ const vec3 = [0.2, 0.3, 0.4, 0.5, 0.6];
+
+ store.insertChunk(makeChunk({ id: "c1", owner: "agent:alpha" }));
+ store.insertChunk(makeChunk({ id: "c2", owner: "agent:beta" }));
+ store.insertChunk(makeChunk({ id: "c3", owner: "public" }));
+
+ store.upsertEmbedding("c1", vec1);
+ store.upsertEmbedding("c2", vec2);
+ store.upsertEmbedding("c3", vec3);
+
+ const queryVec = [0.1, 0.2, 0.3, 0.4, 0.5];
+
+ // Alpha sees own + public
+ const alphaResults = vectorSearch(store, queryVec, 10, undefined, ["agent:alpha", "public"]);
+ const alphaIds = alphaResults.map(r => r.chunkId);
+ expect(alphaIds).toContain("c1");
+ expect(alphaIds).toContain("c3");
+ expect(alphaIds).not.toContain("c2");
+
+ // No filter sees all
+ const allResults = vectorSearch(store, queryVec, 10);
+ expect(allResults.length).toBe(3);
+ });
+});
+
+describe("Skill Visibility", () => {
+ function makeSkill(overrides: Partial = {}): Skill {
+ return {
+ id: overrides.id ?? "skill-1",
+ name: overrides.name ?? "test-skill",
+ description: "A test skill",
+ version: 1,
+ status: "active",
+ tags: "[]",
+ sourceType: "task",
+ dirPath: "/tmp/skills/test",
+ installed: 0,
+ owner: "agent:main",
+ visibility: "private",
+ qualityScore: 8,
+ createdAt: Date.now(),
+ updatedAt: Date.now(),
+ ...overrides,
+ };
+ }
+
+ it("should store skill with owner and visibility", () => {
+ store.insertSkill(makeSkill({ id: "s1", owner: "agent:alpha", visibility: "public" }));
+ const skill = store.getSkill("s1");
+ expect(skill!.owner).toBe("agent:alpha");
+ expect(skill!.visibility).toBe("public");
+ });
+
+ it("should toggle skill visibility", () => {
+ store.insertSkill(makeSkill({ id: "s1" }));
+ expect(store.getSkill("s1")!.visibility).toBe("private");
+
+ store.setSkillVisibility("s1", "public");
+ expect(store.getSkill("s1")!.visibility).toBe("public");
+
+ store.setSkillVisibility("s1", "private");
+ expect(store.getSkill("s1")!.visibility).toBe("private");
+ });
+
+ it("should list public skills", () => {
+ store.insertSkill(makeSkill({ id: "s1", name: "skill-a", visibility: "private" }));
+ store.insertSkill(makeSkill({ id: "s2", name: "skill-b", visibility: "public" }));
+ store.insertSkill(makeSkill({ id: "s3", name: "skill-c", visibility: "public" }));
+
+ const publicSkills = store.listPublicSkills();
+ expect(publicSkills.length).toBe(2);
+ expect(publicSkills.map(s => s.id)).toContain("s2");
+ expect(publicSkills.map(s => s.id)).toContain("s3");
+ });
+
+ it("skill FTS should scope by visibility", () => {
+ store.insertSkill(makeSkill({
+ id: "s1", name: "docker-deploy", description: "Docker deployment guide",
+ owner: "agent:alpha", visibility: "private",
+ }));
+ store.insertSkill(makeSkill({
+ id: "s2", name: "docker-compose", description: "Docker compose workflow",
+ owner: "agent:beta", visibility: "public",
+ }));
+ store.insertSkill(makeSkill({
+ id: "s3", name: "docker-k8s", description: "Docker Kubernetes integration",
+ owner: "agent:alpha", visibility: "public",
+ }));
+
+ // Self: alpha sees only own
+ const selfResults = store.skillFtsSearch("Docker", 10, "self", "agent:alpha");
+ expect(selfResults.map(r => r.skillId)).toContain("s1");
+ expect(selfResults.map(r => r.skillId)).toContain("s3");
+ expect(selfResults.map(r => r.skillId)).not.toContain("s2");
+
+ // Public: sees only public skills
+ const publicResults = store.skillFtsSearch("Docker", 10, "public", "agent:alpha");
+ expect(publicResults.map(r => r.skillId)).toContain("s2");
+ expect(publicResults.map(r => r.skillId)).toContain("s3");
+ expect(publicResults.map(r => r.skillId)).not.toContain("s1");
+
+ // Mix: sees own + public
+ const mixResults = store.skillFtsSearch("Docker", 10, "mix", "agent:alpha");
+ expect(mixResults.length).toBe(3);
+ });
+
+ it("should store and retrieve skill embeddings", () => {
+ store.insertSkill(makeSkill({ id: "s1", name: "embed-test", visibility: "public" }));
+ const vec = [0.1, 0.2, 0.3, 0.4, 0.5];
+ store.upsertSkillEmbedding("s1", vec);
+
+ const retrieved = store.getSkillEmbedding("s1");
+ expect(retrieved).not.toBeNull();
+ expect(retrieved!.length).toBe(5);
+ expect(retrieved![0]).toBeCloseTo(0.1, 4);
+ });
+
+ it("skill embeddings should scope by visibility", () => {
+ store.insertSkill(makeSkill({ id: "s1", name: "priv-skill", owner: "agent:alpha", visibility: "private" }));
+ store.insertSkill(makeSkill({ id: "s2", name: "pub-skill", owner: "agent:beta", visibility: "public" }));
+
+ store.upsertSkillEmbedding("s1", [0.1, 0.2, 0.3]);
+ store.upsertSkillEmbedding("s2", [0.4, 0.5, 0.6]);
+
+ // Self: alpha sees own
+ const selfEmb = store.getSkillEmbeddings("self", "agent:alpha");
+ expect(selfEmb.length).toBe(1);
+ expect(selfEmb[0].skillId).toBe("s1");
+
+ // Public: sees only public
+ const pubEmb = store.getSkillEmbeddings("public", "agent:alpha");
+ expect(pubEmb.length).toBe(1);
+ expect(pubEmb[0].skillId).toBe("s2");
+
+ // Mix: alpha sees own + public
+ const mixEmb = store.getSkillEmbeddings("mix", "agent:alpha");
+ expect(mixEmb.length).toBe(2);
+ });
+});
+
+describe("Task Owner", () => {
+ it("should store task with owner", () => {
+ store.insertTask({
+ id: "t1",
+ sessionKey: "session-1",
+ title: "Test Task",
+ summary: "Test summary",
+ status: "active",
+ owner: "agent:alpha",
+ startedAt: Date.now(),
+ endedAt: null,
+ updatedAt: Date.now(),
+ });
+
+ const task = store.getTask("t1");
+ expect(task!.owner).toBe("agent:alpha");
+ });
+
+ it("getActiveTask should filter by owner", () => {
+ const now = Date.now();
+ store.insertTask({
+ id: "t1", sessionKey: "s1", title: "Alpha Task", summary: "",
+ status: "active", owner: "agent:alpha", startedAt: now, endedAt: null, updatedAt: now,
+ });
+ store.insertTask({
+ id: "t2", sessionKey: "s1", title: "Beta Task", summary: "",
+ status: "active", owner: "agent:beta", startedAt: now + 1, endedAt: null, updatedAt: now + 1,
+ });
+
+ const alphaTask = store.getActiveTask("s1", "agent:alpha");
+ expect(alphaTask).not.toBeNull();
+ expect(alphaTask!.id).toBe("t1");
+
+ const betaTask = store.getActiveTask("s1", "agent:beta");
+ expect(betaTask).not.toBeNull();
+ expect(betaTask!.id).toBe("t2");
+
+ // Without owner filter, returns the most recent
+ const anyTask = store.getActiveTask("s1");
+ expect(anyTask).not.toBeNull();
+ expect(anyTask!.id).toBe("t2");
+ });
+
+ it("getAllActiveTasks should filter by owner", () => {
+ const now = Date.now();
+ store.insertTask({
+ id: "t1", sessionKey: "s1", title: "Alpha Task", summary: "",
+ status: "active", owner: "agent:alpha", startedAt: now, endedAt: null, updatedAt: now,
+ });
+ store.insertTask({
+ id: "t2", sessionKey: "s2", title: "Beta Task", summary: "",
+ status: "active", owner: "agent:beta", startedAt: now, endedAt: null, updatedAt: now,
+ });
+
+ const alphaTasks = store.getAllActiveTasks("agent:alpha");
+ expect(alphaTasks.length).toBe(1);
+ expect(alphaTasks[0].id).toBe("t1");
+
+ const betaTasks = store.getAllActiveTasks("agent:beta");
+ expect(betaTasks.length).toBe(1);
+ expect(betaTasks[0].id).toBe("t2");
+
+ const allTasks = store.getAllActiveTasks();
+ expect(allTasks.length).toBe(2);
+ });
+
+ it("getUnassignedChunks should filter by owner", () => {
+ store.insertChunk(makeChunk({ id: "c1", owner: "agent:alpha", content: "Alpha msg" }));
+ store.insertChunk(makeChunk({ id: "c2", owner: "agent:beta", content: "Beta msg" }));
+
+ const alphaChunks = store.getUnassignedChunks("session-1", "agent:alpha");
+ expect(alphaChunks.length).toBe(1);
+ expect(alphaChunks[0].id).toBe("c1");
+
+ const betaChunks = store.getUnassignedChunks("session-1", "agent:beta");
+ expect(betaChunks.length).toBe(1);
+ expect(betaChunks[0].id).toBe("c2");
+
+ const allChunks = store.getUnassignedChunks("session-1");
+ expect(allChunks.length).toBe(2);
+ });
+
+ it("listTasks should filter by owner", () => {
+ const now = Date.now();
+ store.insertTask({
+ id: "t1", sessionKey: "s1", title: "Alpha Task", summary: "",
+ status: "completed", owner: "agent:alpha", startedAt: now, endedAt: now + 1000, updatedAt: now,
+ });
+ store.insertTask({
+ id: "t2", sessionKey: "s1", title: "Beta Task", summary: "",
+ status: "completed", owner: "agent:beta", startedAt: now, endedAt: now + 1000, updatedAt: now,
+ });
+
+ const alphaResult = store.listTasks({ owner: "agent:alpha" });
+ expect(alphaResult.total).toBe(1);
+ expect(alphaResult.tasks[0].id).toBe("t1");
+
+ const allResult = store.listTasks();
+ expect(allResult.total).toBe(2);
+ });
+});
diff --git a/apps/memos-local-openclaw/tests/plugin-impl-access.test.ts b/apps/memos-local-openclaw/tests/plugin-impl-access.test.ts
new file mode 100644
index 000000000..41951a04d
--- /dev/null
+++ b/apps/memos-local-openclaw/tests/plugin-impl-access.test.ts
@@ -0,0 +1,126 @@
+import { describe, it, expect, beforeEach, afterEach } from "vitest";
+import * as fs from "fs";
+import * as path from "path";
+import * as os from "os";
+import plugin from "../plugin-impl";
+
+function makeApi(stateDir: string) {
+ const tools = new Map();
+ const events = new Map();
+ let service: any;
+
+ const api = {
+ pluginConfig: {},
+ resolvePath(input: string) {
+ return input === "~/.openclaw" ? stateDir : input;
+ },
+ logger: {
+ info: () => {},
+ warn: () => {},
+ },
+ registerTool(def: any) {
+ tools.set(def.name, def);
+ },
+ registerService(def: any) {
+ service = def;
+ },
+ on(eventName: string, handler: Function) {
+ events.set(eventName, handler);
+ },
+ } as any;
+
+ plugin.register(api);
+
+ return { tools, events, service };
+}
+
+async function waitFor(predicate: () => Promise | boolean, timeoutMs = 8000) {
+ const start = Date.now();
+ while (Date.now() - start < timeoutMs) {
+ if (await predicate()) return;
+ await new Promise((resolve) => setTimeout(resolve, 100));
+ }
+ throw new Error("Timed out waiting for condition");
+}
+
+describe("plugin-impl owner isolation", () => {
+ let tmpDir: string;
+ let tools: Map;
+ let events: Map;
+ let service: any;
+
+ beforeEach(async () => {
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "memos-plugin-impl-access-"));
+ ({ tools, events, service } = makeApi(tmpDir));
+
+ const agentEnd = events.get("agent_end")!;
+
+ await agentEnd({
+ success: true,
+ agentId: "alpha",
+ sessionKey: "alpha-session",
+ messages: [
+ { role: "user", content: "alpha private marker deployment guide" },
+ { role: "assistant", content: "alpha private marker response" },
+ ],
+ });
+
+ await agentEnd({
+ success: true,
+ agentId: "beta",
+ sessionKey: "beta-session",
+ messages: [
+ { role: "user", content: "beta private marker rollback guide" },
+ { role: "assistant", content: "beta private marker response" },
+ ],
+ });
+
+ const publicWrite = tools.get("memory_write_public");
+ await publicWrite.execute("call-public", { content: "shared public marker convention" }, { agentId: "alpha" });
+
+ const search = tools.get("memory_search");
+ await waitFor(async () => {
+ const result = await search.execute("call-search", { query: "alpha private marker", maxResults: 5, minScore: 0.1 }, { agentId: "alpha" });
+ return (result?.details?.hits?.length ?? 0) > 0;
+ });
+ });
+
+ afterEach(() => {
+ service?.stop?.();
+ fs.rmSync(tmpDir, { recursive: true, force: true });
+ });
+
+ it("memory_search should scope results by agentId", async () => {
+ const search = tools.get("memory_search");
+
+ const alpha = await search.execute("call-search", { query: "alpha private marker", maxResults: 5, minScore: 0.1 }, { agentId: "alpha" });
+ const beta = await search.execute("call-search", { query: "alpha private marker", maxResults: 5, minScore: 0.1 }, { agentId: "beta" });
+ const publicHit = await search.execute("call-search", { query: "shared public marker", maxResults: 5, minScore: 0.1 }, { agentId: "beta" });
+
+ expect(alpha.details.hits.length).toBeGreaterThan(0);
+ expect(beta.details?.hits ?? []).toEqual([]);
+ expect(publicHit.details.hits.length).toBeGreaterThan(0);
+ });
+
+ it("memory_timeline should not leak another agent's private neighbors", async () => {
+ const search = tools.get("memory_search");
+ const timeline = tools.get("memory_timeline");
+
+ const alpha = await search.execute("call-search", { query: "alpha private marker", maxResults: 5, minScore: 0.1 }, { agentId: "alpha" });
+ const ref = alpha.details.hits[0].ref;
+ const betaTimeline = await timeline.execute("call-timeline", ref, { agentId: "beta" });
+
+ expect(betaTimeline.details.entries).toEqual([]);
+ });
+
+ it("memory_get should not return another agent's private chunk", async () => {
+ const search = tools.get("memory_search");
+ const getTool = tools.get("memory_get");
+
+ const alpha = await search.execute("call-search", { query: "alpha private marker", maxResults: 5, minScore: 0.1 }, { agentId: "alpha" });
+ const ref = alpha.details.hits[0].ref;
+ const betaGet = await getTool.execute("call-get", { chunkId: ref.chunkId }, { agentId: "beta" });
+
+ expect(betaGet.details.error).toBe("not_found");
+ });
+});
diff --git a/apps/memos-local-openclaw/tests/policy.test.ts b/apps/memos-local-openclaw/tests/policy.test.ts
new file mode 100644
index 000000000..3e135f951
--- /dev/null
+++ b/apps/memos-local-openclaw/tests/policy.test.ts
@@ -0,0 +1,283 @@
+/**
+ * Policy test suite — 10 test cases verifying the retrieval strategy:
+ *
+ * 1. Simple math → NO search needed
+ * 2. Creative writing → NO search needed
+ * 3. General knowledge → NO search needed
+ * 4. Recall history → search SHOULD return results
+ * 5. memory_viewer tool → returns URL
+ * 6. System prompt NOT stored in memory
+ * 7. Conversation content correctly written (no instruction leakage)
+ * 8. Reference to past discussion → search returns relevant hits
+ * 9. Context-sufficient scenario → search still returns (engine validates)
+ * 10. Search results include evidence (original_excerpt)
+ */
+
+import { describe, it, expect, beforeAll, afterAll } from "vitest";
+import * as fs from "fs";
+import * as path from "path";
+import * as os from "os";
+import { initPlugin, type MemosLocalPlugin } from "../src/index";
+import { captureMessages } from "../src/capture";
+
+let plugin: MemosLocalPlugin;
+let tmpDir: string;
+
+const noopLog = {
+ debug: () => {},
+ info: () => {},
+ warn: () => {},
+ error: () => {},
+};
+
+beforeAll(async () => {
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "memos-policy-"));
+ plugin = initPlugin({
+ stateDir: tmpDir,
+ config: {
+ embedding: {
+ provider: "openai_compatible" as any,
+ endpoint: "https://cloud.infini-ai.com/AIStudio/inference/api/if-dchmmprfd5jlyvsa/v1",
+ apiKey: "sk-g3k5fclhdufjlzr3",
+ model: "bge-embedding-m3",
+ },
+ },
+ log: noopLog,
+ });
+
+ // Seed diverse conversation history
+ plugin.onConversationTurn([
+ { role: "user", content: "帮我把API服务部署到8443端口,用Docker Compose。" },
+ { role: "assistant", content: "好的,我用 docker compose -f docker-compose.prod.yml up -d 来部署。确保防火墙开放了8443端口。" },
+ ], "session-deploy");
+
+ plugin.onConversationTurn([
+ { role: "user", content: "我们用Next.js 14做前端,App Router架构,主页在app/page.tsx,数据从/api/dashboard获取。" },
+ { role: "assistant", content: "Next.js 14的App Router默认使用Server Components。你的/api/dashboard路由应该放在 app/api/dashboard/route.ts。" },
+ ], "session-frontend");
+
+ plugin.onConversationTurn([
+ { role: "user", content: "构建报错了:Error: Module not found: Can't resolve '@/components/Chart'。tsconfig的路径别名配错了。" },
+ { role: "assistant", content: "tsconfig.json里的paths需要配置 \"@/*\": [\"./src/*\"]。" },
+ ], "session-frontend");
+
+ plugin.onConversationTurn([
+ { role: "user", content: "数据库密码配置在.env里的POSTGRES_PASSWORD变量中,Nginx反向代理配在/etc/nginx/conf.d/api.conf。" },
+ { role: "assistant", content: "收到。记住不要把.env提交到Git。Nginx配置建议加上rate limiting和SSL。" },
+ ], "session-deploy");
+
+ plugin.onConversationTurn([
+ { role: "user", content: "帮我写一首关于春天的诗" },
+ { role: "assistant", content: "春风拂柳绿,细雨润花红。燕来衔新泥,蝶舞满园中。" },
+ ], "session-misc");
+
+ await plugin.flush();
+}, 120_000);
+
+afterAll(() => {
+ plugin.shutdown();
+ fs.rmSync(tmpDir, { recursive: true, force: true });
+});
+
+// ─── Test 1: Simple math should NOT need search ───
+describe("用例1: 简单数学题不需要搜索", () => {
+ it("search for '1+1' returns low-relevance hits (none about deployment or frontend)", async () => {
+ const search = plugin.tools.find((t) => t.name === "memory_search")!;
+ const result = (await search.handler({ query: "1+1等于几", maxResults: 6, minScore: 0.45 })) as any;
+ // Even if engine returns hits, they should be semantically irrelevant to math
+ for (const hit of result.hits) {
+ const text = (hit.original_excerpt ?? "").toLowerCase();
+ expect(text).not.toContain("1+1");
+ }
+ });
+});
+
+// ─── Test 2: Creative writing should NOT need search ───
+describe("用例2: 创意写作不需要搜索", () => {
+ it("search for '写诗关于大海' returns low-relevance noise, not targeted matches", async () => {
+ const search = plugin.tools.find((t) => t.name === "memory_search")!;
+ const result = (await search.handler({ query: "写一首关于大海的五言绝句" })) as any;
+ // The engine may return noise from a small corpus, but in a real
+ // scenario the LLM would recognise these as irrelevant and skip search.
+ // Verify the engine still functions and doesn't crash on unrelated queries.
+ expect(result.meta.usedMinScore).toBe(0.45);
+ // Top hit (if any) gets score=1 after normalisation — that's expected.
+ // The key assertion: totalCandidates should be low for an off-topic query.
+ if (result.hits.length > 0) {
+ expect(result.meta.totalCandidates).toBeLessThanOrEqual(30);
+ }
+ });
+});
+
+// ─── Test 3: General knowledge should NOT need search ───
+describe("用例3: 通用知识不需要搜索", () => {
+ it("search for '法国首都' returns noise from small corpus but engine works", async () => {
+ const search = plugin.tools.find((t) => t.name === "memory_search")!;
+ const result = (await search.handler({ query: "法国的首都是哪里" })) as any;
+ // With only ~10 chunks in the test DB, every query hits something.
+ // Verify structure is correct — in production the LLM policy prevents
+ // unnecessary search calls, not the engine itself.
+ expect(result.meta).toBeDefined();
+ expect(result.meta.usedMinScore).toBe(0.45);
+ if (result.hits.length > 0) {
+ expect(result.hits[0].original_excerpt).toBeTruthy();
+ expect(result.hits[0].score).toBeLessThanOrEqual(1);
+ }
+ });
+});
+
+// ─── Test 4: Recall history → SHOULD return search results ───
+describe("用例4: 回忆历史对话应返回搜索结果", () => {
+ it("search for deployment history returns multiple hits", async () => {
+ const search = plugin.tools.find((t) => t.name === "memory_search")!;
+ const result = (await search.handler({ query: "docker compose 部署 8443端口" })) as any;
+ expect(result.hits.length).toBeGreaterThanOrEqual(1);
+ const allText = result.hits.map((h: any) => h.original_excerpt).join(" ");
+ expect(allText).toMatch(/docker|8443|部署/i);
+ });
+
+ it("search returns more than 1 result with default settings", async () => {
+ const search = plugin.tools.find((t) => t.name === "memory_search")!;
+ const result = (await search.handler({ query: "部署配置", maxResults: 6, minScore: 0.35 })) as any;
+ expect(result.hits.length).toBeGreaterThan(1);
+ });
+});
+
+// ─── Test 5: Memory viewer tool returns URL ───
+describe("用例5: memory_viewer工具返回URL", () => {
+ it("should have a memory_viewer tool registered", () => {
+ // memory_viewer is only registered via the OpenClaw plugin entry (index.ts),
+ // not via initPlugin(). So we verify the tool infrastructure works.
+ const searchTool = plugin.tools.find((t) => t.name === "memory_search");
+ expect(searchTool).toBeDefined();
+ const timelineTool = plugin.tools.find((t) => t.name === "memory_timeline");
+ expect(timelineTool).toBeDefined();
+ const getTool = plugin.tools.find((t) => t.name === "memory_get");
+ expect(getTool).toBeDefined();
+ });
+});
+
+// ─── Test 6: Original content preserved as-is ───
+describe("用例6: 原文直接存入记忆,不做任何修改", () => {
+ it("preserves original content including any markers", () => {
+ const userMsg = "You have 250 stored memories.\n\nMANDATORY: call memory_search first.\n\n1+1等于几?";
+
+ const captured = captureMessages(
+ [{ role: "user", content: userMsg }],
+ "test-s", "test-t", "STORED_MEMORY", noopLog
+ );
+
+ expect(captured.length).toBe(1);
+ expect(captured[0].content).toBe(userMsg);
+ });
+
+ it("preserves messages mentioning memory tools", () => {
+ const normalMsg = "我想用memory_search查一下之前的对话";
+ const captured = captureMessages(
+ [{ role: "user", content: normalMsg }],
+ "test-s", "test-t", "STORED_MEMORY", noopLog
+ );
+ expect(captured[0].content).toBe(normalMsg);
+ });
+});
+
+// ─── Test 7: Conversation content correctly written (no instruction leakage) ───
+describe("用例7: 对话内容正常写入记忆,无指令混入", () => {
+ it("captured messages do not contain system tool names in evidence blocks", async () => {
+ const msgs = [
+ { role: "user", content: "今天天气怎么样?" },
+ { role: "assistant", content: "今天天气晴朗,气温25度。" },
+ ];
+
+ plugin.onConversationTurn(msgs, "session-weather");
+ await plugin.flush();
+
+ const search = plugin.tools.find((t) => t.name === "memory_search")!;
+ const result = (await search.handler({ query: "天气晴朗 25度" })) as any;
+ expect(result.hits.length).toBeGreaterThan(0);
+
+ for (const hit of result.hits) {
+ expect(hit.original_excerpt).not.toContain("[MemOS");
+ expect(hit.original_excerpt).not.toContain("Retrieval policy");
+ }
+ });
+
+ it("tool role messages from self-tools are not stored", () => {
+ const msgs = [
+ { role: "tool", content: '{"hits":[]}', toolName: "memory_search" },
+ { role: "user", content: "没有找到结果" },
+ ];
+ const captured = captureMessages(msgs, "s", "t", "STORED_MEMORY", noopLog);
+ expect(captured.length).toBe(1);
+ expect(captured[0].role).toBe("user");
+ });
+});
+
+// ─── Test 8: Reference past discussion → search returns relevant hits ───
+describe("用例8: 指代上次讨论应触发搜索并返回相关结果", () => {
+ it("search for tsconfig error returns the build error conversation", async () => {
+ const search = plugin.tools.find((t) => t.name === "memory_search")!;
+ const result = (await search.handler({ query: "tsconfig 路径别名 Module not found Chart" })) as any;
+ expect(result.hits.length).toBeGreaterThan(0);
+
+ const allText = result.hits.map((h: any) => h.original_excerpt).join(" ");
+ expect(allText).toMatch(/Chart|tsconfig|Module not found/i);
+ });
+
+ it("search for nginx config returns deployment details", async () => {
+ const search = plugin.tools.find((t) => t.name === "memory_search")!;
+ const result = (await search.handler({ query: "Nginx反向代理配置" })) as any;
+ expect(result.hits.length).toBeGreaterThan(0);
+
+ const allText = result.hits.map((h: any) => h.original_excerpt).join(" ");
+ expect(allText).toMatch(/nginx|Nginx|反向代理/i);
+ });
+});
+
+// ─── Test 9: Score filtering returns multiple results, not just 1 ───
+describe("用例9: minScore过滤不会只返回1条结果", () => {
+ it("broad query returns multiple hits with default minScore", async () => {
+ const search = plugin.tools.find((t) => t.name === "memory_search")!;
+ const result = (await search.handler({ query: "部署服务配置" })) as any;
+ expect(result.hits.length).toBeGreaterThan(1);
+ });
+
+ it("very low minScore returns more results", async () => {
+ const search = plugin.tools.find((t) => t.name === "memory_search")!;
+ const result = (await search.handler({ query: "部署", minScore: 0.1 })) as any;
+ expect(result.hits.length).toBeGreaterThanOrEqual(2);
+ });
+});
+
+// ─── Test 10: Search results include evidence (original_excerpt) ───
+describe("用例10: 搜索结果包含可引用的证据原文", () => {
+ it("each hit has non-empty original_excerpt, summary, score, ref, source", async () => {
+ const search = plugin.tools.find((t) => t.name === "memory_search")!;
+ const result = (await search.handler({ query: "docker compose 部署" })) as any;
+ expect(result.hits.length).toBeGreaterThan(0);
+
+ for (const hit of result.hits) {
+ expect(hit.original_excerpt).toBeTruthy();
+ expect(hit.original_excerpt.length).toBeGreaterThan(10);
+ expect(hit.summary).toBeTruthy();
+ expect(hit.score).toBeGreaterThan(0);
+ expect(hit.score).toBeLessThanOrEqual(1);
+ expect(hit.ref).toBeDefined();
+ expect(hit.ref.chunkId).toBeTruthy();
+ expect(hit.ref.sessionKey).toBeTruthy();
+ expect(hit.source).toBeDefined();
+ expect(hit.source.ts).toBeGreaterThan(0);
+ expect(hit.source.role).toMatch(/^(user|assistant|tool)$/);
+ }
+ });
+
+ it("original_excerpt contains actual conversation content, not instructions", async () => {
+ const search = plugin.tools.find((t) => t.name === "memory_search")!;
+ const result = (await search.handler({ query: "Next.js App Router" })) as any;
+ expect(result.hits.length).toBeGreaterThan(0);
+
+ const topHit = result.hits[0];
+ expect(topHit.original_excerpt).toMatch(/Next\.js|App Router|page\.tsx|dashboard/i);
+ expect(topHit.original_excerpt).not.toContain("Retrieval policy");
+ });
+});
diff --git a/apps/memos-local-openclaw/tests/recall.test.ts b/apps/memos-local-openclaw/tests/recall.test.ts
new file mode 100644
index 000000000..4aff78cde
--- /dev/null
+++ b/apps/memos-local-openclaw/tests/recall.test.ts
@@ -0,0 +1,79 @@
+import { describe, it, expect } from "vitest";
+import { rrfFuse } from "../src/recall/rrf";
+import { applyRecencyDecay } from "../src/recall/recency";
+
+describe("rrfFuse", () => {
+ it("should merge two ranked lists via RRF", () => {
+ const list1 = [
+ { id: "a", score: 0.9 },
+ { id: "b", score: 0.8 },
+ { id: "c", score: 0.7 },
+ ];
+ const list2 = [
+ { id: "b", score: 0.95 },
+ { id: "a", score: 0.85 },
+ { id: "d", score: 0.6 },
+ ];
+
+ const scores = rrfFuse([list1, list2], 60);
+
+ expect(scores.has("a")).toBe(true);
+ expect(scores.has("b")).toBe(true);
+ expect(scores.has("c")).toBe(true);
+ expect(scores.has("d")).toBe(true);
+
+ // b appears at rank 1 in list1 and rank 0 in list2 → highest combined
+ // a appears at rank 0 in list1 and rank 1 in list2
+ // Both should have equal RRF scores since rank(a,l1)=0,rank(a,l2)=1 same as rank(b,l1)=1,rank(b,l2)=0
+ expect(scores.get("a")).toBeCloseTo(scores.get("b")!, 6);
+ });
+
+ it("should handle empty lists", () => {
+ const scores = rrfFuse([[], []], 60);
+ expect(scores.size).toBe(0);
+ });
+
+ it("should handle single list", () => {
+ const list = [{ id: "x", score: 1 }];
+ const scores = rrfFuse([list], 60);
+ expect(scores.has("x")).toBe(true);
+ expect(scores.get("x")).toBeCloseTo(1 / 61, 6);
+ });
+});
+
+describe("applyRecencyDecay", () => {
+ it("should give higher scores to recent items", () => {
+ const now = Date.now();
+ const candidates = [
+ { id: "recent", score: 1.0, createdAt: now - 1 * 24 * 3600_000 },
+ { id: "old", score: 1.0, createdAt: now - 30 * 24 * 3600_000 },
+ ];
+
+ const result = applyRecencyDecay(candidates, 14, now);
+ const recent = result.find((r) => r.id === "recent")!;
+ const old = result.find((r) => r.id === "old")!;
+
+ expect(recent.score).toBeGreaterThan(old.score);
+ });
+
+ it("should not zero out old items (alpha floor)", () => {
+ const now = Date.now();
+ const candidates = [
+ { id: "ancient", score: 1.0, createdAt: now - 365 * 24 * 3600_000 },
+ ];
+
+ const result = applyRecencyDecay(candidates, 14, now);
+ expect(result[0].score).toBeGreaterThan(0.2);
+ });
+
+ it("should preserve relative ordering when all same age", () => {
+ const now = Date.now();
+ const candidates = [
+ { id: "a", score: 0.9, createdAt: now },
+ { id: "b", score: 0.5, createdAt: now },
+ ];
+
+ const result = applyRecencyDecay(candidates, 14, now);
+ expect(result[0].score).toBeGreaterThan(result[1].score);
+ });
+});
diff --git a/apps/memos-local-openclaw/tests/shutdown-lifecycle.test.ts b/apps/memos-local-openclaw/tests/shutdown-lifecycle.test.ts
new file mode 100644
index 000000000..fd523b776
--- /dev/null
+++ b/apps/memos-local-openclaw/tests/shutdown-lifecycle.test.ts
@@ -0,0 +1,116 @@
+import { describe, it, expect, vi, afterEach } from "vitest";
+
+const noopLog = {
+ debug: () => {},
+ info: () => {},
+ warn: () => {},
+ error: () => {},
+};
+
+afterEach(() => {
+ vi.resetModules();
+ vi.clearAllMocks();
+});
+
+describe("shutdown lifecycle", () => {
+ it("initPlugin.shutdown should wait for worker.flush before closing the store", async () => {
+ const events: string[] = [];
+ let release!: () => void;
+ const gate = new Promise((resolve) => {
+ release = resolve;
+ });
+
+ class MockStore {
+ close(): void {
+ events.push("close");
+ }
+ }
+
+ class MockWorker {
+ enqueue(): void {}
+ flush(): Promise {
+ events.push("flush");
+ return gate;
+ }
+ }
+
+ vi.doMock("../src/storage/sqlite", () => ({ SqliteStore: MockStore }));
+ vi.doMock("../src/ingest/worker", () => ({ IngestWorker: MockWorker }));
+ vi.doMock("../src/embedding", () => ({ Embedder: class { provider = "mock"; } }));
+ vi.doMock("../src/recall/engine", () => ({ RecallEngine: class {} }));
+ vi.doMock("../src/capture", () => ({ captureMessages: () => [] }));
+ vi.doMock("../src/tools", () => ({
+ createMemorySearchTool: () => ({ name: "memory_search" }),
+ createMemoryTimelineTool: () => ({ name: "memory_timeline" }),
+ createMemoryGetTool: () => ({ name: "memory_get" }),
+ }));
+
+ const { initPlugin } = await import("../src/index");
+ const plugin = initPlugin({ stateDir: "/tmp/memos-shutdown-test", log: noopLog as any });
+
+ const shutdownPromise = Promise.resolve(plugin.shutdown() as any);
+ expect(events).toEqual(["flush"]);
+
+ release();
+ await shutdownPromise;
+ expect(events).toEqual(["flush", "close"]);
+ });
+
+ it("plugin service stop should wait for worker.flush before closing the store", async () => {
+ const events: string[] = [];
+ let release!: () => void;
+ const gate = new Promise((resolve) => {
+ release = resolve;
+ });
+
+ class MockStore {
+ close(): void {
+ events.push("close");
+ }
+ }
+
+ class MockWorker {
+ enqueue(): void {}
+ flush(): Promise {
+ events.push("flush");
+ return gate;
+ }
+ }
+
+ class MockViewer {
+ async start(): Promise { return "http://127.0.0.1:18799"; }
+ stop(): void { events.push("viewer-stop"); }
+ getResetToken(): string { return "token"; }
+ }
+
+ let registeredService: { stop: () => Promise | void } | undefined;
+
+ vi.doMock("../src/storage/sqlite", () => ({ SqliteStore: MockStore }));
+ vi.doMock("../src/ingest/worker", () => ({ IngestWorker: MockWorker }));
+ vi.doMock("../src/embedding", () => ({ Embedder: class { provider = "mock"; } }));
+ vi.doMock("../src/recall/engine", () => ({ RecallEngine: class { async search() { return { hits: [], meta: {} }; } async searchSkills() { return []; } } }));
+ vi.doMock("../src/capture", () => ({ captureMessages: () => [] }));
+ vi.doMock("../src/viewer/server", () => ({ ViewerServer: MockViewer }));
+
+ const pluginModule = await import("../plugin-impl");
+ const plugin = pluginModule.default;
+ plugin.register({
+ pluginConfig: {},
+ resolvePath: () => "/tmp/memos-service-stop",
+ logger: noopLog,
+ registerTool: () => {},
+ registerService: (service: any) => { registeredService = service; },
+ on: () => {},
+ } as any);
+
+ expect(registeredService).toBeDefined();
+ const stopPromise = Promise.resolve(registeredService!.stop() as any);
+ expect(events).toContain("flush");
+ expect(events).not.toContain("close");
+
+ release();
+ await stopPromise;
+ expect(events).toContain("viewer-stop");
+ expect(events[events.length - 1]).toBe("close");
+ });
+});
diff --git a/apps/memos-local-openclaw/tests/storage.test.ts b/apps/memos-local-openclaw/tests/storage.test.ts
new file mode 100644
index 000000000..fa919e0d8
--- /dev/null
+++ b/apps/memos-local-openclaw/tests/storage.test.ts
@@ -0,0 +1,185 @@
+import { describe, it, expect, beforeEach, afterEach } from "vitest";
+import * as fs from "fs";
+import * as path from "path";
+import * as os from "os";
+import { SqliteStore } from "../src/storage/sqlite";
+import { cosineSimilarity, vectorSearch } from "../src/storage/vector";
+import type { Chunk, Logger } from "../src/types";
+
+const noopLog: Logger = {
+ debug: () => {},
+ info: () => {},
+ warn: () => {},
+ error: () => {},
+};
+
+let store: SqliteStore;
+let tmpDir: string;
+
+beforeEach(() => {
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "memos-test-"));
+ store = new SqliteStore(path.join(tmpDir, "test.db"), noopLog);
+});
+
+afterEach(() => {
+ store.close();
+ fs.rmSync(tmpDir, { recursive: true, force: true });
+});
+
+function makeChunk(overrides: Partial = {}): Chunk {
+ return {
+ id: overrides.id ?? "chunk-1",
+ sessionKey: "session-1",
+ turnId: "turn-1",
+ seq: 0,
+ role: "user",
+ content: "Hello world",
+ kind: "paragraph",
+ summary: "Greeting message",
+ embedding: null,
+ taskId: null,
+ skillId: null,
+ owner: "agent:main",
+ dedupStatus: "active",
+ dedupTarget: null,
+ dedupReason: null,
+ mergeCount: 0,
+ lastHitAt: null,
+ mergeHistory: "[]",
+ createdAt: Date.now(),
+ updatedAt: Date.now(),
+ ...overrides,
+ };
+}
+
+describe("SqliteStore", () => {
+ it("should insert and retrieve a chunk", () => {
+ const chunk = makeChunk();
+ store.insertChunk(chunk);
+
+ const retrieved = store.getChunk("chunk-1");
+ expect(retrieved).not.toBeNull();
+ expect(retrieved!.content).toBe("Hello world");
+ expect(retrieved!.summary).toBe("Greeting message");
+ });
+
+ it("should update summary", () => {
+ store.insertChunk(makeChunk());
+ store.updateSummary("chunk-1", "Updated summary");
+
+ const retrieved = store.getChunk("chunk-1");
+ expect(retrieved!.summary).toBe("Updated summary");
+ });
+
+ it("should store and retrieve embeddings", () => {
+ store.insertChunk(makeChunk());
+ const vec = [0.1, 0.2, 0.3, 0.4, 0.5];
+ store.upsertEmbedding("chunk-1", vec);
+
+ const retrieved = store.getEmbedding("chunk-1");
+ expect(retrieved).not.toBeNull();
+ expect(retrieved!).toHaveLength(5);
+ expect(retrieved![0]).toBeCloseTo(0.1, 5);
+ });
+
+ it("should perform FTS search", () => {
+ store.insertChunk(makeChunk({ id: "c1", content: "Deploy the application to production", summary: "Deployment instructions" }));
+ store.insertChunk(makeChunk({ id: "c2", content: "The cat sat on the mat", summary: "Cat story" }));
+
+ const results = store.ftsSearch("deploy production", 10);
+ expect(results.length).toBeGreaterThanOrEqual(1);
+ expect(results[0].chunkId).toBe("c1");
+ });
+
+ it("should handle FTS with special characters gracefully", () => {
+ store.insertChunk(makeChunk({ id: "c1", content: "Hello world", summary: "test" }));
+
+ const results = store.ftsSearch('hello "world" (test) OR NOT', 10);
+ expect(Array.isArray(results)).toBe(true);
+ });
+
+ it("should get neighbor chunks", () => {
+ const now = Date.now();
+ store.insertChunk(makeChunk({ id: "c1", turnId: "t1", seq: 0, createdAt: now }));
+ store.insertChunk(makeChunk({ id: "c2", turnId: "t1", seq: 1, createdAt: now + 1 }));
+ store.insertChunk(makeChunk({ id: "c3", turnId: "t2", seq: 0, createdAt: now + 2 }));
+ store.insertChunk(makeChunk({ id: "c4", turnId: "t2", seq: 1, createdAt: now + 3 }));
+
+ const neighbors = store.getNeighborChunks("session-1", "t1", 1, 2);
+ expect(neighbors.length).toBeGreaterThanOrEqual(2);
+ });
+
+ it("getRecentEmbeddings returns at most limit rows ordered by created_at DESC", () => {
+ const base = Date.now() - 5000;
+ for (let i = 0; i < 5; i++) {
+ store.insertChunk(makeChunk({ id: `chunk-${i}`, createdAt: base + i * 1000 }));
+ store.upsertEmbedding(`chunk-${i}`, [0.1 * (i + 1), 0.2, 0.3]);
+ }
+ const all = store.getAllEmbeddings();
+ expect(all.length).toBe(5);
+
+ const recent2 = store.getRecentEmbeddings(2);
+ expect(recent2.length).toBe(2);
+ expect(recent2.map((r) => r.chunkId).sort()).toEqual(["chunk-3", "chunk-4"].sort());
+ });
+
+ it("getRecentEmbeddings(0) returns all embeddings", () => {
+ store.insertChunk(makeChunk({ id: "a", createdAt: Date.now() }));
+ store.upsertEmbedding("a", [0.1, 0.2, 0.3]);
+ const recent0 = store.getRecentEmbeddings(0);
+ expect(recent0.length).toBe(1);
+ });
+});
+
+describe("vectorSearch", () => {
+ const noopLog: Logger = {
+ debug: () => {},
+ info: () => {},
+ warn: () => {},
+ error: () => {},
+ };
+ let store: SqliteStore;
+ let tmpDir: string;
+
+ beforeEach(() => {
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "memos-vec-"));
+ store = new SqliteStore(path.join(tmpDir, "test.db"), noopLog);
+ });
+ afterEach(() => {
+ store.close();
+ fs.rmSync(tmpDir, { recursive: true, force: true });
+ });
+
+ it("with maxChunks limits search to recent N chunks", () => {
+ const base = Date.now() - 5000;
+ const dims = 4;
+ for (let i = 0; i < 4; i++) {
+ store.insertChunk(makeChunk({ id: `c${i}`, createdAt: base + i * 1000 }));
+ const vec = new Array(dims).fill(0).map((_, j) => (i === 2 && j === 0 ? 1 : 0.1));
+ store.upsertEmbedding(`c${i}`, vec);
+ }
+ const queryVec = [1, 0, 0, 0];
+ const allHits = vectorSearch(store, queryVec, 10);
+ expect(allHits.length).toBe(4);
+
+ const cappedHits = vectorSearch(store, queryVec, 10, 2);
+ expect(cappedHits.length).toBeLessThanOrEqual(2);
+ const cappedIds = new Set(cappedHits.map((h) => h.chunkId));
+ expect(cappedIds.size).toBeLessThanOrEqual(2);
+ });
+});
+
+describe("cosineSimilarity", () => {
+ it("should return 1 for identical vectors", () => {
+ const v = [0.1, 0.2, 0.3];
+ expect(cosineSimilarity(v, v)).toBeCloseTo(1.0, 5);
+ });
+
+ it("should return 0 for orthogonal vectors", () => {
+ expect(cosineSimilarity([1, 0], [0, 1])).toBeCloseTo(0.0, 5);
+ });
+
+ it("should handle zero vectors", () => {
+ expect(cosineSimilarity([0, 0], [1, 1])).toBe(0);
+ });
+});
diff --git a/apps/memos-local-openclaw/tests/task-processor.test.ts b/apps/memos-local-openclaw/tests/task-processor.test.ts
new file mode 100644
index 000000000..60e2cf4dc
--- /dev/null
+++ b/apps/memos-local-openclaw/tests/task-processor.test.ts
@@ -0,0 +1,481 @@
+import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
+import * as fs from "fs";
+import * as path from "path";
+import * as os from "os";
+import { SqliteStore } from "../src/storage/sqlite";
+import { TaskProcessor } from "../src/ingest/task-processor";
+import { Summarizer } from "../src/ingest/providers";
+import type { Chunk, Logger, PluginContext } from "../src/types";
+
+const noopLog: Logger = {
+ debug: () => {},
+ info: () => {},
+ warn: () => {},
+ error: () => {},
+};
+
+let store: SqliteStore;
+let tmpDir: string;
+let processor: TaskProcessor;
+
+function makeCtx(): PluginContext {
+ return {
+ stateDir: tmpDir,
+ workspaceDir: tmpDir,
+ config: {
+ storage: { dbPath: path.join(tmpDir, "test.db") },
+ recall: {
+ maxResultsDefault: 6,
+ maxResultsMax: 20,
+ minScoreDefault: 0.45,
+ minScoreFloor: 0.35,
+ rrfK: 60,
+ mmrLambda: 0.7,
+ recencyHalfLifeDays: 14,
+ },
+ },
+ log: noopLog,
+ };
+}
+
+function insertTestChunk(overrides: Partial & { id: string }): void {
+ store.insertChunk({
+ sessionKey: "session-1",
+ turnId: "turn-1",
+ seq: 0,
+ role: "user",
+ content: "test content",
+ kind: "paragraph",
+ summary: "test summary",
+ embedding: null,
+ taskId: null,
+ skillId: null,
+ dedupStatus: "active",
+ dedupTarget: null,
+ dedupReason: null,
+ mergeCount: 0,
+ lastHitAt: null,
+ mergeHistory: "[]",
+ createdAt: Date.now(),
+ updatedAt: Date.now(),
+ ...overrides,
+ });
+}
+
+beforeEach(() => {
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "memos-task-test-"));
+ store = new SqliteStore(path.join(tmpDir, "test.db"), noopLog);
+ processor = new TaskProcessor(store, makeCtx());
+});
+
+afterEach(() => {
+ store.close();
+ fs.rmSync(tmpDir, { recursive: true, force: true });
+});
+
+describe("TaskProcessor", () => {
+ it("should drain queued onChunksIngested calls instead of dropping them while busy", async () => {
+ const calls: string[] = [];
+ let releaseFirst!: () => void;
+ const firstGate = new Promise((resolve) => {
+ releaseFirst = resolve;
+ });
+
+ const detectSpy = vi.spyOn(processor as any, "detectAndProcess").mockImplementation(async (sessionKey: string) => {
+ calls.push(sessionKey);
+ if (calls.length === 1) {
+ await firstGate;
+ }
+ });
+
+ const first = processor.onChunksIngested("s1", 1, "agent:main");
+ await Promise.resolve();
+ const second = processor.onChunksIngested("s2", 2, "agent:main");
+
+ expect(detectSpy).toHaveBeenCalledTimes(1);
+
+ releaseFirst();
+ await Promise.all([first, second]);
+
+ expect(calls).toEqual(["s1", "s2"]);
+ });
+
+ it("should create a new task when none exists", async () => {
+ const now = Date.now();
+ insertTestChunk({ id: "c1", sessionKey: "s1", createdAt: now });
+
+ await processor.onChunksIngested("s1", now);
+
+ const task = store.getActiveTask("s1");
+ expect(task).not.toBeNull();
+ expect(task!.status).toBe("active");
+ expect(task!.sessionKey).toBe("s1");
+
+ const chunk = store.getChunk("c1");
+ expect(chunk!.taskId).toBe(task!.id);
+ });
+
+ it("should assign multiple chunks to the same task within timeout", async () => {
+ const now = Date.now();
+ insertTestChunk({ id: "c1", sessionKey: "s1", createdAt: now });
+ await processor.onChunksIngested("s1", now);
+
+ insertTestChunk({ id: "c2", sessionKey: "s1", createdAt: now + 1000 });
+ await processor.onChunksIngested("s1", now + 1000);
+
+ const task = store.getActiveTask("s1");
+ const c1 = store.getChunk("c1");
+ const c2 = store.getChunk("c2");
+ expect(c1!.taskId).toBe(task!.id);
+ expect(c2!.taskId).toBe(task!.id);
+ });
+
+ it("should detect task boundary when time gap exceeds timeout", async () => {
+ const now = Date.now();
+ const overTwoHours = 121 * 60 * 1000; // 2h 1min > 2h timeout
+
+ insertTestChunk({ id: "c1", sessionKey: "s1", content: "First task content", createdAt: now });
+ await processor.onChunksIngested("s1", now);
+
+ const firstTask = store.getActiveTask("s1");
+ expect(firstTask).not.toBeNull();
+ const firstTaskId = firstTask!.id;
+
+ insertTestChunk({ id: "c2", sessionKey: "s1", content: "Second task content", createdAt: now + overTwoHours });
+ await processor.onChunksIngested("s1", now + overTwoHours);
+
+ const oldTask = store.getTask(firstTaskId);
+ expect(["completed", "skipped"]).toContain(oldTask!.status);
+
+ const newTask = store.getActiveTask("s1");
+ expect(newTask).not.toBeNull();
+ expect(newTask!.id).not.toBe(firstTaskId);
+
+ const c2 = store.getChunk("c2");
+ expect(c2!.taskId).toBe(newTask!.id);
+ });
+
+ it("should detect task boundary on session change", async () => {
+ const now = Date.now();
+
+ insertTestChunk({ id: "c1", sessionKey: "s1", createdAt: now });
+ await processor.onChunksIngested("s1", now);
+
+ const firstTask = store.getActiveTask("s1");
+ expect(firstTask).not.toBeNull();
+
+ insertTestChunk({ id: "c2", sessionKey: "s2", createdAt: now + 1000 });
+ await processor.onChunksIngested("s2", now + 1000);
+
+ // Session change finalizes old task (completed) and creates new one
+ const oldTask = store.getTask(firstTask!.id);
+ const task2 = store.getActiveTask("s2");
+
+ expect(oldTask).not.toBeNull();
+ expect(["completed", "skipped"]).toContain(oldTask!.status);
+ expect(task2).not.toBeNull();
+ expect(oldTask!.id).not.toBe(task2!.id);
+ });
+
+ it("should generate task title from first user message", async () => {
+ const now = Date.now();
+
+ insertTestChunk({ id: "c1", sessionKey: "s1", role: "user", content: "Deploy the API to production", createdAt: now });
+ await processor.onChunksIngested("s1", now);
+
+ const overTwoHours = 121 * 60 * 1000;
+ insertTestChunk({ id: "c2", sessionKey: "s1", content: "New task", createdAt: now + overTwoHours });
+ await processor.onChunksIngested("s1", now + overTwoHours);
+
+ const chunks = store.getChunksByTask(store.getActiveTask("s1")!.id);
+ expect(chunks).toBeDefined();
+
+ const allTasks = store.getChunksByTask(store.getChunk("c1")!.taskId!);
+ expect(allTasks.length).toBeGreaterThan(0);
+ });
+
+ it("should get chunks by task id", async () => {
+ const now = Date.now();
+ insertTestChunk({ id: "c1", sessionKey: "s1", createdAt: now });
+ insertTestChunk({ id: "c2", sessionKey: "s1", createdAt: now + 100 });
+ await processor.onChunksIngested("s1", now + 100);
+
+ const task = store.getActiveTask("s1");
+ const taskChunks = store.getChunksByTask(task!.id);
+ expect(taskChunks).toHaveLength(2);
+ });
+
+ it("deleteAll should also clear tasks", () => {
+ const now = Date.now();
+ store.insertTask({
+ id: "t1",
+ sessionKey: "s1",
+ title: "Test",
+ summary: "Test summary",
+ status: "active",
+ startedAt: now,
+ endedAt: null,
+ updatedAt: now,
+ });
+ store.deleteAll();
+ expect(store.getTask("t1")).toBeNull();
+ });
+
+ it("should mark task as skipped when only 1 chunk (too few)", async () => {
+ const now = Date.now();
+ const gap = 121 * 60 * 1000;
+
+ insertTestChunk({ id: "c1", sessionKey: "s1", role: "user", content: "hello", createdAt: now });
+ await processor.onChunksIngested("s1", now);
+
+ const firstTaskId = store.getActiveTask("s1")!.id;
+
+ insertTestChunk({ id: "c2", sessionKey: "s1", content: "next task", createdAt: now + gap });
+ await processor.onChunksIngested("s1", now + gap);
+
+ const oldTask = store.getTask(firstTaskId);
+ expect(oldTask!.status).toBe("skipped");
+ expect(oldTask!.summary).toContain("过少");
+ });
+
+ it("should mark task as skipped for trivial test data", async () => {
+ const now = Date.now();
+ const gap = 121 * 60 * 1000;
+
+ insertTestChunk({ id: "t1", sessionKey: "s1", role: "user", content: "test", createdAt: now });
+ insertTestChunk({ id: "t2", sessionKey: "s1", role: "assistant", content: "ok", createdAt: now + 1 });
+ insertTestChunk({ id: "t3", sessionKey: "s1", role: "user", content: "hello", createdAt: now + 2 });
+ insertTestChunk({ id: "t4", sessionKey: "s1", role: "assistant", content: "hi", createdAt: now + 3 });
+ await processor.onChunksIngested("s1", now + 3);
+
+ const firstTaskId = store.getActiveTask("s1")!.id;
+
+ insertTestChunk({ id: "t5", sessionKey: "s1", content: "new task starts", createdAt: now + gap });
+ await processor.onChunksIngested("s1", now + gap);
+
+ const oldTask = store.getTask(firstTaskId);
+ expect(oldTask!.status).toBe("skipped");
+ expect(oldTask!.summary.length).toBeGreaterThan(0);
+ });
+
+ it("should mark task as skipped when dominated by tool results", async () => {
+ const now = Date.now();
+ const gap = 121 * 60 * 1000;
+
+ insertTestChunk({ id: "r1", sessionKey: "s1", role: "user", content: "run the tests please and check the results", createdAt: now });
+ insertTestChunk({ id: "r2", sessionKey: "s1", role: "assistant", content: "Sure, running the tests now with verbose output enabled", createdAt: now + 1 });
+ insertTestChunk({ id: "r3", sessionKey: "s1", role: "tool", content: "Test suite passed: 10 tests, 0 failures, duration 2.3s", createdAt: now + 2 });
+ insertTestChunk({ id: "r4", sessionKey: "s1", role: "tool", content: "Coverage report: 85% statements, 72% branches, 90% functions", createdAt: now + 3 });
+ insertTestChunk({ id: "r5", sessionKey: "s1", role: "tool", content: "Lint check passed: 0 errors, 3 warnings in 12 files scanned", createdAt: now + 4 });
+ insertTestChunk({ id: "r6", sessionKey: "s1", role: "tool", content: "Build output: dist/index.js 45kb, dist/index.css 12kb gzipped", createdAt: now + 5 });
+ insertTestChunk({ id: "r7", sessionKey: "s1", role: "tool", content: "Deploy status: staging environment updated successfully at 10:23 AM", createdAt: now + 6 });
+ await processor.onChunksIngested("s1", now + 6);
+
+ const firstTaskId = store.getActiveTask("s1")!.id;
+
+ insertTestChunk({ id: "r8", sessionKey: "s1", content: "next", createdAt: now + gap });
+ await processor.onChunksIngested("s1", now + gap);
+
+ const oldTask = store.getTask(firstTaskId);
+ expect(oldTask!.status).toBe("skipped");
+ expect(oldTask!.summary.length).toBeGreaterThan(0);
+ });
+
+ it("should mark task as skipped when user repeats the same message", async () => {
+ const now = Date.now();
+ const gap = 121 * 60 * 1000;
+
+ insertTestChunk({ id: "d1", sessionKey: "s1", role: "user", content: "what is my name and who am I please tell me", createdAt: now });
+ insertTestChunk({ id: "d2", sessionKey: "s1", role: "assistant", content: "I do not have any information about your name or identity in my memory at this time", createdAt: now + 1 });
+ insertTestChunk({ id: "d3", sessionKey: "s1", role: "user", content: "what is my name and who am I please tell me", createdAt: now + 2 });
+ insertTestChunk({ id: "d4", sessionKey: "s1", role: "assistant", content: "I still do not have records of your name, could you please tell me who you are", createdAt: now + 3 });
+ insertTestChunk({ id: "d5", sessionKey: "s1", role: "user", content: "what is my name and who am I please tell me", createdAt: now + 4 });
+ insertTestChunk({ id: "d6", sessionKey: "s1", role: "assistant", content: "I apologize but I cannot find your name or identity in my stored conversation memories", createdAt: now + 5 });
+ await processor.onChunksIngested("s1", now + 5);
+
+ const firstTaskId = store.getActiveTask("s1")!.id;
+
+ insertTestChunk({ id: "d7", sessionKey: "s1", content: "new topic now", createdAt: now + gap });
+ await processor.onChunksIngested("s1", now + gap);
+
+ const oldTask = store.getTask(firstTaskId);
+ expect(oldTask!.status).toBe("skipped");
+ expect(oldTask!.summary).toContain("重复");
+ });
+
+ it("should NOT skip summary for tasks with substantial content", async () => {
+ const now = Date.now();
+ const gap = 121 * 60 * 1000;
+
+ insertTestChunk({ id: "s1", sessionKey: "s1", role: "user", content: "I need to deploy the API to port 8443 using Docker compose", createdAt: now });
+ insertTestChunk({ id: "s2", sessionKey: "s1", role: "assistant", content: "Sure, here is how you can deploy your API service to production using Docker Compose on port 8443", createdAt: now + 1 });
+ insertTestChunk({ id: "s3", sessionKey: "s1", role: "user", content: "The build failed with error: Module not found. How can I fix the tsconfig paths?", createdAt: now + 2 });
+ insertTestChunk({ id: "s4", sessionKey: "s1", role: "assistant", content: "Check your tsconfig.json paths configuration, it should have the correct baseUrl and paths mappings", createdAt: now + 3 });
+ insertTestChunk({ id: "s5", sessionKey: "s1", role: "user", content: "That worked! Now the build passes. What about the health checks?", createdAt: now + 4 });
+ await processor.onChunksIngested("s1", now + 4);
+
+ const firstTaskId = store.getActiveTask("s1")!.id;
+
+ insertTestChunk({ id: "s6", sessionKey: "s1", content: "new topic", createdAt: now + gap });
+ await processor.onChunksIngested("s1", now + gap);
+
+ const oldTask = store.getTask(firstTaskId);
+ expect(oldTask!.status).toBe("completed");
+ expect(oldTask!.summary.length).toBeGreaterThan(0);
+ });
+
+ it("should NOT skip summary for Chinese conversation with real content", async () => {
+ const now = Date.now();
+ const gap = 121 * 60 * 1000;
+
+ insertTestChunk({ id: "z1", sessionKey: "s1", role: "user", content: "我需要把这个项目部署到阿里云的ECS服务器上,端口用8443", createdAt: now });
+ insertTestChunk({ id: "z2", sessionKey: "s1", role: "assistant", content: "好的,我来帮你配置阿里云ECS的部署流程,首先需要确认你的安全组规则允许8443端口", createdAt: now + 1 });
+ insertTestChunk({ id: "z3", sessionKey: "s1", role: "user", content: "安全组已经配好了,但是Docker容器启动失败,报错说找不到配置文件", createdAt: now + 2 });
+ insertTestChunk({ id: "z4", sessionKey: "s1", role: "assistant", content: "请检查docker-compose.yml中的volumes挂载路径是否正确,配置文件需要映射到容器内的/app/config目录", createdAt: now + 3 });
+ insertTestChunk({ id: "z5", sessionKey: "s1", role: "user", content: "搞定了,现在服务正常运行了,谢谢!", createdAt: now + 4 });
+ await processor.onChunksIngested("s1", now + 4);
+
+ const firstTaskId = store.getActiveTask("s1")!.id;
+
+ insertTestChunk({ id: "z6", sessionKey: "s1", content: "下一个话题", createdAt: now + gap });
+ await processor.onChunksIngested("s1", now + gap);
+
+ const oldTask = store.getTask(firstTaskId);
+ expect(oldTask!.status).toBe("completed");
+ expect(oldTask!.summary.length).toBeGreaterThan(0);
+ });
+});
+
+describe("TaskProcessor with LLM topic boundary detection", () => {
+ let store: SqliteStore;
+ let tmpDir: string;
+
+ beforeEach(() => {
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "memos-llm-topic-test-"));
+ store = new SqliteStore(path.join(tmpDir, "test.db"), noopLog);
+ });
+
+ afterEach(() => {
+ store.close();
+ fs.rmSync(tmpDir, { recursive: true, force: true });
+ });
+
+ function insertChunk(overrides: Partial & { id: string }): void {
+ store.insertChunk({
+ sessionKey: "s1",
+ turnId: "turn-1",
+ seq: 0,
+ role: "user",
+ content: "test content",
+ kind: "paragraph",
+ summary: "test summary",
+ embedding: null,
+ taskId: null,
+ skillId: null,
+ dedupStatus: "active",
+ dedupTarget: null,
+ dedupReason: null,
+ mergeCount: 0,
+ lastHitAt: null,
+ mergeHistory: "[]",
+ createdAt: Date.now(),
+ updatedAt: Date.now(),
+ ...overrides,
+ });
+ }
+
+ it("should split task when LLM judges new topic", async () => {
+ const ctx = makeCtx();
+ const proc = new TaskProcessor(store, ctx);
+
+ vi.spyOn(Summarizer.prototype, "judgeNewTopic").mockResolvedValue(true);
+
+ const now = Date.now();
+ insertChunk({ id: "a1", summary: "deploy app to server", content: "deploy app to server", createdAt: now });
+ insertChunk({ id: "a2", role: "assistant", summary: "deployment guide", content: "deployment guide", createdAt: now + 1 });
+ await proc.onChunksIngested("s1", now + 1);
+
+ const task1Id = store.getActiveTask("s1")!.id;
+
+ insertChunk({ id: "a3", summary: "best recipe for pasta", content: "best recipe for pasta", createdAt: now + 60000 });
+ await proc.onChunksIngested("s1", now + 60000);
+
+ const oldTask = store.getTask(task1Id);
+ expect(["completed", "skipped"]).toContain(oldTask!.status);
+
+ const newTask = store.getActiveTask("s1");
+ expect(newTask).not.toBeNull();
+ expect(newTask!.id).not.toBe(task1Id);
+
+ vi.restoreAllMocks();
+ });
+
+ it("should NOT split task when LLM judges same topic", async () => {
+ const ctx = makeCtx();
+ const proc = new TaskProcessor(store, ctx);
+
+ vi.spyOn(Summarizer.prototype, "judgeNewTopic").mockResolvedValue(false);
+
+ const now = Date.now();
+ insertChunk({ id: "b1", summary: "deploy step 1", content: "deploy step 1", createdAt: now });
+ insertChunk({ id: "b2", role: "assistant", summary: "step 1 done", content: "step 1 done", createdAt: now + 1 });
+ await proc.onChunksIngested("s1", now + 1);
+
+ const task1Id = store.getActiveTask("s1")!.id;
+
+ insertChunk({ id: "b3", summary: "deploy step 2", content: "deploy step 2", createdAt: now + 60000 });
+ await proc.onChunksIngested("s1", now + 60000);
+
+ const task = store.getActiveTask("s1");
+ expect(task).not.toBeNull();
+ expect(task!.id).toBe(task1Id);
+
+ vi.restoreAllMocks();
+ });
+
+ it("should keep current task when LLM is not configured (returns null)", async () => {
+ const ctx = makeCtx();
+ const proc = new TaskProcessor(store, ctx);
+
+ vi.spyOn(Summarizer.prototype, "judgeNewTopic").mockResolvedValue(null);
+
+ const now = Date.now();
+ insertChunk({ id: "c1", summary: "topic A", content: "topic A", createdAt: now });
+ await proc.onChunksIngested("s1", now);
+
+ const task1Id = store.getActiveTask("s1")!.id;
+
+ insertChunk({ id: "c2", summary: "totally different topic", content: "totally different topic", createdAt: now + 60000 });
+ await proc.onChunksIngested("s1", now + 60000);
+
+ const task = store.getActiveTask("s1");
+ expect(task!.id).toBe(task1Id);
+
+ vi.restoreAllMocks();
+ });
+
+ it("should still split by 2-hour timeout even if LLM says same topic", async () => {
+ const ctx = makeCtx();
+ const proc = new TaskProcessor(store, ctx);
+
+ // LLM would say SAME, but the gap is > 2h so it should split regardless
+ vi.spyOn(Summarizer.prototype, "judgeNewTopic").mockResolvedValue(false);
+
+ const now = Date.now();
+ const gap = 121 * 60 * 1000; // 2h 1min
+
+ insertChunk({ id: "d1", summary: "topic A", content: "topic A", createdAt: now });
+ insertChunk({ id: "d2", role: "assistant", summary: "about topic A", content: "about topic A", createdAt: now + 1 });
+ await proc.onChunksIngested("s1", now + 1);
+
+ const task1Id = store.getActiveTask("s1")!.id;
+
+ insertChunk({ id: "d3", summary: "still topic A", content: "still topic A", createdAt: now + gap });
+ await proc.onChunksIngested("s1", now + gap);
+
+ const oldTask = store.getTask(task1Id);
+ expect(["completed", "skipped"]).toContain(oldTask!.status);
+
+ vi.restoreAllMocks();
+ });
+});
diff --git a/apps/memos-local-openclaw/tests/worker-lifecycle.test.ts b/apps/memos-local-openclaw/tests/worker-lifecycle.test.ts
new file mode 100644
index 000000000..eb2bdde8a
--- /dev/null
+++ b/apps/memos-local-openclaw/tests/worker-lifecycle.test.ts
@@ -0,0 +1,116 @@
+import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
+import * as fs from "fs";
+import * as path from "path";
+import * as os from "os";
+import { IngestWorker } from "../src/ingest/worker";
+import { SqliteStore } from "../src/storage/sqlite";
+import type { ConversationMessage, Logger, PluginContext } from "../src/types";
+
+const noopLog: Logger = {
+ debug: () => {},
+ info: () => {},
+ warn: () => {},
+ error: () => {},
+};
+
+function makeCtx(tmpDir: string): PluginContext {
+ return {
+ stateDir: tmpDir,
+ workspaceDir: tmpDir,
+ config: {
+ storage: { dbPath: path.join(tmpDir, "test.db") },
+ recall: {
+ maxResultsDefault: 6,
+ maxResultsMax: 20,
+ minScoreDefault: 0.45,
+ minScoreFloor: 0.35,
+ rrfK: 60,
+ mmrLambda: 0.7,
+ recencyHalfLifeDays: 14,
+ },
+ },
+ log: noopLog,
+ };
+}
+
+function makeMessage(id: string, sessionKey = "s1"): ConversationMessage {
+ return {
+ role: "user",
+ content: `message-${id}`,
+ timestamp: Date.now(),
+ turnId: `turn-${id}`,
+ sessionKey,
+ owner: "agent:main",
+ };
+}
+
+describe("IngestWorker lifecycle", () => {
+ let tmpDir: string;
+ let store: SqliteStore;
+
+ beforeEach(() => {
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "memos-worker-test-"));
+ store = new SqliteStore(path.join(tmpDir, "test.db"), noopLog);
+ });
+
+ afterEach(() => {
+ store.close();
+ fs.rmSync(tmpDir, { recursive: true, force: true });
+ });
+
+ it("flush should wait for task post-processing to finish", async () => {
+ const worker = new IngestWorker(store, { embed: vi.fn(), embedQuery: vi.fn() } as any, makeCtx(tmpDir));
+ vi.spyOn(worker as any, "ingestMessage").mockResolvedValue({ action: "stored", summary: "ok" });
+
+ let release!: () => void;
+ const gate = new Promise((resolve) => {
+ release = resolve;
+ });
+
+ vi.spyOn(worker.getTaskProcessor(), "onChunksIngested").mockImplementation(async () => {
+ await gate;
+ });
+
+ worker.enqueue([makeMessage("1")]);
+
+ let flushed = false;
+ const flushPromise = worker.flush().then(() => {
+ flushed = true;
+ });
+
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ expect(flushed).toBe(false);
+
+ release();
+ await flushPromise;
+ expect(flushed).toBe(true);
+ });
+
+ it("flush should not resolve while messages queued during task processing are still pending", async () => {
+ const worker = new IngestWorker(store, { embed: vi.fn(), embedQuery: vi.fn() } as any, makeCtx(tmpDir));
+ const ingestSpy = vi.spyOn(worker as any, "ingestMessage").mockResolvedValue({ action: "stored", summary: "ok" });
+
+ let release!: () => void;
+ const gate = new Promise((resolve) => {
+ release = resolve;
+ });
+
+ let calls = 0;
+ vi.spyOn(worker.getTaskProcessor(), "onChunksIngested").mockImplementation(async () => {
+ calls += 1;
+ if (calls === 1) {
+ worker.enqueue([makeMessage("2")]);
+ await gate;
+ }
+ });
+
+ worker.enqueue([makeMessage("1")]);
+ const flushPromise = worker.flush();
+
+ setTimeout(() => release(), 0);
+ await flushPromise;
+
+ expect(ingestSpy).toHaveBeenCalledTimes(2);
+ expect(calls).toBe(2);
+ });
+});
diff --git a/apps/memos-local-openclaw/tsconfig.json b/apps/memos-local-openclaw/tsconfig.json
new file mode 100644
index 000000000..53c08a1d2
--- /dev/null
+++ b/apps/memos-local-openclaw/tsconfig.json
@@ -0,0 +1,20 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "CommonJS",
+ "lib": ["ES2022"],
+ "outDir": "dist",
+ "rootDir": "src",
+ "strict": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "forceConsistentCasingInFileNames": true,
+ "resolveJsonModule": true,
+ "declaration": true,
+ "declarationMap": true,
+ "sourceMap": true,
+ "moduleResolution": "node"
+ },
+ "include": ["src"],
+ "exclude": ["node_modules", "dist", "**/*.test.ts"]
+}
diff --git a/apps/memos-local-openclaw/vitest.config.ts b/apps/memos-local-openclaw/vitest.config.ts
new file mode 100644
index 000000000..51e003901
--- /dev/null
+++ b/apps/memos-local-openclaw/vitest.config.ts
@@ -0,0 +1,8 @@
+import { defineConfig } from "vitest/config";
+
+export default defineConfig({
+ test: {
+ testTimeout: 60_000,
+ hookTimeout: 120_000,
+ },
+});
diff --git a/apps/openwork-memos-integration/.gitignore b/apps/openwork-memos-integration/.gitignore
new file mode 100644
index 000000000..1a6ae432f
--- /dev/null
+++ b/apps/openwork-memos-integration/.gitignore
@@ -0,0 +1,26 @@
+node_modules/
+dist/
+out/
+.env
+.env.local
+
+# OS files
+.DS_Store
+Thumbs.db
+
+# Lock files
+pnpm-lock.yaml
+package-lock.json
+bun.lock
+
+# Binary assets (fonts, large images, videos)
+*.ttf
+*.woff
+*.woff2
+*.mp4
+*.webm
+public/assets/usecases/
+docs/video-thumbnail.png
+
+# Build artifacts
+*.tsbuildinfo
diff --git a/openwork-memos-integration/CLAUDE.md b/apps/openwork-memos-integration/CLAUDE.md
similarity index 100%
rename from openwork-memos-integration/CLAUDE.md
rename to apps/openwork-memos-integration/CLAUDE.md
diff --git a/openwork-memos-integration/CONTRIBUTING.md b/apps/openwork-memos-integration/CONTRIBUTING.md
similarity index 100%
rename from openwork-memos-integration/CONTRIBUTING.md
rename to apps/openwork-memos-integration/CONTRIBUTING.md
diff --git a/openwork-memos-integration/LICENSE b/apps/openwork-memos-integration/LICENSE
similarity index 100%
rename from openwork-memos-integration/LICENSE
rename to apps/openwork-memos-integration/LICENSE
diff --git a/openwork-memos-integration/README.md b/apps/openwork-memos-integration/README.md
similarity index 100%
rename from openwork-memos-integration/README.md
rename to apps/openwork-memos-integration/README.md
diff --git a/openwork-memos-integration/SECURITY.md b/apps/openwork-memos-integration/SECURITY.md
similarity index 100%
rename from openwork-memos-integration/SECURITY.md
rename to apps/openwork-memos-integration/SECURITY.md
diff --git a/openwork-memos-integration/apps/desktop/.eslintrc.json b/apps/openwork-memos-integration/apps/desktop/.eslintrc.json
similarity index 100%
rename from openwork-memos-integration/apps/desktop/.eslintrc.json
rename to apps/openwork-memos-integration/apps/desktop/.eslintrc.json
diff --git a/openwork-memos-integration/apps/desktop/__tests__/integration/main/appSettings.integration.test.ts b/apps/openwork-memos-integration/apps/desktop/__tests__/integration/main/appSettings.integration.test.ts
similarity index 100%
rename from openwork-memos-integration/apps/desktop/__tests__/integration/main/appSettings.integration.test.ts
rename to apps/openwork-memos-integration/apps/desktop/__tests__/integration/main/appSettings.integration.test.ts
diff --git a/openwork-memos-integration/apps/desktop/__tests__/integration/main/opencode/cli-path.integration.test.ts b/apps/openwork-memos-integration/apps/desktop/__tests__/integration/main/opencode/cli-path.integration.test.ts
similarity index 100%
rename from openwork-memos-integration/apps/desktop/__tests__/integration/main/opencode/cli-path.integration.test.ts
rename to apps/openwork-memos-integration/apps/desktop/__tests__/integration/main/opencode/cli-path.integration.test.ts
diff --git a/openwork-memos-integration/apps/desktop/__tests__/integration/main/opencode/config-generator.integration.test.ts b/apps/openwork-memos-integration/apps/desktop/__tests__/integration/main/opencode/config-generator.integration.test.ts
similarity index 100%
rename from openwork-memos-integration/apps/desktop/__tests__/integration/main/opencode/config-generator.integration.test.ts
rename to apps/openwork-memos-integration/apps/desktop/__tests__/integration/main/opencode/config-generator.integration.test.ts
diff --git a/openwork-memos-integration/apps/desktop/__tests__/integration/main/permission-api.integration.test.ts b/apps/openwork-memos-integration/apps/desktop/__tests__/integration/main/permission-api.integration.test.ts
similarity index 100%
rename from openwork-memos-integration/apps/desktop/__tests__/integration/main/permission-api.integration.test.ts
rename to apps/openwork-memos-integration/apps/desktop/__tests__/integration/main/permission-api.integration.test.ts
diff --git a/openwork-memos-integration/apps/desktop/__tests__/integration/main/secureStorage.integration.test.ts b/apps/openwork-memos-integration/apps/desktop/__tests__/integration/main/secureStorage.integration.test.ts
similarity index 100%
rename from openwork-memos-integration/apps/desktop/__tests__/integration/main/secureStorage.integration.test.ts
rename to apps/openwork-memos-integration/apps/desktop/__tests__/integration/main/secureStorage.integration.test.ts
diff --git a/openwork-memos-integration/apps/desktop/__tests__/integration/main/store/freshInstallCleanup.integration.test.ts b/apps/openwork-memos-integration/apps/desktop/__tests__/integration/main/store/freshInstallCleanup.integration.test.ts
similarity index 100%
rename from openwork-memos-integration/apps/desktop/__tests__/integration/main/store/freshInstallCleanup.integration.test.ts
rename to apps/openwork-memos-integration/apps/desktop/__tests__/integration/main/store/freshInstallCleanup.integration.test.ts
diff --git a/openwork-memos-integration/apps/desktop/__tests__/integration/main/taskHistory.integration.test.ts b/apps/openwork-memos-integration/apps/desktop/__tests__/integration/main/taskHistory.integration.test.ts
similarity index 100%
rename from openwork-memos-integration/apps/desktop/__tests__/integration/main/taskHistory.integration.test.ts
rename to apps/openwork-memos-integration/apps/desktop/__tests__/integration/main/taskHistory.integration.test.ts
diff --git a/openwork-memos-integration/apps/desktop/__tests__/integration/main/utils/bundled-node.integration.test.ts b/apps/openwork-memos-integration/apps/desktop/__tests__/integration/main/utils/bundled-node.integration.test.ts
similarity index 100%
rename from openwork-memos-integration/apps/desktop/__tests__/integration/main/utils/bundled-node.integration.test.ts
rename to apps/openwork-memos-integration/apps/desktop/__tests__/integration/main/utils/bundled-node.integration.test.ts
diff --git a/openwork-memos-integration/apps/desktop/__tests__/integration/main/utils/system-path.integration.test.ts b/apps/openwork-memos-integration/apps/desktop/__tests__/integration/main/utils/system-path.integration.test.ts
similarity index 100%
rename from openwork-memos-integration/apps/desktop/__tests__/integration/main/utils/system-path.integration.test.ts
rename to apps/openwork-memos-integration/apps/desktop/__tests__/integration/main/utils/system-path.integration.test.ts
diff --git a/openwork-memos-integration/apps/desktop/__tests__/integration/preload/preload.integration.test.ts b/apps/openwork-memos-integration/apps/desktop/__tests__/integration/preload/preload.integration.test.ts
similarity index 100%
rename from openwork-memos-integration/apps/desktop/__tests__/integration/preload/preload.integration.test.ts
rename to apps/openwork-memos-integration/apps/desktop/__tests__/integration/preload/preload.integration.test.ts
diff --git a/openwork-memos-integration/apps/desktop/__tests__/integration/renderer/App.integration.test.tsx b/apps/openwork-memos-integration/apps/desktop/__tests__/integration/renderer/App.integration.test.tsx
similarity index 100%
rename from openwork-memos-integration/apps/desktop/__tests__/integration/renderer/App.integration.test.tsx
rename to apps/openwork-memos-integration/apps/desktop/__tests__/integration/renderer/App.integration.test.tsx
diff --git a/openwork-memos-integration/apps/desktop/__tests__/integration/renderer/components/Header.integration.test.tsx b/apps/openwork-memos-integration/apps/desktop/__tests__/integration/renderer/components/Header.integration.test.tsx
similarity index 100%
rename from openwork-memos-integration/apps/desktop/__tests__/integration/renderer/components/Header.integration.test.tsx
rename to apps/openwork-memos-integration/apps/desktop/__tests__/integration/renderer/components/Header.integration.test.tsx
diff --git a/openwork-memos-integration/apps/desktop/__tests__/integration/renderer/components/SettingsDialog.integration.test.tsx b/apps/openwork-memos-integration/apps/desktop/__tests__/integration/renderer/components/SettingsDialog.integration.test.tsx
similarity index 100%
rename from openwork-memos-integration/apps/desktop/__tests__/integration/renderer/components/SettingsDialog.integration.test.tsx
rename to apps/openwork-memos-integration/apps/desktop/__tests__/integration/renderer/components/SettingsDialog.integration.test.tsx
diff --git a/openwork-memos-integration/apps/desktop/__tests__/integration/renderer/components/Sidebar.integration.test.tsx b/apps/openwork-memos-integration/apps/desktop/__tests__/integration/renderer/components/Sidebar.integration.test.tsx
similarity index 100%
rename from openwork-memos-integration/apps/desktop/__tests__/integration/renderer/components/Sidebar.integration.test.tsx
rename to apps/openwork-memos-integration/apps/desktop/__tests__/integration/renderer/components/Sidebar.integration.test.tsx
diff --git a/openwork-memos-integration/apps/desktop/__tests__/integration/renderer/components/StreamingText.integration.test.tsx b/apps/openwork-memos-integration/apps/desktop/__tests__/integration/renderer/components/StreamingText.integration.test.tsx
similarity index 100%
rename from openwork-memos-integration/apps/desktop/__tests__/integration/renderer/components/StreamingText.integration.test.tsx
rename to apps/openwork-memos-integration/apps/desktop/__tests__/integration/renderer/components/StreamingText.integration.test.tsx
diff --git a/openwork-memos-integration/apps/desktop/__tests__/integration/renderer/components/TaskHistory.integration.test.tsx b/apps/openwork-memos-integration/apps/desktop/__tests__/integration/renderer/components/TaskHistory.integration.test.tsx
similarity index 100%
rename from openwork-memos-integration/apps/desktop/__tests__/integration/renderer/components/TaskHistory.integration.test.tsx
rename to apps/openwork-memos-integration/apps/desktop/__tests__/integration/renderer/components/TaskHistory.integration.test.tsx
diff --git a/openwork-memos-integration/apps/desktop/__tests__/integration/renderer/components/TaskInputBar.integration.test.tsx b/apps/openwork-memos-integration/apps/desktop/__tests__/integration/renderer/components/TaskInputBar.integration.test.tsx
similarity index 100%
rename from openwork-memos-integration/apps/desktop/__tests__/integration/renderer/components/TaskInputBar.integration.test.tsx
rename to apps/openwork-memos-integration/apps/desktop/__tests__/integration/renderer/components/TaskInputBar.integration.test.tsx
diff --git a/openwork-memos-integration/apps/desktop/__tests__/integration/renderer/components/TaskLauncher.integration.test.tsx b/apps/openwork-memos-integration/apps/desktop/__tests__/integration/renderer/components/TaskLauncher.integration.test.tsx
similarity index 100%
rename from openwork-memos-integration/apps/desktop/__tests__/integration/renderer/components/TaskLauncher.integration.test.tsx
rename to apps/openwork-memos-integration/apps/desktop/__tests__/integration/renderer/components/TaskLauncher.integration.test.tsx
diff --git a/openwork-memos-integration/apps/desktop/__tests__/integration/renderer/pages/Execution.integration.test.tsx b/apps/openwork-memos-integration/apps/desktop/__tests__/integration/renderer/pages/Execution.integration.test.tsx
similarity index 100%
rename from openwork-memos-integration/apps/desktop/__tests__/integration/renderer/pages/Execution.integration.test.tsx
rename to apps/openwork-memos-integration/apps/desktop/__tests__/integration/renderer/pages/Execution.integration.test.tsx
diff --git a/openwork-memos-integration/apps/desktop/__tests__/integration/renderer/pages/Home.integration.test.tsx b/apps/openwork-memos-integration/apps/desktop/__tests__/integration/renderer/pages/Home.integration.test.tsx
similarity index 100%
rename from openwork-memos-integration/apps/desktop/__tests__/integration/renderer/pages/Home.integration.test.tsx
rename to apps/openwork-memos-integration/apps/desktop/__tests__/integration/renderer/pages/Home.integration.test.tsx
diff --git a/openwork-memos-integration/apps/desktop/__tests__/integration/renderer/taskStore.integration.test.ts b/apps/openwork-memos-integration/apps/desktop/__tests__/integration/renderer/taskStore.integration.test.ts
similarity index 100%
rename from openwork-memos-integration/apps/desktop/__tests__/integration/renderer/taskStore.integration.test.ts
rename to apps/openwork-memos-integration/apps/desktop/__tests__/integration/renderer/taskStore.integration.test.ts
diff --git a/openwork-memos-integration/apps/desktop/__tests__/main/config.unit.test.ts b/apps/openwork-memos-integration/apps/desktop/__tests__/main/config.unit.test.ts
similarity index 100%
rename from openwork-memos-integration/apps/desktop/__tests__/main/config.unit.test.ts
rename to apps/openwork-memos-integration/apps/desktop/__tests__/main/config.unit.test.ts
diff --git a/openwork-memos-integration/apps/desktop/__tests__/main/ipc/handlers-utils.unit.test.ts b/apps/openwork-memos-integration/apps/desktop/__tests__/main/ipc/handlers-utils.unit.test.ts
similarity index 100%
rename from openwork-memos-integration/apps/desktop/__tests__/main/ipc/handlers-utils.unit.test.ts
rename to apps/openwork-memos-integration/apps/desktop/__tests__/main/ipc/handlers-utils.unit.test.ts
diff --git a/openwork-memos-integration/apps/desktop/__tests__/main/ipc/validation.unit.test.ts b/apps/openwork-memos-integration/apps/desktop/__tests__/main/ipc/validation.unit.test.ts
similarity index 100%
rename from openwork-memos-integration/apps/desktop/__tests__/main/ipc/validation.unit.test.ts
rename to apps/openwork-memos-integration/apps/desktop/__tests__/main/ipc/validation.unit.test.ts
diff --git a/openwork-memos-integration/apps/desktop/__tests__/main/opencode/stream-parser.unit.test.ts b/apps/openwork-memos-integration/apps/desktop/__tests__/main/opencode/stream-parser.unit.test.ts
similarity index 100%
rename from openwork-memos-integration/apps/desktop/__tests__/main/opencode/stream-parser.unit.test.ts
rename to apps/openwork-memos-integration/apps/desktop/__tests__/main/opencode/stream-parser.unit.test.ts
diff --git a/openwork-memos-integration/apps/desktop/__tests__/setup.ts b/apps/openwork-memos-integration/apps/desktop/__tests__/setup.ts
similarity index 100%
rename from openwork-memos-integration/apps/desktop/__tests__/setup.ts
rename to apps/openwork-memos-integration/apps/desktop/__tests__/setup.ts
diff --git a/openwork-memos-integration/apps/desktop/__tests__/unit/main/ipc/handlers.unit.test.ts b/apps/openwork-memos-integration/apps/desktop/__tests__/unit/main/ipc/handlers.unit.test.ts
similarity index 100%
rename from openwork-memos-integration/apps/desktop/__tests__/unit/main/ipc/handlers.unit.test.ts
rename to apps/openwork-memos-integration/apps/desktop/__tests__/unit/main/ipc/handlers.unit.test.ts
diff --git a/openwork-memos-integration/apps/desktop/__tests__/unit/main/opencode/adapter.unit.test.ts b/apps/openwork-memos-integration/apps/desktop/__tests__/unit/main/opencode/adapter.unit.test.ts
similarity index 100%
rename from openwork-memos-integration/apps/desktop/__tests__/unit/main/opencode/adapter.unit.test.ts
rename to apps/openwork-memos-integration/apps/desktop/__tests__/unit/main/opencode/adapter.unit.test.ts
diff --git a/openwork-memos-integration/apps/desktop/__tests__/unit/main/opencode/task-manager.unit.test.ts b/apps/openwork-memos-integration/apps/desktop/__tests__/unit/main/opencode/task-manager.unit.test.ts
similarity index 100%
rename from openwork-memos-integration/apps/desktop/__tests__/unit/main/opencode/task-manager.unit.test.ts
rename to apps/openwork-memos-integration/apps/desktop/__tests__/unit/main/opencode/task-manager.unit.test.ts
diff --git a/openwork-memos-integration/apps/desktop/clean_dmg_install.sh b/apps/openwork-memos-integration/apps/desktop/clean_dmg_install.sh
similarity index 100%
rename from openwork-memos-integration/apps/desktop/clean_dmg_install.sh
rename to apps/openwork-memos-integration/apps/desktop/clean_dmg_install.sh
diff --git a/openwork-memos-integration/apps/desktop/e2e/README.md b/apps/openwork-memos-integration/apps/desktop/e2e/README.md
similarity index 100%
rename from openwork-memos-integration/apps/desktop/e2e/README.md
rename to apps/openwork-memos-integration/apps/desktop/e2e/README.md
diff --git a/openwork-memos-integration/apps/desktop/e2e/config/index.ts b/apps/openwork-memos-integration/apps/desktop/e2e/config/index.ts
similarity index 100%
rename from openwork-memos-integration/apps/desktop/e2e/config/index.ts
rename to apps/openwork-memos-integration/apps/desktop/e2e/config/index.ts
diff --git a/openwork-memos-integration/apps/desktop/e2e/config/timeouts.ts b/apps/openwork-memos-integration/apps/desktop/e2e/config/timeouts.ts
similarity index 100%
rename from openwork-memos-integration/apps/desktop/e2e/config/timeouts.ts
rename to apps/openwork-memos-integration/apps/desktop/e2e/config/timeouts.ts
diff --git a/openwork-memos-integration/apps/desktop/e2e/docker/Dockerfile b/apps/openwork-memos-integration/apps/desktop/e2e/docker/Dockerfile
similarity index 100%
rename from openwork-memos-integration/apps/desktop/e2e/docker/Dockerfile
rename to apps/openwork-memos-integration/apps/desktop/e2e/docker/Dockerfile
diff --git a/openwork-memos-integration/apps/desktop/e2e/docker/docker-compose.yml b/apps/openwork-memos-integration/apps/desktop/e2e/docker/docker-compose.yml
similarity index 100%
rename from openwork-memos-integration/apps/desktop/e2e/docker/docker-compose.yml
rename to apps/openwork-memos-integration/apps/desktop/e2e/docker/docker-compose.yml
diff --git a/openwork-memos-integration/apps/desktop/e2e/fixtures/electron-app.ts b/apps/openwork-memos-integration/apps/desktop/e2e/fixtures/electron-app.ts
similarity index 100%
rename from openwork-memos-integration/apps/desktop/e2e/fixtures/electron-app.ts
rename to apps/openwork-memos-integration/apps/desktop/e2e/fixtures/electron-app.ts
diff --git a/openwork-memos-integration/apps/desktop/e2e/fixtures/index.ts b/apps/openwork-memos-integration/apps/desktop/e2e/fixtures/index.ts
similarity index 100%
rename from openwork-memos-integration/apps/desktop/e2e/fixtures/index.ts
rename to apps/openwork-memos-integration/apps/desktop/e2e/fixtures/index.ts
diff --git a/openwork-memos-integration/apps/desktop/e2e/pages/execution.page.ts b/apps/openwork-memos-integration/apps/desktop/e2e/pages/execution.page.ts
similarity index 100%
rename from openwork-memos-integration/apps/desktop/e2e/pages/execution.page.ts
rename to apps/openwork-memos-integration/apps/desktop/e2e/pages/execution.page.ts
diff --git a/openwork-memos-integration/apps/desktop/e2e/pages/home.page.ts b/apps/openwork-memos-integration/apps/desktop/e2e/pages/home.page.ts
similarity index 100%
rename from openwork-memos-integration/apps/desktop/e2e/pages/home.page.ts
rename to apps/openwork-memos-integration/apps/desktop/e2e/pages/home.page.ts
diff --git a/openwork-memos-integration/apps/desktop/e2e/pages/index.ts b/apps/openwork-memos-integration/apps/desktop/e2e/pages/index.ts
similarity index 100%
rename from openwork-memos-integration/apps/desktop/e2e/pages/index.ts
rename to apps/openwork-memos-integration/apps/desktop/e2e/pages/index.ts
diff --git a/openwork-memos-integration/apps/desktop/e2e/pages/settings.page.ts b/apps/openwork-memos-integration/apps/desktop/e2e/pages/settings.page.ts
similarity index 100%
rename from openwork-memos-integration/apps/desktop/e2e/pages/settings.page.ts
rename to apps/openwork-memos-integration/apps/desktop/e2e/pages/settings.page.ts
diff --git a/openwork-memos-integration/apps/desktop/e2e/playwright.config.ts b/apps/openwork-memos-integration/apps/desktop/e2e/playwright.config.ts
similarity index 100%
rename from openwork-memos-integration/apps/desktop/e2e/playwright.config.ts
rename to apps/openwork-memos-integration/apps/desktop/e2e/playwright.config.ts
diff --git a/openwork-memos-integration/apps/desktop/e2e/specs/execution.spec.ts b/apps/openwork-memos-integration/apps/desktop/e2e/specs/execution.spec.ts
similarity index 100%
rename from openwork-memos-integration/apps/desktop/e2e/specs/execution.spec.ts
rename to apps/openwork-memos-integration/apps/desktop/e2e/specs/execution.spec.ts
diff --git a/openwork-memos-integration/apps/desktop/e2e/specs/home.spec.ts b/apps/openwork-memos-integration/apps/desktop/e2e/specs/home.spec.ts
similarity index 100%
rename from openwork-memos-integration/apps/desktop/e2e/specs/home.spec.ts
rename to apps/openwork-memos-integration/apps/desktop/e2e/specs/home.spec.ts
diff --git a/openwork-memos-integration/apps/desktop/e2e/specs/settings-bedrock.spec.ts b/apps/openwork-memos-integration/apps/desktop/e2e/specs/settings-bedrock.spec.ts
similarity index 100%
rename from openwork-memos-integration/apps/desktop/e2e/specs/settings-bedrock.spec.ts
rename to apps/openwork-memos-integration/apps/desktop/e2e/specs/settings-bedrock.spec.ts
diff --git a/openwork-memos-integration/apps/desktop/e2e/specs/settings-providers.spec.ts b/apps/openwork-memos-integration/apps/desktop/e2e/specs/settings-providers.spec.ts
similarity index 100%
rename from openwork-memos-integration/apps/desktop/e2e/specs/settings-providers.spec.ts
rename to apps/openwork-memos-integration/apps/desktop/e2e/specs/settings-providers.spec.ts
diff --git a/openwork-memos-integration/apps/desktop/e2e/specs/settings.spec.ts b/apps/openwork-memos-integration/apps/desktop/e2e/specs/settings.spec.ts
similarity index 100%
rename from openwork-memos-integration/apps/desktop/e2e/specs/settings.spec.ts
rename to apps/openwork-memos-integration/apps/desktop/e2e/specs/settings.spec.ts
diff --git a/openwork-memos-integration/apps/desktop/e2e/specs/task-launch-guard.spec.ts b/apps/openwork-memos-integration/apps/desktop/e2e/specs/task-launch-guard.spec.ts
similarity index 100%
rename from openwork-memos-integration/apps/desktop/e2e/specs/task-launch-guard.spec.ts
rename to apps/openwork-memos-integration/apps/desktop/e2e/specs/task-launch-guard.spec.ts
diff --git a/openwork-memos-integration/apps/desktop/e2e/utils/index.ts b/apps/openwork-memos-integration/apps/desktop/e2e/utils/index.ts
similarity index 100%
rename from openwork-memos-integration/apps/desktop/e2e/utils/index.ts
rename to apps/openwork-memos-integration/apps/desktop/e2e/utils/index.ts
diff --git a/openwork-memos-integration/apps/desktop/e2e/utils/screenshots.ts b/apps/openwork-memos-integration/apps/desktop/e2e/utils/screenshots.ts
similarity index 100%
rename from openwork-memos-integration/apps/desktop/e2e/utils/screenshots.ts
rename to apps/openwork-memos-integration/apps/desktop/e2e/utils/screenshots.ts
diff --git a/openwork-memos-integration/apps/desktop/index.html b/apps/openwork-memos-integration/apps/desktop/index.html
similarity index 100%
rename from openwork-memos-integration/apps/desktop/index.html
rename to apps/openwork-memos-integration/apps/desktop/index.html
diff --git a/openwork-memos-integration/apps/desktop/package.json b/apps/openwork-memos-integration/apps/desktop/package.json
similarity index 100%
rename from openwork-memos-integration/apps/desktop/package.json
rename to apps/openwork-memos-integration/apps/desktop/package.json
diff --git a/openwork-memos-integration/apps/desktop/postcss.config.js b/apps/openwork-memos-integration/apps/desktop/postcss.config.js
similarity index 100%
rename from openwork-memos-integration/apps/desktop/postcss.config.js
rename to apps/openwork-memos-integration/apps/desktop/postcss.config.js
diff --git a/openwork-memos-integration/apps/desktop/public/assets/ai-logos/anthropic.svg b/apps/openwork-memos-integration/apps/desktop/public/assets/ai-logos/anthropic.svg
similarity index 100%
rename from openwork-memos-integration/apps/desktop/public/assets/ai-logos/anthropic.svg
rename to apps/openwork-memos-integration/apps/desktop/public/assets/ai-logos/anthropic.svg
diff --git a/openwork-memos-integration/apps/desktop/public/assets/ai-logos/bedrock.svg b/apps/openwork-memos-integration/apps/desktop/public/assets/ai-logos/bedrock.svg
similarity index 100%
rename from openwork-memos-integration/apps/desktop/public/assets/ai-logos/bedrock.svg
rename to apps/openwork-memos-integration/apps/desktop/public/assets/ai-logos/bedrock.svg
diff --git a/openwork-memos-integration/apps/desktop/public/assets/ai-logos/deepseek.svg b/apps/openwork-memos-integration/apps/desktop/public/assets/ai-logos/deepseek.svg
similarity index 100%
rename from openwork-memos-integration/apps/desktop/public/assets/ai-logos/deepseek.svg
rename to apps/openwork-memos-integration/apps/desktop/public/assets/ai-logos/deepseek.svg
diff --git a/openwork-memos-integration/apps/desktop/public/assets/ai-logos/google.svg b/apps/openwork-memos-integration/apps/desktop/public/assets/ai-logos/google.svg
similarity index 100%
rename from openwork-memos-integration/apps/desktop/public/assets/ai-logos/google.svg
rename to apps/openwork-memos-integration/apps/desktop/public/assets/ai-logos/google.svg
diff --git a/openwork-memos-integration/apps/desktop/public/assets/ai-logos/litellm.svg b/apps/openwork-memos-integration/apps/desktop/public/assets/ai-logos/litellm.svg
similarity index 100%
rename from openwork-memos-integration/apps/desktop/public/assets/ai-logos/litellm.svg
rename to apps/openwork-memos-integration/apps/desktop/public/assets/ai-logos/litellm.svg
diff --git a/openwork-memos-integration/apps/desktop/public/assets/ai-logos/ollama.svg b/apps/openwork-memos-integration/apps/desktop/public/assets/ai-logos/ollama.svg
similarity index 100%
rename from openwork-memos-integration/apps/desktop/public/assets/ai-logos/ollama.svg
rename to apps/openwork-memos-integration/apps/desktop/public/assets/ai-logos/ollama.svg
diff --git a/openwork-memos-integration/apps/desktop/public/assets/ai-logos/openai.svg b/apps/openwork-memos-integration/apps/desktop/public/assets/ai-logos/openai.svg
similarity index 100%
rename from openwork-memos-integration/apps/desktop/public/assets/ai-logos/openai.svg
rename to apps/openwork-memos-integration/apps/desktop/public/assets/ai-logos/openai.svg
diff --git a/openwork-memos-integration/apps/desktop/public/assets/ai-logos/openrouter.svg b/apps/openwork-memos-integration/apps/desktop/public/assets/ai-logos/openrouter.svg
similarity index 100%
rename from openwork-memos-integration/apps/desktop/public/assets/ai-logos/openrouter.svg
rename to apps/openwork-memos-integration/apps/desktop/public/assets/ai-logos/openrouter.svg
diff --git a/openwork-memos-integration/apps/desktop/public/assets/ai-logos/provider-logos.svg b/apps/openwork-memos-integration/apps/desktop/public/assets/ai-logos/provider-logos.svg
similarity index 100%
rename from openwork-memos-integration/apps/desktop/public/assets/ai-logos/provider-logos.svg
rename to apps/openwork-memos-integration/apps/desktop/public/assets/ai-logos/provider-logos.svg
diff --git a/openwork-memos-integration/apps/desktop/public/assets/ai-logos/vertex.svg b/apps/openwork-memos-integration/apps/desktop/public/assets/ai-logos/vertex.svg
similarity index 100%
rename from openwork-memos-integration/apps/desktop/public/assets/ai-logos/vertex.svg
rename to apps/openwork-memos-integration/apps/desktop/public/assets/ai-logos/vertex.svg
diff --git a/openwork-memos-integration/apps/desktop/public/assets/ai-logos/xai.svg b/apps/openwork-memos-integration/apps/desktop/public/assets/ai-logos/xai.svg
similarity index 100%
rename from openwork-memos-integration/apps/desktop/public/assets/ai-logos/xai.svg
rename to apps/openwork-memos-integration/apps/desktop/public/assets/ai-logos/xai.svg
diff --git a/openwork-memos-integration/apps/desktop/public/assets/ai-logos/zai.svg b/apps/openwork-memos-integration/apps/desktop/public/assets/ai-logos/zai.svg
similarity index 100%
rename from openwork-memos-integration/apps/desktop/public/assets/ai-logos/zai.svg
rename to apps/openwork-memos-integration/apps/desktop/public/assets/ai-logos/zai.svg
diff --git a/openwork-memos-integration/apps/desktop/public/assets/icons/connect.svg b/apps/openwork-memos-integration/apps/desktop/public/assets/icons/connect.svg
similarity index 100%
rename from openwork-memos-integration/apps/desktop/public/assets/icons/connect.svg
rename to apps/openwork-memos-integration/apps/desktop/public/assets/icons/connect.svg
diff --git a/openwork-memos-integration/apps/desktop/public/assets/icons/connected-key.svg b/apps/openwork-memos-integration/apps/desktop/public/assets/icons/connected-key.svg
similarity index 100%
rename from openwork-memos-integration/apps/desktop/public/assets/icons/connected-key.svg
rename to apps/openwork-memos-integration/apps/desktop/public/assets/icons/connected-key.svg
diff --git a/openwork-memos-integration/apps/desktop/public/assets/icons/connected.svg b/apps/openwork-memos-integration/apps/desktop/public/assets/icons/connected.svg
similarity index 100%
rename from openwork-memos-integration/apps/desktop/public/assets/icons/connected.svg
rename to apps/openwork-memos-integration/apps/desktop/public/assets/icons/connected.svg
diff --git a/openwork-memos-integration/apps/desktop/public/assets/icons/pending-key.svg b/apps/openwork-memos-integration/apps/desktop/public/assets/icons/pending-key.svg
similarity index 100%
rename from openwork-memos-integration/apps/desktop/public/assets/icons/pending-key.svg
rename to apps/openwork-memos-integration/apps/desktop/public/assets/icons/pending-key.svg
diff --git a/openwork-memos-integration/apps/desktop/public/assets/loading-symbol.svg b/apps/openwork-memos-integration/apps/desktop/public/assets/loading-symbol.svg
similarity index 100%
rename from openwork-memos-integration/apps/desktop/public/assets/loading-symbol.svg
rename to apps/openwork-memos-integration/apps/desktop/public/assets/loading-symbol.svg
diff --git a/openwork-memos-integration/apps/desktop/public/assets/logo-1.png b/apps/openwork-memos-integration/apps/desktop/public/assets/logo-1.png
similarity index 100%
rename from openwork-memos-integration/apps/desktop/public/assets/logo-1.png
rename to apps/openwork-memos-integration/apps/desktop/public/assets/logo-1.png
diff --git a/openwork-memos-integration/apps/desktop/public/assets/logo.png b/apps/openwork-memos-integration/apps/desktop/public/assets/logo.png
similarity index 100%
rename from openwork-memos-integration/apps/desktop/public/assets/logo.png
rename to apps/openwork-memos-integration/apps/desktop/public/assets/logo.png
diff --git a/openwork-memos-integration/apps/desktop/public/assets/openwork-icon.png b/apps/openwork-memos-integration/apps/desktop/public/assets/openwork-icon.png
similarity index 100%
rename from openwork-memos-integration/apps/desktop/public/assets/openwork-icon.png
rename to apps/openwork-memos-integration/apps/desktop/public/assets/openwork-icon.png
diff --git a/openwork-memos-integration/apps/desktop/resources/entitlements.mac.plist b/apps/openwork-memos-integration/apps/desktop/resources/entitlements.mac.plist
similarity index 100%
rename from openwork-memos-integration/apps/desktop/resources/entitlements.mac.plist
rename to apps/openwork-memos-integration/apps/desktop/resources/entitlements.mac.plist
diff --git a/openwork-memos-integration/apps/desktop/resources/icon.png b/apps/openwork-memos-integration/apps/desktop/resources/icon.png
similarity index 100%
rename from openwork-memos-integration/apps/desktop/resources/icon.png
rename to apps/openwork-memos-integration/apps/desktop/resources/icon.png
diff --git a/openwork-memos-integration/apps/desktop/run_local_ui_prod_api.sh b/apps/openwork-memos-integration/apps/desktop/run_local_ui_prod_api.sh
similarity index 100%
rename from openwork-memos-integration/apps/desktop/run_local_ui_prod_api.sh
rename to apps/openwork-memos-integration/apps/desktop/run_local_ui_prod_api.sh
diff --git a/openwork-memos-integration/apps/desktop/run_local_ui_staging_api.sh b/apps/openwork-memos-integration/apps/desktop/run_local_ui_staging_api.sh
similarity index 100%
rename from openwork-memos-integration/apps/desktop/run_local_ui_staging_api.sh
rename to apps/openwork-memos-integration/apps/desktop/run_local_ui_staging_api.sh
diff --git a/openwork-memos-integration/apps/desktop/run_prod.sh b/apps/openwork-memos-integration/apps/desktop/run_prod.sh
similarity index 100%
rename from openwork-memos-integration/apps/desktop/run_prod.sh
rename to apps/openwork-memos-integration/apps/desktop/run_prod.sh
diff --git a/openwork-memos-integration/apps/desktop/run_staging.sh b/apps/openwork-memos-integration/apps/desktop/run_staging.sh
similarity index 100%
rename from openwork-memos-integration/apps/desktop/run_staging.sh
rename to apps/openwork-memos-integration/apps/desktop/run_staging.sh
diff --git a/openwork-memos-integration/apps/desktop/scripts/after-pack.cjs b/apps/openwork-memos-integration/apps/desktop/scripts/after-pack.cjs
similarity index 100%
rename from openwork-memos-integration/apps/desktop/scripts/after-pack.cjs
rename to apps/openwork-memos-integration/apps/desktop/scripts/after-pack.cjs
diff --git a/openwork-memos-integration/apps/desktop/scripts/download-nodejs.cjs b/apps/openwork-memos-integration/apps/desktop/scripts/download-nodejs.cjs
similarity index 100%
rename from openwork-memos-integration/apps/desktop/scripts/download-nodejs.cjs
rename to apps/openwork-memos-integration/apps/desktop/scripts/download-nodejs.cjs
diff --git a/openwork-memos-integration/apps/desktop/scripts/package.cjs b/apps/openwork-memos-integration/apps/desktop/scripts/package.cjs
similarity index 100%
rename from openwork-memos-integration/apps/desktop/scripts/package.cjs
rename to apps/openwork-memos-integration/apps/desktop/scripts/package.cjs
diff --git a/openwork-memos-integration/apps/desktop/scripts/patch-electron-name.cjs b/apps/openwork-memos-integration/apps/desktop/scripts/patch-electron-name.cjs
similarity index 100%
rename from openwork-memos-integration/apps/desktop/scripts/patch-electron-name.cjs
rename to apps/openwork-memos-integration/apps/desktop/scripts/patch-electron-name.cjs
diff --git a/openwork-memos-integration/apps/desktop/skills/ask-user-question/SKILL.md b/apps/openwork-memos-integration/apps/desktop/skills/ask-user-question/SKILL.md
similarity index 100%
rename from openwork-memos-integration/apps/desktop/skills/ask-user-question/SKILL.md
rename to apps/openwork-memos-integration/apps/desktop/skills/ask-user-question/SKILL.md
diff --git a/openwork-memos-integration/apps/desktop/skills/ask-user-question/package.json b/apps/openwork-memos-integration/apps/desktop/skills/ask-user-question/package.json
similarity index 100%
rename from openwork-memos-integration/apps/desktop/skills/ask-user-question/package.json
rename to apps/openwork-memos-integration/apps/desktop/skills/ask-user-question/package.json
diff --git a/openwork-memos-integration/apps/desktop/skills/ask-user-question/src/index.ts b/apps/openwork-memos-integration/apps/desktop/skills/ask-user-question/src/index.ts
similarity index 100%
rename from openwork-memos-integration/apps/desktop/skills/ask-user-question/src/index.ts
rename to apps/openwork-memos-integration/apps/desktop/skills/ask-user-question/src/index.ts
diff --git a/openwork-memos-integration/apps/desktop/skills/ask-user-question/tsconfig.json b/apps/openwork-memos-integration/apps/desktop/skills/ask-user-question/tsconfig.json
similarity index 100%
rename from openwork-memos-integration/apps/desktop/skills/ask-user-question/tsconfig.json
rename to apps/openwork-memos-integration/apps/desktop/skills/ask-user-question/tsconfig.json
diff --git a/openwork-memos-integration/apps/desktop/skills/dev-browser/.gitignore b/apps/openwork-memos-integration/apps/desktop/skills/dev-browser/.gitignore
similarity index 100%
rename from openwork-memos-integration/apps/desktop/skills/dev-browser/.gitignore
rename to apps/openwork-memos-integration/apps/desktop/skills/dev-browser/.gitignore
diff --git a/openwork-memos-integration/apps/desktop/skills/dev-browser/SKILL.md b/apps/openwork-memos-integration/apps/desktop/skills/dev-browser/SKILL.md
similarity index 100%
rename from openwork-memos-integration/apps/desktop/skills/dev-browser/SKILL.md
rename to apps/openwork-memos-integration/apps/desktop/skills/dev-browser/SKILL.md
diff --git a/openwork-memos-integration/apps/desktop/skills/dev-browser/package.json b/apps/openwork-memos-integration/apps/desktop/skills/dev-browser/package.json
similarity index 100%
rename from openwork-memos-integration/apps/desktop/skills/dev-browser/package.json
rename to apps/openwork-memos-integration/apps/desktop/skills/dev-browser/package.json
diff --git a/openwork-memos-integration/apps/desktop/skills/dev-browser/references/scraping.md b/apps/openwork-memos-integration/apps/desktop/skills/dev-browser/references/scraping.md
similarity index 100%
rename from openwork-memos-integration/apps/desktop/skills/dev-browser/references/scraping.md
rename to apps/openwork-memos-integration/apps/desktop/skills/dev-browser/references/scraping.md
diff --git a/openwork-memos-integration/apps/desktop/skills/dev-browser/scripts/start-relay.ts b/apps/openwork-memos-integration/apps/desktop/skills/dev-browser/scripts/start-relay.ts
similarity index 100%
rename from openwork-memos-integration/apps/desktop/skills/dev-browser/scripts/start-relay.ts
rename to apps/openwork-memos-integration/apps/desktop/skills/dev-browser/scripts/start-relay.ts
diff --git a/openwork-memos-integration/apps/desktop/skills/dev-browser/scripts/start-server.ts b/apps/openwork-memos-integration/apps/desktop/skills/dev-browser/scripts/start-server.ts
similarity index 100%
rename from openwork-memos-integration/apps/desktop/skills/dev-browser/scripts/start-server.ts
rename to apps/openwork-memos-integration/apps/desktop/skills/dev-browser/scripts/start-server.ts
diff --git a/openwork-memos-integration/apps/desktop/skills/dev-browser/server.sh b/apps/openwork-memos-integration/apps/desktop/skills/dev-browser/server.sh
similarity index 100%
rename from openwork-memos-integration/apps/desktop/skills/dev-browser/server.sh
rename to apps/openwork-memos-integration/apps/desktop/skills/dev-browser/server.sh
diff --git a/openwork-memos-integration/apps/desktop/skills/dev-browser/src/client.ts b/apps/openwork-memos-integration/apps/desktop/skills/dev-browser/src/client.ts
similarity index 100%
rename from openwork-memos-integration/apps/desktop/skills/dev-browser/src/client.ts
rename to apps/openwork-memos-integration/apps/desktop/skills/dev-browser/src/client.ts
diff --git a/openwork-memos-integration/apps/desktop/skills/dev-browser/src/index.ts b/apps/openwork-memos-integration/apps/desktop/skills/dev-browser/src/index.ts
similarity index 100%
rename from openwork-memos-integration/apps/desktop/skills/dev-browser/src/index.ts
rename to apps/openwork-memos-integration/apps/desktop/skills/dev-browser/src/index.ts
diff --git a/openwork-memos-integration/apps/desktop/skills/dev-browser/src/relay.ts b/apps/openwork-memos-integration/apps/desktop/skills/dev-browser/src/relay.ts
similarity index 100%
rename from openwork-memos-integration/apps/desktop/skills/dev-browser/src/relay.ts
rename to apps/openwork-memos-integration/apps/desktop/skills/dev-browser/src/relay.ts
diff --git a/openwork-memos-integration/apps/desktop/skills/dev-browser/src/snapshot/__tests__/snapshot.test.ts b/apps/openwork-memos-integration/apps/desktop/skills/dev-browser/src/snapshot/__tests__/snapshot.test.ts
similarity index 100%
rename from openwork-memos-integration/apps/desktop/skills/dev-browser/src/snapshot/__tests__/snapshot.test.ts
rename to apps/openwork-memos-integration/apps/desktop/skills/dev-browser/src/snapshot/__tests__/snapshot.test.ts
diff --git a/openwork-memos-integration/apps/desktop/skills/dev-browser/src/snapshot/browser-script.ts b/apps/openwork-memos-integration/apps/desktop/skills/dev-browser/src/snapshot/browser-script.ts
similarity index 100%
rename from openwork-memos-integration/apps/desktop/skills/dev-browser/src/snapshot/browser-script.ts
rename to apps/openwork-memos-integration/apps/desktop/skills/dev-browser/src/snapshot/browser-script.ts
diff --git a/openwork-memos-integration/apps/desktop/skills/dev-browser/src/snapshot/index.ts b/apps/openwork-memos-integration/apps/desktop/skills/dev-browser/src/snapshot/index.ts
similarity index 100%
rename from openwork-memos-integration/apps/desktop/skills/dev-browser/src/snapshot/index.ts
rename to apps/openwork-memos-integration/apps/desktop/skills/dev-browser/src/snapshot/index.ts
diff --git a/openwork-memos-integration/apps/desktop/skills/dev-browser/src/snapshot/inject.ts b/apps/openwork-memos-integration/apps/desktop/skills/dev-browser/src/snapshot/inject.ts
similarity index 100%
rename from openwork-memos-integration/apps/desktop/skills/dev-browser/src/snapshot/inject.ts
rename to apps/openwork-memos-integration/apps/desktop/skills/dev-browser/src/snapshot/inject.ts
diff --git a/openwork-memos-integration/apps/desktop/skills/dev-browser/src/types.ts b/apps/openwork-memos-integration/apps/desktop/skills/dev-browser/src/types.ts
similarity index 100%
rename from openwork-memos-integration/apps/desktop/skills/dev-browser/src/types.ts
rename to apps/openwork-memos-integration/apps/desktop/skills/dev-browser/src/types.ts
diff --git a/openwork-memos-integration/apps/desktop/skills/dev-browser/tsconfig.json b/apps/openwork-memos-integration/apps/desktop/skills/dev-browser/tsconfig.json
similarity index 100%
rename from openwork-memos-integration/apps/desktop/skills/dev-browser/tsconfig.json
rename to apps/openwork-memos-integration/apps/desktop/skills/dev-browser/tsconfig.json
diff --git a/openwork-memos-integration/apps/desktop/skills/dev-browser/vitest.config.ts b/apps/openwork-memos-integration/apps/desktop/skills/dev-browser/vitest.config.ts
similarity index 100%
rename from openwork-memos-integration/apps/desktop/skills/dev-browser/vitest.config.ts
rename to apps/openwork-memos-integration/apps/desktop/skills/dev-browser/vitest.config.ts
diff --git a/openwork-memos-integration/apps/desktop/skills/file-permission/package.json b/apps/openwork-memos-integration/apps/desktop/skills/file-permission/package.json
similarity index 100%
rename from openwork-memos-integration/apps/desktop/skills/file-permission/package.json
rename to apps/openwork-memos-integration/apps/desktop/skills/file-permission/package.json
diff --git a/openwork-memos-integration/apps/desktop/skills/file-permission/src/index.ts b/apps/openwork-memos-integration/apps/desktop/skills/file-permission/src/index.ts
similarity index 100%
rename from openwork-memos-integration/apps/desktop/skills/file-permission/src/index.ts
rename to apps/openwork-memos-integration/apps/desktop/skills/file-permission/src/index.ts
diff --git a/openwork-memos-integration/apps/desktop/skills/file-permission/tsconfig.json b/apps/openwork-memos-integration/apps/desktop/skills/file-permission/tsconfig.json
similarity index 100%
rename from openwork-memos-integration/apps/desktop/skills/file-permission/tsconfig.json
rename to apps/openwork-memos-integration/apps/desktop/skills/file-permission/tsconfig.json
diff --git a/openwork-memos-integration/apps/desktop/skills/safe-file-deletion/SKILL.md b/apps/openwork-memos-integration/apps/desktop/skills/safe-file-deletion/SKILL.md
similarity index 100%
rename from openwork-memos-integration/apps/desktop/skills/safe-file-deletion/SKILL.md
rename to apps/openwork-memos-integration/apps/desktop/skills/safe-file-deletion/SKILL.md
diff --git a/openwork-memos-integration/apps/desktop/src/main/config.ts b/apps/openwork-memos-integration/apps/desktop/src/main/config.ts
similarity index 100%
rename from openwork-memos-integration/apps/desktop/src/main/config.ts
rename to apps/openwork-memos-integration/apps/desktop/src/main/config.ts
diff --git a/openwork-memos-integration/apps/desktop/src/main/index.ts b/apps/openwork-memos-integration/apps/desktop/src/main/index.ts
similarity index 100%
rename from openwork-memos-integration/apps/desktop/src/main/index.ts
rename to apps/openwork-memos-integration/apps/desktop/src/main/index.ts
diff --git a/openwork-memos-integration/apps/desktop/src/main/ipc/handlers.ts b/apps/openwork-memos-integration/apps/desktop/src/main/ipc/handlers.ts
similarity index 100%
rename from openwork-memos-integration/apps/desktop/src/main/ipc/handlers.ts
rename to apps/openwork-memos-integration/apps/desktop/src/main/ipc/handlers.ts
diff --git a/openwork-memos-integration/apps/desktop/src/main/ipc/validation.ts b/apps/openwork-memos-integration/apps/desktop/src/main/ipc/validation.ts
similarity index 100%
rename from openwork-memos-integration/apps/desktop/src/main/ipc/validation.ts
rename to apps/openwork-memos-integration/apps/desktop/src/main/ipc/validation.ts
diff --git a/openwork-memos-integration/apps/desktop/src/main/opencode/adapter.ts b/apps/openwork-memos-integration/apps/desktop/src/main/opencode/adapter.ts
similarity index 100%
rename from openwork-memos-integration/apps/desktop/src/main/opencode/adapter.ts
rename to apps/openwork-memos-integration/apps/desktop/src/main/opencode/adapter.ts
diff --git a/openwork-memos-integration/apps/desktop/src/main/opencode/cli-path.ts b/apps/openwork-memos-integration/apps/desktop/src/main/opencode/cli-path.ts
similarity index 100%
rename from openwork-memos-integration/apps/desktop/src/main/opencode/cli-path.ts
rename to apps/openwork-memos-integration/apps/desktop/src/main/opencode/cli-path.ts
diff --git a/openwork-memos-integration/apps/desktop/src/main/opencode/config-generator.ts b/apps/openwork-memos-integration/apps/desktop/src/main/opencode/config-generator.ts
similarity index 100%
rename from openwork-memos-integration/apps/desktop/src/main/opencode/config-generator.ts
rename to apps/openwork-memos-integration/apps/desktop/src/main/opencode/config-generator.ts
diff --git a/openwork-memos-integration/apps/desktop/src/main/opencode/stream-parser.ts b/apps/openwork-memos-integration/apps/desktop/src/main/opencode/stream-parser.ts
similarity index 100%
rename from openwork-memos-integration/apps/desktop/src/main/opencode/stream-parser.ts
rename to apps/openwork-memos-integration/apps/desktop/src/main/opencode/stream-parser.ts
diff --git a/openwork-memos-integration/apps/desktop/src/main/opencode/task-manager.ts b/apps/openwork-memos-integration/apps/desktop/src/main/opencode/task-manager.ts
similarity index 100%
rename from openwork-memos-integration/apps/desktop/src/main/opencode/task-manager.ts
rename to apps/openwork-memos-integration/apps/desktop/src/main/opencode/task-manager.ts
diff --git a/openwork-memos-integration/apps/desktop/src/main/permission-api.ts b/apps/openwork-memos-integration/apps/desktop/src/main/permission-api.ts
similarity index 100%
rename from openwork-memos-integration/apps/desktop/src/main/permission-api.ts
rename to apps/openwork-memos-integration/apps/desktop/src/main/permission-api.ts
diff --git a/openwork-memos-integration/apps/desktop/src/main/services/memory.ts b/apps/openwork-memos-integration/apps/desktop/src/main/services/memory.ts
similarity index 100%
rename from openwork-memos-integration/apps/desktop/src/main/services/memory.ts
rename to apps/openwork-memos-integration/apps/desktop/src/main/services/memory.ts
diff --git a/openwork-memos-integration/apps/desktop/src/main/services/summarizer.ts b/apps/openwork-memos-integration/apps/desktop/src/main/services/summarizer.ts
similarity index 100%
rename from openwork-memos-integration/apps/desktop/src/main/services/summarizer.ts
rename to apps/openwork-memos-integration/apps/desktop/src/main/services/summarizer.ts
diff --git a/openwork-memos-integration/apps/desktop/src/main/store/appSettings.ts b/apps/openwork-memos-integration/apps/desktop/src/main/store/appSettings.ts
similarity index 100%
rename from openwork-memos-integration/apps/desktop/src/main/store/appSettings.ts
rename to apps/openwork-memos-integration/apps/desktop/src/main/store/appSettings.ts
diff --git a/openwork-memos-integration/apps/desktop/src/main/store/freshInstallCleanup.ts b/apps/openwork-memos-integration/apps/desktop/src/main/store/freshInstallCleanup.ts
similarity index 100%
rename from openwork-memos-integration/apps/desktop/src/main/store/freshInstallCleanup.ts
rename to apps/openwork-memos-integration/apps/desktop/src/main/store/freshInstallCleanup.ts
diff --git a/openwork-memos-integration/apps/desktop/src/main/store/providerSettings.ts b/apps/openwork-memos-integration/apps/desktop/src/main/store/providerSettings.ts
similarity index 100%
rename from openwork-memos-integration/apps/desktop/src/main/store/providerSettings.ts
rename to apps/openwork-memos-integration/apps/desktop/src/main/store/providerSettings.ts
diff --git a/openwork-memos-integration/apps/desktop/src/main/store/secureStorage.ts b/apps/openwork-memos-integration/apps/desktop/src/main/store/secureStorage.ts
similarity index 100%
rename from openwork-memos-integration/apps/desktop/src/main/store/secureStorage.ts
rename to apps/openwork-memos-integration/apps/desktop/src/main/store/secureStorage.ts
diff --git a/openwork-memos-integration/apps/desktop/src/main/store/taskHistory.ts b/apps/openwork-memos-integration/apps/desktop/src/main/store/taskHistory.ts
similarity index 100%
rename from openwork-memos-integration/apps/desktop/src/main/store/taskHistory.ts
rename to apps/openwork-memos-integration/apps/desktop/src/main/store/taskHistory.ts
diff --git a/openwork-memos-integration/apps/desktop/src/main/test-utils/mock-task-flow.ts b/apps/openwork-memos-integration/apps/desktop/src/main/test-utils/mock-task-flow.ts
similarity index 100%
rename from openwork-memos-integration/apps/desktop/src/main/test-utils/mock-task-flow.ts
rename to apps/openwork-memos-integration/apps/desktop/src/main/test-utils/mock-task-flow.ts
diff --git a/openwork-memos-integration/apps/desktop/src/main/utils/bundled-node.ts b/apps/openwork-memos-integration/apps/desktop/src/main/utils/bundled-node.ts
similarity index 100%
rename from openwork-memos-integration/apps/desktop/src/main/utils/bundled-node.ts
rename to apps/openwork-memos-integration/apps/desktop/src/main/utils/bundled-node.ts
diff --git a/openwork-memos-integration/apps/desktop/src/main/utils/system-path.ts b/apps/openwork-memos-integration/apps/desktop/src/main/utils/system-path.ts
similarity index 100%
rename from openwork-memos-integration/apps/desktop/src/main/utils/system-path.ts
rename to apps/openwork-memos-integration/apps/desktop/src/main/utils/system-path.ts
diff --git a/openwork-memos-integration/apps/desktop/src/preload/index.ts b/apps/openwork-memos-integration/apps/desktop/src/preload/index.ts
similarity index 100%
rename from openwork-memos-integration/apps/desktop/src/preload/index.ts
rename to apps/openwork-memos-integration/apps/desktop/src/preload/index.ts
diff --git a/openwork-memos-integration/apps/desktop/src/renderer/App.tsx b/apps/openwork-memos-integration/apps/desktop/src/renderer/App.tsx
similarity index 100%
rename from openwork-memos-integration/apps/desktop/src/renderer/App.tsx
rename to apps/openwork-memos-integration/apps/desktop/src/renderer/App.tsx
diff --git a/openwork-memos-integration/apps/desktop/src/renderer/components/TaskLauncher/TaskLauncher.tsx b/apps/openwork-memos-integration/apps/desktop/src/renderer/components/TaskLauncher/TaskLauncher.tsx
similarity index 100%
rename from openwork-memos-integration/apps/desktop/src/renderer/components/TaskLauncher/TaskLauncher.tsx
rename to apps/openwork-memos-integration/apps/desktop/src/renderer/components/TaskLauncher/TaskLauncher.tsx
diff --git a/openwork-memos-integration/apps/desktop/src/renderer/components/TaskLauncher/TaskLauncherItem.tsx b/apps/openwork-memos-integration/apps/desktop/src/renderer/components/TaskLauncher/TaskLauncherItem.tsx
similarity index 100%
rename from openwork-memos-integration/apps/desktop/src/renderer/components/TaskLauncher/TaskLauncherItem.tsx
rename to apps/openwork-memos-integration/apps/desktop/src/renderer/components/TaskLauncher/TaskLauncherItem.tsx
diff --git a/openwork-memos-integration/apps/desktop/src/renderer/components/TaskLauncher/index.ts b/apps/openwork-memos-integration/apps/desktop/src/renderer/components/TaskLauncher/index.ts
similarity index 100%
rename from openwork-memos-integration/apps/desktop/src/renderer/components/TaskLauncher/index.ts
rename to apps/openwork-memos-integration/apps/desktop/src/renderer/components/TaskLauncher/index.ts
diff --git a/openwork-memos-integration/apps/desktop/src/renderer/components/history/TaskHistory.tsx b/apps/openwork-memos-integration/apps/desktop/src/renderer/components/history/TaskHistory.tsx
similarity index 100%
rename from openwork-memos-integration/apps/desktop/src/renderer/components/history/TaskHistory.tsx
rename to apps/openwork-memos-integration/apps/desktop/src/renderer/components/history/TaskHistory.tsx
diff --git a/openwork-memos-integration/apps/desktop/src/renderer/components/landing/TaskInputBar.tsx b/apps/openwork-memos-integration/apps/desktop/src/renderer/components/landing/TaskInputBar.tsx
similarity index 100%
rename from openwork-memos-integration/apps/desktop/src/renderer/components/landing/TaskInputBar.tsx
rename to apps/openwork-memos-integration/apps/desktop/src/renderer/components/landing/TaskInputBar.tsx
diff --git a/openwork-memos-integration/apps/desktop/src/renderer/components/layout/ConversationListItem.tsx b/apps/openwork-memos-integration/apps/desktop/src/renderer/components/layout/ConversationListItem.tsx
similarity index 100%
rename from openwork-memos-integration/apps/desktop/src/renderer/components/layout/ConversationListItem.tsx
rename to apps/openwork-memos-integration/apps/desktop/src/renderer/components/layout/ConversationListItem.tsx
diff --git a/openwork-memos-integration/apps/desktop/src/renderer/components/layout/Header.tsx b/apps/openwork-memos-integration/apps/desktop/src/renderer/components/layout/Header.tsx
similarity index 100%
rename from openwork-memos-integration/apps/desktop/src/renderer/components/layout/Header.tsx
rename to apps/openwork-memos-integration/apps/desktop/src/renderer/components/layout/Header.tsx
diff --git a/openwork-memos-integration/apps/desktop/src/renderer/components/layout/SettingsDialog.tsx b/apps/openwork-memos-integration/apps/desktop/src/renderer/components/layout/SettingsDialog.tsx
similarity index 100%
rename from openwork-memos-integration/apps/desktop/src/renderer/components/layout/SettingsDialog.tsx
rename to apps/openwork-memos-integration/apps/desktop/src/renderer/components/layout/SettingsDialog.tsx
diff --git a/openwork-memos-integration/apps/desktop/src/renderer/components/layout/Sidebar.tsx b/apps/openwork-memos-integration/apps/desktop/src/renderer/components/layout/Sidebar.tsx
similarity index 100%
rename from openwork-memos-integration/apps/desktop/src/renderer/components/layout/Sidebar.tsx
rename to apps/openwork-memos-integration/apps/desktop/src/renderer/components/layout/Sidebar.tsx
diff --git a/openwork-memos-integration/apps/desktop/src/renderer/components/settings/ProviderCard.tsx b/apps/openwork-memos-integration/apps/desktop/src/renderer/components/settings/ProviderCard.tsx
similarity index 100%
rename from openwork-memos-integration/apps/desktop/src/renderer/components/settings/ProviderCard.tsx
rename to apps/openwork-memos-integration/apps/desktop/src/renderer/components/settings/ProviderCard.tsx
diff --git a/openwork-memos-integration/apps/desktop/src/renderer/components/settings/ProviderGrid.tsx b/apps/openwork-memos-integration/apps/desktop/src/renderer/components/settings/ProviderGrid.tsx
similarity index 100%
rename from openwork-memos-integration/apps/desktop/src/renderer/components/settings/ProviderGrid.tsx
rename to apps/openwork-memos-integration/apps/desktop/src/renderer/components/settings/ProviderGrid.tsx
diff --git a/openwork-memos-integration/apps/desktop/src/renderer/components/settings/ProviderSettingsPanel.tsx b/apps/openwork-memos-integration/apps/desktop/src/renderer/components/settings/ProviderSettingsPanel.tsx
similarity index 100%
rename from openwork-memos-integration/apps/desktop/src/renderer/components/settings/ProviderSettingsPanel.tsx
rename to apps/openwork-memos-integration/apps/desktop/src/renderer/components/settings/ProviderSettingsPanel.tsx
diff --git a/openwork-memos-integration/apps/desktop/src/renderer/components/settings/hooks/useProviderSettings.ts b/apps/openwork-memos-integration/apps/desktop/src/renderer/components/settings/hooks/useProviderSettings.ts
similarity index 100%
rename from openwork-memos-integration/apps/desktop/src/renderer/components/settings/hooks/useProviderSettings.ts
rename to apps/openwork-memos-integration/apps/desktop/src/renderer/components/settings/hooks/useProviderSettings.ts
diff --git a/openwork-memos-integration/apps/desktop/src/renderer/components/settings/providers/BedrockProviderForm.tsx b/apps/openwork-memos-integration/apps/desktop/src/renderer/components/settings/providers/BedrockProviderForm.tsx
similarity index 100%
rename from openwork-memos-integration/apps/desktop/src/renderer/components/settings/providers/BedrockProviderForm.tsx
rename to apps/openwork-memos-integration/apps/desktop/src/renderer/components/settings/providers/BedrockProviderForm.tsx
diff --git a/openwork-memos-integration/apps/desktop/src/renderer/components/settings/providers/ClassicProviderForm.tsx b/apps/openwork-memos-integration/apps/desktop/src/renderer/components/settings/providers/ClassicProviderForm.tsx
similarity index 100%
rename from openwork-memos-integration/apps/desktop/src/renderer/components/settings/providers/ClassicProviderForm.tsx
rename to apps/openwork-memos-integration/apps/desktop/src/renderer/components/settings/providers/ClassicProviderForm.tsx
diff --git a/openwork-memos-integration/apps/desktop/src/renderer/components/settings/providers/LiteLLMProviderForm.tsx b/apps/openwork-memos-integration/apps/desktop/src/renderer/components/settings/providers/LiteLLMProviderForm.tsx
similarity index 100%
rename from openwork-memos-integration/apps/desktop/src/renderer/components/settings/providers/LiteLLMProviderForm.tsx
rename to apps/openwork-memos-integration/apps/desktop/src/renderer/components/settings/providers/LiteLLMProviderForm.tsx
diff --git a/openwork-memos-integration/apps/desktop/src/renderer/components/settings/providers/OllamaProviderForm.tsx b/apps/openwork-memos-integration/apps/desktop/src/renderer/components/settings/providers/OllamaProviderForm.tsx
similarity index 100%
rename from openwork-memos-integration/apps/desktop/src/renderer/components/settings/providers/OllamaProviderForm.tsx
rename to apps/openwork-memos-integration/apps/desktop/src/renderer/components/settings/providers/OllamaProviderForm.tsx
diff --git a/openwork-memos-integration/apps/desktop/src/renderer/components/settings/providers/OpenRouterProviderForm.tsx b/apps/openwork-memos-integration/apps/desktop/src/renderer/components/settings/providers/OpenRouterProviderForm.tsx
similarity index 100%
rename from openwork-memos-integration/apps/desktop/src/renderer/components/settings/providers/OpenRouterProviderForm.tsx
rename to apps/openwork-memos-integration/apps/desktop/src/renderer/components/settings/providers/OpenRouterProviderForm.tsx
diff --git a/openwork-memos-integration/apps/desktop/src/renderer/components/settings/providers/index.ts b/apps/openwork-memos-integration/apps/desktop/src/renderer/components/settings/providers/index.ts
similarity index 100%
rename from openwork-memos-integration/apps/desktop/src/renderer/components/settings/providers/index.ts
rename to apps/openwork-memos-integration/apps/desktop/src/renderer/components/settings/providers/index.ts
diff --git a/openwork-memos-integration/apps/desktop/src/renderer/components/settings/shared/ApiKeyInput.tsx b/apps/openwork-memos-integration/apps/desktop/src/renderer/components/settings/shared/ApiKeyInput.tsx
similarity index 100%
rename from openwork-memos-integration/apps/desktop/src/renderer/components/settings/shared/ApiKeyInput.tsx
rename to apps/openwork-memos-integration/apps/desktop/src/renderer/components/settings/shared/ApiKeyInput.tsx
diff --git a/openwork-memos-integration/apps/desktop/src/renderer/components/settings/shared/ConnectButton.tsx b/apps/openwork-memos-integration/apps/desktop/src/renderer/components/settings/shared/ConnectButton.tsx
similarity index 100%
rename from openwork-memos-integration/apps/desktop/src/renderer/components/settings/shared/ConnectButton.tsx
rename to apps/openwork-memos-integration/apps/desktop/src/renderer/components/settings/shared/ConnectButton.tsx
diff --git a/openwork-memos-integration/apps/desktop/src/renderer/components/settings/shared/ConnectedControls.tsx b/apps/openwork-memos-integration/apps/desktop/src/renderer/components/settings/shared/ConnectedControls.tsx
similarity index 100%
rename from openwork-memos-integration/apps/desktop/src/renderer/components/settings/shared/ConnectedControls.tsx
rename to apps/openwork-memos-integration/apps/desktop/src/renderer/components/settings/shared/ConnectedControls.tsx
diff --git a/openwork-memos-integration/apps/desktop/src/renderer/components/settings/shared/ConnectionStatus.tsx b/apps/openwork-memos-integration/apps/desktop/src/renderer/components/settings/shared/ConnectionStatus.tsx
similarity index 100%
rename from openwork-memos-integration/apps/desktop/src/renderer/components/settings/shared/ConnectionStatus.tsx
rename to apps/openwork-memos-integration/apps/desktop/src/renderer/components/settings/shared/ConnectionStatus.tsx
diff --git a/openwork-memos-integration/apps/desktop/src/renderer/components/settings/shared/FormError.tsx b/apps/openwork-memos-integration/apps/desktop/src/renderer/components/settings/shared/FormError.tsx
similarity index 100%
rename from openwork-memos-integration/apps/desktop/src/renderer/components/settings/shared/FormError.tsx
rename to apps/openwork-memos-integration/apps/desktop/src/renderer/components/settings/shared/FormError.tsx
diff --git a/openwork-memos-integration/apps/desktop/src/renderer/components/settings/shared/ModelSelector.tsx b/apps/openwork-memos-integration/apps/desktop/src/renderer/components/settings/shared/ModelSelector.tsx
similarity index 100%
rename from openwork-memos-integration/apps/desktop/src/renderer/components/settings/shared/ModelSelector.tsx
rename to apps/openwork-memos-integration/apps/desktop/src/renderer/components/settings/shared/ModelSelector.tsx
diff --git a/openwork-memos-integration/apps/desktop/src/renderer/components/settings/shared/ProviderFormHeader.tsx b/apps/openwork-memos-integration/apps/desktop/src/renderer/components/settings/shared/ProviderFormHeader.tsx
similarity index 100%
rename from openwork-memos-integration/apps/desktop/src/renderer/components/settings/shared/ProviderFormHeader.tsx
rename to apps/openwork-memos-integration/apps/desktop/src/renderer/components/settings/shared/ProviderFormHeader.tsx
diff --git a/openwork-memos-integration/apps/desktop/src/renderer/components/settings/shared/RegionSelector.tsx b/apps/openwork-memos-integration/apps/desktop/src/renderer/components/settings/shared/RegionSelector.tsx
similarity index 100%
rename from openwork-memos-integration/apps/desktop/src/renderer/components/settings/shared/RegionSelector.tsx
rename to apps/openwork-memos-integration/apps/desktop/src/renderer/components/settings/shared/RegionSelector.tsx
diff --git a/openwork-memos-integration/apps/desktop/src/renderer/components/settings/shared/index.ts b/apps/openwork-memos-integration/apps/desktop/src/renderer/components/settings/shared/index.ts
similarity index 100%
rename from openwork-memos-integration/apps/desktop/src/renderer/components/settings/shared/index.ts
rename to apps/openwork-memos-integration/apps/desktop/src/renderer/components/settings/shared/index.ts
diff --git a/openwork-memos-integration/apps/desktop/src/renderer/components/ui/avatar.tsx b/apps/openwork-memos-integration/apps/desktop/src/renderer/components/ui/avatar.tsx
similarity index 100%
rename from openwork-memos-integration/apps/desktop/src/renderer/components/ui/avatar.tsx
rename to apps/openwork-memos-integration/apps/desktop/src/renderer/components/ui/avatar.tsx
diff --git a/openwork-memos-integration/apps/desktop/src/renderer/components/ui/badge.tsx b/apps/openwork-memos-integration/apps/desktop/src/renderer/components/ui/badge.tsx
similarity index 100%
rename from openwork-memos-integration/apps/desktop/src/renderer/components/ui/badge.tsx
rename to apps/openwork-memos-integration/apps/desktop/src/renderer/components/ui/badge.tsx
diff --git a/openwork-memos-integration/apps/desktop/src/renderer/components/ui/button.tsx b/apps/openwork-memos-integration/apps/desktop/src/renderer/components/ui/button.tsx
similarity index 100%
rename from openwork-memos-integration/apps/desktop/src/renderer/components/ui/button.tsx
rename to apps/openwork-memos-integration/apps/desktop/src/renderer/components/ui/button.tsx
diff --git a/openwork-memos-integration/apps/desktop/src/renderer/components/ui/card.tsx b/apps/openwork-memos-integration/apps/desktop/src/renderer/components/ui/card.tsx
similarity index 100%
rename from openwork-memos-integration/apps/desktop/src/renderer/components/ui/card.tsx
rename to apps/openwork-memos-integration/apps/desktop/src/renderer/components/ui/card.tsx
diff --git a/openwork-memos-integration/apps/desktop/src/renderer/components/ui/dialog.tsx b/apps/openwork-memos-integration/apps/desktop/src/renderer/components/ui/dialog.tsx
similarity index 100%
rename from openwork-memos-integration/apps/desktop/src/renderer/components/ui/dialog.tsx
rename to apps/openwork-memos-integration/apps/desktop/src/renderer/components/ui/dialog.tsx
diff --git a/openwork-memos-integration/apps/desktop/src/renderer/components/ui/dropdown-menu.tsx b/apps/openwork-memos-integration/apps/desktop/src/renderer/components/ui/dropdown-menu.tsx
similarity index 100%
rename from openwork-memos-integration/apps/desktop/src/renderer/components/ui/dropdown-menu.tsx
rename to apps/openwork-memos-integration/apps/desktop/src/renderer/components/ui/dropdown-menu.tsx
diff --git a/openwork-memos-integration/apps/desktop/src/renderer/components/ui/input.tsx b/apps/openwork-memos-integration/apps/desktop/src/renderer/components/ui/input.tsx
similarity index 100%
rename from openwork-memos-integration/apps/desktop/src/renderer/components/ui/input.tsx
rename to apps/openwork-memos-integration/apps/desktop/src/renderer/components/ui/input.tsx
diff --git a/openwork-memos-integration/apps/desktop/src/renderer/components/ui/label.tsx b/apps/openwork-memos-integration/apps/desktop/src/renderer/components/ui/label.tsx
similarity index 100%
rename from openwork-memos-integration/apps/desktop/src/renderer/components/ui/label.tsx
rename to apps/openwork-memos-integration/apps/desktop/src/renderer/components/ui/label.tsx
diff --git a/openwork-memos-integration/apps/desktop/src/renderer/components/ui/scroll-area.tsx b/apps/openwork-memos-integration/apps/desktop/src/renderer/components/ui/scroll-area.tsx
similarity index 100%
rename from openwork-memos-integration/apps/desktop/src/renderer/components/ui/scroll-area.tsx
rename to apps/openwork-memos-integration/apps/desktop/src/renderer/components/ui/scroll-area.tsx
diff --git a/openwork-memos-integration/apps/desktop/src/renderer/components/ui/separator.tsx b/apps/openwork-memos-integration/apps/desktop/src/renderer/components/ui/separator.tsx
similarity index 100%
rename from openwork-memos-integration/apps/desktop/src/renderer/components/ui/separator.tsx
rename to apps/openwork-memos-integration/apps/desktop/src/renderer/components/ui/separator.tsx
diff --git a/openwork-memos-integration/apps/desktop/src/renderer/components/ui/skeleton.tsx b/apps/openwork-memos-integration/apps/desktop/src/renderer/components/ui/skeleton.tsx
similarity index 100%
rename from openwork-memos-integration/apps/desktop/src/renderer/components/ui/skeleton.tsx
rename to apps/openwork-memos-integration/apps/desktop/src/renderer/components/ui/skeleton.tsx
diff --git a/openwork-memos-integration/apps/desktop/src/renderer/components/ui/streaming-text.tsx b/apps/openwork-memos-integration/apps/desktop/src/renderer/components/ui/streaming-text.tsx
similarity index 100%
rename from openwork-memos-integration/apps/desktop/src/renderer/components/ui/streaming-text.tsx
rename to apps/openwork-memos-integration/apps/desktop/src/renderer/components/ui/streaming-text.tsx
diff --git a/openwork-memos-integration/apps/desktop/src/renderer/components/ui/textarea.tsx b/apps/openwork-memos-integration/apps/desktop/src/renderer/components/ui/textarea.tsx
similarity index 100%
rename from openwork-memos-integration/apps/desktop/src/renderer/components/ui/textarea.tsx
rename to apps/openwork-memos-integration/apps/desktop/src/renderer/components/ui/textarea.tsx
diff --git a/openwork-memos-integration/apps/desktop/src/renderer/main.tsx b/apps/openwork-memos-integration/apps/desktop/src/renderer/main.tsx
similarity index 100%
rename from openwork-memos-integration/apps/desktop/src/renderer/main.tsx
rename to apps/openwork-memos-integration/apps/desktop/src/renderer/main.tsx
diff --git a/openwork-memos-integration/apps/desktop/src/renderer/pages/Execution.tsx b/apps/openwork-memos-integration/apps/desktop/src/renderer/pages/Execution.tsx
similarity index 100%
rename from openwork-memos-integration/apps/desktop/src/renderer/pages/Execution.tsx
rename to apps/openwork-memos-integration/apps/desktop/src/renderer/pages/Execution.tsx
diff --git a/openwork-memos-integration/apps/desktop/src/renderer/pages/History.tsx b/apps/openwork-memos-integration/apps/desktop/src/renderer/pages/History.tsx
similarity index 100%
rename from openwork-memos-integration/apps/desktop/src/renderer/pages/History.tsx
rename to apps/openwork-memos-integration/apps/desktop/src/renderer/pages/History.tsx
diff --git a/openwork-memos-integration/apps/desktop/src/renderer/pages/Home.tsx b/apps/openwork-memos-integration/apps/desktop/src/renderer/pages/Home.tsx
similarity index 100%
rename from openwork-memos-integration/apps/desktop/src/renderer/pages/Home.tsx
rename to apps/openwork-memos-integration/apps/desktop/src/renderer/pages/Home.tsx
diff --git a/openwork-memos-integration/apps/desktop/src/renderer/stores/taskStore.ts b/apps/openwork-memos-integration/apps/desktop/src/renderer/stores/taskStore.ts
similarity index 100%
rename from openwork-memos-integration/apps/desktop/src/renderer/stores/taskStore.ts
rename to apps/openwork-memos-integration/apps/desktop/src/renderer/stores/taskStore.ts
diff --git a/openwork-memos-integration/apps/desktop/src/renderer/styles/globals.css b/apps/openwork-memos-integration/apps/desktop/src/renderer/styles/globals.css
similarity index 100%
rename from openwork-memos-integration/apps/desktop/src/renderer/styles/globals.css
rename to apps/openwork-memos-integration/apps/desktop/src/renderer/styles/globals.css
diff --git a/openwork-memos-integration/apps/desktop/src/vite-env.d.ts b/apps/openwork-memos-integration/apps/desktop/src/vite-env.d.ts
similarity index 100%
rename from openwork-memos-integration/apps/desktop/src/vite-env.d.ts
rename to apps/openwork-memos-integration/apps/desktop/src/vite-env.d.ts
diff --git a/openwork-memos-integration/apps/desktop/tailwind.config.ts b/apps/openwork-memos-integration/apps/desktop/tailwind.config.ts
similarity index 100%
rename from openwork-memos-integration/apps/desktop/tailwind.config.ts
rename to apps/openwork-memos-integration/apps/desktop/tailwind.config.ts
diff --git a/openwork-memos-integration/apps/desktop/tsconfig.json b/apps/openwork-memos-integration/apps/desktop/tsconfig.json
similarity index 100%
rename from openwork-memos-integration/apps/desktop/tsconfig.json
rename to apps/openwork-memos-integration/apps/desktop/tsconfig.json
diff --git a/openwork-memos-integration/apps/desktop/vite.config.ts b/apps/openwork-memos-integration/apps/desktop/vite.config.ts
similarity index 100%
rename from openwork-memos-integration/apps/desktop/vite.config.ts
rename to apps/openwork-memos-integration/apps/desktop/vite.config.ts
diff --git a/openwork-memos-integration/apps/desktop/vitest.config.ts b/apps/openwork-memos-integration/apps/desktop/vitest.config.ts
similarity index 100%
rename from openwork-memos-integration/apps/desktop/vitest.config.ts
rename to apps/openwork-memos-integration/apps/desktop/vitest.config.ts
diff --git a/openwork-memos-integration/apps/desktop/vitest.integration.config.ts b/apps/openwork-memos-integration/apps/desktop/vitest.integration.config.ts
similarity index 100%
rename from openwork-memos-integration/apps/desktop/vitest.integration.config.ts
rename to apps/openwork-memos-integration/apps/desktop/vitest.integration.config.ts
diff --git a/openwork-memos-integration/apps/desktop/vitest.unit.config.ts b/apps/openwork-memos-integration/apps/desktop/vitest.unit.config.ts
similarity index 100%
rename from openwork-memos-integration/apps/desktop/vitest.unit.config.ts
rename to apps/openwork-memos-integration/apps/desktop/vitest.unit.config.ts
diff --git a/openwork-memos-integration/docs/banner.svg b/apps/openwork-memos-integration/docs/banner.svg
similarity index 100%
rename from openwork-memos-integration/docs/banner.svg
rename to apps/openwork-memos-integration/docs/banner.svg
diff --git a/openwork-memos-integration/docs/plans/2026-01-17-safe-file-deletion-impl.md b/apps/openwork-memos-integration/docs/plans/2026-01-17-safe-file-deletion-impl.md
similarity index 100%
rename from openwork-memos-integration/docs/plans/2026-01-17-safe-file-deletion-impl.md
rename to apps/openwork-memos-integration/docs/plans/2026-01-17-safe-file-deletion-impl.md
diff --git a/openwork-memos-integration/package.json b/apps/openwork-memos-integration/package.json
similarity index 100%
rename from openwork-memos-integration/package.json
rename to apps/openwork-memos-integration/package.json
diff --git a/openwork-memos-integration/packages/shared/package.json b/apps/openwork-memos-integration/packages/shared/package.json
similarity index 100%
rename from openwork-memos-integration/packages/shared/package.json
rename to apps/openwork-memos-integration/packages/shared/package.json
diff --git a/openwork-memos-integration/packages/shared/src/index.ts b/apps/openwork-memos-integration/packages/shared/src/index.ts
similarity index 100%
rename from openwork-memos-integration/packages/shared/src/index.ts
rename to apps/openwork-memos-integration/packages/shared/src/index.ts
diff --git a/openwork-memos-integration/packages/shared/src/types/auth.ts b/apps/openwork-memos-integration/packages/shared/src/types/auth.ts
similarity index 100%
rename from openwork-memos-integration/packages/shared/src/types/auth.ts
rename to apps/openwork-memos-integration/packages/shared/src/types/auth.ts
diff --git a/openwork-memos-integration/packages/shared/src/types/index.ts b/apps/openwork-memos-integration/packages/shared/src/types/index.ts
similarity index 100%
rename from openwork-memos-integration/packages/shared/src/types/index.ts
rename to apps/openwork-memos-integration/packages/shared/src/types/index.ts
diff --git a/openwork-memos-integration/packages/shared/src/types/opencode.ts b/apps/openwork-memos-integration/packages/shared/src/types/opencode.ts
similarity index 100%
rename from openwork-memos-integration/packages/shared/src/types/opencode.ts
rename to apps/openwork-memos-integration/packages/shared/src/types/opencode.ts
diff --git a/openwork-memos-integration/packages/shared/src/types/permission.ts b/apps/openwork-memos-integration/packages/shared/src/types/permission.ts
similarity index 100%
rename from openwork-memos-integration/packages/shared/src/types/permission.ts
rename to apps/openwork-memos-integration/packages/shared/src/types/permission.ts
diff --git a/openwork-memos-integration/packages/shared/src/types/provider.ts b/apps/openwork-memos-integration/packages/shared/src/types/provider.ts
similarity index 100%
rename from openwork-memos-integration/packages/shared/src/types/provider.ts
rename to apps/openwork-memos-integration/packages/shared/src/types/provider.ts
diff --git a/openwork-memos-integration/packages/shared/src/types/providerSettings.ts b/apps/openwork-memos-integration/packages/shared/src/types/providerSettings.ts
similarity index 100%
rename from openwork-memos-integration/packages/shared/src/types/providerSettings.ts
rename to apps/openwork-memos-integration/packages/shared/src/types/providerSettings.ts
diff --git a/openwork-memos-integration/packages/shared/src/types/task.ts b/apps/openwork-memos-integration/packages/shared/src/types/task.ts
similarity index 100%
rename from openwork-memos-integration/packages/shared/src/types/task.ts
rename to apps/openwork-memos-integration/packages/shared/src/types/task.ts
diff --git a/openwork-memos-integration/packages/shared/tsconfig.json b/apps/openwork-memos-integration/packages/shared/tsconfig.json
similarity index 100%
rename from openwork-memos-integration/packages/shared/tsconfig.json
rename to apps/openwork-memos-integration/packages/shared/tsconfig.json
diff --git a/openwork-memos-integration/pnpm-workspace.yaml b/apps/openwork-memos-integration/pnpm-workspace.yaml
similarity index 100%
rename from openwork-memos-integration/pnpm-workspace.yaml
rename to apps/openwork-memos-integration/pnpm-workspace.yaml
diff --git a/docker/Dockerfile.krolik b/docker/Dockerfile.krolik
index c475a6d30..dcae7e0d9 100644
--- a/docker/Dockerfile.krolik
+++ b/docker/Dockerfile.krolik
@@ -1,5 +1,5 @@
# MemOS with Krolik Security Extensions
-#
+#
# This Dockerfile builds MemOS with authentication, rate limiting, and admin API.
# It uses the overlay pattern to keep customizations separate from base code.
diff --git a/examples/data/mem_cube_tree/textual_memory.json b/examples/data/mem_cube_tree/textual_memory.json
index 91f426ca2..97a2b1dd0 100644
--- a/examples/data/mem_cube_tree/textual_memory.json
+++ b/examples/data/mem_cube_tree/textual_memory.json
@@ -4216,4 +4216,4 @@
"edges": [],
"total_nodes": 4,
"total_edges": 0
-}
\ No newline at end of file
+}
diff --git a/openwork-memos-integration/apps/desktop/__tests__/renderer/lib/utils.unit.test.ts b/openwork-memos-integration/apps/desktop/__tests__/renderer/lib/utils.unit.test.ts
deleted file mode 100644
index 372124cd7..000000000
--- a/openwork-memos-integration/apps/desktop/__tests__/renderer/lib/utils.unit.test.ts
+++ /dev/null
@@ -1,437 +0,0 @@
-import { describe, it, expect } from 'vitest';
-import { cn } from '../../../src/renderer/lib/utils';
-
-describe('utils.ts', () => {
- describe('cn() - class name merging', () => {
- describe('basic usage', () => {
- it('should return single class unchanged', () => {
- // Act
- const result = cn('text-red-500');
-
- // Assert
- expect(result).toBe('text-red-500');
- });
-
- it('should merge multiple classes', () => {
- // Act
- const result = cn('text-red-500', 'bg-white');
-
- // Assert
- expect(result).toBe('text-red-500 bg-white');
- });
-
- it('should handle empty string inputs', () => {
- // Act
- const result = cn('', 'text-red-500', '');
-
- // Assert
- expect(result).toBe('text-red-500');
- });
-
- it('should handle no arguments', () => {
- // Act
- const result = cn();
-
- // Assert
- expect(result).toBe('');
- });
-
- it('should handle single empty string', () => {
- // Act
- const result = cn('');
-
- // Assert
- expect(result).toBe('');
- });
- });
-
- describe('conditional classes with clsx', () => {
- it('should include class when condition is true', () => {
- // Arrange
- const isActive = true;
-
- // Act
- const result = cn('base', isActive && 'active');
-
- // Assert
- expect(result).toBe('base active');
- });
-
- it('should exclude class when condition is false', () => {
- // Arrange
- const isActive = false;
-
- // Act
- const result = cn('base', isActive && 'active');
-
- // Assert
- expect(result).toBe('base');
- });
-
- it('should handle object syntax for conditionals', () => {
- // Arrange
- const isActive = true;
- const isDisabled = false;
-
- // Act
- const result = cn('base', {
- active: isActive,
- disabled: isDisabled,
- });
-
- // Assert
- expect(result).toBe('base active');
- });
-
- it('should handle array of classes', () => {
- // Act
- const result = cn(['text-red-500', 'bg-white']);
-
- // Assert
- expect(result).toBe('text-red-500 bg-white');
- });
-
- it('should handle nested arrays', () => {
- // Act
- const result = cn(['base', ['nested1', 'nested2']]);
-
- // Assert
- expect(result).toBe('base nested1 nested2');
- });
-
- it('should handle null and undefined values', () => {
- // Act
- const result = cn('base', null, undefined, 'end');
-
- // Assert
- expect(result).toBe('base end');
- });
-
- it('should handle false and 0 values', () => {
- // Act
- const result = cn('base', false, 0, 'end');
-
- // Assert
- expect(result).toBe('base end');
- });
- });
-
- describe('Tailwind conflict resolution', () => {
- it('should resolve conflicting padding classes (later wins)', () => {
- // Act
- const result = cn('p-4', 'p-8');
-
- // Assert
- expect(result).toBe('p-8');
- });
-
- it('should resolve conflicting margin classes', () => {
- // Act
- const result = cn('m-2', 'm-4');
-
- // Assert
- expect(result).toBe('m-4');
- });
-
- it('should resolve conflicting text color classes', () => {
- // Act
- const result = cn('text-red-500', 'text-blue-500');
-
- // Assert
- expect(result).toBe('text-blue-500');
- });
-
- it('should resolve conflicting background color classes', () => {
- // Act
- const result = cn('bg-white', 'bg-black');
-
- // Assert
- expect(result).toBe('bg-black');
- });
-
- it('should not merge non-conflicting classes', () => {
- // Act
- const result = cn('text-red-500', 'bg-white', 'p-4');
-
- // Assert
- expect(result).toBe('text-red-500 bg-white p-4');
- });
-
- it('should resolve conflicting font size classes', () => {
- // Act
- const result = cn('text-sm', 'text-lg');
-
- // Assert
- expect(result).toBe('text-lg');
- });
-
- it('should resolve conflicting font weight classes', () => {
- // Act
- const result = cn('font-normal', 'font-bold');
-
- // Assert
- expect(result).toBe('font-bold');
- });
-
- it('should resolve conflicting display classes', () => {
- // Act
- const result = cn('block', 'flex');
-
- // Assert
- expect(result).toBe('flex');
- });
-
- it('should resolve conflicting width classes', () => {
- // Act
- const result = cn('w-full', 'w-1/2');
-
- // Assert
- expect(result).toBe('w-1/2');
- });
-
- it('should resolve conflicting height classes', () => {
- // Act
- const result = cn('h-10', 'h-20');
-
- // Assert
- expect(result).toBe('h-20');
- });
-
- it('should handle directional padding without conflict', () => {
- // Act
- const result = cn('px-4', 'py-2');
-
- // Assert
- expect(result).toBe('px-4 py-2');
- });
-
- it('should resolve px vs px conflicts', () => {
- // Act
- const result = cn('px-4', 'px-8');
-
- // Assert
- expect(result).toBe('px-8');
- });
-
- it('should not confuse px with p', () => {
- // Act
- const result = cn('p-4', 'px-8');
-
- // Assert
- expect(result).toContain('p-4');
- expect(result).toContain('px-8');
- });
-
- it('should resolve conflicting rounded classes', () => {
- // Act
- const result = cn('rounded', 'rounded-lg');
-
- // Assert
- expect(result).toBe('rounded-lg');
- });
-
- it('should resolve conflicting border classes', () => {
- // Act
- const result = cn('border', 'border-2');
-
- // Assert
- expect(result).toBe('border-2');
- });
-
- it('should resolve conflicting z-index classes', () => {
- // Act
- const result = cn('z-10', 'z-50');
-
- // Assert
- expect(result).toBe('z-50');
- });
- });
-
- describe('responsive and state variants', () => {
- it('should handle responsive prefixes', () => {
- // Act
- const result = cn('text-sm', 'md:text-base', 'lg:text-lg');
-
- // Assert
- expect(result).toBe('text-sm md:text-base lg:text-lg');
- });
-
- it('should resolve conflicts within same breakpoint', () => {
- // Act
- const result = cn('md:text-sm', 'md:text-lg');
-
- // Assert
- expect(result).toBe('md:text-lg');
- });
-
- it('should handle hover states', () => {
- // Act
- const result = cn('bg-white', 'hover:bg-gray-100');
-
- // Assert
- expect(result).toBe('bg-white hover:bg-gray-100');
- });
-
- it('should resolve hover state conflicts', () => {
- // Act
- const result = cn('hover:bg-gray-100', 'hover:bg-gray-200');
-
- // Assert
- expect(result).toBe('hover:bg-gray-200');
- });
-
- it('should handle focus states', () => {
- // Act
- const result = cn('outline-none', 'focus:outline-2');
-
- // Assert
- expect(result).toBe('outline-none focus:outline-2');
- });
-
- it('should handle dark mode', () => {
- // Act
- const result = cn('bg-white', 'dark:bg-gray-900');
-
- // Assert
- expect(result).toBe('bg-white dark:bg-gray-900');
- });
- });
-
- describe('complex real-world usage', () => {
- it('should handle button variant pattern', () => {
- // Arrange
- const baseClasses = 'px-4 py-2 rounded font-medium';
- const variantClasses = 'bg-blue-500 text-white hover:bg-blue-600';
- const sizeOverride = 'px-6 py-3';
-
- // Act
- const result = cn(baseClasses, variantClasses, sizeOverride);
-
- // Assert
- expect(result).toContain('px-6');
- expect(result).toContain('py-3');
- expect(result).toContain('rounded');
- expect(result).toContain('font-medium');
- expect(result).toContain('bg-blue-500');
- expect(result).not.toContain('px-4');
- expect(result).not.toContain('py-2');
- });
-
- it('should handle conditional disabled state', () => {
- // Arrange
- const isDisabled = true;
- const baseClasses = 'bg-blue-500 cursor-pointer';
- const disabledClasses = isDisabled && 'bg-gray-300 cursor-not-allowed';
-
- // Act
- const result = cn(baseClasses, disabledClasses);
-
- // Assert
- expect(result).toContain('bg-gray-300');
- expect(result).toContain('cursor-not-allowed');
- expect(result).not.toContain('bg-blue-500');
- expect(result).not.toContain('cursor-pointer');
- });
-
- it('should handle component prop className override', () => {
- // Arrange - simulating component with default + user override
- const defaultClasses = 'text-sm text-gray-500';
- const userClassName = 'text-lg text-blue-500';
-
- // Act
- const result = cn(defaultClasses, userClassName);
-
- // Assert
- expect(result).toBe('text-lg text-blue-500');
- });
-
- it('should handle mixed array and string inputs', () => {
- // Arrange
- const conditionalClasses = ['rounded-lg', 'shadow-md'];
- const isLarge = true;
-
- // Act
- const result = cn('base', conditionalClasses, isLarge && 'w-full');
-
- // Assert
- expect(result).toBe('base rounded-lg shadow-md w-full');
- });
-
- it('should handle arbitrary values', () => {
- // Act
- const result = cn('w-[200px]', 'h-[100px]');
-
- // Assert
- expect(result).toBe('w-[200px] h-[100px]');
- });
-
- it('should resolve arbitrary value conflicts', () => {
- // Act
- const result = cn('w-[200px]', 'w-[300px]');
-
- // Assert
- expect(result).toBe('w-[300px]');
- });
- });
-
- describe('edge cases', () => {
- it('should handle classes with numbers', () => {
- // Act
- const result = cn('grid-cols-3', 'gap-4');
-
- // Assert
- expect(result).toBe('grid-cols-3 gap-4');
- });
-
- it('should handle negative values', () => {
- // Act
- const result = cn('-mt-4', '-ml-2');
-
- // Assert
- expect(result).toBe('-mt-4 -ml-2');
- });
-
- it('should handle important modifier', () => {
- // Act
- const result = cn('!text-red-500', '!bg-white');
-
- // Assert
- expect(result).toBe('!text-red-500 !bg-white');
- });
-
- it('should handle whitespace in class strings', () => {
- // Act
- const result = cn(' text-red-500 ', ' bg-white ');
-
- // Assert
- expect(result).toBe('text-red-500 bg-white');
- });
-
- it('should handle multiple spaces between classes', () => {
- // Act
- const result = cn('text-red-500 bg-white');
-
- // Assert
- expect(result).toBe('text-red-500 bg-white');
- });
-
- it('should handle deeply nested conditionals', () => {
- // Arrange
- const a = true;
- const b = false;
- const c = true;
-
- // Act
- const result = cn(
- 'base',
- a && 'a-true',
- b && 'b-true',
- c && ['c-true', b && 'cb-true']
- );
-
- // Assert
- expect(result).toBe('base a-true c-true');
- });
- });
- });
-});
diff --git a/openwork-memos-integration/apps/desktop/__tests__/unit/renderer/lib/accomplish.unit.test.ts b/openwork-memos-integration/apps/desktop/__tests__/unit/renderer/lib/accomplish.unit.test.ts
deleted file mode 100644
index ff647538d..000000000
--- a/openwork-memos-integration/apps/desktop/__tests__/unit/renderer/lib/accomplish.unit.test.ts
+++ /dev/null
@@ -1,234 +0,0 @@
-/**
- * Unit tests for Accomplish API library
- *
- * Tests the Electron detection and shell utilities:
- * - isRunningInElectron() detection
- * - getShellVersion() retrieval
- * - getShellPlatform() retrieval
- * - getAccomplish() and useAccomplish() API access
- */
-
-import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
-
-// Store original window
-const originalWindow = globalThis.window;
-
-describe('Accomplish API', () => {
- beforeEach(() => {
- vi.clearAllMocks();
- vi.resetModules();
- (globalThis as unknown as { window: Record }).window = {};
- });
-
- afterEach(() => {
- vi.clearAllMocks();
- (globalThis as unknown as { window: typeof window }).window = originalWindow;
- });
-
- describe('isRunningInElectron', () => {
- it('should return true when accomplishShell.isElectron is true', async () => {
- (globalThis as unknown as { window: { accomplishShell: { isElectron: boolean } } }).window = {
- accomplishShell: { isElectron: true },
- };
-
- const { isRunningInElectron } = await import('@renderer/lib/accomplish');
- expect(isRunningInElectron()).toBe(true);
- });
-
- it('should return false when accomplishShell.isElectron is false', async () => {
- (globalThis as unknown as { window: { accomplishShell: { isElectron: boolean } } }).window = {
- accomplishShell: { isElectron: false },
- };
-
- const { isRunningInElectron } = await import('@renderer/lib/accomplish');
- expect(isRunningInElectron()).toBe(false);
- });
-
- it('should return false when accomplishShell is unavailable', async () => {
- // Test undefined, null, missing property, and empty object
- const unavailableScenarios = [
- { accomplishShell: undefined },
- { accomplishShell: null },
- { accomplishShell: { version: '1.0.0' } }, // missing isElectron
- {}, // no accomplishShell at all
- ];
-
- for (const scenario of unavailableScenarios) {
- vi.resetModules();
- (globalThis as unknown as { window: Record }).window = scenario;
- const { isRunningInElectron } = await import('@renderer/lib/accomplish');
- expect(isRunningInElectron()).toBe(false);
- }
- });
-
- it('should use strict equality for isElectron check', async () => {
- // Truthy but not true should return false
- (globalThis as unknown as { window: { accomplishShell: { isElectron: number } } }).window = {
- accomplishShell: { isElectron: 1 },
- };
-
- const { isRunningInElectron } = await import('@renderer/lib/accomplish');
- expect(isRunningInElectron()).toBe(false);
- });
- });
-
- describe('getShellVersion', () => {
- it('should return version when available', async () => {
- (globalThis as unknown as { window: { accomplishShell: { version: string } } }).window = {
- accomplishShell: { version: '1.2.3' },
- };
-
- const { getShellVersion } = await import('@renderer/lib/accomplish');
- expect(getShellVersion()).toBe('1.2.3');
- });
-
- it('should return null when version is unavailable', async () => {
- const unavailableScenarios = [
- { accomplishShell: undefined },
- { accomplishShell: { isElectron: true } }, // no version property
- {},
- ];
-
- for (const scenario of unavailableScenarios) {
- vi.resetModules();
- (globalThis as unknown as { window: Record }).window = scenario;
- const { getShellVersion } = await import('@renderer/lib/accomplish');
- expect(getShellVersion()).toBeNull();
- }
- });
-
- it('should handle various version formats', async () => {
- const versions = ['0.0.1', '1.0.0', '2.5.10', '1.0.0-beta.1', '1.0.0-rc.2'];
-
- for (const version of versions) {
- vi.resetModules();
- (globalThis as unknown as { window: { accomplishShell: { version: string } } }).window = {
- accomplishShell: { version },
- };
- const { getShellVersion } = await import('@renderer/lib/accomplish');
- expect(getShellVersion()).toBe(version);
- }
- });
- });
-
- describe('getShellPlatform', () => {
- it('should return platform when available', async () => {
- const platforms = ['darwin', 'linux', 'win32'];
-
- for (const platform of platforms) {
- vi.resetModules();
- (globalThis as unknown as { window: { accomplishShell: { platform: string } } }).window = {
- accomplishShell: { platform },
- };
- const { getShellPlatform } = await import('@renderer/lib/accomplish');
- expect(getShellPlatform()).toBe(platform);
- }
- });
-
- it('should return null when platform is unavailable', async () => {
- const unavailableScenarios = [
- { accomplishShell: undefined },
- { accomplishShell: { isElectron: true } }, // no platform property
- {},
- ];
-
- for (const scenario of unavailableScenarios) {
- vi.resetModules();
- (globalThis as unknown as { window: Record }).window = scenario;
- const { getShellPlatform } = await import('@renderer/lib/accomplish');
- expect(getShellPlatform()).toBeNull();
- }
- });
- });
-
- describe('getAccomplish', () => {
- it('should return accomplish API when available', async () => {
- const mockApi = {
- getVersion: vi.fn(),
- startTask: vi.fn(),
- validateBedrockCredentials: vi.fn(),
- saveBedrockCredentials: vi.fn(),
- getBedrockCredentials: vi.fn(),
- };
- (globalThis as unknown as { window: { accomplish: typeof mockApi } }).window = {
- accomplish: mockApi,
- };
-
- const { getAccomplish } = await import('@renderer/lib/accomplish');
- const result = getAccomplish();
- // getAccomplish returns a wrapper object with spread methods + Bedrock wrappers
- expect(result.getVersion).toBeDefined();
- expect(result.startTask).toBeDefined();
- expect(result.validateBedrockCredentials).toBeDefined();
- expect(result.saveBedrockCredentials).toBeDefined();
- expect(result.getBedrockCredentials).toBeDefined();
- });
-
- it('should throw when accomplish API is not available', async () => {
- const unavailableScenarios = [
- { accomplish: undefined },
- {},
- ];
-
- for (const scenario of unavailableScenarios) {
- vi.resetModules();
- (globalThis as unknown as { window: Record }).window = scenario;
- const { getAccomplish } = await import('@renderer/lib/accomplish');
- expect(() => getAccomplish()).toThrow('Accomplish API not available - not running in Electron');
- }
- });
- });
-
- describe('useAccomplish', () => {
- it('should return accomplish API when available', async () => {
- const mockApi = { getVersion: vi.fn(), startTask: vi.fn() };
- (globalThis as unknown as { window: { accomplish: typeof mockApi } }).window = {
- accomplish: mockApi,
- };
-
- const { useAccomplish } = await import('@renderer/lib/accomplish');
- expect(useAccomplish()).toBe(mockApi);
- });
-
- it('should throw when accomplish API is not available', async () => {
- (globalThis as unknown as { window: { accomplish?: unknown } }).window = {
- accomplish: undefined,
- };
-
- const { useAccomplish } = await import('@renderer/lib/accomplish');
- expect(() => useAccomplish()).toThrow('Accomplish API not available - not running in Electron');
- });
- });
-
- describe('Complete Shell Object', () => {
- it('should recognize complete shell object with all properties', async () => {
- const completeShell = {
- version: '1.0.0',
- platform: 'darwin',
- isElectron: true as const,
- };
- (globalThis as unknown as { window: { accomplishShell: typeof completeShell } }).window = {
- accomplishShell: completeShell,
- };
-
- const { isRunningInElectron, getShellVersion, getShellPlatform } = await import('@renderer/lib/accomplish');
-
- expect(isRunningInElectron()).toBe(true);
- expect(getShellVersion()).toBe('1.0.0');
- expect(getShellPlatform()).toBe('darwin');
- });
-
- it('should handle partial shell object gracefully', async () => {
- const partialShell = { version: '1.0.0', isElectron: true as const };
- (globalThis as unknown as { window: { accomplishShell: typeof partialShell } }).window = {
- accomplishShell: partialShell,
- };
-
- const { isRunningInElectron, getShellVersion, getShellPlatform } = await import('@renderer/lib/accomplish');
-
- expect(isRunningInElectron()).toBe(true);
- expect(getShellVersion()).toBe('1.0.0');
- expect(getShellPlatform()).toBeNull();
- });
- });
-});
diff --git a/openwork-memos-integration/apps/desktop/__tests__/unit/renderer/lib/analytics.unit.test.ts b/openwork-memos-integration/apps/desktop/__tests__/unit/renderer/lib/analytics.unit.test.ts
deleted file mode 100644
index ae505cb3a..000000000
--- a/openwork-memos-integration/apps/desktop/__tests__/unit/renderer/lib/analytics.unit.test.ts
+++ /dev/null
@@ -1,281 +0,0 @@
-/**
- * Unit tests for Analytics library
- *
- * Tests the analytics tracking utilities:
- * - trackPageView() and trackEvent() behavior
- * - No-op behavior when gtag is unavailable
- * - Correct gtag calls when available
- * - All predefined event trackers in the analytics object
- */
-
-import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
-
-// Mock window.gtag before importing the module
-const mockGtag = vi.fn();
-
-// Set up window mock
-const originalWindow = globalThis.window;
-
-describe('Analytics', () => {
- beforeEach(() => {
- vi.clearAllMocks();
- vi.resetModules();
- (globalThis as unknown as { window: typeof window }).window = {
- ...originalWindow,
- gtag: undefined,
- } as unknown as typeof window;
- });
-
- afterEach(() => {
- vi.clearAllMocks();
- (globalThis as unknown as { window: typeof window }).window = originalWindow;
- });
-
- describe('trackPageView', () => {
- it('should not throw when gtag is unavailable', async () => {
- (globalThis as unknown as { window: { gtag?: unknown } }).window = { gtag: undefined };
-
- const { trackPageView } = await import('@renderer/lib/analytics');
- // Should not throw - just returns without error
- expect(() => trackPageView('/test-page', 'Test Page')).not.toThrow();
- });
-
- it('should call gtag with correct parameters when available', async () => {
- (globalThis as unknown as { window: { gtag: typeof mockGtag } }).window = { gtag: mockGtag };
-
- const { trackPageView } = await import('@renderer/lib/analytics');
- trackPageView('/test-page', 'Test Page');
-
- expect(mockGtag).toHaveBeenCalledWith('config', 'G-RQWHYJ5NEG', {
- page_path: '/test-page',
- page_title: 'Test Page',
- });
- });
-
- it('should handle missing page title', async () => {
- (globalThis as unknown as { window: { gtag: typeof mockGtag } }).window = { gtag: mockGtag };
-
- const { trackPageView } = await import('@renderer/lib/analytics');
- trackPageView('/test-page');
-
- expect(mockGtag).toHaveBeenCalledWith('config', 'G-RQWHYJ5NEG', {
- page_path: '/test-page',
- page_title: undefined,
- });
- });
-
- it('should return immediately if gtag is not a function', async () => {
- (globalThis as unknown as { window: { gtag: string } }).window = { gtag: 'not a function' };
-
- const { trackPageView } = await import('@renderer/lib/analytics');
- const result = trackPageView('/test-page');
-
- expect(result).toBeUndefined();
- });
- });
-
- describe('trackEvent', () => {
- it('should not throw when gtag is unavailable', async () => {
- (globalThis as unknown as { window: { gtag?: unknown } }).window = { gtag: undefined };
-
- const { trackEvent } = await import('@renderer/lib/analytics');
- expect(() => trackEvent('test_event', { category: 'test' })).not.toThrow();
- });
-
- it('should call gtag with event name and parameters', async () => {
- (globalThis as unknown as { window: { gtag: typeof mockGtag } }).window = { gtag: mockGtag };
-
- const { trackEvent } = await import('@renderer/lib/analytics');
- trackEvent('test_event', { category: 'test', value: 123 });
-
- expect(mockGtag).toHaveBeenCalledWith('event', 'test_event', {
- category: 'test',
- value: 123,
- });
- });
-
- it('should handle events without parameters', async () => {
- (globalThis as unknown as { window: { gtag: typeof mockGtag } }).window = { gtag: mockGtag };
-
- const { trackEvent } = await import('@renderer/lib/analytics');
- trackEvent('simple_event');
-
- expect(mockGtag).toHaveBeenCalledWith('event', 'simple_event', undefined);
- });
- });
-
- describe('Predefined Event Trackers', () => {
- beforeEach(async () => {
- vi.resetModules();
- (globalThis as unknown as { window: { gtag: typeof mockGtag } }).window = { gtag: mockGtag };
- });
-
- it('trackSubmitTask should call gtag with correct parameters', async () => {
- const { analytics } = await import('@renderer/lib/analytics');
- analytics.trackSubmitTask();
-
- expect(mockGtag).toHaveBeenCalledWith('event', 'submit_task', {
- event_category: 'engagement',
- event_label: 'task_submission',
- });
- });
-
- it('trackNewTask should call gtag with correct parameters', async () => {
- const { analytics } = await import('@renderer/lib/analytics');
- analytics.trackNewTask();
-
- expect(mockGtag).toHaveBeenCalledWith('event', 'new_task', {
- event_category: 'engagement',
- event_label: 'new_task_click',
- });
- });
-
- it('trackOpenSettings should call gtag with correct parameters', async () => {
- const { analytics } = await import('@renderer/lib/analytics');
- analytics.trackOpenSettings();
-
- expect(mockGtag).toHaveBeenCalledWith('event', 'open_settings', {
- event_category: 'engagement',
- event_label: 'settings_click',
- });
- });
-
- it('trackSaveApiKey should include provider parameter', async () => {
- const { analytics } = await import('@renderer/lib/analytics');
- analytics.trackSaveApiKey('anthropic');
-
- expect(mockGtag).toHaveBeenCalledWith('event', 'save_api_key', {
- event_category: 'settings',
- event_label: 'api_key_save',
- provider: 'anthropic',
- });
- });
-
- it('trackSelectProvider should include provider parameter', async () => {
- const { analytics } = await import('@renderer/lib/analytics');
- analytics.trackSelectProvider('openai');
-
- expect(mockGtag).toHaveBeenCalledWith('event', 'select_provider', {
- event_category: 'settings',
- event_label: 'provider_selection',
- provider: 'openai',
- });
- });
-
- it('trackSelectModel should include model parameter', async () => {
- const { analytics } = await import('@renderer/lib/analytics');
- analytics.trackSelectModel('claude-3-sonnet');
-
- expect(mockGtag).toHaveBeenCalledWith('event', 'select_model', {
- event_category: 'settings',
- event_label: 'model_selection',
- model: 'claude-3-sonnet',
- });
- });
-
- it('trackToggleDebugMode should include enabled flag', async () => {
- const { analytics } = await import('@renderer/lib/analytics');
-
- analytics.trackToggleDebugMode(true);
- expect(mockGtag).toHaveBeenCalledWith('event', 'toggle_debug_mode', {
- event_category: 'settings',
- event_label: 'debug_mode_toggle',
- enabled: true,
- });
-
- mockGtag.mockClear();
-
- analytics.trackToggleDebugMode(false);
- expect(mockGtag).toHaveBeenCalledWith('event', 'toggle_debug_mode', {
- event_category: 'settings',
- event_label: 'debug_mode_toggle',
- enabled: false,
- });
- });
- });
-
- describe('Analytics Object Structure', () => {
- it('should expose all required tracker functions', async () => {
- const { analytics } = await import('@renderer/lib/analytics');
-
- expect(typeof analytics.trackPageView).toBe('function');
- expect(typeof analytics.trackEvent).toBe('function');
- expect(typeof analytics.trackSubmitTask).toBe('function');
- expect(typeof analytics.trackNewTask).toBe('function');
- expect(typeof analytics.trackOpenSettings).toBe('function');
- expect(typeof analytics.trackSaveApiKey).toBe('function');
- expect(typeof analytics.trackSelectProvider).toBe('function');
- expect(typeof analytics.trackSelectModel).toBe('function');
- expect(typeof analytics.trackToggleDebugMode).toBe('function');
- });
-
- it('should export analytics object as default', async () => {
- const analyticsDefault = (await import('@renderer/lib/analytics')).default;
- expect(analyticsDefault).toBeDefined();
- expect(analyticsDefault.trackSubmitTask).toBeDefined();
- expect(analyticsDefault.trackPageView).toBeDefined();
- });
- });
-
- describe('Event Categories', () => {
- beforeEach(async () => {
- vi.resetModules();
- (globalThis as unknown as { window: { gtag: typeof mockGtag } }).window = { gtag: mockGtag };
- });
-
- it('should use engagement category for user actions', async () => {
- const { analytics } = await import('@renderer/lib/analytics');
- analytics.trackSubmitTask();
- analytics.trackNewTask();
- analytics.trackOpenSettings();
-
- const engagementCalls = mockGtag.mock.calls.filter(
- (call) => call[2]?.event_category === 'engagement'
- );
- expect(engagementCalls).toHaveLength(3);
- });
-
- it('should use settings category for configuration changes', async () => {
- const { analytics } = await import('@renderer/lib/analytics');
- analytics.trackSaveApiKey('anthropic');
- analytics.trackSelectProvider('openai');
- analytics.trackSelectModel('gpt-4');
- analytics.trackToggleDebugMode(true);
-
- const settingsCalls = mockGtag.mock.calls.filter(
- (call) => call[2]?.event_category === 'settings'
- );
- expect(settingsCalls).toHaveLength(4);
- });
- });
-
- describe('Edge Cases', () => {
- it('should handle null gtag gracefully', async () => {
- (globalThis as unknown as { window: { gtag: null } }).window = { gtag: null };
-
- const { trackEvent } = await import('@renderer/lib/analytics');
- expect(() => trackEvent('test')).not.toThrow();
- });
-
- it('should handle empty event name', async () => {
- (globalThis as unknown as { window: { gtag: typeof mockGtag } }).window = { gtag: mockGtag };
-
- const { trackEvent } = await import('@renderer/lib/analytics');
- trackEvent('');
-
- expect(mockGtag).toHaveBeenCalledWith('event', '', undefined);
- });
-
- it('should handle empty page path', async () => {
- (globalThis as unknown as { window: { gtag: typeof mockGtag } }).window = { gtag: mockGtag };
-
- const { trackPageView } = await import('@renderer/lib/analytics');
- trackPageView('');
-
- expect(mockGtag).toHaveBeenCalledWith('config', 'G-RQWHYJ5NEG', {
- page_path: '',
- page_title: undefined,
- });
- });
- });
-});
diff --git a/openwork-memos-integration/apps/desktop/__tests__/unit/renderer/lib/animations.unit.test.ts b/openwork-memos-integration/apps/desktop/__tests__/unit/renderer/lib/animations.unit.test.ts
deleted file mode 100644
index c878a3fd9..000000000
--- a/openwork-memos-integration/apps/desktop/__tests__/unit/renderer/lib/animations.unit.test.ts
+++ /dev/null
@@ -1,141 +0,0 @@
-/**
- * Unit tests for Animation library
- *
- * Tests the animation configuration objects:
- * - Spring configurations have expected values
- * - Variants have correct initial/animate/exit states
- * - Interaction presets (hover/tap) have correct scale values
- */
-
-import { describe, it, expect } from 'vitest';
-import {
- springs,
- variants,
- staggerContainer,
- staggerItem,
- cardHover,
- buttonPress,
-} from '@renderer/lib/animations';
-
-describe('Animation Library', () => {
- describe('Spring Configurations', () => {
- it('should have correct bouncy spring values', () => {
- expect(springs.bouncy).toEqual({
- type: 'spring',
- stiffness: 400,
- damping: 25,
- });
- });
-
- it('should have correct gentle spring values', () => {
- expect(springs.gentle).toEqual({
- type: 'spring',
- stiffness: 300,
- damping: 30,
- });
- });
-
- it('should have correct snappy spring values', () => {
- expect(springs.snappy).toEqual({
- type: 'spring',
- stiffness: 500,
- damping: 30,
- });
- });
-
- it('should have valid ranges for all springs', () => {
- Object.values(springs).forEach((spring) => {
- expect(spring.stiffness).toBeGreaterThanOrEqual(100);
- expect(spring.stiffness).toBeLessThanOrEqual(1000);
- expect(spring.damping).toBeGreaterThanOrEqual(10);
- expect(spring.damping).toBeLessThanOrEqual(100);
- });
- });
- });
-
- describe('Animation Variants', () => {
- it('should have correct fadeUp values', () => {
- expect(variants.fadeUp.initial).toEqual({ opacity: 0, y: 12 });
- expect(variants.fadeUp.animate).toEqual({ opacity: 1, y: 0 });
- expect(variants.fadeUp.exit).toEqual({ opacity: 0, y: -8 });
- });
-
- it('should have correct fadeIn values', () => {
- expect(variants.fadeIn.initial).toEqual({ opacity: 0 });
- expect(variants.fadeIn.animate).toEqual({ opacity: 1 });
- expect(variants.fadeIn.exit).toEqual({ opacity: 0 });
- });
-
- it('should have correct scaleIn values', () => {
- expect(variants.scaleIn.initial).toEqual({ opacity: 0, scale: 0.95 });
- expect(variants.scaleIn.animate).toEqual({ opacity: 1, scale: 1 });
- expect(variants.scaleIn.exit).toEqual({ opacity: 0, scale: 0.95 });
- });
-
- it('should have correct slideInRight values', () => {
- expect(variants.slideInRight.initial).toEqual({ opacity: 0, x: 20 });
- expect(variants.slideInRight.animate).toEqual({ opacity: 1, x: 0 });
- expect(variants.slideInRight.exit).toEqual({ opacity: 0, x: -20 });
- });
-
- it('should have correct slideInLeft values', () => {
- expect(variants.slideInLeft.initial).toEqual({ opacity: 0, x: -12 });
- expect(variants.slideInLeft.animate).toEqual({ opacity: 1, x: 0 });
- expect(variants.slideInLeft.exit).toEqual({ opacity: 0, x: -12 });
- });
-
- it('should all start with opacity 0 and animate to opacity 1', () => {
- Object.values(variants).forEach((variant) => {
- expect((variant.initial as { opacity: number }).opacity).toBe(0);
- expect((variant.animate as { opacity: number }).opacity).toBe(1);
- expect((variant.exit as { opacity: number }).opacity).toBe(0);
- });
- });
- });
-
- describe('Stagger Animations', () => {
- it('should have correct stagger container configuration', () => {
- expect(staggerContainer.initial).toEqual({});
- expect(staggerContainer.animate).toEqual({
- transition: {
- staggerChildren: 0.05,
- delayChildren: 0.1,
- },
- });
- });
-
- it('should have correct stagger item configuration', () => {
- expect(staggerItem.initial).toEqual({ opacity: 0, y: 8 });
- expect(staggerItem.animate).toEqual({ opacity: 1, y: 0 });
- });
- });
-
- describe('Interaction Presets', () => {
- it('should have correct cardHover scale values', () => {
- expect(cardHover.rest).toEqual({ scale: 1 });
- expect(cardHover.hover).toEqual({ scale: 1.02 });
- expect(cardHover.tap).toEqual({ scale: 0.98 });
- });
-
- it('should have correct buttonPress scale values', () => {
- expect(buttonPress.rest).toEqual({ scale: 1 });
- expect(buttonPress.hover).toEqual({ scale: 1.02 });
- expect(buttonPress.tap).toEqual({ scale: 0.95 });
- });
-
- it('should have button tap more pronounced than card tap', () => {
- expect(buttonPress.tap.scale).toBeLessThan(cardHover.tap.scale);
- });
- });
-
- describe('Export Structure', () => {
- it('should export all required animations', () => {
- expect(Object.keys(springs)).toEqual(['bouncy', 'gentle', 'snappy']);
- expect(Object.keys(variants)).toEqual(['fadeUp', 'fadeIn', 'scaleIn', 'slideInRight', 'slideInLeft']);
- expect(staggerContainer).toBeDefined();
- expect(staggerItem).toBeDefined();
- expect(cardHover).toBeDefined();
- expect(buttonPress).toBeDefined();
- });
- });
-});
diff --git a/openwork-memos-integration/apps/desktop/__tests__/unit/renderer/lib/waiting-detection.unit.test.ts b/openwork-memos-integration/apps/desktop/__tests__/unit/renderer/lib/waiting-detection.unit.test.ts
deleted file mode 100644
index 5dd871727..000000000
--- a/openwork-memos-integration/apps/desktop/__tests__/unit/renderer/lib/waiting-detection.unit.test.ts
+++ /dev/null
@@ -1,185 +0,0 @@
-import { describe, it, expect } from 'vitest';
-import { isWaitingForUser } from '../../../../src/renderer/lib/waiting-detection';
-
-describe('isWaitingForUser', () => {
- describe('should return true for messages indicating waiting', () => {
- // "Let me know" patterns
- it.each([
- 'Let me know when you are done',
- 'let me know once you have logged in',
- 'Let me know after you complete the form',
- 'let me know if you need help',
- ])('detects "let me know" pattern: "%s"', (message) => {
- expect(isWaitingForUser(message)).toBe(true);
- });
-
- // "Tell me" patterns
- it.each([
- 'Tell me when you are ready',
- 'tell me once you finish',
- 'Tell me after you have entered your credentials',
- ])('detects "tell me" pattern: "%s"', (message) => {
- expect(isWaitingForUser(message)).toBe(true);
- });
-
- // "Waiting for you" patterns
- it.each([
- 'I am waiting for you to complete this',
- 'I will wait for your response',
- "I'll wait until you are done",
- 'Waiting on you to finish',
- ])('detects "waiting for you" pattern: "%s"', (message) => {
- expect(isWaitingForUser(message)).toBe(true);
- });
-
- // "Once you" / "After you" / "When you" patterns
- it.each([
- "Once you've logged in, I can continue",
- 'Once you have completed the form',
- 'After you enter your password',
- "After you've finished, click continue",
- 'When you are done, let me know',
- "When you've entered the code",
- 'When you want to proceed',
- ])('detects conditional patterns: "%s"', (message) => {
- expect(isWaitingForUser(message)).toBe(true);
- });
-
- // "Please [action]" patterns
- it.each([
- 'Please log in to continue',
- 'Please login with your credentials',
- 'Please sign in to your account',
- 'Please enter your password',
- 'Please fill out the form',
- 'Please complete the verification',
- 'Please click the submit button',
- 'Please select an option',
- 'Please confirm your identity',
- 'Please verify your email',
- 'Please authenticate using 2FA',
- ])('detects "please" action patterns: "%s"', (message) => {
- expect(isWaitingForUser(message)).toBe(true);
- });
-
- // Login/authentication specific
- it.each([
- 'You need to log in manually',
- 'Please sign in yourself',
- 'Enter your credentials to proceed',
- 'Enter your password in the field',
- 'Enter your OTP code',
- 'Authenticate yourself to continue',
- 'Complete the login process',
- 'Complete the authentication',
- 'Complete the captcha verification',
- 'Verify your identity',
- 'Verify your account',
- ])('detects authentication patterns: "%s"', (message) => {
- expect(isWaitingForUser(message)).toBe(true);
- });
-
- // Manual action required
- it.each([
- 'This requires manual action',
- 'A manual step is needed',
- 'You need to manually complete this',
- 'Manually enter your details',
- 'I need you to click the button',
- 'This requires you to fill the form',
- "You'll need to do this yourself",
- 'You will need to verify',
- ])('detects manual action patterns: "%s"', (message) => {
- expect(isWaitingForUser(message)).toBe(true);
- });
-
- // Ready/done prompts
- it.each([
- "When you're done, I can proceed",
- 'When you are ready, continue',
- 'Once done, click the button',
- 'Once ready, let me know',
- 'After done, we can move on',
- "After you're finished",
- ])('detects ready/done prompts: "%s"', (message) => {
- expect(isWaitingForUser(message)).toBe(true);
- });
-
- // Continuation prompts
- it.each([
- 'Ready to continue?',
- 'Ready to proceed with the next step?',
- 'Continue when you are done',
- 'Proceed when ready',
- 'Click continue when finished',
- 'Press continue after you log in',
- 'Hit continue once complete',
- ])('detects continuation prompts: "%s"', (message) => {
- expect(isWaitingForUser(message)).toBe(true);
- });
-
- // Explicit waiting statements
- it.each([
- "I'll be here when you need me",
- 'I will be here waiting',
- 'Standing by for your input',
- 'Awaiting your response',
- 'Waiting for your input',
- 'Waiting for the user to act',
- 'Waiting for manual intervention',
- ])('detects explicit waiting: "%s"', (message) => {
- expect(isWaitingForUser(message)).toBe(true);
- });
- });
-
- describe('should return false for completed task messages', () => {
- it.each([
- 'I have navigated to ynet.co.il',
- 'Done! The page has loaded.',
- 'Finished navigating to the website.',
- 'Successfully opened the page.',
- 'The task is complete.',
- 'I clicked the button as requested.',
- 'The form has been submitted.',
- 'Here is the information you requested.',
- 'I found the following results:',
- 'The file has been saved.',
- 'Screenshot captured successfully.',
- '',
- 'All done!',
- 'Task completed successfully.',
- 'Navigation complete.',
- ])('returns false for: "%s"', (message) => {
- expect(isWaitingForUser(message)).toBe(false);
- });
- });
-
- describe('edge cases', () => {
- it('returns false for empty string', () => {
- expect(isWaitingForUser('')).toBe(false);
- });
-
- it('returns false for null-ish content', () => {
- expect(isWaitingForUser(null as unknown as string)).toBe(false);
- expect(isWaitingForUser(undefined as unknown as string)).toBe(false);
- });
-
- it('is case insensitive', () => {
- expect(isWaitingForUser('LET ME KNOW WHEN YOU ARE DONE')).toBe(true);
- expect(isWaitingForUser('Please Log In')).toBe(true);
- expect(isWaitingForUser('WAITING FOR YOU')).toBe(true);
- });
-
- it('handles multi-line messages', () => {
- const multiLineWaiting = `I've opened the login page.
-
-Please enter your credentials and let me know when you're done.`;
- expect(isWaitingForUser(multiLineWaiting)).toBe(true);
-
- const multiLineComplete = `I've navigated to the page.
-
-The content has loaded successfully.`;
- expect(isWaitingForUser(multiLineComplete)).toBe(false);
- });
- });
-});
diff --git a/openwork-memos-integration/apps/desktop/public/assets/usecases/ai-image-wizard.webp b/openwork-memos-integration/apps/desktop/public/assets/usecases/ai-image-wizard.webp
deleted file mode 100644
index ce7e762613b297a60a304cec937c4858d9c9db73..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001
literal 2828
zcmV+n3-k0+Nk&El3jhFDMM6+kP&il$0000G000300093006|PpNIC-m00EFtZF}7|
zh9C%nAP9nB2x2fZm>ARy6b3E>K@bE%5QF`@&zt`I?{Yhh2ncT5NRs5*`);HMwyX97
z3%)-5=fi*gAug8ITP=B8qbKImkl(A<`j%4+4)eW5&a2gfa1;;u7aC-v_*u>gbQHau
z6X_@xQJ`LrH^WDCgqbqsUq
z`fMjX-d>tI+hs3RUF&8iUw)yeYu)T6t83ltWz*NXnX8rFc1l#&y4j1Tw_T#Hb;Gxv
z0pE5-L%)~S*qQ8QSJ%4z2iQ(?mH2HJK7qb<6Gv}5$FoW6mhb+pTYI`{-RdKtb@M*P
zH9BhBHGb0*)ua>LGq}TcWCRVi)B5GrwQjgJEZUF6>8%_6i|q`s+Yp(6FuQf5|3D>R
zviofK3
zI=OXY51tzY+hp6x9=tCi@WHd<(?sBd=YXKiZQa;|XP{uv+fI?Gts8vs%J}RU#F?!d
zeDJIoq?xT7eDD+u^4!*qeCIWCQ0BI7LT?2dlk3+sNMXdryAr!Ou5uHj7FDy
zG&qqj+LUfDyfz{Tm^%^?pQP>sw-;F*{pO;mqvCUb1W(E>xu%XCBzRJ8Nsot9@C>(C
z@LaXwZm;0Ey_^Qm?e+?uP8;j(6}-d4Y3Fs^T)}f!N4vR#_d9;@toq;&p7!#35W(TNCcimH2PnZwd7+Ke{=@fO>?eSPr)-=B-8|8FOE(ipu6hHU3t*vsO=U7C
zpl9|yC=UbMj1C9u>Fi->t~s{BS-^k$x5sLJoZR}*ENURd>dsgxA?Wi*ERp128}yCM
z`u%E4ca3R``NgkMOuz(l?y2xq#f5L(uJJyOL7HO>8I?B1PHP7aa=yh3~eDji7pX@`s#
zXkvXxCfPvC%K*&dr0ggfZjYr++L(ze?#F5*8C$G=ndTg&UVIWl>oOn2j
z@5PTwbJD~);2|rcPwrx&M;9;#f~Kr{(M^*N&k||12!zt@}G?qW8BNOl2Pd0i%
z-}0_L0!4oHXg84oNFx(I=k8>RGks3bpDFIdLLm@{L?RC00RH=rIH&uf3R8vS?YUv@dQnS@;4C;Kbw!vA^hq>UGV%^=_vNvNpC#A
zyK{g4=Ag|?D)&;zd8v9-(+VfG+IjPaEkF$4-INa4W*(S&s+z6|0Nr$#FuN|8jm09F
zl;1hZrnUxj)2sd5a>1QNE~#)pALHV%8iQEusL?w>lxv`1>w+@%AtKR?dx(KjJiikW
zZ5lY%;M5LrD>IJ15=GR>=MRme9EQ{X`H2bw+|r(0OA+81dpHV&oR74JT-+oB`3;;0
zo`1MFl#=1c!+XW1^HG
zbIV>K6zf=iVKw9YJJ;GvZ>k4@9`CVP9%fN=%{5RUytHBNY}(
z%D4eEG%#B!kw7*cW?Qj}SmPHkjo?I2a2qP~K=pO2Jus