Skip to content

Commit 5ab6bdc

Browse files
authored
fix(openclaw): gate append retention on stored text (#2511)
Fixes #2505: the OpenClaw append-capability probe only checked API version, ignoring features.store_document_text, so every session-scoped retain 400'd (silent memory loss) on text-disabled deployments. Now gates update_mode=append on BOTH version >= 0.5.0 AND features.store_document_text=true, falling back to per-turn document IDs otherwise. Verified locally: 281/281 openclaw tests pass on the PR head.
1 parent ed10838 commit 5ab6bdc

2 files changed

Lines changed: 120 additions & 19 deletions

File tree

hindsight-integrations/openclaw/src/index.test.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ import {
1313
truncateRecallQuery,
1414
buildRetainRequest,
1515
meetsMinimumVersion,
16+
parseHindsightApiCapabilities,
17+
supportsAppendFromCapabilities,
1618
parseSessionKey,
1719
extractTelegramDirectSenderId,
1820
resolveSessionIdentity,
@@ -1537,6 +1539,58 @@ describe("meetsMinimumVersion", () => {
15371539
});
15381540
});
15391541

1542+
describe("append capability helpers", () => {
1543+
it("supports append for legacy version payloads without a features block", () => {
1544+
const capabilities = parseHindsightApiCapabilities({ api_version: "0.8.4" });
1545+
1546+
expect(capabilities).toEqual({ version: "0.8.4", storeDocumentText: true });
1547+
expect(supportsAppendFromCapabilities(capabilities)).toBe(true);
1548+
});
1549+
1550+
it("supports append when the version is new enough and document text storage is enabled", () => {
1551+
const capabilities = parseHindsightApiCapabilities({
1552+
api_version: "0.8.4",
1553+
features: { store_document_text: true },
1554+
});
1555+
1556+
expect(capabilities).toEqual({ version: "0.8.4", storeDocumentText: true });
1557+
expect(supportsAppendFromCapabilities(capabilities)).toBe(true);
1558+
});
1559+
1560+
it("rejects append when features are present but document text storage is not enabled", () => {
1561+
expect(
1562+
supportsAppendFromCapabilities(
1563+
parseHindsightApiCapabilities({
1564+
api_version: "0.8.4",
1565+
features: { store_document_text: false },
1566+
})
1567+
)
1568+
).toBe(false);
1569+
expect(
1570+
supportsAppendFromCapabilities(
1571+
parseHindsightApiCapabilities({
1572+
api_version: "0.8.4",
1573+
features: {},
1574+
})
1575+
)
1576+
).toBe(false);
1577+
});
1578+
1579+
it("rejects append for old API versions even when document text storage is enabled", () => {
1580+
const capabilities = parseHindsightApiCapabilities({
1581+
api_version: "0.4.99",
1582+
features: { store_document_text: true },
1583+
});
1584+
1585+
expect(supportsAppendFromCapabilities(capabilities)).toBe(false);
1586+
});
1587+
1588+
it("returns null for malformed version payloads", () => {
1589+
expect(parseHindsightApiCapabilities({ features: { store_document_text: true } })).toBeNull();
1590+
expect(parseHindsightApiCapabilities(null)).toBeNull();
1591+
});
1592+
});
1593+
15401594
// ---------------------------------------------------------------------------
15411595
// getPluginConfig — whitelist normalisation
15421596
// ---------------------------------------------------------------------------

hindsight-integrations/openclaw/src/index.ts

Lines changed: 66 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -82,8 +82,9 @@ let usingExternalApi = false; // Track if using external API (skip daemon manage
8282

8383
// Capability detected once per service.start() against `<apiUrl>/version`.
8484
// `true` when the Hindsight API supports `update_mode: 'append'` (added in
85-
// 0.5.0 — see vectorize-io/hindsight#932). When false, retain falls back to a
86-
// per-turn document id so prior turns aren't silently overwritten.
85+
// 0.5.0 — see vectorize-io/hindsight#932) and stores document text. When false,
86+
// retain falls back to a per-turn document id so prior turns aren't silently
87+
// overwritten or rejected by text-disabled deployments.
8788
let supportsUpdateModeAppend = false;
8889
let appendCapabilityProbed = false;
8990
const MIN_VERSION_FOR_UPDATE_MODE_APPEND = "0.5.0";
@@ -1382,16 +1383,52 @@ export function meetsMinimumVersion(actual: string, minimum: string): boolean {
13821383
return true;
13831384
}
13841385

1386+
export interface HindsightApiCapabilities {
1387+
version: string;
1388+
storeDocumentText: boolean;
1389+
}
1390+
1391+
function isRecord(value: unknown): value is Record<string, unknown> {
1392+
return typeof value === "object" && value !== null;
1393+
}
1394+
1395+
export function parseHindsightApiCapabilities(payload: unknown): HindsightApiCapabilities | null {
1396+
if (!isRecord(payload) || typeof payload.api_version !== "string") {
1397+
return null;
1398+
}
1399+
1400+
let storeDocumentText = true;
1401+
if ("features" in payload) {
1402+
const features = payload.features;
1403+
storeDocumentText = isRecord(features) && features.store_document_text === true;
1404+
}
1405+
1406+
return {
1407+
version: payload.api_version,
1408+
storeDocumentText,
1409+
};
1410+
}
1411+
1412+
export function supportsAppendFromCapabilities(
1413+
capabilities: HindsightApiCapabilities | null
1414+
): boolean {
1415+
return (
1416+
capabilities !== null &&
1417+
capabilities.storeDocumentText &&
1418+
meetsMinimumVersion(capabilities.version, MIN_VERSION_FOR_UPDATE_MODE_APPEND)
1419+
);
1420+
}
1421+
13851422
/**
13861423
* Probe `<apiUrl>/version` once at service.start to learn the running
1387-
* Hindsight API version. Returns `null` (treated as "no append support") if
1388-
* the endpoint is unreachable or returns malformed payload — conservative
1424+
* Hindsight API capabilities. Returns `null` (treated as "no append support")
1425+
* if the endpoint is unreachable or returns malformed payload — conservative
13891426
* fallback path is the right call when we can't be sure.
13901427
*/
1391-
async function fetchHindsightApiVersion(
1428+
async function fetchHindsightApiCapabilities(
13921429
apiUrl: string,
13931430
apiToken?: string | null
1394-
): Promise<string | null> {
1431+
): Promise<HindsightApiCapabilities | null> {
13951432
const versionUrl = `${apiUrl.replace(/\/$/, "")}/version`;
13961433
try {
13971434
const headers: Record<string, string> = { "User-Agent": USER_AGENT };
@@ -1404,12 +1441,12 @@ async function fetchHindsightApiVersion(
14041441
debug(`[Hindsight] /version returned HTTP ${response.status}; assuming legacy`);
14051442
return null;
14061443
}
1407-
const data = (await response.json()) as { api_version?: unknown };
1408-
const v = typeof data.api_version === "string" ? data.api_version : null;
1409-
if (!v) {
1444+
const data = await response.json();
1445+
const capabilities = parseHindsightApiCapabilities(data);
1446+
if (!capabilities) {
14101447
debug(`[Hindsight] /version payload missing api_version; assuming legacy`);
14111448
}
1412-
return v;
1449+
return capabilities;
14131450
} catch (error) {
14141451
debug(`[Hindsight] /version probe failed: ${String(error)}; assuming legacy`);
14151452
return null;
@@ -1419,32 +1456,42 @@ async function fetchHindsightApiVersion(
14191456
/**
14201457
* Probe `/version` and update the module-level `supportsUpdateModeAppend`
14211458
* capability flag accordingly. Logs a one-time WARN block when the API is
1422-
* older than 0.5.0 — without `update_mode: 'append'`, every retain on the
1423-
* same session id silently overwrites prior turns server-side.
1459+
* older than 0.5.0 or cannot store document text — without
1460+
* `update_mode: 'append'`, every retain on the same session id silently
1461+
* overwrites prior turns server-side, and append itself requires stored
1462+
* document text.
14241463
*
14251464
* Called from the same code paths as the health check, so capability is
14261465
* always re-evaluated when the plugin (re)connects to the API.
14271466
*/
14281467
async function detectAppendCapability(apiUrl: string, apiToken?: string | null): Promise<void> {
1429-
const version = await fetchHindsightApiVersion(apiUrl, apiToken);
1430-
const supported =
1431-
version !== null && meetsMinimumVersion(version, MIN_VERSION_FOR_UPDATE_MODE_APPEND);
1468+
const capabilities = await fetchHindsightApiCapabilities(apiUrl, apiToken);
1469+
const supported = supportsAppendFromCapabilities(capabilities);
14321470
const transitionedToUnsupported = supportsUpdateModeAppend && !supported;
14331471
const firstProbe = !appendCapabilityProbed;
14341472
appendCapabilityProbed = true;
14351473
supportsUpdateModeAppend = supported;
1436-
if (supported) {
1437-
debug(`[Hindsight] API version ${version} supports update_mode=append`);
1474+
if (supported && capabilities) {
1475+
debug(
1476+
`[Hindsight] API version ${capabilities.version} supports update_mode=append with stored document text`
1477+
);
14381478
return;
14391479
}
14401480
// Warn on the first probe when unsupported, and on any transition from
14411481
// supported -> unsupported. Stay silent on subsequent re-probes that
14421482
// confirm the same unsupported state.
14431483
if (!firstProbe && !transitionedToUnsupported) return;
1484+
const version = capabilities?.version ?? null;
1485+
const reason =
1486+
capabilities !== null &&
1487+
meetsMinimumVersion(capabilities.version, MIN_VERSION_FOR_UPDATE_MODE_APPEND) &&
1488+
!capabilities.storeDocumentText
1489+
? `reports version "${capabilities.version}" but has features.store_document_text disabled`
1490+
: `reports version "${version ?? "unknown"}", which is older than ${MIN_VERSION_FOR_UPDATE_MODE_APPEND}`;
14441491
log.warn(
1445-
`[Hindsight] ⚠️ API at ${apiUrl} reports version "${version ?? "unknown"}", which is older than ${MIN_VERSION_FOR_UPDATE_MODE_APPEND}. ` +
1492+
`[Hindsight] ⚠️ API at ${apiUrl} ${reason}. ` +
14461493
`Falling back to per-turn document ids — each retain becomes its own document instead of accumulating into one per-session document. ` +
1447-
`Upgrade Hindsight to ${MIN_VERSION_FOR_UPDATE_MODE_APPEND} or newer to enable session-scoped retention with update_mode=append.`
1494+
`Enable document text storage on Hindsight ${MIN_VERSION_FOR_UPDATE_MODE_APPEND} or newer to use session-scoped retention with update_mode=append.`
14481495
);
14491496
}
14501497

0 commit comments

Comments
 (0)