Skip to content

Commit a22667d

Browse files
authored
feat(coding-agents): retainTags / retainMetadata, with HINDSIGHT_RETAIN_TAGS (#3269, #2896) (#3346)
* feat(coding-agents): retainTags / retainMetadata with template placeholders Closes #3269. Every conversation retain carries `source:chat` and `harness:<id>` — what wrote the memory, but nothing about where it came from. That is fine while each repo has its own bank, since the bank is the answer. It stops being fine on a deliberately shared bank, the setup in the issue: one bank holding cross-project knowledge so facts recall everywhere, where a retained fact then carries no record of the repository it came out of. Both settings take `{placeholder}` templates resolved per retain, against the vocabulary the dynamic bank id already uses plus what only a retain knows: {gitProject} {project} {harness} {bankId} {sessionId} {timestamp} {channel} {user} { "retainTags": ["project:{gitProject}"], "retainMetadata": {"repo": "{gitProject}"} } {gitProject} is worktree-aware here too, so linked worktrees of one repo stamp a single name rather than project:app and project:app-wt2. The substitution itself moves to core/template.ts, shared with bank.ts rather than duplicated — each call site keeps its own resolver map, because the valid placeholders genuinely differ (a bank id cannot reference {bankId}). Two things are deliberately not user-controllable. Built-in metadata is written last and wins, and retainTags entries in the `source:`/`harness:` namespaces are dropped with a warning: the documents list filters on those and resolves each document's agent logo from them, so a template that could forge them would break attribution for everyone reading the list. Unconfigured, this adds nothing to a retain. * docs(coding-agents): document retainTags/retainMetadata in the README, not the generated page The docs page is generated from the integration's README by hindsight-docs/scripts/sync-coding-agents-doc.mjs, and build-docs runs it with --check. The first pass edited the generated page, so the build failed with "docs page is out of date with the README". Same content, moved to the source and re-synced (README, generated page and the docs skill mirror). The row's cross-reference is plain text rather than an anchor link because the generator flattens links. * feat(coding-agents): HINDSIGHT_RETAIN_TAGS env override Closes #2896. The old Claude Code plugin had HINDSIGHT_RECALL_TAGS but no retain counterpart, so per-project retain tagging could only be configured globally in the file. Now that retainTags exists here, it joins the env surface on the same convention: HINDSIGHT_ + the field in SCREAMING_SNAKE, still a FALLBACK the file wins over. It is a list rather than a scalar, so a new ENV_LISTS branch splits on commas and trims — blank entries dropped, so a trailing comma or "a,,b" is a typo rather than an empty tag reaching the API. HINDSIGHT_RETAIN_TAGS="project:{gitProject},env:work" retainMetadata deliberately gets no env form: it is map-valued, and per-key branching doesn't survive flattening into one variable — the same rule already applied to mapPathToBank, harnesses and banks. Also corrects this file's header, which still claimed the plugin reads no environment variables at all — untrue since ENV_KEYS was added.
1 parent 20bd4f3 commit a22667d

15 files changed

Lines changed: 498 additions & 37 deletions

File tree

hindsight-docs/docs-integrations/coding-agents.md

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -270,8 +270,10 @@ Configuration is **one JSON file**: `~/.hindsight/coding-agent.json`. Layering,
270270
4. its `harnesses.<name>` section — per-agent override
271271

272272
Environment variables are a **fallback**: the file wins wherever it sets a value, so adding env to
273-
an existing setup changes nothing. The map-valued settings (`mapPathToBank`, `harnesses`, `banks`)
274-
are file-only — nested branching doesn't survive flattening into one variable.
273+
an existing setup changes nothing. `retainTags` takes a comma-separated list
274+
(`HINDSIGHT_RETAIN_TAGS="project:{gitProject},env:work"`); entries are trimmed and blanks dropped.
275+
The map-valued settings (`mapPathToBank`, `harnesses`, `banks`, `retainMetadata`) are file-only —
276+
per-key branching doesn't survive flattening into one variable.
275277

276278
There is deliberately no repo-carried config file — per-repo bank routing is `mapPathToBank`,
277279
per-agent differences are `harnesses.<name>`.
@@ -300,6 +302,8 @@ hook by Codex...), so one shared config serves several agents side by side:
300302
| `bankIdTemplate` | `"coding-agent::{gitProject}"` | dynamic bank id format; the default makes every agent share one bank per repo |
301303
| `mapPathToBank` || absolute path → bank; **longest prefix wins**; overrides everything |
302304
| `resolveWorktrees` | `true` | `{gitProject}`: linked worktrees share the main repo's bank |
305+
| `retainTags` || extra tags on every session write-back, e.g. `["project:{gitProject}"]` — see **Recording where a memory came from** below |
306+
| `retainMetadata` || extra metadata on every session write-back, e.g. `{"repo": "{gitProject}"}` |
303307
| `disabled` | `false` | hard off-switch (inert plugin/hook — a no-memory baseline) |
304308
| `reflectTimeoutMs` | `120000` | session-reflect timeout (hook harnesses additionally cap it at 25s to fit the host's hook window); on timeout the session runs without reflect (recorded) |
305309
| `pageRefreshEveryTurns` | `10` | refetch the knowledge pages and re-inject the page roster + tool guide every N user turns |
@@ -382,6 +386,29 @@ Coding memory is **per repository**. Resolution order for the working directory:
382386
The default `"coding-agent::{gitProject}"` is **harness-neutral**, so opencode, Claude Code, and Codex
383387
all share one memory per repo — use `"{harness}-{gitProject}"` to split per agent instead.
384388

389+
### Recording where a memory came from
390+
391+
With a bank per repo, the bank _is_ the answer to "where did this come from". On a deliberately
392+
**shared** bank — one bank holding cross-project knowledge so facts recall everywhere — it isn't:
393+
every memory looks alike. `retainTags` and `retainMetadata` stamp that provenance onto each session
394+
write-back:
395+
396+
```jsonc
397+
{
398+
"bankId": "shared", // one bank for everything
399+
"retainTags": ["project:{gitProject}", "env:work"],
400+
"retainMetadata": { "repo": "{gitProject}" },
401+
}
402+
```
403+
404+
Recalls can then filter by `project:<repo>`, and every document shows which repository it came out
405+
of. Both accept the same placeholders as `bankIdTemplate``{gitProject}`, `{project}`,
406+
`{harness}`, `{channel}`, `{user}` — plus `{bankId}`, `{sessionId}` and `{timestamp}`.
407+
`{gitProject}` is worktree-aware here too, so every linked worktree of a repo stamps one name.
408+
409+
The plugin's own `source:` and `harness:` tags are reserved: entries in those namespaces are ignored
410+
with a warning, so a document's agent attribution always reflects the agent that actually wrote it.
411+
385412
## Diagnostics & logging
386413

387414
Two files, two audiences:

hindsight-integrations/coding-agents/README.md

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -263,8 +263,10 @@ Configuration is **one JSON file**: `~/.hindsight/coding-agent.json`. Layering,
263263
4. its `harnesses.<name>` section — per-agent override
264264

265265
Environment variables are a **fallback**: the file wins wherever it sets a value, so adding env to
266-
an existing setup changes nothing. The map-valued settings (`mapPathToBank`, `harnesses`, `banks`)
267-
are file-only — nested branching doesn't survive flattening into one variable.
266+
an existing setup changes nothing. `retainTags` takes a comma-separated list
267+
(`HINDSIGHT_RETAIN_TAGS="project:{gitProject},env:work"`); entries are trimmed and blanks dropped.
268+
The map-valued settings (`mapPathToBank`, `harnesses`, `banks`, `retainMetadata`) are file-only —
269+
per-key branching doesn't survive flattening into one variable.
268270

269271
There is deliberately no repo-carried config file — per-repo bank routing is `mapPathToBank`,
270272
per-agent differences are `harnesses.<name>`.
@@ -293,6 +295,8 @@ hook by Codex...), so one shared config serves several agents side by side:
293295
| `bankIdTemplate` | `"coding-agent::{gitProject}"` | dynamic bank id format; the default makes every agent share one bank per repo |
294296
| `mapPathToBank` || absolute path → bank; **longest prefix wins**; overrides everything |
295297
| `resolveWorktrees` | `true` | `{gitProject}`: linked worktrees share the main repo's bank |
298+
| `retainTags` || extra tags on every session write-back, e.g. `["project:{gitProject}"]` — see **Recording where a memory came from** below |
299+
| `retainMetadata` || extra metadata on every session write-back, e.g. `{"repo": "{gitProject}"}` |
296300
| `disabled` | `false` | hard off-switch (inert plugin/hook — a no-memory baseline) |
297301
| `reflectTimeoutMs` | `120000` | session-reflect timeout (hook harnesses additionally cap it at 25s to fit the host's hook window); on timeout the session runs without reflect (recorded) |
298302
| `pageRefreshEveryTurns` | `10` | refetch the knowledge pages and re-inject the page roster + tool guide every N user turns |
@@ -375,6 +379,29 @@ Coding memory is **per repository**. Resolution order for the working directory:
375379
The default `"coding-agent::{gitProject}"` is **harness-neutral**, so opencode, Claude Code, and Codex
376380
all share one memory per repo — use `"{harness}-{gitProject}"` to split per agent instead.
377381

382+
### Recording where a memory came from
383+
384+
With a bank per repo, the bank _is_ the answer to "where did this come from". On a deliberately
385+
**shared** bank — one bank holding cross-project knowledge so facts recall everywhere — it isn't:
386+
every memory looks alike. `retainTags` and `retainMetadata` stamp that provenance onto each session
387+
write-back:
388+
389+
```jsonc
390+
{
391+
"bankId": "shared", // one bank for everything
392+
"retainTags": ["project:{gitProject}", "env:work"],
393+
"retainMetadata": { "repo": "{gitProject}" },
394+
}
395+
```
396+
397+
Recalls can then filter by `project:<repo>`, and every document shows which repository it came out
398+
of. Both accept the same placeholders as `bankIdTemplate``{gitProject}`, `{project}`,
399+
`{harness}`, `{channel}`, `{user}` — plus `{bankId}`, `{sessionId}` and `{timestamp}`.
400+
`{gitProject}` is worktree-aware here too, so every linked worktree of a repo stamps one name.
401+
402+
The plugin's own `source:` and `harness:` tags are reserved: entries in those namespaces are ignored
403+
with a warning, so a document's agent attribution always reflects the agent that actually wrote it.
404+
378405
## Ingestion internals (no CLI)
379406

380407
There is no user-facing ingest command — the deepen engine (`dist/deepen.js`) is spawned by every

hindsight-integrations/coding-agents/src/cline.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -193,7 +193,7 @@ function createRuntime(workspaceRoot: string | undefined): RuntimeCore | undefin
193193
apiToken: cfg.apiToken,
194194
bank: resolved.bankId,
195195
});
196-
return new RuntimeCore(client, resolved.bankId, cfg, HARNESS);
196+
return new RuntimeCore(client, resolved.bankId, cfg, HARNESS, workspaceRoot || process.cwd());
197197
}
198198

199199
const plugin: ClinePlugin = {

hindsight-integrations/coding-agents/src/core/bank.ts

Lines changed: 2 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
import { execFileSync } from "node:child_process";
2424
import { homedir } from "node:os";
2525
import { basename, dirname, join, normalize, sep } from "node:path";
26+
import { applyTemplate } from "./template";
2627

2728
export interface BankConfig {
2829
bankId?: string;
@@ -38,7 +39,6 @@ const DEFAULT_BANK_NAME = "coding";
3839
// banks and avoid collisions with other Hindsight banks. Deliberately NOT `{harness}::…` (that would
3940
// split memory per agent, defeating cross-agent sharing).
4041
const DEFAULT_TEMPLATE = "coding-agent::{gitProject}";
41-
const PLACEHOLDER = /\{([a-zA-Z]+)\}/g;
4242

4343
/** Main-worktree root for a directory inside a git repo (worktree- and bare-repo-aware), else null. */
4444
export function getProjectRootFromGit(directory: string): string | null {
@@ -103,18 +103,5 @@ export function deriveBankId(config: BankConfig, directory: string, harness = "c
103103
channel: () => process.env.HINDSIGHT_CHANNEL_ID || "default",
104104
user: () => process.env.HINDSIGHT_USER_ID || "anonymous",
105105
};
106-
return (config.bankIdTemplate || DEFAULT_TEMPLATE).replace(PLACEHOLDER, (_, name: string) => {
107-
const r = resolvers[name];
108-
if (!r) {
109-
console.error(
110-
`hindsight: unknown bankIdTemplate placeholder "{${name}}" — valid: ` +
111-
Object.keys(resolvers)
112-
.sort()
113-
.map((k) => `{${k}}`)
114-
.join(", ")
115-
);
116-
return "unknown";
117-
}
118-
return r();
119-
});
106+
return applyTemplate(config.bankIdTemplate || DEFAULT_TEMPLATE, resolvers, "bankIdTemplate");
120107
}

hindsight-integrations/coding-agents/src/core/chat.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,45 @@ describe("retainLiveSession — incremental write-back", () => {
270270
]);
271271
});
272272

273+
it("stamps configured tags and metadata onto the write-back", async () => {
274+
const { retain, client } = stubClient();
275+
await retainLiveSession(client, "s1", turns(2), "2026-01-01T00:00:00Z", "codex", {
276+
cursors: memoryCursorStore(),
277+
stamp: { tags: ["project:acme-api", "env:work"], metadata: { repo: "acme-api" } },
278+
});
279+
expect(retain.mock.calls[0][3]).toEqual([
280+
"project:acme-api",
281+
"env:work",
282+
"source:chat",
283+
"harness:codex",
284+
]);
285+
expect(retain.mock.calls[0][5].metadata).toMatchObject({
286+
repo: "acme-api",
287+
source: "chat",
288+
harness: "codex",
289+
});
290+
});
291+
292+
it("keeps built-in metadata authoritative and does not double a tag", async () => {
293+
// The documents list filters on `source:chat` and draws its agent logo from `metadata.harness`,
294+
// so the built-ins are written last and win. (retainTags entries in those namespaces are
295+
// dropped earlier, at the source — see retain-stamp.test.ts.)
296+
const { retain, client } = stubClient();
297+
await retainLiveSession(client, "s1", turns(2), "2026-01-01T00:00:00Z", "codex", {
298+
cursors: memoryCursorStore(),
299+
stamp: {
300+
tags: ["source:chat", "env:work"],
301+
metadata: { harness: "not-codex", source: "elsewhere", session_id: "spoofed" },
302+
},
303+
});
304+
expect(retain.mock.calls[0][3]).toEqual(["source:chat", "env:work", "harness:codex"]);
305+
expect(retain.mock.calls[0][5].metadata).toMatchObject({
306+
harness: "codex",
307+
source: "chat",
308+
session_id: "s1",
309+
});
310+
});
311+
273312
it("keeps the write-back when the capability probe itself fails", async () => {
274313
const retain = vi.fn().mockResolvedValue(undefined);
275314
const client = {

hindsight-integrations/coding-agents/src/core/chat.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
*/
66
import type { HindsightClient } from "./hindsight";
77
import { fingerprintTurns, planRetain, type RetainCursorStore } from "./retain-cursor";
8+
import type { RetainStamp } from "./retain-stamp";
89
import type { ChatSession } from "./types";
910
import { uuidV5 } from "./uuid";
1011
import { pool } from "./util";
@@ -152,13 +153,13 @@ export async function retainLiveSession(
152153
turns: TransportTurn[],
153154
startTs: string,
154155
harness?: string,
155-
opts: { cursors?: RetainCursorStore } = {}
156+
opts: { cursors?: RetainCursorStore; stamp?: RetainStamp } = {}
156157
): Promise<void> {
157158
const cursors = opts.cursors;
158-
if (!cursors) return writeSession(client, sessionId, turns, startTs, harness);
159+
if (!cursors) return writeSession(client, sessionId, turns, startTs, harness, opts.stamp);
159160
// Serialised so the plan is made against the previous write-back's CONFIRMED cursor (see above).
160161
return serialize(cursors, sessionId, () =>
161-
writeSession(client, sessionId, turns, startTs, harness, cursors)
162+
writeSession(client, sessionId, turns, startTs, harness, opts.stamp, cursors)
162163
);
163164
}
164165

@@ -168,6 +169,7 @@ async function writeSession(
168169
turns: TransportTurn[],
169170
startTs: string,
170171
harness?: string,
172+
stamp?: RetainStamp,
171173
cursors?: RetainCursorStore
172174
): Promise<void> {
173175
const refId = `conversation:${sessionId}`;
@@ -196,7 +198,15 @@ async function writeSession(
196198
content,
197199
"coding agent session",
198200
refId,
199-
["source:chat", ...(harness ? [`harness:${harness}`] : [])],
201+
// Configured tags first, built-ins last and deduped: `source:chat` and `harness:<id>` are what
202+
// the documents list filters and draws its agent logo from, so a template cannot displace them.
203+
[
204+
...new Set([
205+
...(stamp?.tags ?? []),
206+
"source:chat",
207+
...(harness ? [`harness:${harness}`] : []),
208+
]),
209+
],
200210
"conversation",
201211
{
202212
timestamp: startTs,
@@ -205,6 +215,7 @@ async function writeSession(
205215
// the original operation instead of extracting (or appending) twice.
206216
operationId: uuidV5(`${client.bank}\n${refId}\n${plan.mode}\n${content}`),
207217
metadata: {
218+
...stamp?.metadata, // configured first: the built-ins below win on any key collision
208219
source: "chat",
209220
session_id: sessionId,
210221
ref_id: refId,

hindsight-integrations/coding-agents/src/core/config.test.ts

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
22
import { tmpdir } from "node:os";
33
import { join } from "node:path";
44
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5-
import { loadConfig, applyBankConfig, resolveConfig } from "./config";
5+
import { loadConfig, applyBankConfig, readEnvConfig, resolveConfig } from "./config";
66

77
let root: string;
88
let globalCfg: string;
@@ -212,3 +212,52 @@ describe("environment fallback", () => {
212212
expect(cfg.surveyModel).toBe("haiku");
213213
});
214214
});
215+
216+
describe("retainTags / retainMetadata", () => {
217+
it("default to empty, so a retain is unchanged unless configured", () => {
218+
const cfg = resolveConfig({});
219+
expect(cfg.retainTags).toEqual([]);
220+
expect(cfg.retainMetadata).toEqual({});
221+
});
222+
223+
it("carries templates through verbatim — resolution happens per retain", () => {
224+
const cfg = resolveConfig({
225+
retainTags: ["project:{gitProject}"],
226+
retainMetadata: { repo: "{gitProject}" },
227+
});
228+
expect(cfg.retainTags).toEqual(["project:{gitProject}"]);
229+
expect(cfg.retainMetadata).toEqual({ repo: "{gitProject}" });
230+
});
231+
232+
it("ignores non-string entries rather than failing the whole retain", () => {
233+
// A config typo (a number, a nested object) would otherwise reach the API as a tag.
234+
const cfg = resolveConfig({
235+
retainTags: ["ok", 42, null, " "] as unknown as string[],
236+
retainMetadata: { good: "x", bad: { nested: true } } as unknown as Record<string, string>,
237+
});
238+
expect(cfg.retainTags).toEqual(["ok"]);
239+
expect(cfg.retainMetadata).toEqual({ good: "x" });
240+
});
241+
});
242+
243+
describe("HINDSIGHT_RETAIN_TAGS", () => {
244+
it("reads a comma-separated list — the env form of retainTags (#2896)", () => {
245+
expect(
246+
readEnvConfig({ HINDSIGHT_RETAIN_TAGS: "project:{gitProject},env:work" }).retainTags
247+
).toEqual(["project:{gitProject}", "env:work"]);
248+
});
249+
250+
it("trims entries and drops empties, so a trailing comma is not an empty tag", () => {
251+
expect(readEnvConfig({ HINDSIGHT_RETAIN_TAGS: " a , ,b, " }).retainTags).toEqual(["a", "b"]);
252+
});
253+
254+
it("is absent when unset or empty, leaving the file value alone", () => {
255+
expect(readEnvConfig({}).retainTags).toBeUndefined();
256+
expect(readEnvConfig({ HINDSIGHT_RETAIN_TAGS: "" }).retainTags).toBeUndefined();
257+
expect(readEnvConfig({ HINDSIGHT_RETAIN_TAGS: " , " }).retainTags).toBeUndefined();
258+
});
259+
260+
it("has no retainMetadata counterpart — map-valued settings stay file-only", () => {
261+
expect(readEnvConfig({ HINDSIGHT_RETAIN_METADATA: "repo=x" }).retainMetadata).toBeUndefined();
262+
});
263+
});

0 commit comments

Comments
 (0)