feat: release v0.3.3 — Hermes adapter, context offload, core refactor

This commit is contained in:
chrishuan
2026-05-13 01:58:18 +08:00
parent a74b0b3e43
commit db8f3e516a
100 changed files with 29832 additions and 454 deletions
+19
View File
@@ -0,0 +1,19 @@
/**
* TDAI Adapters — barrel re-export for all host adapter implementations.
*
* Each adapter translates a specific host environment's API into
* the host-neutral HostAdapter interface consumed by TdaiCore.
*
* Directory structure:
* adapters/
* ├── openclaw/ — OpenClaw plugin host (in-process, runEmbeddedPiAgent)
* └── standalone/ — Gateway / Hermes sidecar (HTTP, OpenAI-compatible API)
*/
// OpenClaw adapter
export { OpenClawHostAdapter, OpenClawLLMRunner, OpenClawLLMRunnerFactory } from "./openclaw/index.js";
export type { OpenClawHostAdapterOptions, OpenClawLLMRunnerFactoryOptions } from "./openclaw/index.js";
// Standalone adapter
export { StandaloneHostAdapter, StandaloneLLMRunner, StandaloneLLMRunnerFactory } from "./standalone/index.js";
export type { StandaloneHostAdapterOptions, StandaloneLLMConfig, StandaloneLLMRunnerFactoryOptions } from "./standalone/index.js";
+117
View File
@@ -0,0 +1,117 @@
/**
* OpenClawHostAdapter — translates OpenClaw's plugin API into TDAI Core's
* unified HostAdapter interface.
*
* This is the "thin shell" that keeps OpenClaw-specific dependencies
* (OpenClawPluginApi, pluginConfig, resolveStateDir, event system)
* confined to the adapter layer while TDAI Core remains host-neutral.
*
* Usage (in index.ts):
* const adapter = new OpenClawHostAdapter({ api, pluginDataDir, config });
* const core = new TdaiCore({ hostAdapter: adapter, config: parsedConfig });
*/
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
import { OpenClawLLMRunnerFactory } from "./llm-runner.js";
import type {
HostAdapter,
RuntimeContext,
Logger,
LLMRunnerFactory,
} from "../../core/types.js";
// ============================
// Options
// ============================
export interface OpenClawHostAdapterOptions {
/** OpenClaw plugin API instance. */
api: OpenClawPluginApi;
/** Resolved plugin data directory (e.g. ~/.openclaw/state/memory-tdai). */
pluginDataDir: string;
/** Parsed OpenClaw config (for LLM model resolution). */
openclawConfig: unknown;
}
// ============================
// OpenClawHostAdapter
// ============================
export class OpenClawHostAdapter implements HostAdapter {
readonly hostType = "openclaw" as const;
private api: OpenClawPluginApi;
private pluginDataDir: string;
private openclawConfig: unknown;
private runnerFactory: OpenClawLLMRunnerFactory;
constructor(opts: OpenClawHostAdapterOptions) {
this.api = opts.api;
this.pluginDataDir = opts.pluginDataDir;
this.openclawConfig = opts.openclawConfig;
this.runnerFactory = new OpenClawLLMRunnerFactory({
config: opts.openclawConfig,
agentRuntime: opts.api.runtime.agent,
logger: opts.api.logger,
});
}
/**
* Build a RuntimeContext from the current OpenClaw session.
*
* In OpenClaw, sessionKey and sessionId come from the event/ctx objects
* passed to hooks. This method returns a context with sensible defaults;
* callers can override sessionKey/sessionId per-hook invocation using
* `buildRuntimeContextForSession()`.
*/
getRuntimeContext(): RuntimeContext {
return {
userId: "default_user",
sessionId: "",
sessionKey: "",
platform: "openclaw",
workspaceDir: process.cwd(),
dataDir: this.pluginDataDir,
};
}
/**
* Build a RuntimeContext for a specific session (used per-hook).
*
* This is an OpenClaw-specific convenience that merges session-level
* identifiers from hook ctx into the base context.
*/
buildRuntimeContextForSession(sessionKey: string, sessionId?: string): RuntimeContext {
return {
...this.getRuntimeContext(),
sessionKey,
sessionId: sessionId ?? "",
};
}
getLogger(): Logger {
return this.api.logger;
}
getLLMRunnerFactory(): LLMRunnerFactory {
return this.runnerFactory;
}
// -- OpenClaw-specific accessors (for index.ts bridge) --------------------
/** Get the raw OpenClaw plugin API (for legacy callers during migration). */
getPluginApi(): OpenClawPluginApi {
return this.api;
}
/** Get the OpenClaw config object (for legacy callers during migration). */
getOpenClawConfig(): unknown {
return this.openclawConfig;
}
/** Get the resolved plugin data directory. */
getPluginDataDir(): string {
return this.pluginDataDir;
}
}
+7
View File
@@ -0,0 +1,7 @@
/**
* OpenClaw adapter — barrel exports.
*/
export { OpenClawHostAdapter } from "./host-adapter.js";
export type { OpenClawHostAdapterOptions } from "./host-adapter.js";
export { OpenClawLLMRunner, OpenClawLLMRunnerFactory } from "./llm-runner.js";
export type { OpenClawLLMRunnerFactoryOptions } from "./llm-runner.js";
+104
View File
@@ -0,0 +1,104 @@
/**
* OpenClawLLMRunner — wraps the existing CleanContextRunner as a host-neutral LLMRunner.
*
* This is a compatibility bridge: TDAI Core modules (L1 extractor, L2 scene extractor,
* L3 persona generator, L1 dedup) can depend on the `LLMRunner` interface, while
* OpenClaw continues to use its native `runEmbeddedPiAgent` mechanism under the hood.
*
* Usage:
* const factory = new OpenClawLLMRunnerFactory({ config, agentRuntime, logger });
* const runner = factory.createRunner({ modelRef: "openai/gpt-4o", enableTools: true });
* const result = await runner.run({ prompt: "...", taskId: "l1-extraction" });
*/
import { CleanContextRunner } from "../../utils/clean-context-runner.js";
import type { EmbeddedAgentRuntimeLike } from "../../utils/clean-context-runner.js";
import type {
LLMRunner,
LLMRunParams,
LLMRunnerFactory,
LLMRunnerCreateOptions,
Logger,
} from "../../core/types.js";
const TAG = "[memory-tdai] [openclaw-runner]";
// ============================
// OpenClawLLMRunner
// ============================
/**
* LLMRunner implementation backed by CleanContextRunner.
*
* Each instance is configured with a fixed model + tools setting.
* Create via `OpenClawLLMRunnerFactory.createRunner()`.
*/
export class OpenClawLLMRunner implements LLMRunner {
private runner: CleanContextRunner;
constructor(runner: CleanContextRunner) {
this.runner = runner;
}
async run(params: LLMRunParams): Promise<string> {
return this.runner.run({
prompt: params.prompt,
systemPrompt: params.systemPrompt,
taskId: params.taskId,
timeoutMs: params.timeoutMs,
maxTokens: params.maxTokens,
workspaceDir: params.workspaceDir,
instanceId: params.instanceId,
});
}
}
// ============================
// OpenClawLLMRunnerFactory
// ============================
export interface OpenClawLLMRunnerFactoryOptions {
/** OpenClaw config object (passed to CleanContextRunner). */
config: unknown;
/** Preferred embedded agent runtime (host-injected). */
agentRuntime?: EmbeddedAgentRuntimeLike;
/** Logger for runner tracing. */
logger?: Logger;
}
/**
* Factory that creates OpenClawLLMRunner instances.
*
* Encapsulates the OpenClaw-specific dependencies (config, agentRuntime)
* so that callers only need to specify model + tools.
*/
export class OpenClawLLMRunnerFactory implements LLMRunnerFactory {
private config: unknown;
private agentRuntime?: EmbeddedAgentRuntimeLike;
private logger?: Logger;
constructor(opts: OpenClawLLMRunnerFactoryOptions) {
this.config = opts.config;
this.agentRuntime = opts.agentRuntime;
this.logger = opts.logger;
}
createRunner(opts?: LLMRunnerCreateOptions): LLMRunner {
const enableTools = opts?.enableTools ?? false;
const modelRef = opts?.modelRef;
this.logger?.debug?.(
`${TAG} Creating OpenClawLLMRunner: model=${modelRef ?? "(default)"}, tools=${enableTools}`,
);
const cleanRunner = new CleanContextRunner({
config: this.config,
modelRef,
enableTools,
agentRuntime: this.agentRuntime,
logger: this.logger,
});
return new OpenClawLLMRunner(cleanRunner);
}
}
+97
View File
@@ -0,0 +1,97 @@
/**
* StandaloneHostAdapter — HostAdapter for the TDAI Gateway (Hermes sidecar).
*
* Does NOT depend on OpenClaw. Context is constructed from Gateway config
* and per-request parameters (session_id, user_id, etc.).
*/
import { StandaloneLLMRunnerFactory } from "./llm-runner.js";
import type { StandaloneLLMConfig } from "./llm-runner.js";
import type {
HostAdapter,
RuntimeContext,
Logger,
LLMRunnerFactory,
} from "../../core/types.js";
// ============================
// Options
// ============================
export interface StandaloneHostAdapterOptions {
/** Base data directory for TDAI storage. */
dataDir: string;
/** LLM configuration for model calls. */
llmConfig: StandaloneLLMConfig;
/** Logger instance. */
logger: Logger;
/** Default user ID (can be overridden per-request). */
defaultUserId?: string;
/** Platform identifier. */
platform?: string;
}
// ============================
// StandaloneHostAdapter
// ============================
export class StandaloneHostAdapter implements HostAdapter {
readonly hostType = "standalone" as const;
private dataDir: string;
private logger: Logger;
private runnerFactory: StandaloneLLMRunnerFactory;
private defaultUserId: string;
private platform: string;
constructor(opts: StandaloneHostAdapterOptions) {
this.dataDir = opts.dataDir;
this.logger = opts.logger;
this.defaultUserId = opts.defaultUserId ?? "default_user";
this.platform = opts.platform ?? "gateway";
this.runnerFactory = new StandaloneLLMRunnerFactory({
config: opts.llmConfig,
logger: opts.logger,
});
}
getRuntimeContext(): RuntimeContext {
return {
userId: this.defaultUserId,
sessionId: "",
sessionKey: "",
platform: this.platform,
workspaceDir: this.dataDir,
dataDir: this.dataDir,
};
}
/**
* Build a RuntimeContext for a specific request.
* Used by Gateway route handlers to scope each request to the correct user/session.
*/
buildRuntimeContextForRequest(params: {
userId?: string;
sessionId?: string;
sessionKey?: string;
platform?: string;
}): RuntimeContext {
return {
userId: params.userId ?? this.defaultUserId,
sessionId: params.sessionId ?? "",
sessionKey: params.sessionKey ?? params.sessionId ?? "",
platform: params.platform ?? this.platform,
workspaceDir: this.dataDir,
dataDir: this.dataDir,
};
}
getLogger(): Logger {
return this.logger;
}
getLLMRunnerFactory(): LLMRunnerFactory {
return this.runnerFactory;
}
}
+7
View File
@@ -0,0 +1,7 @@
/**
* Standalone adapter — barrel exports.
*/
export { StandaloneHostAdapter } from "./host-adapter.js";
export type { StandaloneHostAdapterOptions } from "./host-adapter.js";
export { StandaloneLLMRunner, StandaloneLLMRunnerFactory } from "./llm-runner.js";
export type { StandaloneLLMConfig, StandaloneLLMRunnerFactoryOptions } from "./llm-runner.js";
+316
View File
@@ -0,0 +1,316 @@
/**
* StandaloneLLMRunner — powered by Vercel AI SDK (`ai` + `@ai-sdk/openai`).
*
* This runner does NOT depend on OpenClaw's `runEmbeddedPiAgent`. It is designed
* for the Hermes Gateway scenario where TDAI runs as an independent Node.js sidecar
* without the OpenClaw host.
*
* Capabilities:
* - `enableTools: false`: pure text output (L1 extraction, L1 dedup)
* - `enableTools: true`: automatic tool-call loop with local file operations
* (L2 scene, L3 persona) via AI SDK's `maxSteps`
*
* Tool sandbox:
* When tools are enabled, three basic file operations are exposed:
* `read_file`, `write_to_file`, `replace_in_file`.
* All file paths are resolved relative to `workspaceDir`, enforcing sandbox boundaries.
*/
import fsPromises from "node:fs/promises";
import path from "node:path";
import { generateText, tool, stepCountIs, jsonSchema } from "ai";
import { createOpenAI } from "@ai-sdk/openai";
import { report } from "../../core/report/reporter.js";
import type {
LLMRunner,
LLMRunParams,
LLMRunnerFactory,
LLMRunnerCreateOptions,
Logger,
} from "../../core/types.js";
const TAG = "[memory-tdai] [standalone-runner]";
// Max iterations in the tool-call loop to prevent infinite loops
const MAX_TOOL_ITERATIONS = 20;
// ============================
// Configuration
// ============================
export interface StandaloneLLMConfig {
/** OpenAI-compatible API base URL (e.g. "https://api.openai.com/v1"). */
baseUrl: string;
/** API key for authentication. */
apiKey: string;
/** Default model name (e.g. "gpt-4o"). */
model: string;
/** Default max output tokens. */
maxTokens?: number;
/** Request timeout in milliseconds (default: 120_000). */
timeoutMs?: number;
}
// ============================
// Sandboxed tool execution helpers
// ============================
function resolveSandboxedPath(workspaceDir: string, relativePath: string): string | null {
const resolved = path.resolve(workspaceDir, relativePath);
if (!resolved.startsWith(path.resolve(workspaceDir))) {
return null;
}
return resolved;
}
// ============================
// Tool definitions (Vercel AI SDK `tool()` format)
// ============================
function createSandboxedTools(workspaceDir: string, logger?: Logger) {
return {
read_file: tool({
description: "Read the contents of a file at the given relative path.",
inputSchema: jsonSchema<{ path: string }>({
type: "object",
properties: {
path: { type: "string", description: "Relative file path to read." },
},
required: ["path"],
}),
execute: (async (args: { path: string }) => {
const resolved = resolveSandboxedPath(workspaceDir, args.path);
if (!resolved) return JSON.stringify({ error: `Path "${args.path}" escapes workspace boundary.` });
try {
return await fsPromises.readFile(resolved, "utf-8");
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
logger?.warn?.(`${TAG} read_file failed: ${msg}`);
return JSON.stringify({ error: msg });
}
}) as any,
}),
write_to_file: tool({
description: "Write content to a file at the given relative path. Creates or overwrites.",
inputSchema: jsonSchema<{ path: string; content: string }>({
type: "object",
properties: {
path: { type: "string", description: "Relative file path to write." },
content: { type: "string", description: "Content to write." },
},
required: ["path", "content"],
}),
execute: (async (args: { path: string; content: string }) => {
const resolved = resolveSandboxedPath(workspaceDir, args.path);
if (!resolved) return JSON.stringify({ error: `Path "${args.path}" escapes workspace boundary.` });
try {
await fsPromises.mkdir(path.dirname(resolved), { recursive: true });
await fsPromises.writeFile(resolved, args.content, "utf-8");
return JSON.stringify({ success: true });
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
logger?.warn?.(`${TAG} write_to_file failed: ${msg}`);
return JSON.stringify({ error: msg });
}
}) as any,
}),
replace_in_file: tool({
description: "Replace an exact substring in a file with new content.",
inputSchema: jsonSchema<{ path: string; old_str: string; new_str: string }>({
type: "object",
properties: {
path: { type: "string", description: "Relative file path." },
old_str: { type: "string", description: "Exact string to find and replace." },
new_str: { type: "string", description: "Replacement string." },
},
required: ["path", "old_str", "new_str"],
}),
execute: (async (args: { path: string; old_str: string; new_str: string }) => {
const resolved = resolveSandboxedPath(workspaceDir, args.path);
if (!resolved) return JSON.stringify({ error: `Path "${args.path}" escapes workspace boundary.` });
if (!args.old_str) return JSON.stringify({ error: "old_str cannot be empty." });
try {
const existing = await fsPromises.readFile(resolved, "utf-8");
if (!existing.includes(args.old_str)) {
return JSON.stringify({ error: `old_str not found in file "${args.path}".` });
}
const updated = existing.replace(args.old_str, args.new_str);
await fsPromises.writeFile(resolved, updated, "utf-8");
return JSON.stringify({ success: true });
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
logger?.warn?.(`${TAG} replace_in_file failed: ${msg}`);
return JSON.stringify({ error: msg });
}
}) as any,
}),
};
}
/** Read-only tool subset — used when enableTools=false to avoid empty tools rejection. */
function createReadOnlyTools(workspaceDir: string, logger?: Logger) {
const all = createSandboxedTools(workspaceDir, logger);
return { read_file: all.read_file };
}
// ============================
// StandaloneLLMRunner
// ============================
export class StandaloneLLMRunner implements LLMRunner {
private config: StandaloneLLMConfig;
private model: string;
private enableTools: boolean;
private logger?: Logger;
constructor(opts: {
config: StandaloneLLMConfig;
model?: string;
enableTools?: boolean;
logger?: Logger;
}) {
this.config = opts.config;
this.model = opts.model ?? opts.config.model;
this.enableTools = opts.enableTools ?? false;
this.logger = opts.logger;
}
async run(params: LLMRunParams): Promise<string> {
const runStartMs = Date.now();
const timeoutMs = params.timeoutMs ?? this.config.timeoutMs ?? 120_000;
const maxTokens = params.maxTokens ?? this.config.maxTokens ?? 4096;
const workspaceDir = params.workspaceDir ?? process.cwd();
this.logger?.debug?.(
`${TAG} run() start: taskId=${params.taskId}, model=${this.model}, ` +
`tools=${this.enableTools}, timeout=${timeoutMs}ms`,
);
// Create OpenAI-compatible provider via AI SDK
// Use "compatible" mode to call /chat/completions (not Responses API),
// which works with all OpenAI-compatible backends (DeepSeek, Qwen, etc.)
const provider = createOpenAI({
baseURL: this.config.baseUrl,
apiKey: this.config.apiKey,
compatibility: "compatible",
});
// Select tools based on mode
const tools = this.enableTools
? createSandboxedTools(workspaceDir, this.logger)
: createReadOnlyTools(workspaceDir, this.logger);
try {
const result = await generateText({
model: provider.chat(this.model),
system: params.systemPrompt,
prompt: params.prompt,
tools,
stopWhen: stepCountIs(this.enableTools ? MAX_TOOL_ITERATIONS : 1),
maxOutputTokens: maxTokens,
abortSignal: AbortSignal.timeout(timeoutMs),
});
const text = result.text.trim();
const totalMs = Date.now() - runStartMs;
this.logger?.debug?.(
`${TAG} run() completed: ${totalMs}ms, steps=${result.steps.length}, output=${text.length} chars`,
);
// Log tool usage if any
if (result.steps.length > 1) {
const toolCalls = result.steps.flatMap((s) => s.toolCalls ?? []);
this.logger?.debug?.(
`${TAG} Tool calls: ${toolCalls.map((tc) => tc.toolName).join(", ")}`,
);
}
// Metric
if (params.instanceId) {
report("llm_call", {
taskId: params.taskId,
provider: "standalone",
model: this.model,
inputLength: params.prompt.length,
outputLength: text.length,
totalDurationMs: totalMs,
success: true,
error: null,
});
}
return text;
} catch (err) {
const totalMs = Date.now() - runStartMs;
const errMsg = err instanceof Error ? err.message : String(err);
this.logger?.error(`${TAG} run() failed after ${totalMs}ms: ${errMsg}`);
if (params.instanceId) {
report("llm_call", {
taskId: params.taskId,
provider: "standalone",
model: this.model,
inputLength: params.prompt.length,
outputLength: 0,
totalDurationMs: totalMs,
success: false,
error: errMsg,
});
}
throw err;
}
}
}
// ============================
// StandaloneLLMRunnerFactory
// ============================
export interface StandaloneLLMRunnerFactoryOptions {
/** LLM API configuration. */
config: StandaloneLLMConfig;
/** Logger instance. */
logger?: Logger;
}
/**
* Factory that creates StandaloneLLMRunner instances.
*
* Used by the Gateway and Hermes host adapters.
*/
export class StandaloneLLMRunnerFactory implements LLMRunnerFactory {
private config: StandaloneLLMConfig;
private logger?: Logger;
constructor(opts: StandaloneLLMRunnerFactoryOptions) {
this.config = opts.config;
this.logger = opts.logger;
}
createRunner(opts?: LLMRunnerCreateOptions): LLMRunner {
const enableTools = opts?.enableTools ?? false;
const modelRef = opts?.modelRef;
// Parse "provider/model" → just use the model part for OpenAI-compatible API
let model = this.config.model;
if (modelRef) {
const slashIdx = modelRef.indexOf("/");
model = slashIdx > 0 ? modelRef.slice(slashIdx + 1) : modelRef;
}
this.logger?.debug?.(
`${TAG} Creating StandaloneLLMRunner: model=${model}, tools=${enableTools}`,
);
return new StandaloneLLMRunner({
config: this.config,
model,
enableTools,
logger: this.logger,
});
}
}