Skip to content
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
"plugin/modes",
"plugin/scripts/*.js",
"plugin/scripts/*.cjs",
"plugin/sqlite",
"plugin/skills",
"plugin/ui",
"openclaw"
Expand Down
80 changes: 40 additions & 40 deletions plugin/scripts/context-generator.cjs

Large diffs are not rendered by default.

56 changes: 28 additions & 28 deletions plugin/scripts/mcp-server.cjs

Large diffs are not rendered by default.

252 changes: 126 additions & 126 deletions plugin/scripts/server-service.cjs

Large diffs are not rendered by default.

26 changes: 13 additions & 13 deletions plugin/scripts/transcript-watcher.cjs

Large diffs are not rendered by default.

454 changes: 227 additions & 227 deletions plugin/scripts/worker-service.cjs

Large diffs are not rendered by default.

869 changes: 869 additions & 0 deletions plugin/sqlite/SessionStore.cjs

Large diffs are not rendered by default.

80 changes: 80 additions & 0 deletions scripts/build-hooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,45 @@ function stripHardcodedDirname(filePath) {
}
}

/**
* Assert that every relative module specifier (`"../x.js"` / `"./x.js"`)
* appearing as a string literal inside plugin/scripts/*.cjs resolves to a file
* on disk, relative to that .cjs. The worker reaches SessionStore via a lazy
* createRequire('../sqlite/SessionStore.cjs') that esbuild leaves as a bare
* string literal in the bundle (not a static import it can follow) — so a
* missing emit is invisible to esbuild and only explodes at runtime on the
* first observation/backfill. This static scan catches it at build time.
* Model: scripts/check-sdk-bundle.cjs (string match against emitted JS).
*/
function assertPluginRelativeRequiresResolve(scriptsDir) {
const RELATIVE_SPECIFIER = /["'](\.\.?\/[A-Za-z0-9_./-]+\.(?:c?js))["']/g;
const failures = [];
for (const entry of fs.readdirSync(scriptsDir)) {
if (!entry.endsWith('.cjs')) continue;
const filePath = path.join(scriptsDir, entry);
const content = fs.readFileSync(filePath, 'utf-8');
const seen = new Set();
let m;
while ((m = RELATIVE_SPECIFIER.exec(content)) !== null) {
const spec = m[1];
if (seen.has(spec)) continue;
seen.add(spec);
const resolved = path.resolve(path.dirname(filePath), spec);
if (!fs.existsSync(resolved)) {
failures.push(`${entry} requires "${spec}" but ${path.relative(scriptsDir, resolved)} was not emitted`);
}
Comment on lines +67 to +83
}
}
if (failures.length > 0) {
throw new Error(
'Plugin relative-require guard FAILED — a bundled script references a ' +
'sibling module the build did not emit (this is the #3091/#3092/#3107 ' +
'class of bug):\n - ' + failures.join('\n - ')
);
}
console.log('✓ Plugin relative-require guard: all ../ and ./ require targets in plugin/scripts/*.cjs resolve');
}

/**
* Rule A canonical-template manifest: maps each host-managed config file's
* command string to the buildShellCommand() options that generate it. The
Expand Down Expand Up @@ -349,6 +388,35 @@ async function buildHooks() {
);
}

// worker-service reaches SessionStore through a runtime
// createRequire(import.meta.url)('../sqlite/SessionStore.cjs') call (see
// ChromaSync.ts loadSessionStoreCtor), not a static import: SessionStore
// pulls in `bun:sqlite`, so a static import would drag it into the cmem-sdk
// (tsup) bundle and fail scripts/check-sdk-bundle.cjs. esbuild's worker
// bundle does not follow that indirection either, so SessionStore.cjs must be
// emitted as a loose sibling of the bundle for the runtime require to
// resolve (#3091/#3092/#3107). We emit it with a .cjs extension (not .js) so
// Node consumers can require() it despite plugin/package.json's
// "type":"module" — Bun ignores the field, but Node otherwise refuses to
// require a .js file under an ESM package. observations/files.js is intentionally NOT
// emitted — parseFileList is a static import inlined into the worker bundle
// (no bun:sqlite in its chain), the more robust fix for the hot path.
console.log(`\n🔧 Building lazy-loaded SessionStore module for worker-service...`);
const sessionStoreOut = `${hooksDir}/../sqlite/SessionStore.cjs`;
await build({
entryPoints: ['src/services/sqlite/SessionStore.ts'],
bundle: true,
platform: 'node',
target: 'node18',
format: 'cjs',
outfile: sessionStoreOut,
minify: true,
logLevel: 'error',
external: ['bun:sqlite'],
});
const sessionStoreStats = fs.statSync(sessionStoreOut);
console.log(`✓ sqlite/SessionStore.cjs built (${(sessionStoreStats.size / 1024).toFixed(2)} KB)`);

console.log(`\n🔧 Building server beta service...`);
await build({
entryPoints: [SERVER_SERVICE.source],
Expand Down Expand Up @@ -515,6 +583,18 @@ async function buildHooks() {
);
}

// Fast, local half of the packaging guard: assert every relative require
// target inside the just-built plugin/scripts/*.cjs resolves to an emitted
// sibling. Runs AFTER all five plugin/scripts bundles (worker-service,
// server-service, mcp-server, context-generator, transcript-watcher) are
// (re)built this run, so it scans the fresh output for every target — not a
// stale mix. smoke:clean-room PART 3 re-checks the same invariant against the
// packed tarball (what actually ships). Together they make the
// #3091/#3092/#3107 class of bug — a lazy createRequire('../…') whose target
// the build never emits — impossible to ship. See scripts/check-sdk-bundle.cjs
// for the sibling string-scan pattern.
assertPluginRelativeRequiresResolve(hooksDir);

console.log(`\n🔧 Building NPX CLI...`);
const npxCliOutDir = 'dist/npx-cli';
if (!fs.existsSync(npxCliOutDir)) {
Expand Down
49 changes: 48 additions & 1 deletion scripts/smoke-clean-room.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,52 @@ function checkPackageCompleteness(failures) {
log(` Loaded ${entry.label} cleanly (no missing module).`);
}
}

return pkgRoot;
}

// ---------------------------------------------------------------------------
// PART 3 — plugin relative-require closure (the #3091/#3092/#3107 guard)
// ---------------------------------------------------------------------------
// The worker reaches SessionStore via a lazy
// createRequire('../sqlite/SessionStore.cjs') that fires only on the first
// observation/backfill — never during the `--version` boots PART 1/2 do, so
// those parts cannot catch a missing sibling. Statically assert every relative
// require target inside the PACKED plugin/scripts/*.cjs resolves inside the
// tarball. This is the guard that would have caught 13.9.x shipping without
// plugin/sqlite/.
function checkPluginRelativeRequires(failures, pkgRoot) {
log('\nPART 3 — plugin relative-require closure (#3091/#3092/#3107 guard)');

const scriptsDir = path.join(pkgRoot, 'plugin', 'scripts');
if (!fs.existsSync(scriptsDir)) {
failures.push(`packed tarball is missing plugin/scripts (${scriptsDir})`);
return;
}

const RELATIVE_SPECIFIER = /["'](\.\.?\/[A-Za-z0-9_./-]+\.(?:c?js))["']/g;
let checked = 0;
for (const entry of fs.readdirSync(scriptsDir)) {
if (!entry.endsWith('.cjs')) continue;
const filePath = path.join(scriptsDir, entry);
const content = fs.readFileSync(filePath, 'utf8');
const seen = new Set();
let m;
while ((m = RELATIVE_SPECIFIER.exec(content)) !== null) {
const spec = m[1];
if (seen.has(spec)) continue;
seen.add(spec);
checked++;
const resolved = path.resolve(path.dirname(filePath), spec);
if (!fs.existsSync(resolved)) {
failures.push(
Comment on lines +344 to +372
`published plugin/scripts/${entry} requires "${spec}" but it is ` +
`absent from the tarball (would throw MODULE_NOT_FOUND at runtime)`
);
}
}
}
log(` Checked ${checked} relative require target(s) across plugin/scripts/*.cjs.`);
}

// ---------------------------------------------------------------------------
Expand All @@ -342,7 +388,8 @@ function main() {

try {
checkPluginClosure(failures);
checkPackageCompleteness(failures);
const pkgRoot = checkPackageCompleteness(failures);
if (pkgRoot) checkPluginRelativeRequires(failures, pkgRoot);
} finally {
rmrf(cleanup.tmpPlugin);
rmrf(cleanup.tmpPkg);
Expand Down
43 changes: 15 additions & 28 deletions src/services/sync/ChromaSync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,20 @@
import { ChromaMcpManager } from './ChromaMcpManager.js';
import { ChromaSyncState, ProjectWatermarks } from './ChromaSyncState.js';
import { ParsedObservation, ParsedSummary } from '../../sdk/parser.js';
// cmem-sdk: keep SessionStore + parseFileList off the SDK's import graph.
// Both come from the SQLite layer (`bun:sqlite`). The SDK only uses the
// constructor + ensureCollectionExists + close() surface of ChromaSync,
// so a TYPE-ONLY import is sufficient — value-level uses (`new
// SessionStore()` / parseFileList(...)) are loaded lazily inside the
// SQLite-only methods that need them. Plan §3 anti-pattern: do NOT add
// `bun:sqlite` to the SDK bundle externals — fix the import chain.
// cmem-sdk: keep SessionStore off the SDK's import graph. It comes from the
// SQLite layer (`bun:sqlite`). The SDK only uses the constructor +
// ensureCollectionExists + close() surface of ChromaSync, so a TYPE-ONLY
// import is sufficient — the value-level use (`new SessionStore()`) is loaded
// lazily inside the SQLite-only backfill methods that need it. Plan §3
// anti-pattern: do NOT add `bun:sqlite` to the SDK bundle externals — fix the
// import chain. parseFileList is pure (imports only `logger`, no `bun:sqlite`),
// so it is a normal static import — inlined into the worker bundle, still
// absent from anything that would pull `bun:sqlite` into the SDK bundle.
import type { SessionStore as SessionStoreType } from '../sqlite/SessionStore.js';
import { logger } from '../../utils/logger.js';
import { ChromaUnavailableError } from '../worker/search/errors.js';
import { normalizePlatformSource } from '../../shared/platform-source.js';
import type * as SqliteFilesModule from '../sqlite/observations/files.js';
import { parseFileList } from '../sqlite/observations/files.js';

type SessionStore = SessionStoreType;
type SessionStoreCtor = new () => SessionStoreType;
Expand All @@ -32,21 +34,12 @@ let _sessionStoreCtor: SessionStoreCtor | undefined;
function loadSessionStoreCtor(): SessionStoreCtor {
if (!_sessionStoreCtor) {
const req = lazyCreateRequire();
const m = req('../sqlite/SessionStore.js') as { SessionStore: SessionStoreCtor };
const m = req('../sqlite/SessionStore.cjs') as { SessionStore: SessionStoreCtor };
_sessionStoreCtor = m.SessionStore;
}
return _sessionStoreCtor;
}

let _filesHelper: typeof SqliteFilesModule | undefined;
function loadFilesHelper(): typeof SqliteFilesModule {
if (!_filesHelper) {
const req = lazyCreateRequire();
_filesHelper = req('../sqlite/observations/files.js') as typeof SqliteFilesModule;
}
return _filesHelper;
}

// Exported for cmem-sdk Phase 6: the SDK builds ChromaDocument values from
// Postgres observations (UUID id, content string, metadata bag) and calls
// the now-public addDocuments() to index them. Shape is unchanged.
Expand Down Expand Up @@ -152,12 +145,8 @@ export class ChromaSync {

const facts = obs.facts ? JSON.parse(obs.facts) : [];
const concepts = obs.concepts ? JSON.parse(obs.concepts) : [];
// parseFileList is SQLite-shaped (`bun:sqlite` in the import chain) —
// resolve it through the deferred loader so this method stays out of
// the SDK bundle's import graph. Plan §3.
const filesHelper = loadFilesHelper();
const files_read = filesHelper.parseFileList(obs.files_read);
const files_modified = filesHelper.parseFileList(obs.files_modified);
const files_read = parseFileList(obs.files_read);
const files_modified = parseFileList(obs.files_modified);

const baseMetadata: Record<string, string | number | null> = {
sqlite_id: obs.id,
Expand Down Expand Up @@ -625,8 +614,7 @@ export class ChromaSync {

const watermarks = ChromaSyncState.get(backfillProject);

const SessionStoreCtor = loadSessionStoreCtor();
const db = storeOverride ?? new SessionStoreCtor();
const db = storeOverride ?? new (loadSessionStoreCtor())();

try {
await this.runBackfillPipeline(db, backfillProject, watermarks);
Expand Down Expand Up @@ -1048,8 +1036,7 @@ export class ChromaSync {
let db: SessionStore | undefined;
let sync: ChromaSync | undefined;
try {
const SessionStoreCtor = loadSessionStoreCtor();
db = storeOverride ?? new SessionStoreCtor();
db = storeOverride ?? new (loadSessionStoreCtor())();
sync = new ChromaSync('claude-mem');
} catch (error) {
logger.error('CHROMA_SYNC', 'Failed to initialize backfill resources',
Expand Down
4 changes: 2 additions & 2 deletions src/services/worker-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -594,7 +594,7 @@ export class WorkerService implements WorkerRef {
// try/caught), so this .catch is an unhandled-rejection backstop that
// keeps the worker alive if that contract ever regresses.
runHistoricalBackfill(this.dbManager.getConnection()).catch(error => {
logger.error('SYSTEM', 'Telemetry historical backfill failed (non-blocking)', {}, error as Error);
logger.error('SYSTEM', 'Telemetry historical backfill failed (non-blocking)', {}, error instanceof Error ? error : new Error(String(error)));
});

await this.startTranscriptWatcher(settings);
Expand All @@ -603,7 +603,7 @@ export class WorkerService implements WorkerRef {
ChromaSync.backfillAllProjects(this.dbManager.getSessionStore()).then(() => {
logger.info('CHROMA_SYNC', 'Backfill check complete for all projects');
}).catch(error => {
logger.error('CHROMA_SYNC', 'Backfill failed (non-blocking)', {}, error as Error);
logger.error('CHROMA_SYNC', 'Backfill failed (non-blocking)', {}, error instanceof Error ? error : new Error(String(error)));
});
}

Expand Down
4 changes: 2 additions & 2 deletions src/services/worker/agents/ResponseProcessor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -325,7 +325,7 @@ async function syncAndBroadcastObservations(
obsId,
type: obs.type,
title: obs.title || '(untitled)'
}, error);
}, error instanceof Error ? error : new Error(String(error)));
});

broadcastObservation(worker, {
Expand Down Expand Up @@ -410,7 +410,7 @@ async function syncAndBroadcastSummary(
logger.error('CHROMA', `${agentName} chroma sync failed, continuing without vector search`, {
summaryId: result.summaryId,
request: summaryForStore.request || '(no request)'
}, error);
}, error instanceof Error ? error : new Error(String(error)));
});

broadcastSummary(worker, {
Expand Down
2 changes: 1 addition & 1 deletion src/services/worker/http/routes/SessionRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -620,7 +620,7 @@ export class SessionRoutes extends BaseRouteHandler {
logger.error('CHROMA', 'User prompt sync failed, continuing without vector search', {
promptId: latestPrompt.id,
prompt: promptText.length > 60 ? promptText.substring(0, 60) + '...' : promptText
}, error);
}, error instanceof Error ? error : new Error(String(error)));
});
}

Expand Down
35 changes: 27 additions & 8 deletions src/utils/logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,23 @@ interface LogContext {
export type ErrorSink = (err: unknown, ctx?: Record<string, unknown>) => void;
let errorSink: ErrorSink | null = null;

/**
* Bun throws `ResolveMessage` / `BuildMessage` for a failed require()/import.
* These are NOT `instanceof Error`, and their `.message`/`.stack` are
* non-enumerable, so `Object.keys()`/JSON serialization renders them as `{}` —
* exactly why the #3091/#3092 MODULE_NOT_FOUND presented as an empty `{}` and
* went undiagnosed. Treat any object exposing a string `message` (or `stack`)
* as error-like so it serializes usefully.
*/
function isErrorLike(value: unknown): value is { message: string; stack?: string } {
return (
value instanceof Error ||
(typeof value === 'object' &&
value !== null &&
typeof (value as { message?: unknown }).message === 'string')
);
}


class Logger {
private level: LogLevel | null = null;
Expand Down Expand Up @@ -128,9 +145,10 @@ class Logger {
if (typeof data === 'boolean') return data.toString();

if (typeof data === 'object') {
if (data instanceof Error) {
return this.getLevel() === LogLevel.DEBUG
? `${data.message}\n${data.stack}`
if (isErrorLike(data)) {
const stack = data.stack;
return this.getLevel() === LogLevel.DEBUG && stack
? `${data.message}\n${stack}`
: data.message;
}

Expand Down Expand Up @@ -244,9 +262,10 @@ class Logger {

let dataStr = '';
if (data !== undefined && data !== null) {
if (data instanceof Error) {
dataStr = this.getLevel() === LogLevel.DEBUG
? `\n${data.message}\n${data.stack}`
if (isErrorLike(data)) {
const stack = data.stack;
dataStr = this.getLevel() === LogLevel.DEBUG && stack
? `\n${data.message}\n${stack}`
: ` ${data.message}`;
} else if (this.getLevel() === LogLevel.DEBUG && typeof data === 'object') {
try {
Expand Down Expand Up @@ -321,13 +340,13 @@ class Logger {
*/
private routeErrorToSink(message: string, context?: LogContext, data?: any): void {
try {
if (!errorSink || !(data instanceof Error)) return;
if (!errorSink || !isErrorLike(data)) return;
// Pass the message as context so the sink can fingerprint on it too; the
// sink (captureException) scrubs everything through error-scrub /
// scrubProperties, so an unsafe message here cannot leak — but `message`
// is not whitelisted, so it is dropped by scrubProperties anyway. We pass
// only the Error itself; context is intentionally minimal.
errorSink(data);
errorSink(data instanceof Error ? data : new Error(data.message));
} catch {
Comment on lines 341 to 350
// Telemetry/error-sink must never break logging.
}
Expand Down
Loading