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
94 changes: 88 additions & 6 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { join } from "node:path";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { resolveLcmConfig } from "./src/db/config.js";
import { LcmContextEngine } from "./src/engine.js";
import { registerAgentMemoryScopeContextEngine } from "./src/plugins/agent-memory-scope/register.js";
import { createLcmDescribeTool } from "./src/tools/lcm-describe-tool.js";
import { createLcmExpandQueryTool } from "./src/tools/lcm-expand-query-tool.js";
import { createLcmExpandTool } from "./src/tools/lcm-expand-tool.js";
Expand Down Expand Up @@ -527,6 +528,49 @@ function readLatestAssistantReply(messages: unknown[]): string | undefined {
/** Construct LCM dependencies from plugin API/runtime surfaces. */
function createLcmDependencies(api: OpenClawPluginApi): LcmDependencies {
const config = resolveLcmConfig(process.env);
const sessionMetaCache = new Map<
string,
{ sessionKey?: string; channel?: string; chatType?: string }
>();
const sessionIdCache = new Map<string, string>();

function readAgentSessionStore(agentId: string): Record<string, Record<string, unknown>> {
const cfg = api.runtime.config.loadConfig();
const storePath = api.runtime.channel.session.resolveStorePath(cfg.session?.store, {
agentId: normalizeAgentId(agentId),
});
const raw = readFileSync(storePath, "utf8");
const parsed = JSON.parse(raw) as unknown;
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return {};
}
return parsed as Record<string, Record<string, unknown>>;
}

function cacheSessionMetaFromStore(store: Record<string, Record<string, unknown>>): void {
for (const [sessionKey, value] of Object.entries(store)) {
if (!value || typeof value !== "object") {
continue;
}
const sessionId =
typeof value.sessionId === "string" && value.sessionId.trim() ? value.sessionId.trim() : undefined;
if (!sessionId) {
continue;
}
const meta = {
sessionKey,
channel: typeof value.channel === "string" ? value.channel : undefined,
chatType:
typeof value.chatType === "string"
? value.chatType
: typeof value.chat_type === "string"
? value.chat_type
: undefined,
};
sessionMetaCache.set(sessionId, meta);
sessionIdCache.set(sessionKey, sessionId);
}
}

return {
config,
Expand Down Expand Up @@ -717,21 +761,58 @@ function createLcmDependencies(api: OpenClawPluginApi): LcmDependencies {
return undefined;
}

const cached = sessionIdCache.get(key);
if (cached) {
return cached;
}

try {
const cfg = api.runtime.config.loadConfig();
const parsed = parseAgentSessionKey(key);
const agentId = normalizeAgentId(parsed?.agentId);
const storePath = api.runtime.channel.session.resolveStorePath(cfg.session?.store, {
agentId,
});
const raw = readFileSync(storePath, "utf8");
const store = JSON.parse(raw) as Record<string, { sessionId?: string } | undefined>;
const store = readAgentSessionStore(agentId);
cacheSessionMetaFromStore(store);
const sessionId = store[key]?.sessionId;
return typeof sessionId === "string" && sessionId.trim() ? sessionId.trim() : undefined;
} catch {
return undefined;
}
},
resolveAgentIdFromSessionKey: async (sessionKey) => {
const parsed = parseAgentSessionKey(sessionKey.trim());
if (!parsed?.agentId) {
return undefined;
}
return normalizeAgentId(parsed.agentId);
},
listAgentSessionIds: async (agentId) => {
const normalizedAgentId = normalizeAgentId(agentId);
try {
const store = readAgentSessionStore(normalizedAgentId);
cacheSessionMetaFromStore(store);
return Array.from(
new Set(
Object.values(store)
.map((entry) =>
entry && typeof entry.sessionId === "string" ? entry.sessionId.trim() : "",
)
.filter(Boolean),
),
);
} catch {
return [];
}
},
resolveSessionMeta: async (sessionId) => {
const normalized = sessionId.trim();
if (!normalized) {
return undefined;
}
const cached = sessionMetaCache.get(normalized);
if (cached) {
return cached;
}
return undefined;
},
agentLaneSubagent: "subagent",
log: {
info: (msg) => api.logger.info(msg),
Expand Down Expand Up @@ -769,6 +850,7 @@ const lcmPlugin = {
const lcm = new LcmContextEngine(deps);

api.registerContextEngine("lossless-claw", () => lcm);
registerAgentMemoryScopeContextEngine(api, lcm);
api.registerTool((ctx) =>
createLcmGrepTool({
deps,
Expand Down
34 changes: 34 additions & 0 deletions src/plugins/agent-memory-scope/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
export type AgentMemoryScopeOptions = {
baseEngine: "lcm";
defaultScope: "current" | "agent";
allowAgentScope: boolean;
maxAgentConversations: number;
maxScopeResults: number;
sortAgentConversationsBy: "updated_at" | "created_at";
};

export const DEFAULT_AGENT_MEMORY_SCOPE_OPTIONS: AgentMemoryScopeOptions = {
baseEngine: "lcm",
defaultScope: "current",
allowAgentScope: true,
maxAgentConversations: 200,
maxScopeResults: 200,
sortAgentConversationsBy: "updated_at",
};

export function resolveAgentMemoryScopeOptions(
raw?: Partial<AgentMemoryScopeOptions>,
): AgentMemoryScopeOptions {
return {
...DEFAULT_AGENT_MEMORY_SCOPE_OPTIONS,
...(raw ?? {}),
maxAgentConversations: Math.max(
1,
Math.floor(raw?.maxAgentConversations ?? DEFAULT_AGENT_MEMORY_SCOPE_OPTIONS.maxAgentConversations),
),
maxScopeResults: Math.max(
1,
Math.floor(raw?.maxScopeResults ?? DEFAULT_AGENT_MEMORY_SCOPE_OPTIONS.maxScopeResults),
),
};
}
11 changes: 11 additions & 0 deletions src/plugins/agent-memory-scope/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export {
DEFAULT_AGENT_MEMORY_SCOPE_OPTIONS,
resolveAgentMemoryScopeOptions,
type AgentMemoryScopeOptions,
} from "./config.js";
export {
resolveAgentScopedConversations,
type AgentScopeResolutionInput,
type AgentScopeResolutionResult,
} from "./scope-resolver.js";
export { makeScopeResultProvenance, type ScopeResultProvenance } from "./provenance.js";
23 changes: 23 additions & 0 deletions src/plugins/agent-memory-scope/provenance.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import type { SessionMeta } from "../../types.js";

export type ScopeResultProvenance = {
conversationId: number;
sessionId?: string;
sessionKey?: string;
channel?: string;
chatType?: string;
};

export function makeScopeResultProvenance(params: {
conversationId: number;
sessionId?: string;
meta?: SessionMeta;
}): ScopeResultProvenance {
return {
conversationId: params.conversationId,
sessionId: params.sessionId,
sessionKey: params.meta?.sessionKey,
channel: params.meta?.channel,
chatType: params.meta?.chatType,
};
}
9 changes: 9 additions & 0 deletions src/plugins/agent-memory-scope/register.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import type { LcmContextEngine } from "../../engine.js";

/**
* Register the alias context engine id used by the OpenClaw integration branch.
*/
export function registerAgentMemoryScopeContextEngine(api: OpenClawPluginApi, lcm: LcmContextEngine): void {
api.registerContextEngine("agent-memory-scope", () => lcm);
}
121 changes: 121 additions & 0 deletions src/plugins/agent-memory-scope/scope-resolver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import type { LcmContextEngine } from "../../engine.js";
import type { LcmDependencies } from "../../types.js";
import type { ScopeResultProvenance } from "./provenance.js";
import { makeScopeResultProvenance } from "./provenance.js";

export type AgentScopeResolutionInput = {
deps?: Pick<
LcmDependencies,
| "resolveAgentIdFromSessionKey"
| "listAgentSessionIds"
| "resolveSessionMeta"
| "normalizeAgentId"
>;
lcm: LcmContextEngine;
sessionKey?: string;
agentIdOverride?: string;
maxAgentConversations: number;
sortBy: "updated_at" | "created_at";
};

export type AgentScopeResolutionResult = {
conversationIds: number[];
agentId?: string;
provenance: ScopeResultProvenance[];
fallbackReason?: string;
};

export async function resolveAgentScopedConversations(
input: AgentScopeResolutionInput,
): Promise<AgentScopeResolutionResult> {
const explicitAgentId = input.agentIdOverride?.trim();

let resolvedAgentId = explicitAgentId;
if (!resolvedAgentId && input.sessionKey && input.deps?.resolveAgentIdFromSessionKey) {
resolvedAgentId = await input.deps.resolveAgentIdFromSessionKey(input.sessionKey.trim());
}
const normalizedAgentId = input.deps?.normalizeAgentId
? input.deps.normalizeAgentId(resolvedAgentId)
: resolvedAgentId?.trim();

if (!normalizedAgentId) {
return {
conversationIds: [],
provenance: [],
fallbackReason: "agent-id-unresolved",
};
}

if (!input.deps?.listAgentSessionIds) {
return {
conversationIds: [],
agentId: normalizedAgentId,
provenance: [],
fallbackReason: "list-agent-sessions-unavailable",
};
}

const sessionIds = Array.from(
new Set(
(await input.deps.listAgentSessionIds(normalizedAgentId))
.map((value) => value.trim())
.filter(Boolean),
),
);

if (sessionIds.length === 0) {
return {
conversationIds: [],
agentId: normalizedAgentId,
provenance: [],
fallbackReason: "agent-has-no-sessions",
};
}

const conversationStore = input.lcm.getConversationStore();
const mapped = await Promise.all(
sessionIds.map(async (sessionId) => {
const conversation = await conversationStore.getConversationBySessionId(sessionId);
if (!conversation) {
return undefined;
}
const meta = input.deps?.resolveSessionMeta
? await input.deps.resolveSessionMeta(conversation.sessionId)
: undefined;
return {
conversation,
provenance: makeScopeResultProvenance({
conversationId: conversation.conversationId,
sessionId: conversation.sessionId,
meta,
}),
};
}),
);

const rows = mapped.filter(
(value): value is { conversation: { conversationId: number; createdAt: Date; updatedAt: Date }; provenance: ScopeResultProvenance } =>
Boolean(value),
);

rows.sort((a, b) => {
const aTs =
input.sortBy === "created_at"
? a.conversation.createdAt.getTime()
: a.conversation.updatedAt.getTime();
const bTs =
input.sortBy === "created_at"
? b.conversation.createdAt.getTime()
: b.conversation.updatedAt.getTime();
return bTs - aTs;
});

const limited = rows.slice(0, Math.max(1, input.maxAgentConversations));

return {
conversationIds: limited.map((row) => row.conversation.conversationId),
agentId: normalizedAgentId,
provenance: limited.map((row) => row.provenance),
fallbackReason: limited.length === 0 ? "agent-conversations-empty" : undefined,
};
}
5 changes: 3 additions & 2 deletions src/retrieval.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export interface GrepInput {
mode: "regex" | "full_text";
scope: "messages" | "summaries" | "both";
conversationId?: number;
conversationIds?: number[];
since?: Date;
before?: Date;
limit?: number;
Expand Down Expand Up @@ -179,9 +180,9 @@ export class RetrievalEngine {
* Depending on `scope`, searches messages, summaries, or both (in parallel).
*/
async grep(input: GrepInput): Promise<GrepResult> {
const { query, mode, scope, conversationId, since, before, limit } = input;
const { query, mode, scope, conversationId, conversationIds, since, before, limit } = input;

const searchInput = { query, mode, conversationId, since, before, limit };
const searchInput = { query, mode, conversationId, conversationIds, since, before, limit };

let messages: MessageSearchResult[] = [];
let summaries: SummarySearchResult[] = [];
Expand Down
Loading