@etzelm/openclaw-memory-hindsight 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,253 @@
1
+ import { createHash } from "node:crypto";
2
+ import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
3
+ import { definePluginEntry, } from "openclaw/plugin-sdk/plugin-entry";
4
+ import { isIncognitoSessionKey } from "openclaw/plugin-sdk/routing";
5
+ import { asOptionalRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
6
+ import { hasUnresolvedToken, hindsightConfigSchema, isPlainHttpRemote, RECALL_MAX_TOKENS, RECALL_TIMEOUT_MS, RETAIN_SOURCE, RETAIN_TIMEOUT_MS, } from "./config.js";
7
+ import { createLearnContribution, createLegacyLearnFields, createProviderFlushPlanResolver, RECALL_TOOL_NAMES, } from "./memory-capability.js";
8
+ import { createHindsightProviderRuntime } from "./provider-runtime.js";
9
+ import { routeAudience, sessionUsesMemory } from "./routing.js";
10
+ import { resolveLearnRegistrar } from "./sdk-types.js";
11
+ import { createHindsightService } from "./service.js";
12
+ import { createFlushDeduper, createRecallTool, createReflectTool, createRetainTool, RECALL_TOOL, REFLECT_TOOL, RETAIN_TOOL, } from "./tools.js";
13
+ import { buildRecallContext, extractRecallQuery, isEphemeralOperationalText, prepareRetentionTranscript, retainStartIndex, runStartMarker, } from "./transcript.js";
14
+ export const PLUGIN_ID = "hindsight-native";
15
+ // Bounds per-session run markers; one entry per active session is enough.
16
+ const MAX_RUN_MARKERS = 1_000;
17
+ /**
18
+ * Reads `plugins.entries[<id>].config` from a runtime config snapshot. Returns undefined when the
19
+ * snapshot has no entry, so callers fall back to the register-time `api.pluginConfig`.
20
+ */
21
+ export function readPluginConfigEntry(config, pluginId) {
22
+ const plugins = asOptionalRecord(asOptionalRecord(config)?.plugins);
23
+ const entry = asOptionalRecord(asOptionalRecord(plugins?.entries)?.[pluginId]);
24
+ return asOptionalRecord(entry?.config);
25
+ }
26
+ function sanitizeDocumentIdPart(value, fallback) {
27
+ const cleaned = (value ?? "")
28
+ .trim()
29
+ .replace(/[^a-zA-Z0-9:_-]+/g, "_")
30
+ .replace(/_+/g, "_")
31
+ .replace(/^_+|_+$/g, "");
32
+ return cleaned || fallback;
33
+ }
34
+ /** Short stable digest of the raw key, so keys that sanitize to the same text stay distinct. */
35
+ export function sessionKeyDigest(sessionKey) {
36
+ return createHash("sha256")
37
+ .update(sessionKey ?? "")
38
+ .digest("hex")
39
+ .slice(0, 12);
40
+ }
41
+ export default definePluginEntry({
42
+ id: PLUGIN_ID,
43
+ name: "Memory (Hindsight)",
44
+ description: "Hindsight-backed long-term memory with auto-recall, auto-retain and agent tools",
45
+ kind: "memory",
46
+ configSchema: hindsightConfigSchema,
47
+ register(api) {
48
+ let startupConfig;
49
+ try {
50
+ startupConfig = hindsightConfigSchema.parse(api.pluginConfig);
51
+ }
52
+ catch (error) {
53
+ api.registerService({
54
+ id: PLUGIN_ID,
55
+ start: () => {
56
+ api.logger.warn(`memory-hindsight: disabled until configured (${formatErrorMessage(error)})`);
57
+ },
58
+ });
59
+ return;
60
+ }
61
+ // Live config so `openclaw config set` and secret reloads apply without a restart. Reads the
62
+ // host's current snapshot (api.runtime.config.current), falling back to api.pluginConfig.
63
+ const currentConfig = () => {
64
+ let live;
65
+ try {
66
+ live = readPluginConfigEntry(api.runtime?.config?.current?.(), PLUGIN_ID);
67
+ }
68
+ catch {
69
+ live = undefined;
70
+ }
71
+ if (!live || live === api.pluginConfig) {
72
+ return startupConfig;
73
+ }
74
+ try {
75
+ return hindsightConfigSchema.parse(live);
76
+ }
77
+ catch (error) {
78
+ api.logger.warn?.(`memory-hindsight: ignoring invalid live config (${formatErrorMessage(error)})`);
79
+ return startupConfig;
80
+ }
81
+ };
82
+ const service = createHindsightService({ config: currentConfig, logger: api.logger });
83
+ const flushDeduper = createFlushDeduper();
84
+ const runStartBySession = new Map();
85
+ const capability = {
86
+ providerRuntime: createHindsightProviderRuntime({ providerId: PLUGIN_ID, service }),
87
+ providerFlushPlanResolver: createProviderFlushPlanResolver(currentConfig),
88
+ recallToolNames: RECALL_TOOL_NAMES,
89
+ };
90
+ // /learn: the generic seam where the host has it (openclaw#191665), else the memory-only
91
+ // capability fields that main builds before that change read. Tagged releases through
92
+ // 2026.10.10 have neither, so /learn there does not route lessons to this plugin.
93
+ const registerLearnContribution = resolveLearnRegistrar(api);
94
+ if (!registerLearnContribution) {
95
+ Object.assign(capability, createLegacyLearnFields());
96
+ }
97
+ api.registerMemoryCapability(capability);
98
+ registerLearnContribution?.(createLearnContribution());
99
+ const registerTool = (name, create) => {
100
+ api.registerTool((ctx) => {
101
+ const body = create(ctx);
102
+ return body ? { name, ...body } : null;
103
+ }, { name });
104
+ };
105
+ registerTool(RETAIN_TOOL, (ctx) => createRetainTool(service, flushDeduper, ctx));
106
+ registerTool(RECALL_TOOL, (ctx) => createRecallTool(service, ctx));
107
+ registerTool(REFLECT_TOOL, (ctx) => createReflectTool(service, ctx));
108
+ api.on("before_prompt_build", async (event, ctx) => {
109
+ const config = currentConfig();
110
+ const sessionKey = ctx.sessionKey;
111
+ // Marks the run start for agent_end even when recall is skipped.
112
+ if (sessionKey && !runStartBySession.has(sessionKey) && Array.isArray(event.messages)) {
113
+ runStartBySession.set(sessionKey, runStartMarker(event.messages, event.currentUserMessage));
114
+ if (runStartBySession.size > MAX_RUN_MARKERS) {
115
+ runStartBySession.delete(runStartBySession.keys().next().value);
116
+ }
117
+ }
118
+ if (service.stopped() ||
119
+ !config.autoRecall ||
120
+ config.memoryMode === "tools" ||
121
+ isIncognitoSessionKey(sessionKey) ||
122
+ !sessionUsesMemory(config, { sessionKey, messageProvider: ctx.messageProvider })) {
123
+ return undefined;
124
+ }
125
+ const route = routeAudience(ctx.agentId, ctx.memoryAudience, config.agentBankMap);
126
+ if (!route.ok) {
127
+ api.logger.debug?.(`memory-hindsight: auto-recall skipped: ${route.reason}`);
128
+ return undefined;
129
+ }
130
+ const query = extractRecallQuery(event.currentUserMessage, event.prompt);
131
+ if (!query || isEphemeralOperationalText(query)) {
132
+ return undefined;
133
+ }
134
+ try {
135
+ ctx.assertMemoryAudienceCurrent?.();
136
+ const results = await service.client.recall(route.bankId, query, {
137
+ budget: config.recallBudget,
138
+ maxTokens: RECALL_MAX_TOKENS,
139
+ ...(config.recallTypes ? { types: config.recallTypes } : {}),
140
+ ...(config.preferObservations === undefined
141
+ ? {}
142
+ : { preferObservations: config.preferObservations }),
143
+ signal: AbortSignal.timeout(RECALL_TIMEOUT_MS),
144
+ });
145
+ ctx.assertMemoryAudienceCurrent?.();
146
+ ctx.hookInvocation?.assertActive();
147
+ if (results.length === 0) {
148
+ return undefined;
149
+ }
150
+ api.logger.info?.(`memory-hindsight: injecting ${results.length} memories`);
151
+ // Before the user message, outside the system prompt, so the cached prefix stays stable.
152
+ return { prependContext: buildRecallContext(results, config.recallPromptPreamble) };
153
+ }
154
+ catch (error) {
155
+ api.logger.warn?.(`memory-hindsight: auto-recall skipped: ${formatErrorMessage(error)}`);
156
+ return undefined;
157
+ }
158
+ });
159
+ api.on("agent_end", async (event, ctx) => {
160
+ const sessionKey = ctx.sessionKey;
161
+ // Taken before any skip so the next run marks its own start.
162
+ const runStart = sessionKey ? runStartBySession.get(sessionKey) : undefined;
163
+ if (sessionKey) {
164
+ runStartBySession.delete(sessionKey);
165
+ }
166
+ const config = currentConfig();
167
+ if (service.stopped() ||
168
+ !config.autoRetain ||
169
+ !event.success ||
170
+ !Array.isArray(event.messages) ||
171
+ event.messages.length === 0 ||
172
+ isIncognitoSessionKey(sessionKey) ||
173
+ !sessionUsesMemory(config, { sessionKey, messageProvider: ctx.messageProvider })) {
174
+ return;
175
+ }
176
+ const route = routeAudience(ctx.agentId, ctx.memoryAudience, config.agentBankMap);
177
+ if (!route.ok) {
178
+ api.logger.debug?.(`memory-hindsight: auto-retain skipped: ${route.reason}`);
179
+ return;
180
+ }
181
+ const retention = prepareRetentionTranscript(event.messages, retainStartIndex(event.messages, runStart));
182
+ if (!retention || isEphemeralOperationalText(retention.transcript)) {
183
+ return;
184
+ }
185
+ const { tags } = retention;
186
+ // One document per run: a re-delivered agent_end for the same run replaces, not duplicates.
187
+ const runPart = sanitizeDocumentIdPart(event.runId ?? ctx.runId, `${Date.now()}`);
188
+ const documentId = `openclaw:${sanitizeDocumentIdPart(sessionKey, "session")}-${sessionKeyDigest(sessionKey)}:run:${runPart}`;
189
+ try {
190
+ // No ctx.assertMemoryAudienceCurrent() here. The host starts agent_end handlers without
191
+ // awaiting them (runAgentEndSideEffectsAsync -> void runAgentEnd, after awaiting the
192
+ // completed-turn snapshot and message loader), while the attempt releases the run's
193
+ // audience in its finally. The check would race that release and drop most retains.
194
+ // The audience was current when the run produced these messages, and the bank comes
195
+ // from that host-minted audience, so the write stays in the right partition.
196
+ const signal = AbortSignal.timeout(RETAIN_TIMEOUT_MS);
197
+ await service.ensureBank(route.bankId, signal);
198
+ await service.client.retain(route.bankId, {
199
+ content: retention.transcript,
200
+ context: config.retainContext,
201
+ document_id: documentId,
202
+ update_mode: "replace",
203
+ ...(tags.length > 0 ? { tags } : {}),
204
+ metadata: {
205
+ retained_at: new Date().toISOString(),
206
+ message_count: String(retention.messageCount),
207
+ source: RETAIN_SOURCE,
208
+ retention_scope: "turn",
209
+ audience: route.audience.kind,
210
+ ...(sessionKey ? { session_key: sessionKey } : {}),
211
+ ...(ctx.agentId ? { agent_id: ctx.agentId } : {}),
212
+ ...(ctx.messageProvider ? { provider: ctx.messageProvider } : {}),
213
+ ...(ctx.channelId ? { channel_id: ctx.channelId } : {}),
214
+ },
215
+ }, { signal });
216
+ api.logger.debug?.(`memory-hindsight: retained ${retention.messageCount} messages (${route.audience.kind})`);
217
+ }
218
+ catch (error) {
219
+ api.logger.warn?.(`memory-hindsight: auto-retain failed: ${formatErrorMessage(error)}`);
220
+ }
221
+ });
222
+ api.on("session_end", async (event, ctx) => {
223
+ // Compaction rotates the transcript inside a live run; agent_end still needs its marker.
224
+ if (event.reason === "compaction") {
225
+ return;
226
+ }
227
+ const key = ctx.sessionKey ?? event.sessionKey;
228
+ if (key) {
229
+ runStartBySession.delete(key);
230
+ }
231
+ });
232
+ api.registerService({
233
+ id: PLUGIN_ID,
234
+ start: () => {
235
+ service.start();
236
+ const raw = readPluginConfigEntry(api.runtime?.config?.current?.(), PLUGIN_ID) ?? api.pluginConfig;
237
+ const config = currentConfig();
238
+ if (hasUnresolvedToken(raw)) {
239
+ api.logger.warn("memory-hindsight: hindsightApiToken not resolved; requests go unauthenticated");
240
+ }
241
+ else if (config.apiToken && isPlainHttpRemote(config.apiUrl)) {
242
+ api.logger.warn("memory-hindsight: hindsightApiUrl is plain http to a non-loopback host; the bearer token is sent unencrypted");
243
+ }
244
+ api.logger.info(`memory-hindsight: initialized (mode: ${currentConfig().memoryMode})`);
245
+ },
246
+ stop: () => {
247
+ service.stop();
248
+ runStartBySession.clear();
249
+ api.logger.info("memory-hindsight: stopped");
250
+ },
251
+ });
252
+ },
253
+ });
@@ -0,0 +1,61 @@
1
+ import { RECALL_TOOL, REFLECT_TOOL, RETAIN_TOOL } from "./tools.js";
2
+ export const FLUSH_SYSTEM_PROMPT = [
3
+ "Pre-compaction memory flush.",
4
+ "The conversation is about to be compacted. Save durable context with hindsight_retain before older turns leave the context window.",
5
+ "Use hindsight_recall only to check whether something is already stored. Do not reply to the user.",
6
+ ].join(" ");
7
+ export const FLUSH_PROMPT = [
8
+ "Review the conversation so far and call hindsight_retain once per durable item that is not already in long-term memory:",
9
+ "decisions and their reasons, user preferences and rules, facts about people, hosts, services and repositories, task outcomes, and lessons from failures.",
10
+ "Skip greetings, progress chatter, command output and anything that is only true right now. Never store credential values.",
11
+ "Pass occurred_at when an item refers to a specific time. If nothing needs saving, reply with NO_REPLY.",
12
+ ].join(" ");
13
+ /**
14
+ * The tools arm persists through the plugin's own retain tool. Auto-retain already stores each
15
+ * finished turn, so the flush only needs to catch what the model knows but has not said in a
16
+ * completed turn. Mode "context" registers no tools, so there is nothing to flush with.
17
+ */
18
+ export function createProviderFlushPlanResolver(config) {
19
+ return () => {
20
+ if (config().memoryMode === "context") {
21
+ return null;
22
+ }
23
+ return {
24
+ prompt: FLUSH_PROMPT,
25
+ systemPrompt: FLUSH_SYSTEM_PROMPT,
26
+ persistenceToolNames: [RETAIN_TOOL],
27
+ lookupToolNames: [RECALL_TOOL],
28
+ };
29
+ };
30
+ }
31
+ function learnGuidanceLines(availableTools) {
32
+ if (!availableTools.has(RETAIN_TOOL)) {
33
+ return [];
34
+ }
35
+ const lines = [
36
+ "Persist each lesson with hindsight_retain: one call per lesson, written as a self-contained statement (what to do, when, and why).",
37
+ "Add context such as 'lesson' or 'user preference', and occurred_at when the lesson comes from a dated event.",
38
+ ];
39
+ if (availableTools.has(RECALL_TOOL)) {
40
+ lines.push("Check with hindsight_recall first and skip lessons that are already stored in the same words.");
41
+ }
42
+ return lines;
43
+ }
44
+ /** `/learn` contribution for hosts with `api.registerLearnContribution` (openclaw#191665). */
45
+ export function createLearnContribution() {
46
+ return {
47
+ medium: "memory",
48
+ toolNames: LEARN_TOOL_NAMES,
49
+ requiredToolNames: LEARN_TOOL_NAMES,
50
+ buildGuidance: ({ availableTools }) => ({ lines: learnGuidanceLines(availableTools) }),
51
+ };
52
+ }
53
+ /** Capability fields for hosts built from main before openclaw#191665. */
54
+ export function createLegacyLearnFields() {
55
+ return {
56
+ learnToolNames: LEARN_TOOL_NAMES,
57
+ learnGuidanceBuilder: ({ availableTools }) => learnGuidanceLines(availableTools),
58
+ };
59
+ }
60
+ export const RECALL_TOOL_NAMES = [RECALL_TOOL, REFLECT_TOOL];
61
+ export const LEARN_TOOL_NAMES = [RETAIN_TOOL];
@@ -0,0 +1,169 @@
1
+ import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
2
+ import { normalizeAgentId } from "openclaw/plugin-sdk/routing";
3
+ import { boundedSignal, isBankNotFound } from "./client.js";
4
+ import { GET_TIMEOUT_MS, RECALL_MAX_TOKENS, RECALL_TIMEOUT_MS } from "./config.js";
5
+ import { ownerBankId, routeAudience } from "./routing.js";
6
+ const DEFAULT_MAX_RESULTS = 5;
7
+ const MAX_RESULTS = 50;
8
+ /**
9
+ * Operators read the opened agent's owner bank. A session reads only the bank its host-minted
10
+ * audience selects (owner-private or its own conversation). Host authority reads nothing.
11
+ */
12
+ export function resolveBankGrant(authority, agentId, bankMap) {
13
+ if (authority.kind === "operator") {
14
+ return { bankId: ownerBankId(agentId, bankMap) };
15
+ }
16
+ if (authority.kind === "host") {
17
+ return { denial: "host operations cannot read stored memories" };
18
+ }
19
+ const route = routeAudience(agentId, authority.audience, bankMap);
20
+ return route.ok ? { bankId: route.bankId } : { denial: route.reason };
21
+ }
22
+ function sliceLines(text, from, lines) {
23
+ const all = text.split("\n");
24
+ const start = Math.max(1, Math.floor(from ?? 1));
25
+ const count = lines === undefined ? all.length : Math.max(1, Math.floor(lines));
26
+ const selected = all.slice(start - 1, start - 1 + count);
27
+ const end = start - 1 + selected.length;
28
+ const truncated = end < all.length;
29
+ return {
30
+ text: selected.join("\n"),
31
+ from: start,
32
+ lines: selected.length,
33
+ truncated,
34
+ ...(truncated ? { nextFrom: end + 1 } : {}),
35
+ };
36
+ }
37
+ export function createHindsightProviderRuntime(params) {
38
+ const { providerId, service } = params;
39
+ return {
40
+ async open({ agentId: rawAgentId, context }) {
41
+ if (!rawAgentId?.trim()) {
42
+ return { provider: null, error: "An agent id is required for Hindsight memory." };
43
+ }
44
+ const agentId = normalizeAgentId(rawAgentId);
45
+ const grant = resolveBankGrant(context.authority, agentId, service.config().agentBankMap);
46
+ const assertCurrent = () => {
47
+ context.assertCurrent();
48
+ context.signal?.throwIfAborted();
49
+ if (service.stopped()) {
50
+ throw new Error("memory-hindsight: the plugin has stopped");
51
+ }
52
+ };
53
+ const readableBank = () => {
54
+ if ("denial" in grant) {
55
+ throw new Error(`memory-hindsight: memory access denied: ${grant.denial}`);
56
+ }
57
+ assertCurrent();
58
+ return grant.bankId;
59
+ };
60
+ const reference = (id) => ({ providerId, id });
61
+ const provider = {
62
+ capabilities: {
63
+ sources: ["memory"],
64
+ pagination: false,
65
+ candidates: [],
66
+ projectFilter: false,
67
+ },
68
+ async search(request) {
69
+ if ("denial" in grant) {
70
+ // Like an owner filtering every hit: cross-session consumers get no rows, no failure.
71
+ assertCurrent();
72
+ return {
73
+ hits: [],
74
+ warning: `memory-hindsight: stored memories were not searched: ${grant.denial}`,
75
+ };
76
+ }
77
+ const bankId = readableBank();
78
+ const query = request.query.trim();
79
+ if (!query) {
80
+ return { hits: [], coverage: "complete" };
81
+ }
82
+ const config = service.config();
83
+ const maxResults = Math.min(MAX_RESULTS, Math.max(1, Math.floor(request.maxResults ?? DEFAULT_MAX_RESULTS)));
84
+ const minScore = request.minScore;
85
+ let results;
86
+ try {
87
+ results = await service.client.recall(bankId, query, {
88
+ budget: config.recallBudget,
89
+ maxTokens: RECALL_MAX_TOKENS,
90
+ ...(config.recallTypes ? { types: config.recallTypes } : {}),
91
+ ...(minScore === undefined ? {} : { minScores: { final: minScore } }),
92
+ signal: AbortSignal.any([context.signal, AbortSignal.timeout(RECALL_TIMEOUT_MS)].filter((signal) => Boolean(signal))),
93
+ });
94
+ }
95
+ catch (error) {
96
+ if (isBankNotFound(error)) {
97
+ assertCurrent();
98
+ return { hits: [], coverage: "complete" };
99
+ }
100
+ throw new Error(`memory-hindsight: memory search failed: ${formatErrorMessage(error)}`, {
101
+ cause: error,
102
+ });
103
+ }
104
+ assertCurrent();
105
+ const hits = results
106
+ // An unscored hit cannot be shown to clear a floor, so a floor drops it.
107
+ .filter((r) => {
108
+ const score = r.scores?.final;
109
+ return minScore === undefined || (typeof score === "number" && score >= minScore);
110
+ })
111
+ .slice(0, maxResults)
112
+ .map((r) => {
113
+ const ref = reference(r.id);
114
+ const hit = {
115
+ reference: ref,
116
+ excerpt: r.text,
117
+ source: "memory",
118
+ citations: [{ label: `hindsight:${r.id}`, reference: ref }],
119
+ };
120
+ const score = r.scores?.final;
121
+ if (typeof score === "number") {
122
+ hit.score = score;
123
+ }
124
+ return hit;
125
+ });
126
+ return { hits, coverage: "complete" };
127
+ },
128
+ async get(request) {
129
+ const bankId = readableBank();
130
+ const { id, revision } = request.reference;
131
+ // Hindsight memories carry no revisions; a revisioned reference names nothing current.
132
+ if (request.reference.providerId !== providerId || revision !== undefined || !id) {
133
+ return { status: "not_found" };
134
+ }
135
+ const memory = await service.client.getMemory(bankId, id, boundedSignal(GET_TIMEOUT_MS, context.signal));
136
+ assertCurrent();
137
+ if (!memory) {
138
+ return { status: "not_found" };
139
+ }
140
+ const ref = reference(id);
141
+ return {
142
+ status: "ok",
143
+ reference: ref,
144
+ citations: [{ label: `hindsight:${id}`, reference: ref }],
145
+ ...sliceLines(memory.text, request.from, request.lines),
146
+ };
147
+ },
148
+ async health() {
149
+ assertCurrent();
150
+ const { autoRecall, autoRetain, memoryMode } = service.config();
151
+ // Health reaches every authenticated caller: no memory text, URLs or tokens.
152
+ const details = { backend: "hindsight", autoRecall, autoRetain, memoryMode };
153
+ const healthy = await service.client.healthy();
154
+ assertCurrent();
155
+ return healthy
156
+ ? { status: "ready", details }
157
+ : {
158
+ status: "unavailable",
159
+ message: "The Hindsight API is not reachable. Check the Gateway log.",
160
+ details,
161
+ };
162
+ },
163
+ // The HTTP client is shared with hooks and tools; nothing is leased per caller.
164
+ async close() { },
165
+ };
166
+ return { provider };
167
+ },
168
+ };
169
+ }
@@ -0,0 +1,62 @@
1
+ import { createHash } from "node:crypto";
2
+ import { normalizeAgentId } from "openclaw/plugin-sdk/routing";
3
+ /**
4
+ * The owner's private bank for one agent; matches the third-party plugin's per-agent bank.
5
+ * `agentBankMap` (same key as the vendor plugin) lets several agents share one owner bank.
6
+ * The id stays raw here; `HindsightClient` percent-encodes it once when it builds the URL.
7
+ */
8
+ export function ownerBankId(agentId, bankMap) {
9
+ const normalized = normalizeAgentId(agentId);
10
+ return bankMap?.[normalized] ?? normalized;
11
+ }
12
+ /**
13
+ * A conversation audience gets its own bank per durable root session, so memories formed with
14
+ * other senders or in groups never surface in the owner's private recall and vice versa.
15
+ */
16
+ export function conversationBankId(agentId, rootSessionKey, bankMap) {
17
+ const digest = createHash("sha256").update(rootSessionKey).digest("hex").slice(0, 16);
18
+ return `${ownerBankId(agentId, bankMap)}::conv::${digest}`;
19
+ }
20
+ /**
21
+ * Picks the bank from the host-minted audience only. Session keys, ids and record ids never
22
+ * select a partition, and an audience for another agent is refused.
23
+ */
24
+ export function routeAudience(agentId, audience, bankMap) {
25
+ if (!agentId?.trim()) {
26
+ return { ok: false, reason: "no agent id" };
27
+ }
28
+ if (!audience) {
29
+ return { ok: false, reason: "this session has no memory audience" };
30
+ }
31
+ const normalized = normalizeAgentId(agentId);
32
+ if (normalizeAgentId(audience.agentId) !== normalized) {
33
+ return { ok: false, reason: "the memory audience belongs to another agent" };
34
+ }
35
+ if (audience.kind === "owner-private") {
36
+ return { ok: true, bankId: ownerBankId(normalized, bankMap), audience };
37
+ }
38
+ return {
39
+ ok: true,
40
+ bankId: conversationBankId(normalized, audience.sessionKey, bankMap),
41
+ audience,
42
+ };
43
+ }
44
+ const OPERATIONAL_SESSION_RE = /^agent:[^:]+:(cron|heartbeat|subagent):/;
45
+ const OPERATIONAL_PROVIDERS = new Set(["cron", "heartbeat", "subagent"]);
46
+ /** Session filter shared by auto-recall, auto-retain and tool registration. */
47
+ export function sessionUsesMemory(config, params) {
48
+ const { sessionKey, messageProvider } = params;
49
+ if (messageProvider && config.excludeProviders.includes(messageProvider)) {
50
+ return false;
51
+ }
52
+ if (sessionKey?.startsWith("temp:")) {
53
+ return false;
54
+ }
55
+ if (!config.skipOperationalSessions) {
56
+ return true;
57
+ }
58
+ if (messageProvider && OPERATIONAL_PROVIDERS.has(messageProvider)) {
59
+ return false;
60
+ }
61
+ return !(sessionKey && OPERATIONAL_SESSION_RE.test(sessionKey));
62
+ }
@@ -0,0 +1,10 @@
1
+ /** Returns the host's `registerLearnContribution` when this host has the generic seam. */
2
+ export function resolveLearnRegistrar(api) {
3
+ const candidate = api.registerLearnContribution;
4
+ if (typeof candidate !== "function") {
5
+ return undefined;
6
+ }
7
+ return (contribution) => {
8
+ candidate.call(api, contribution);
9
+ };
10
+ }
@@ -0,0 +1,40 @@
1
+ import { HindsightClient } from "./client.js";
2
+ export function createHindsightService(params) {
3
+ const preparedBanks = new Map();
4
+ let stopped = false;
5
+ const client = new HindsightClient({
6
+ resolveEndpoint: () => {
7
+ const { apiUrl, apiToken } = params.config();
8
+ return { apiUrl, ...(apiToken ? { apiToken } : {}) };
9
+ },
10
+ ...(params.fetch ? { fetch: params.fetch } : {}),
11
+ });
12
+ return {
13
+ config: params.config,
14
+ client,
15
+ logger: params.logger,
16
+ async ensureBank(bankId, signal) {
17
+ let pending = preparedBanks.get(bankId);
18
+ if (!pending) {
19
+ const { retainMission } = params.config();
20
+ pending = client.applyBankDefaults(bankId, { retainMission }, signal);
21
+ preparedBanks.set(bankId, pending);
22
+ // A failed setup retries on the next write instead of caching the failure.
23
+ pending.catch(() => {
24
+ if (preparedBanks.get(bankId) === pending) {
25
+ preparedBanks.delete(bankId);
26
+ }
27
+ });
28
+ }
29
+ await pending;
30
+ },
31
+ stopped: () => stopped,
32
+ stop() {
33
+ stopped = true;
34
+ preparedBanks.clear();
35
+ },
36
+ start() {
37
+ stopped = false;
38
+ },
39
+ };
40
+ }