mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-08 03:42:54 +00:00
Summary: - This PR adds an internal gateway active-run projection flag, clears it during terminal lifecycle handling be ... ons.list on that flag, adds gateway regression coverage, and tightens memory-wiki confidence normalization. - PR surface: Source +29, Tests +131. Total +160 across 7 files. - Reproducibility: yes. Source inspection shows current main can broadcast terminal sessions.changed before ch ... the abort-controller entry, and the before/after recording supports the visible stuck In progress symptom. Automerge notes: - PR branch already contained follow-up commit before automerge: fix(gateway): preserve chat retry guard after terminal state - PR branch already contained follow-up commit before automerge: fix(gateway): clear completed session active runs Validation: - ClawSweeper review passed for head9b132bdc2b. - Required merge gates passed before the squash merge. Prepared head SHA:9b132bdc2bReview: https://github.com/openclaw/openclaw/pull/87810#issuecomment-4569094800 Co-authored-by: scotthuang <scotthuang@tencent.com> Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com> Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com> Approved-by: takhoffman Co-authored-by: takhoffman <781889+takhoffman@users.noreply.github.com>
347 lines
11 KiB
TypeScript
347 lines
11 KiB
TypeScript
import path from "node:path";
|
|
import {
|
|
replaceManagedMarkdownBlock,
|
|
withTrailingNewline,
|
|
} from "openclaw/plugin-sdk/memory-host-markdown";
|
|
import { readFiniteNumberParam } from "openclaw/plugin-sdk/param-readers";
|
|
import { root as fsRoot } from "openclaw/plugin-sdk/security-runtime";
|
|
import { normalizeStringEntries, uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime";
|
|
import { compileMemoryWikiVault, type CompileMemoryWikiResult } from "./compile.js";
|
|
import type { ResolvedMemoryWikiConfig } from "./config.js";
|
|
import {
|
|
parseWikiMarkdown,
|
|
renderWikiMarkdown,
|
|
slugifyWikiSegment,
|
|
normalizeSourceIds,
|
|
normalizeWikiClaims,
|
|
type WikiClaim,
|
|
} from "./markdown.js";
|
|
import {
|
|
readQueryableWikiPages,
|
|
resolveQueryableWikiPageByLookup,
|
|
type QueryableWikiPage,
|
|
} from "./query.js";
|
|
import { initializeMemoryWikiVault } from "./vault.js";
|
|
|
|
const GENERATED_START = "<!-- openclaw:wiki:generated:start -->";
|
|
const GENERATED_END = "<!-- openclaw:wiki:generated:end -->";
|
|
const HUMAN_START = "<!-- openclaw:human:start -->";
|
|
const HUMAN_END = "<!-- openclaw:human:end -->";
|
|
|
|
type CreateSynthesisMemoryWikiMutation = {
|
|
op: "create_synthesis";
|
|
title: string;
|
|
body: string;
|
|
sourceIds: string[];
|
|
claims?: WikiClaim[];
|
|
contradictions?: string[];
|
|
questions?: string[];
|
|
confidence?: number;
|
|
status?: string;
|
|
};
|
|
|
|
type UpdateMetadataMemoryWikiMutation = {
|
|
op: "update_metadata";
|
|
lookup: string;
|
|
sourceIds?: string[];
|
|
claims?: WikiClaim[];
|
|
contradictions?: string[];
|
|
questions?: string[];
|
|
confidence?: number | null;
|
|
status?: string;
|
|
};
|
|
|
|
export type ApplyMemoryWikiMutation =
|
|
| CreateSynthesisMemoryWikiMutation
|
|
| UpdateMetadataMemoryWikiMutation;
|
|
|
|
type ApplyMemoryWikiMutationResult = {
|
|
changed: boolean;
|
|
operation: ApplyMemoryWikiMutation["op"];
|
|
pagePath: string;
|
|
pageId?: string;
|
|
compile: CompileMemoryWikiResult;
|
|
};
|
|
|
|
function normalizeMutationConfidence(
|
|
params: Record<string, unknown>,
|
|
options: { allowNull: false },
|
|
): number | undefined;
|
|
function normalizeMutationConfidence(
|
|
params: Record<string, unknown>,
|
|
options: { allowNull: true },
|
|
): number | null | undefined;
|
|
function normalizeMutationConfidence(
|
|
params: Record<string, unknown>,
|
|
options: { allowNull: boolean },
|
|
): number | null | undefined {
|
|
if (options.allowNull && params.confidence === null) {
|
|
return null;
|
|
}
|
|
return readFiniteNumberParam(params, "confidence", {
|
|
min: 0,
|
|
max: 1,
|
|
});
|
|
}
|
|
|
|
export function normalizeMemoryWikiMutationInput(rawParams: unknown): ApplyMemoryWikiMutation {
|
|
const params = rawParams as {
|
|
op: ApplyMemoryWikiMutation["op"];
|
|
title?: string;
|
|
body?: string;
|
|
lookup?: string;
|
|
sourceIds?: string[];
|
|
claims?: WikiClaim[];
|
|
contradictions?: string[];
|
|
questions?: string[];
|
|
confidence?: number | null;
|
|
status?: string;
|
|
};
|
|
if (params.op === "create_synthesis") {
|
|
if (!params.title?.trim()) {
|
|
throw new Error("wiki mutation requires title for create_synthesis.");
|
|
}
|
|
if (!params.body?.trim()) {
|
|
throw new Error("wiki mutation requires body for create_synthesis.");
|
|
}
|
|
if (!params.sourceIds || params.sourceIds.length === 0) {
|
|
throw new Error("wiki mutation requires at least one sourceId for create_synthesis.");
|
|
}
|
|
const confidence = normalizeMutationConfidence(params as Record<string, unknown>, {
|
|
allowNull: false,
|
|
});
|
|
return {
|
|
op: "create_synthesis",
|
|
title: params.title,
|
|
body: params.body,
|
|
sourceIds: params.sourceIds,
|
|
...(Array.isArray(params.claims) ? { claims: normalizeWikiClaims(params.claims) } : {}),
|
|
...(params.contradictions ? { contradictions: params.contradictions } : {}),
|
|
...(params.questions ? { questions: params.questions } : {}),
|
|
...(typeof confidence === "number" ? { confidence } : {}),
|
|
...(params.status ? { status: params.status } : {}),
|
|
};
|
|
}
|
|
if (!params.lookup?.trim()) {
|
|
throw new Error("wiki mutation requires lookup for update_metadata.");
|
|
}
|
|
const confidence = normalizeMutationConfidence(params as Record<string, unknown>, {
|
|
allowNull: true,
|
|
});
|
|
return {
|
|
op: "update_metadata",
|
|
lookup: params.lookup,
|
|
...(params.sourceIds ? { sourceIds: params.sourceIds } : {}),
|
|
...(Array.isArray(params.claims) ? { claims: normalizeWikiClaims(params.claims) } : {}),
|
|
...(params.contradictions ? { contradictions: params.contradictions } : {}),
|
|
...(params.questions ? { questions: params.questions } : {}),
|
|
...(confidence !== undefined ? { confidence } : {}),
|
|
...(params.status ? { status: params.status } : {}),
|
|
};
|
|
}
|
|
|
|
function normalizeUniqueStrings(values: string[] | undefined): string[] | undefined {
|
|
if (!values) {
|
|
return undefined;
|
|
}
|
|
return uniqueStrings(normalizeStringEntries(values));
|
|
}
|
|
|
|
function ensureHumanNotesBlock(body: string): string {
|
|
if (body.includes(HUMAN_START) && body.includes(HUMAN_END)) {
|
|
return body;
|
|
}
|
|
const trimmed = body.trimEnd();
|
|
const prefix = trimmed.length > 0 ? `${trimmed}\n\n` : "";
|
|
return `${prefix}## Notes\n${HUMAN_START}\n${HUMAN_END}\n`;
|
|
}
|
|
|
|
function buildSynthesisBody(params: {
|
|
title: string;
|
|
originalBody?: string;
|
|
generatedBody: string;
|
|
}): string {
|
|
const base = params.originalBody?.trim().length
|
|
? params.originalBody
|
|
: `# ${params.title}\n\n## Notes\n${HUMAN_START}\n${HUMAN_END}\n`;
|
|
const withGenerated = replaceManagedMarkdownBlock({
|
|
original: base,
|
|
heading: "## Summary",
|
|
startMarker: GENERATED_START,
|
|
endMarker: GENERATED_END,
|
|
body: params.generatedBody,
|
|
});
|
|
return ensureHumanNotesBlock(withGenerated);
|
|
}
|
|
|
|
async function writeWikiPage(params: {
|
|
rootDir: string;
|
|
relativePath: string;
|
|
frontmatter: Record<string, unknown>;
|
|
body: string;
|
|
}): Promise<boolean> {
|
|
const root = await fsRoot(params.rootDir);
|
|
const rendered = withTrailingNewline(
|
|
renderWikiMarkdown({
|
|
frontmatter: params.frontmatter,
|
|
body: params.body,
|
|
}),
|
|
);
|
|
const existing = await root.readText(params.relativePath).catch(() => "");
|
|
if (existing === rendered) {
|
|
return false;
|
|
}
|
|
await root.write(params.relativePath, rendered);
|
|
return true;
|
|
}
|
|
|
|
async function resolveWritablePage(params: {
|
|
config: ResolvedMemoryWikiConfig;
|
|
lookup: string;
|
|
}): Promise<QueryableWikiPage | null> {
|
|
const pages = await readQueryableWikiPages(params.config.vault.path);
|
|
return resolveQueryableWikiPageByLookup(pages, params.lookup);
|
|
}
|
|
|
|
async function applyCreateSynthesisMutation(params: {
|
|
config: ResolvedMemoryWikiConfig;
|
|
mutation: CreateSynthesisMemoryWikiMutation;
|
|
}): Promise<{ changed: boolean; pagePath: string; pageId: string }> {
|
|
const slug = slugifyWikiSegment(params.mutation.title);
|
|
const pagePath = path.join("syntheses", `${slug}.md`).replace(/\\/g, "/");
|
|
const root = await fsRoot(params.config.vault.path);
|
|
const existing = await root.readText(pagePath).catch(() => "");
|
|
const parsed = parseWikiMarkdown(existing);
|
|
const pageId =
|
|
(typeof parsed.frontmatter.id === "string" && parsed.frontmatter.id.trim()) ||
|
|
`synthesis.${slug}`;
|
|
const changed = await writeWikiPage({
|
|
rootDir: params.config.vault.path,
|
|
relativePath: pagePath,
|
|
frontmatter: {
|
|
...parsed.frontmatter,
|
|
pageType: "synthesis",
|
|
id: pageId,
|
|
title: params.mutation.title,
|
|
sourceIds: normalizeSourceIds(params.mutation.sourceIds),
|
|
...(params.mutation.claims ? { claims: normalizeWikiClaims(params.mutation.claims) } : {}),
|
|
...(normalizeUniqueStrings(params.mutation.contradictions)
|
|
? { contradictions: normalizeUniqueStrings(params.mutation.contradictions) }
|
|
: {}),
|
|
...(normalizeUniqueStrings(params.mutation.questions)
|
|
? { questions: normalizeUniqueStrings(params.mutation.questions) }
|
|
: {}),
|
|
...(typeof params.mutation.confidence === "number"
|
|
? { confidence: params.mutation.confidence }
|
|
: {}),
|
|
status: params.mutation.status?.trim() || "active",
|
|
updatedAt: new Date().toISOString(),
|
|
},
|
|
body: buildSynthesisBody({
|
|
title: params.mutation.title,
|
|
originalBody: parsed.body,
|
|
generatedBody: params.mutation.body.trim(),
|
|
}),
|
|
});
|
|
return { changed, pagePath, pageId };
|
|
}
|
|
|
|
function buildUpdatedFrontmatter(params: {
|
|
original: Record<string, unknown>;
|
|
mutation: UpdateMetadataMemoryWikiMutation;
|
|
}): Record<string, unknown> {
|
|
const frontmatter: Record<string, unknown> = {
|
|
...params.original,
|
|
updatedAt: new Date().toISOString(),
|
|
};
|
|
if (params.mutation.sourceIds) {
|
|
frontmatter.sourceIds = normalizeSourceIds(params.mutation.sourceIds);
|
|
}
|
|
if (params.mutation.claims) {
|
|
const claims = normalizeWikiClaims(params.mutation.claims);
|
|
if (claims.length > 0) {
|
|
frontmatter.claims = claims;
|
|
} else {
|
|
delete frontmatter.claims;
|
|
}
|
|
}
|
|
if (params.mutation.contradictions) {
|
|
const contradictions = normalizeUniqueStrings(params.mutation.contradictions) ?? [];
|
|
if (contradictions.length > 0) {
|
|
frontmatter.contradictions = contradictions;
|
|
} else {
|
|
delete frontmatter.contradictions;
|
|
}
|
|
}
|
|
if (params.mutation.questions) {
|
|
const questions = normalizeUniqueStrings(params.mutation.questions) ?? [];
|
|
if (questions.length > 0) {
|
|
frontmatter.questions = questions;
|
|
} else {
|
|
delete frontmatter.questions;
|
|
}
|
|
}
|
|
if (params.mutation.confidence === null) {
|
|
delete frontmatter.confidence;
|
|
} else if (typeof params.mutation.confidence === "number") {
|
|
frontmatter.confidence = params.mutation.confidence;
|
|
}
|
|
if (params.mutation.status?.trim()) {
|
|
frontmatter.status = params.mutation.status.trim();
|
|
}
|
|
return frontmatter;
|
|
}
|
|
|
|
async function applyUpdateMetadataMutation(params: {
|
|
config: ResolvedMemoryWikiConfig;
|
|
mutation: UpdateMetadataMemoryWikiMutation;
|
|
}): Promise<{ changed: boolean; pagePath: string; pageId?: string }> {
|
|
const page = await resolveWritablePage({
|
|
config: params.config,
|
|
lookup: params.mutation.lookup,
|
|
});
|
|
if (!page) {
|
|
throw new Error(`Wiki page not found: ${params.mutation.lookup}`);
|
|
}
|
|
const parsed = parseWikiMarkdown(page.raw);
|
|
const changed = await writeWikiPage({
|
|
rootDir: params.config.vault.path,
|
|
relativePath: page.relativePath,
|
|
frontmatter: buildUpdatedFrontmatter({
|
|
original: parsed.frontmatter,
|
|
mutation: params.mutation,
|
|
}),
|
|
body: parsed.body,
|
|
});
|
|
return {
|
|
changed,
|
|
pagePath: page.relativePath,
|
|
...(page.id ? { pageId: page.id } : {}),
|
|
};
|
|
}
|
|
|
|
export async function applyMemoryWikiMutation(params: {
|
|
config: ResolvedMemoryWikiConfig;
|
|
mutation: ApplyMemoryWikiMutation;
|
|
}): Promise<ApplyMemoryWikiMutationResult> {
|
|
await initializeMemoryWikiVault(params.config);
|
|
const result =
|
|
params.mutation.op === "create_synthesis"
|
|
? await applyCreateSynthesisMutation({
|
|
config: params.config,
|
|
mutation: params.mutation,
|
|
})
|
|
: await applyUpdateMetadataMutation({
|
|
config: params.config,
|
|
mutation: params.mutation,
|
|
});
|
|
const compile = await compileMemoryWikiVault(params.config);
|
|
return {
|
|
changed: result.changed,
|
|
operation: params.mutation.op,
|
|
pagePath: result.pagePath,
|
|
...(result.pageId ? { pageId: result.pageId } : {}),
|
|
compile,
|
|
};
|
|
}
|