Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
10 changes: 8 additions & 2 deletions apps/memos-local-openclaw/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import { SkillInstaller } from "./src/skill/installer";
import { Summarizer } from "./src/ingest/providers";
import { MEMORY_GUIDE_SKILL_MD } from "./src/skill/bundled-memory-guide";
import { Telemetry } from "./src/telemetry";
import { parseJsonWithComments } from "./src/shared/json5-lite";


/** Remove near-duplicate hits based on summary word overlap (>70%). Keeps first (highest-scored) hit. */
Expand Down Expand Up @@ -356,12 +357,17 @@ const memosLocalPlugin = {
const openclawJsonPath = path.join(stateDir, "openclaw.json");
if (fs.existsSync(openclawJsonPath)) {
const raw = fs.readFileSync(openclawJsonPath, "utf-8");
const cfg = JSON.parse(raw);
// openclaw.json is JSON5: comments and trailing commas are legal.
// Parse via the JSON5-tolerant helper (writeback below is a targeted
// regex replace on `raw`, so comments are preserved on round-trip).
const cfg = parseJsonWithComments<{ tools?: { allow?: string[] } }>(raw);
const allow: string[] | undefined = cfg?.tools?.allow;
if (Array.isArray(allow) && allow.length > 0 && !allow.includes("group:plugins") && !allow.includes("*")) {
const lastEntry = JSON.stringify(allow[allow.length - 1]);
// Match the last entry + optional trailing comma (legal in JSON5)
// + closing `]`. The replacement always re-inserts a single comma.
const patched = raw.replace(
new RegExp(`(${lastEntry})(\\s*\\])`),
new RegExp(`(${lastEntry})\\s*,?(\\s*\\])`),
`$1,\n "group:plugins"$2`,
);
if (patched !== raw && patched.includes("group:plugins")) {
Expand Down
91 changes: 91 additions & 0 deletions apps/memos-local-openclaw/src/shared/json5-lite.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
/**
* Lightweight JSON5-tolerant parser.
*
* `openclaw.json` is JSON5: line/block comments and trailing commas are legal.
* The standard `JSON.parse` chokes on those, so anywhere we *read* `openclaw.json`
* we go through this helper first.
*
* This is a deliberately small shim, not a full JSON5 implementation:
* - strips `// …` line comments (string-literal aware)
* - strips `/* … *\/` block comments (string-literal aware)
* - strips trailing commas before `]` and `}`
* - delegates to `JSON.parse`
*
* It does NOT support unquoted keys, single-quoted strings, hex literals, etc.
* Comments are by far the dominant JSON5 affordance users hit (issue #1543);
* the rest can be added if a real case shows up.
*
* NOTE: this helper is read-only. It cannot round-trip — any writeback path
* must operate on the original raw text (e.g. targeted regex replace) to
* preserve the user's comments and formatting.
*/
export function parseJsonWithComments<T = unknown>(text: string): T {
return JSON.parse(stripJsonComments(text)) as T;
}

/**
* Strip `//` line comments, `/* *\/` block comments, and trailing commas from
* a JSON-ish string. String literals (including escaped quotes) are left alone.
*
* Exported for tests; prefer `parseJsonWithComments` for normal use.
*/
export function stripJsonComments(text: string): string {
let out = "";
let i = 0;
const n = text.length;
let inString = false;
let stringQuote = "";

while (i < n) {
const ch = text[i];
const next = i + 1 < n ? text[i + 1] : "";

if (inString) {
out += ch;
if (ch === "\\" && i + 1 < n) {
// Preserve escape sequences verbatim (e.g. \", \\, \n).
out += text[i + 1];
i += 2;
continue;
}
if (ch === stringQuote) {
inString = false;
}
i += 1;
continue;
}

// Enter a string literal.
if (ch === '"' || ch === "'") {
inString = true;
stringQuote = ch;
out += ch;
i += 1;
continue;
}

// Line comment: `// …` to end-of-line.
if (ch === "/" && next === "/") {
i += 2;
while (i < n && text[i] !== "\n") i += 1;
// Leave the newline so line numbers stay aligned in error messages.
continue;
}

// Block comment: `/* … */`
if (ch === "/" && next === "*") {
i += 2;
while (i < n && !(text[i] === "*" && text[i + 1] === "/")) i += 1;
i += 2; // skip closing `*/`
continue;
}

out += ch;
i += 1;
}

// Strip trailing commas: `,` followed by optional whitespace and `]` or `}`.
// Run outside the per-char loop so it doesn't have to be string-aware itself
// (the prior pass already preserved string content).
return out.replace(/,(\s*[\]}])/g, "$1");
}
80 changes: 80 additions & 0 deletions apps/memos-local-openclaw/tests/json5-lite.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { describe, expect, it } from "vitest";
import { parseJsonWithComments, stripJsonComments } from "../src/shared/json5-lite";

describe("parseJsonWithComments", () => {
it("parses plain JSON unchanged (regression)", () => {
expect(parseJsonWithComments(`{"a":1,"b":[2,3]}`)).toEqual({ a: 1, b: [2, 3] });
});

it("tolerates // line comments", () => {
const src = `{
// a leading comment
"tools": {
"allow": ["task-cli"] // trailing inline note
}
}`;
expect(parseJsonWithComments(src)).toEqual({ tools: { allow: ["task-cli"] } });
});

it("tolerates /* block */ comments", () => {
const src = `{
/* multi-line
block comment */
"tools": { "allow": ["a"] }
}`;
expect(parseJsonWithComments(src)).toEqual({ tools: { allow: ["a"] } });
});

it("tolerates trailing commas in arrays and objects", () => {
const src = `{
"tools": {
"allow": [
"a",
"b",
],
},
}`;
expect(parseJsonWithComments(src)).toEqual({ tools: { allow: ["a", "b"] } });
});

it("does not touch comment-like sequences inside string literals", () => {
const src = `{ "url": "https://example.com/a//b", "note": "/* not a comment */" }`;
expect(parseJsonWithComments(src)).toEqual({
url: "https://example.com/a//b",
note: "/* not a comment */",
});
});

it("respects escaped quotes inside strings", () => {
const src = `{ "q": "he said \\"hi\\" // not a comment" }`;
expect(parseJsonWithComments(src)).toEqual({ q: 'he said "hi" // not a comment' });
});

it("handles the openclaw.json shape from issue #1543", () => {
// Mirrors the structure in the bug report: comments scattered through a
// realistic config, including in/around tools.allow.
const src = `{
// top-level openclaw config
"tools": {
"allow": [
"task-cli", // first tool
"memos",
/* a block-comment listed mid-array */
"summarizer",
],
},
"agents": { "defaults": { "model": "primary" } }, // trailing object comma too
}`;
const parsed = parseJsonWithComments<{ tools: { allow: string[] } }>(src);
expect(parsed.tools.allow).toEqual(["task-cli", "memos", "summarizer"]);
});
});

describe("stripJsonComments", () => {
it("preserves newlines so line numbers stay aligned in error messages", () => {
const src = "{\n// foo\n\"a\":1\n}";
const stripped = stripJsonComments(src);
// The `// foo` line becomes empty but the newline is retained.
expect(stripped.split("\n").length).toBe(src.split("\n").length);
});
});
Loading