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
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -462,6 +462,7 @@ Query → BM25 FTS ─────┘
},
"smartExtraction": true,
"llm": {
"api": "openai-completions",
"apiKey": "${OPENAI_API_KEY}",
"model": "gpt-4o-mini",
"baseURL": "https://api.openai.com/v1"
Expand Down Expand Up @@ -509,13 +510,21 @@ Any Jina-compatible rerank endpoint also works — set `rerankProvider: "jina"`

When `smartExtraction` is enabled (default: `true`), the plugin uses an LLM to intelligently extract and classify memories instead of regex-based triggers.

Sensitive config fields support both `${ENV_VAR}` interpolation and direct Bitwarden Secrets Manager refs via `bws://<secret-id>`:

- `embedding.apiKey`
- `llm.apiKey`
- `retrieval.rerankApiKey`

| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `smartExtraction` | boolean | `true` | Enable/disable LLM-powered 6-category extraction |
| `llm.api` | string | `openai-completions` | `openai-completions` uses OpenAI-compatible chat completions; `anthropic-messages` uses Anthropic-compatible `/v1/messages` |
| `llm.auth` | string | `api-key` | `api-key` uses `llm.apiKey` / `embedding.apiKey`; `oauth` uses a plugin-scoped OAuth token file by default |
| `llm.apiKey` | string | *(falls back to `embedding.apiKey`)* | API key for the LLM provider |
| `llm.model` | string | `openai/gpt-oss-120b` | LLM model name |
| `llm.baseURL` | string | *(falls back to `embedding.baseURL`)* | LLM API endpoint |
| `llm.anthropicVersion` | string | `2023-06-01` | `anthropic-version` header used when `llm.api = anthropic-messages` |
| `llm.oauthProvider` | string | `openai-codex` | OAuth provider id used when `llm.auth` is `oauth` |
| `llm.oauthPath` | string | `~/.openclaw/.memory-lancedb-pro/oauth.json` | OAuth token file used when `llm.auth` is `oauth` |
| `llm.timeoutMs` | number | `30000` | LLM request timeout in milliseconds |
Expand All @@ -527,6 +536,7 @@ OAuth `llm` config (use existing Codex / ChatGPT login cache for LLM calls):
```json
{
"llm": {
"api": "openai-completions",
"auth": "oauth",
"oauthProvider": "openai-codex",
"model": "gpt-5.4",
Expand All @@ -538,12 +548,27 @@ OAuth `llm` config (use existing Codex / ChatGPT login cache for LLM calls):

Notes for `llm.auth: "oauth"`:

- OAuth currently requires `llm.api: "openai-completions"`.
- `llm.oauthProvider` is currently `openai-codex`.
- OAuth tokens default to `~/.openclaw/.memory-lancedb-pro/oauth.json`.
- You can set `llm.oauthPath` if you want to store that file somewhere else.
- `auth login` snapshots the previous api-key `llm` config next to the OAuth file, and `auth logout` restores that snapshot when available.
- Switching from `api-key` to `oauth` does not automatically carry over `llm.baseURL`. Set it manually in OAuth mode only when you intentionally want a custom ChatGPT/Codex-compatible backend.

Anthropic-compatible `llm` config:
```json
{
"llm": {
"api": "anthropic-messages",
"apiKey": "bws://YOUR-BWS-SECRET-UUID",
"model": "claude-sonnet-4-5",
"baseURL": "https://api.anthropic.com/v1",
"anthropicVersion": "2023-06-01",
"timeoutMs": 30000
}
}
```

</details>

<details>
Expand Down
11 changes: 11 additions & 0 deletions cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,10 +109,12 @@ function resolveConfiguredOauthPath(configPath: string, rawPath: unknown): strin
}

type RestorableApiKeyLlmConfig = {
api?: "openai-completions" | "anthropic-messages";
auth?: "api-key";
apiKey?: string;
model?: string;
baseURL?: string;
anthropicVersion?: string;
timeoutMs?: number;
};

Expand All @@ -136,6 +138,9 @@ function extractRestorableApiKeyLlmConfig(value: unknown): RestorableApiKeyLlmCo
}

const result: RestorableApiKeyLlmConfig = {};
if (value.api === "openai-completions" || value.api === "anthropic-messages") {
result.api = value.api;
}
if (value.auth === "api-key") {
result.auth = "api-key";
}
Expand All @@ -148,6 +153,9 @@ function extractRestorableApiKeyLlmConfig(value: unknown): RestorableApiKeyLlmCo
if (typeof value.baseURL === "string") {
result.baseURL = value.baseURL;
}
if (typeof value.anthropicVersion === "string") {
result.anthropicVersion = value.anthropicVersion;
}
if (typeof value.timeoutMs === "number" && Number.isFinite(value.timeoutMs) && value.timeoutMs > 0) {
result.timeoutMs = Math.trunc(value.timeoutMs);
}
Expand All @@ -160,6 +168,7 @@ function extractOauthSafeLlmConfig(value: unknown): RestorableApiKeyLlmConfig {
}

const result: RestorableApiKeyLlmConfig = {};
result.api = "openai-completions";
if (typeof value.baseURL === "string") {
result.baseURL = value.baseURL;
}
Expand Down Expand Up @@ -540,6 +549,7 @@ export function registerMemoryCLI(program: Command, context: CLIContext): void {
}
pluginConfig.llm = {
...nextLlm,
api: "openai-completions",
auth: "oauth",
oauthProvider: selectedProvider.providerId,
model: oauthModel,
Expand Down Expand Up @@ -589,6 +599,7 @@ export function registerMemoryCLI(program: Command, context: CLIContext): void {

console.log(`Config file: ${configPath}`);
console.log(`Plugin: ${pluginId}`);
console.log(`llm.api: ${typeof llm.api === "string" ? llm.api : "openai-completions"}`);
console.log(`llm.auth: ${typeof llm.auth === "string" ? llm.auth : "api-key"}`);
console.log(`llm.oauthProvider: ${oauthProviderDisplay}`);
console.log(`llm.model: ${typeof llm.model === "string" ? llm.model : "openai/gpt-oss-120b"}`);
Expand Down
18 changes: 15 additions & 3 deletions examples/new-session-distill/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ This example shows a **non-blocking /new distillation pipeline**:

- Trigger: `command:new` (when you type `/new`)
- Hook: enqueue a small JSON task file (fast, no LLM calls)
- Worker: a user-level systemd service watches the inbox and runs **Gemini Map-Reduce** over the session JSONL transcript
- Worker: a user-level systemd service watches the inbox and runs **Gemini or Anthropic-compatible Map-Reduce** over the session JSONL transcript
- Storage: write high-signal, atomic lessons into LanceDB Pro via `openclaw memory-pro import`
- Notify: send a notification message (optional)

Expand All @@ -13,7 +13,19 @@ Files included:
- `worker/lesson-extract-worker.mjs` — Map-Reduce extractor + importer + notifier
- `worker/systemd/lesson-extract-worker.service` — user systemd unit

You must provide:
- `GEMINI_API_KEY` in an env file loaded by systemd
You must provide one of:

- Gemini-native:
- `DISTILL_API=gemini-native` or omit it
- `GEMINI_API_KEY` or `DISTILL_API_KEY`
- optional `GEMINI_MODEL` / `DISTILL_MODEL`
- Anthropic-compatible:
- `DISTILL_API=anthropic-messages`
- `DISTILL_API_KEY`
- `DISTILL_MODEL`
- optional `DISTILL_BASE_URL` (defaults to `https://api.anthropic.com/v1`)
- optional `DISTILL_ANTHROPIC_VERSION` (defaults to `2023-06-01`)

`DISTILL_API_KEY` and `GEMINI_API_KEY` also accept `bws://<secret-id>` Bitwarden Secrets Manager refs.

Install steps are documented in the main repo README.
112 changes: 105 additions & 7 deletions examples/new-session-distill/worker/lesson-extract-worker.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,13 @@ import fs from "node:fs";
import fsp from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { spawn } from "node:child_process";
import { spawn, execFile as execFileCallback } from "node:child_process";
import readline from "node:readline";
import { promisify } from "node:util";

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const execFile = promisify(execFileCallback);

// In your deployment, set LESSON_QUEUE_ROOT to your workspace queue.
// By default we assume repo layout similar to OpenClaw-Memory.
Expand All @@ -31,8 +33,10 @@ const PROCESSING = path.join(QUEUE_ROOT, "processing");
const DONE = path.join(QUEUE_ROOT, "done");
const ERROR = path.join(QUEUE_ROOT, "error");

const GEMINI_API_KEY = process.env.GEMINI_API_KEY;
const GEMINI_MODEL = process.env.GEMINI_MODEL || "gemini-3-flash-preview";
const DISTILL_API = process.env.DISTILL_API || "gemini-native";
const DISTILL_MODEL = process.env.DISTILL_MODEL || process.env.GEMINI_MODEL || "gemini-3-flash-preview";
const DISTILL_BASE_URL = process.env.DISTILL_BASE_URL || "";
const DISTILL_ANTHROPIC_VERSION = process.env.DISTILL_ANTHROPIC_VERSION || "2023-06-01";

const ONCE = process.argv.includes("--once");

Expand Down Expand Up @@ -65,6 +69,54 @@ function safeJsonParse(text) {
}
}

function resolveEnvVars(value) {
return String(value).replace(/\$\{([^}]+)\}/g, (_, envVar) => {
const envValue = process.env[envVar];
if (!envValue) {
throw new Error(`Environment variable ${envVar} is not set`);
}
return envValue;
});
}

async function resolveMaybeBitwardenSecret(value) {
const resolved = resolveEnvVars(String(value || "").trim());
if (!resolved) return "";
if (!resolved.startsWith("bws://")) return resolved;

const parsed = new URL(resolved);
const secretId = `${parsed.hostname}${parsed.pathname}`.replace(/^\/+/, "").replace(/^secret\//i, "");
if (!secretId) throw new Error(`Invalid Bitwarden secret reference: ${resolved}`);

const args = ["secret", "get", secretId, "--output", "json"];
const accessToken = parsed.searchParams.get("accessToken");
const configFile = parsed.searchParams.get("configFile");
const profile = parsed.searchParams.get("profile");
const serverUrl = parsed.searchParams.get("serverUrl");
if (accessToken) args.push("--access-token", resolveEnvVars(accessToken));
if (configFile) args.push("--config-file", resolveEnvVars(configFile));
if (profile) args.push("--profile", resolveEnvVars(profile));
if (serverUrl) args.push("--server-url", resolveEnvVars(serverUrl));

const { stdout } = await execFile("bws", args, { timeout: 10_000 });
const payload = JSON.parse(stdout);
if (typeof payload?.value !== "string" || !payload.value.trim()) {
throw new Error(`Bitwarden secret ${secretId} has no value`);
}
return payload.value;
}

async function resolveDistillApiKey() {
const raw = process.env.DISTILL_API_KEY || process.env.GEMINI_API_KEY || "";
if (!raw.trim()) {
if (DISTILL_API === "gemini-native") {
throw new Error("DISTILL_API_KEY or GEMINI_API_KEY is not set");
}
throw new Error("DISTILL_API_KEY is not set");
}
return resolveMaybeBitwardenSecret(raw);
}

function normalizeText(s) {
return (s || "")
.trim()
Expand Down Expand Up @@ -156,9 +208,8 @@ function buildMapPrompt({ lang, chunk }) {
}

async function geminiGenerateJson(prompt) {
if (!GEMINI_API_KEY) throw new Error("GEMINI_API_KEY is not set");

const url = `https://generativelanguage.googleapis.com/v1beta/models/${GEMINI_MODEL}:generateContent?key=${GEMINI_API_KEY}`;
const apiKey = await resolveDistillApiKey();
const url = `https://generativelanguage.googleapis.com/v1beta/models/${DISTILL_MODEL}:generateContent?key=${encodeURIComponent(apiKey)}`;

const body = {
contents: [{ role: "user", parts: [{ text: prompt }] }],
Expand All @@ -183,6 +234,53 @@ async function geminiGenerateJson(prompt) {
return text;
}

function normalizeAnthropicEndpoint(baseURL) {
const trimmed = String(baseURL || "").trim();
if (!trimmed) return "https://api.anthropic.com/v1/messages";
if (/\/messages\/?$/i.test(trimmed)) return trimmed;
return `${trimmed.replace(/\/+$/, "")}/messages`;
}

async function anthropicGenerateJson(prompt) {
const apiKey = await resolveDistillApiKey();
const res = await fetch(normalizeAnthropicEndpoint(DISTILL_BASE_URL), {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
"x-api-key": apiKey,
"anthropic-version": DISTILL_ANTHROPIC_VERSION,
},
body: JSON.stringify({
model: DISTILL_MODEL,
system: "You extract high-signal technical lessons. Return valid JSON only.",
messages: [{ role: "user", content: prompt }],
max_tokens: 4096,
temperature: 0.2,
}),
});

const json = await res.json();
if (!res.ok) {
throw new Error(`Anthropic error ${res.status}: ${JSON.stringify(json).slice(0, 500)}`);
}

const text = Array.isArray(json?.content)
? json.content
.filter((part) => part && part.type === "text" && typeof part.text === "string")
.map((part) => part.text)
.join("")
: "";
return text;
}

async function generateJson(prompt) {
if (DISTILL_API === "anthropic-messages") {
return anthropicGenerateJson(prompt);
}
return geminiGenerateJson(prompt);
}

function coerceLessons(obj) {
const lessons = Array.isArray(obj?.lessons) ? obj.lessons : [];
return lessons
Expand Down Expand Up @@ -297,7 +395,7 @@ async function processTaskFile(taskPath) {
for (let idx = 0; idx < chunks.length; idx++) {
const prompt = buildMapPrompt({ lang, chunk: chunks[idx] });
try {
const text = await geminiGenerateJson(prompt);
const text = await generateJson(prompt);
const obj = safeJsonParse(text);
if (!obj) {
mapErrors++;
Expand Down
Loading