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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 49 additions & 9 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { sanitizeToolUseResultPairing } from "./src/format/transcript-repair.ts"
import { runMaintenance } from "./src/graph/maintenance.ts";
import { invalidateGraphCache, computeGlobalPageRank } from "./src/graph/pagerank.ts";
import { detectCommunities } from "./src/graph/community.ts";
import { ReadonlySessionRegistry } from "./src/session-policy.ts";
import { DEFAULT_CONFIG, type GmConfig } from "./src/types.ts";

// ─── 从 OpenClaw config 读 provider/model ────────────────────
Expand Down Expand Up @@ -158,10 +159,24 @@ const graphMemoryPlugin = {
const msgSeq = new Map<string, number>();
const recalled = new Map<string, { nodes: any[]; edges: any[] }>();
const turnCounter = new Map<string, number>(); // 社区维护计数器
const readonlySessions = new ReadonlySessionRegistry();

// ── 提取串行化(同 session Promise chain,不同 session 并行)────
const extractChain = new Map<string, Promise<void>>();

function isReadonlySession(sessionKey?: string): boolean {
return readonlySessions.has(sessionKey);
}

function cleanupSessionState(sessionKey: string | undefined, forgetReadonly = false): void {
if (!sessionKey) return;
extractChain.delete(sessionKey);
msgSeq.delete(sessionKey);
recalled.delete(sessionKey);
turnCounter.delete(sessionKey);
if (forgetReadonly) readonlySessions.clear(sessionKey);
}

/** 存一条消息到 gm_messages(同步,零 LLM) */
function ingestMessage(sessionId: string, message: any): void {
let seq = msgSeq.get(sessionId);
Expand Down Expand Up @@ -245,6 +260,7 @@ const graphMemoryPlugin = {
if (prompt.includes("/new or /reset") || prompt.includes("new session was started")) return;

const sid = ctx?.sessionId ?? ctx?.sessionKey;
if (isReadonlySession(sid)) return;

api.logger.info(`[graph-memory] recall query: "${prompt.slice(0, 80)}"`);

Expand Down Expand Up @@ -286,6 +302,7 @@ const graphMemoryPlugin = {
isHeartbeat?: boolean;
}) {
if (isHeartbeat) return { ingested: false };
if (isReadonlySession(sessionId)) return { ingested: false };
ingestMessage(sessionId, message);
return { ingested: true };
},
Expand All @@ -301,7 +318,7 @@ const graphMemoryPlugin = {
tokenBudget?: number;
prompt?: string; // Added in OpenClaw 2026.03.28: prompt-aware retrieval
}) {
const activeNodes = getBySession(db, sessionId);
const activeNodes = isReadonlySession(sessionId) ? [] : getBySession(db, sessionId);
const activeEdges = activeNodes.flatMap((n) => [
...edgesFrom(db, n.id),
...edgesTo(db, n.id),
Expand Down Expand Up @@ -378,6 +395,10 @@ const graphMemoryPlugin = {
force?: boolean;
currentTokenCount?: number;
}) {
if (isReadonlySession(sessionId)) {
return { ok: true, compacted: false, reason: "readonly session" };
}

// compact 仍然保留作为兜底,但主要提取在 afterTurn 完成
const msgs = getUnextracted(db, sessionId, 50);

Expand Down Expand Up @@ -444,6 +465,7 @@ const graphMemoryPlugin = {
tokenBudget?: number;
}) {
if (isHeartbeat) return;
if (isReadonlySession(sessionId)) return;

// Messages are already persisted by ingest() — only slice to
// determine the new-message count for extraction triggering.
Expand Down Expand Up @@ -503,20 +525,26 @@ const graphMemoryPlugin = {
parentSessionKey: string;
childSessionKey: string;
}) {
readonlySessions.markReadonly(childSessionKey);
const rec = recalled.get(parentSessionKey);
if (rec) recalled.set(childSessionKey, rec);
return { rollback: () => { recalled.delete(childSessionKey); } };
return {
rollback: () => {
cleanupSessionState(childSessionKey, true);
},
};
},

async onSubagentEnded({ childSessionKey }: { childSessionKey: string }) {
recalled.delete(childSessionKey);
msgSeq.delete(childSessionKey);
cleanupSessionState(childSessionKey, true);
},

async dispose() {
extractChain.clear();
msgSeq.clear();
recalled.clear();
turnCounter.clear();
readonlySessions.clearAll();
},
};

Expand All @@ -533,6 +561,8 @@ const graphMemoryPlugin = {
if (!sid) return;

try {
if (isReadonlySession(sid)) return;

const nodes = getBySession(db, sid);
if (nodes.length) {
const summary = (
Expand Down Expand Up @@ -581,10 +611,7 @@ const graphMemoryPlugin = {
} catch (err) {
api.logger.error(`[graph-memory] session_end error: ${err}`);
} finally {
extractChain.delete(sid);
msgSeq.delete(sid);
recalled.delete(sid);
turnCounter.delete(sid);
cleanupSessionState(sid, true);
}
});

Expand Down Expand Up @@ -651,6 +678,12 @@ const graphMemoryPlugin = {
p: { name: string; type: string; description: string; content: string; relatedSkill?: string },
) {
const sid = ctx?.sessionKey ?? ctx?.sessionId ?? "manual";
if (isReadonlySession(sid)) {
return {
content: [{ type: "text", text: "subagent session is running in read-only graph-memory mode." }],
details: { readonly: true, sessionKey: sid },
};
}
const { node } = upsertNode(db, {
type: p.type as any, name: p.name,
description: p.description, content: p.content,
Expand Down Expand Up @@ -704,12 +737,19 @@ const graphMemoryPlugin = {
);

api.registerTool(
(_ctx: any) => ({
(ctx: any) => ({
name: "gm_maintain",
label: "Graph Memory Maintenance",
description: "手动触发图维护:运行去重、PageRank 重算、社区检测。通常 session_end 时自动运行,这个工具用于手动触发。",
parameters: Type.Object({}),
async execute(_toolCallId: string, _params: any) {
const sid = ctx?.sessionKey ?? ctx?.sessionId;
if (isReadonlySession(sid)) {
return {
content: [{ type: "text", text: "subagent session is running in read-only graph-memory mode." }],
details: { readonly: true, sessionKey: sid },
};
}
const embedFn = (recaller as any).embed ?? undefined;
const result = await runMaintenance(db, cfg, llm, embedFn);
const text = [
Expand Down
56 changes: 56 additions & 0 deletions src/session-policy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/**
* graph-memory — session policy
*
* Subagent sessions should be able to consume inherited recall context
* without writing new long-term memory into the shared graph.
*/

function normalizeSessionKey(sessionKey?: string | null): string {
return sessionKey?.trim() ?? "";
}

export function isHelperSessionKey(sessionKey?: string | null): boolean {
const normalized = normalizeSessionKey(sessionKey).toLowerCase();
if (!normalized) return false;
return (
normalized.startsWith("temp:") ||
normalized.startsWith("slug-generator-") ||
normalized === "slug-gen"
);
}

export function isSubagentSessionKey(sessionKey?: string | null): boolean {
const normalized = normalizeSessionKey(sessionKey).toLowerCase();
if (!normalized) return false;
return normalized.startsWith("subagent:") || normalized.includes(":subagent:");
}

export class ReadonlySessionRegistry {
private readonlySessions = new Set<string>();

markReadonly(sessionKey?: string | null): void {
const normalized = normalizeSessionKey(sessionKey);
if (!normalized) return;
this.readonlySessions.add(normalized);
}

clear(sessionKey?: string | null): void {
const normalized = normalizeSessionKey(sessionKey);
if (!normalized) return;
this.readonlySessions.delete(normalized);
}

has(sessionKey?: string | null): boolean {
const normalized = normalizeSessionKey(sessionKey);
if (!normalized) return false;
return (
this.readonlySessions.has(normalized) ||
isSubagentSessionKey(normalized) ||
isHelperSessionKey(normalized)
);
}

clearAll(): void {
this.readonlySessions.clear();
}
}
47 changes: 47 additions & 0 deletions test/session-policy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";

import {
ReadonlySessionRegistry,
isHelperSessionKey,
isSubagentSessionKey,
} from "../src/session-policy.ts";

describe("subagent session detection", () => {
it("detects OpenClaw subagent session keys", () => {
expect(isSubagentSessionKey("agent:minimax-clerk:subagent:0cc464e3-3244-4443-9dbf-cea199b73abb")).toBe(true);
expect(isSubagentSessionKey("subagent:one-shot")).toBe(true);
expect(isSubagentSessionKey("agent:main:feishu:default:direct:ou_df0924becc2951992502da488004bf1d")).toBe(false);
expect(isSubagentSessionKey("temp:slug-generator")).toBe(false);
});
});

describe("helper session detection", () => {
it("detects helper session keys", () => {
expect(isHelperSessionKey("temp:slug-generator")).toBe(true);
expect(isHelperSessionKey("slug-generator-1775243719190")).toBe(true);
expect(isHelperSessionKey("slug-gen")).toBe(true);
expect(isHelperSessionKey("agent:main:feishu:default:direct:ou_df0924becc2951992502da488004bf1d")).toBe(false);
});
});

describe("readonly session registry", () => {
it("tracks explicit readonly child sessions", () => {
const registry = new ReadonlySessionRegistry();

expect(registry.has("agent:main:task:1")).toBe(false);

registry.markReadonly("agent:main:task:1");
expect(registry.has("agent:main:task:1")).toBe(true);

registry.clear("agent:main:task:1");
expect(registry.has("agent:main:task:1")).toBe(false);
});

it("treats helper sessions as readonly by default", () => {
const registry = new ReadonlySessionRegistry();

expect(registry.has("temp:slug-generator")).toBe(true);
expect(registry.has("slug-generator-1775243719190")).toBe(true);
expect(registry.has("slug-gen")).toBe(true);
});
});