Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
3 changes: 3 additions & 0 deletions hindsight-integrations/openclaw/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ Optional settings in `~/.openclaw/openclaw.json` under `plugins.entries.hindsigh
| `bankIdPrefix` | — | Prefix for bank IDs (e.g. `"prod"`) |
| `retainTags` | `[]` | Tags applied to every retained document, useful for cross-agent/source labeling (e.g. `source_system:openclaw`, `agent:agentname`). Auto-retain also merges inline per-message tags from `<retain_tags>...</retain_tags>` or `<hindsight_retain_tags>...</hindsight_retain_tags>` blocks in user messages. |
| `retainSource` | `"openclaw"` | `source` value written into retained document metadata |
| `retainContext` | built-in OpenClaw guidance | Interpretation guidance sent through the Hindsight retain API `context` field. The default tells the extraction LLM that `[context]` sender/channel/provider values, bank IDs, session keys, source systems, and tags are operational routing metadata, not human names, project names, or organizations. |
| `dynamicBankGranularity` | `["agent", "channel", "user"]` | Fields used to derive bank ID. Options: `agent`, `channel`, `user`, `provider` |
| `excludeProviders` | `["heartbeat"]` | Message providers to skip for recall/retain (e.g. `heartbeat`, `slack`, `telegram`, `discord`) |
| `autoRecall` | `true` | Auto-inject memories before each turn. Set to `false` when the agent has its own recall tool. |
Expand Down Expand Up @@ -162,6 +163,8 @@ Glob syntax:

Retained documents use stable session-scoped IDs derived from the OpenClaw `sessionKey`. By default (`retainDocumentScope: 'session'`) every retain in a session shares one document id like `openclaw:agent:agentname:discord:channel:123`, so all turns of the conversation accumulate under a single Hindsight document. Set `retainDocumentScope: 'turn'` to fall back to the per-retain ids (`...:turn:000001`, `...:window:000002` for chunked retention). Either way, retained documents include richer metadata such as `session_key`, `agent_id`, `provider`, `channel_id`, `thread_id`, `sender_id`, `turn_index`, and `retention_scope`. Each message in the retained JSON also carries a structured `timestamp` field (ISO 8601) lifted from OpenClaw's per-message time, so facts are not polluted by inline weekday/date prefixes.

`retainContext` is sent separately from the transcript content and gives Hindsight's extraction LLM interpretation guidance for the retained document. The default is designed for OpenClaw transcripts: it explains that the optional `[context]` block is routing metadata, that assistant-role first-person statements belong to the AI assistant, and that bank IDs or tags should not be treated as the discussed project. This does not remove or change `includeSenderContext`; set `includeSenderContext: false` only if you want to omit the transcript's `[context]` block itself.

## Documentation

For full documentation, configuration options, troubleshooting, and development guide, see:
Expand Down
27 changes: 23 additions & 4 deletions hindsight-integrations/openclaw/openclaw.plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,14 @@
"configContracts": {
"secretInputs": {
"paths": [
{ "path": "llmApiKey", "expected": "string" },
{ "path": "hindsightApiToken", "expected": "string" }
{
"path": "llmApiKey",
"expected": "string"
},
{
"path": "hindsightApiToken",
"expected": "string"
}
]
}
},
Expand Down Expand Up @@ -112,6 +118,11 @@
"description": "Source value written into retained document metadata. Defaults to 'openclaw'.",
"default": "openclaw"
},
"retainContext": {
"type": "string",
"description": "Interpretation guidance sent via the Hindsight retain API context field. It tells the extraction LLM that OpenClaw sender/channel/provider values, bank IDs, session keys, source systems, and tags are operational routing metadata, not human names, project names, or organizations.",
"default": "This content is an AI-assistant conversation transcript from OpenClaw. The [context] block at the beginning of each turn contains routing identifiers: 'sender' is an opaque user ID (not a human name), 'channel' is a chat identifier, 'provider' is the messaging platform name. These are operational routing metadata, not semantic actors or people. Messages with role 'assistant' are from the AI assistant; first-person statements in assistant messages refer to the AI, not the human user. Messages with role 'user' are from the human user. Bank IDs, session keys, agent IDs, thread IDs, source systems, and tags in metadata are also operational routing identifiers, not human names, project names, or organizations."
},
"autoRecall": {
"type": "boolean",
"description": "Automatically recall memories on every prompt and inject them as context. Set to false when agent has its own recall tool.",
Expand Down Expand Up @@ -283,12 +294,16 @@
},
"ignoreSessionPatterns": {
"type": "array",
"items": { "type": "string" },
"items": {
"type": "string"
},
"description": "Session key glob patterns to skip entirely (no recall, no retain). E.g. [\"agent:main:**\", \"agent:*:cron:**\"]. * matches non-colon chars, ** matches anything."
},
"statelessSessionPatterns": {
"type": "array",
"items": { "type": "string" },
"items": {
"type": "string"
},
"description": "Session key glob patterns for read-only sessions: retain is always skipped, recall is skipped when skipStatelessSessions is true. E.g. [\"agent:*:subagent:**\", \"agent:*:heartbeat:**\"]."
},
"skipStatelessSessions": {
Expand Down Expand Up @@ -383,6 +398,10 @@
"label": "Retain Source",
"placeholder": "openclaw"
},
"retainContext": {
"label": "Retain Context",
"placeholder": "Guidance for interpreting retained OpenClaw transcripts"
},
"autoRecall": {
"label": "Auto-Recall",
"placeholder": "true (inject memories on every prompt)"
Expand Down
3 changes: 2 additions & 1 deletion hindsight-integrations/openclaw/src/backfill.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,8 +197,9 @@ describe("backfill helpers", () => {
it("treats a symlinked bin path as direct execution", async () => {
const { isDirectExecution } = await import("./backfill.js");
const dir = mkdtempSync(join(tmpdir(), "hindsight-backfill-bin-"));
const modulePath = join(process.cwd(), "dist", "backfill.js");
const modulePath = join(dir, "backfill.js");
const symlinkPath = join(dir, "hindsight-openclaw-backfill");
writeFileSync(modulePath, "#!/usr/bin/env node\n", "utf8");
symlinkSync(modulePath, symlinkPath);

const moduleUrl = pathToFileURL(modulePath).href;
Expand Down
3 changes: 2 additions & 1 deletion hindsight-integrations/openclaw/src/backfill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ function loadPackageVersion(): string {
}

const USER_AGENT = `hindsight-openclaw/${loadPackageVersion()}`;
import { detectExternalApi, detectLLMConfig } from "./index.js";
import { DEFAULT_RETAIN_CONTEXT, detectExternalApi, detectLLMConfig } from "./index.js";
import type { BankStats, PluginConfig } from "./types.js";
import {
buildBackfillPlan,
Expand Down Expand Up @@ -547,6 +547,7 @@ export async function runCli(argv: string[] = process.argv.slice(2)): Promise<vo
}
await client.retain(entry.bankId, entry.transcript, {
documentId: entry.documentId,
context: pluginConfig.retainContext || DEFAULT_RETAIN_CONTEXT,
metadata,
async: true,
});
Expand Down
84 changes: 84 additions & 0 deletions hindsight-integrations/openclaw/src/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { describe, it, expect } from "vitest";
import { createRequire } from "module";
import {
stripMemoryTags,
extractRecallQuery,
Expand All @@ -25,9 +26,21 @@ import {
stripInlineTimestampPrefix,
getPluginConfig,
formatHookPerf,
DEFAULT_RETAIN_CONTEXT,
} from "./index.js";
import type { PluginConfig, MemoryResult, MoltbotPluginAPI } from "./types.js";

const require = createRequire(import.meta.url);
const openclawManifest = require("../openclaw.plugin.json") as {
configSchema?: {
properties?: {
retainContext?: {
default?: string;
};
};
};
};

// ---------------------------------------------------------------------------
// stripMemoryTags
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -366,6 +379,7 @@ describe("buildRetainRequest", () => {
expect(request).toEqual({
content: "hello world",
documentId: "openclaw:agent:main:main",
context: DEFAULT_RETAIN_CONTEXT,
metadata: {
retained_at: expect.any(String),
message_count: "2",
Expand All @@ -385,6 +399,44 @@ describe("buildRetainRequest", () => {
});
});

it("includes the default retain context guidance", () => {
const request = buildRetainRequest("hello world", 1, {}, {}, 1700000000000, { turnIndex: 1 });

expect(request.context).toBe(DEFAULT_RETAIN_CONTEXT);
});

it("describes routing metadata and assistant/user roles in the default retain context", () => {
expect(DEFAULT_RETAIN_CONTEXT).toContain("routing identifiers");
expect(DEFAULT_RETAIN_CONTEXT).toContain("operational routing identifiers");
expect(DEFAULT_RETAIN_CONTEXT).toContain("AI assistant");
});

it("uses a configured retain context when provided", () => {
const request = buildRetainRequest(
"hello world",
1,
{},
{ retainContext: "Custom extraction guidance." },
1700000000000,
{ turnIndex: 1 }
);

expect(request.context).toBe("Custom extraction guidance.");
});

it("trims configured retain context before sending it", () => {
const request = buildRetainRequest(
"hello world",
1,
{},
{ retainContext: " Custom extraction guidance. \n" },
1700000000000,
{ turnIndex: 1 }
);

expect(request.context).toBe("Custom extraction guidance.");
});

it("falls back to per-turn doc id when appendSupported is false (older API)", () => {
const request = buildRetainRequest(
"hello world",
Expand Down Expand Up @@ -1499,3 +1551,35 @@ describe("getPluginConfig — mission semantics (#1270, #1353)", () => {
expect(cfg.observationsMission).toBeUndefined();
});
});

describe("getPluginConfig — retainContext", () => {
it("defaults retainContext to the built-in OpenClaw transcript guidance", () => {
const cfg = getPluginConfig(makeApi({}));
expect(cfg.retainContext).toBe(DEFAULT_RETAIN_CONTEXT);
});

it("passes through an explicit non-empty retainContext", () => {
const cfg = getPluginConfig(makeApi({ retainContext: "Treat IDs as routing metadata." }));
expect(cfg.retainContext).toBe("Treat IDs as routing metadata.");
});

it("trims an explicit retainContext", () => {
const cfg = getPluginConfig(makeApi({ retainContext: " Treat IDs as routing metadata. \n" }));
expect(cfg.retainContext).toBe("Treat IDs as routing metadata.");
});

it("falls back to the default when retainContext is blank or non-string", () => {
expect(getPluginConfig(makeApi({ retainContext: "" })).retainContext).toBe(
DEFAULT_RETAIN_CONTEXT
);
expect(getPluginConfig(makeApi({ retainContext: 42 })).retainContext).toBe(
DEFAULT_RETAIN_CONTEXT
);
});

it("keeps the plugin manifest default in sync with the code default", () => {
expect(openclawManifest.configSchema?.properties?.retainContext?.default).toBe(
DEFAULT_RETAIN_CONTEXT
);
});
});
23 changes: 23 additions & 0 deletions hindsight-integrations/openclaw/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,19 @@ function loadPackageVersion(): string {

const USER_AGENT = `hindsight-openclaw/${loadPackageVersion()}`;

export const DEFAULT_RETAIN_CONTEXT =
"This content is an AI-assistant conversation transcript from OpenClaw. " +
"The [context] block at the beginning of each turn contains routing identifiers: " +
"'sender' is an opaque user ID (not a human name), 'channel' is a chat identifier, " +
"'provider' is the messaging platform name. " +
"These are operational routing metadata, not semantic actors or people. " +
"Messages with role 'assistant' are from the AI assistant; first-person statements " +
"in assistant messages refer to the AI, not the human user. " +
"Messages with role 'user' are from the human user. " +
"Bank IDs, session keys, agent IDs, thread IDs, source systems, " +
"and tags in metadata are also operational routing identifiers, " +
"not human names, project names, or organizations.";

// Logger adapter that routes the embed wrapper's output through openclaw's
// batched structured logger so messages share the same prefix and respect
// the configured log level.
Expand Down Expand Up @@ -114,6 +127,7 @@ function scopeClient(c: HindsightClient, bankId: string): BankScopedClient {
async retain(req) {
await c.retain(bankId, req.content, {
documentId: req.documentId,
context: req.context,
metadata: toStringMetadata(req.metadata),
tags: req.tags,
updateMode: req.updateMode,
Expand Down Expand Up @@ -284,6 +298,7 @@ async function flushRetainQueue(): Promise<void> {
try {
await client.retain(item.bankId, item.content, {
documentId: item.documentId,
context: item.context,
metadata: toStringMetadata(item.metadata),
tags: item.tags,
updateMode: item.updateMode,
Expand Down Expand Up @@ -1462,6 +1477,10 @@ export function getPluginConfig(api: MoltbotPluginAPI): PluginConfig {
typeof config.retainSource === "string" && config.retainSource.trim().length > 0
? config.retainSource.trim()
: undefined,
retainContext:
typeof config.retainContext === "string" && config.retainContext.trim().length > 0
? config.retainContext.trim()
: DEFAULT_RETAIN_CONTEXT,
excludeProviders: Array.isArray(config.excludeProviders)
? Array.from(
new Set([
Expand Down Expand Up @@ -2715,6 +2734,10 @@ export function buildRetainRequest(
return {
content: transcript,
documentId: documentId,
context:
typeof pluginConfig.retainContext === "string" && pluginConfig.retainContext.trim().length > 0
? pluginConfig.retainContext.trim()
: DEFAULT_RETAIN_CONTEXT,
metadata: {
retained_at: new Date(now).toISOString(),
message_count: String(messageCount),
Expand Down
3 changes: 3 additions & 0 deletions hindsight-integrations/openclaw/src/retain-queue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { randomBytes } from "crypto";
export interface QueuedRetainPayload {
content: string;
documentId?: string;
context?: string;
metadata?: Record<string, unknown>;
tags?: string[];
updateMode?: "replace" | "append";
Expand All @@ -32,6 +33,7 @@ export interface QueuedRetain {
bankId: string;
content: string;
documentId: string;
context?: string;
metadata: Record<string, unknown>;
tags?: string[];
updateMode?: "replace" | "append";
Expand Down Expand Up @@ -63,6 +65,7 @@ export class RetainQueue {
bankId,
content: request.content,
documentId: request.documentId || "conversation",
context: request.context,
metadata: metadata || request.metadata || {},
tags: request.tags,
updateMode: request.updateMode,
Expand Down
2 changes: 2 additions & 0 deletions hindsight-integrations/openclaw/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ export interface PluginConfig {
bankIdPrefix?: string; // Prefix for bank IDs (e.g. 'prod' -> 'prod-slack-C123')
retainTags?: string[]; // Tags applied to all retained documents after trimming and deduplication; auto-retain merges these with inline per-message retain-tag directives (e.g. ['source_system:openclaw', 'agent:agentname'])
retainSource?: string; // Source written into retained document metadata (default: 'openclaw')
retainContext?: string; // Interpretation guidance sent via the retain API context field. Defaults to built-in OpenClaw transcript/routing metadata guidance.
excludeProviders?: string[]; // Message providers to exclude from recall/retain (e.g. ['telegram', 'discord'])
autoRecall?: boolean; // Auto-recall memories on every prompt (default: true). Set to false when agent has its own recall tool.
dynamicBankGranularity?: Array<"agent" | "provider" | "channel" | "user">; // Fields for bank ID derivation. Default: ['agent', 'channel', 'user']
Expand Down Expand Up @@ -165,6 +166,7 @@ export type {
export interface RetainRequest {
content: string;
documentId?: string;
context?: string;
metadata?: Record<string, unknown>;
tags?: string[];
/**
Expand Down