Skip to content
Closed
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
26 changes: 26 additions & 0 deletions hindsight-docs/docs-integrations/coding-agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,9 @@ hook by Codex...), so one shared config serves several agents side by side:
| `resolveWorktrees` | `true` | `{gitProject}`: linked worktrees share the main repo's bank |
| `retainTags` | — | extra tags on every document written by the integration, e.g. `["project:{gitProject}"]` — see **Recording where a memory came from** below |
| `retainMetadata` | — | extra metadata on every document written by the integration, e.g. `{"repo": "{gitProject}"}` |
| `projectScope` | `"bank"` | `"bank"` keeps existing behavior; `"tags"` scopes ordinary reflect within a shared bank |
| `projectTagTemplate` | `"project:{gitProject}"` | project tag automatically retained and used for scoped reflect |
| `globalTags` | — | shared tags included alongside the current project during scoped reflect |
| `disabled` | `false` | hard off-switch (inert plugin/hook — a no-memory baseline) |
| `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) |
| `pageRefreshEveryTurns` | `10` | refetch the knowledge pages and re-inject the page roster + tool guide every N user turns |
Expand Down Expand Up @@ -448,6 +451,29 @@ of. Both accept the same placeholders as `bankIdTemplate` — `{gitProject}`, `{
The plugin's own `source:` and `harness:` tags are reserved: entries in those namespaces are ignored
with a warning, so a document's agent attribution always reflects the agent that actually wrote it.

### Project-scoped reflect inside one shared bank

Per-repository banks remain the default. To keep one static bank while making ordinary reflect
project-specific, opt into strict tag scope:

```jsonc
{
"bankId": "shared",
"projectScope": "tags",
"projectTagTemplate": "project:{gitProject}",
"globalTags": ["scope:global"],
}
```

This automatically stamps the project tag on every document written by the integration. Automatic
reflect and `hindsight_reflect` then search only the current project's tag plus any `globalTags`.
Untagged and unrelated-project memories are excluded. For an intentional cross-project comparison,
or while migrating legacy untagged documents, use `hindsight_reflect_all_projects` to query the full
bank explicitly.

Knowledge Pages remain bank-wide in this mode. Their search endpoint does not currently accept tag
filters, so project scoping is not presented as covering page search, listing, or page generation.

## Diagnostics & logging

Two files, two audiences:
Expand Down
26 changes: 26 additions & 0 deletions hindsight-integrations/coding-agents/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,9 @@ hook by Codex...), so one shared config serves several agents side by side:
| `resolveWorktrees` | `true` | `{gitProject}`: linked worktrees share the main repo's bank |
| `retainTags` | — | extra tags on every document written by the integration, e.g. `["project:{gitProject}"]` — see **Recording where a memory came from** below |
| `retainMetadata` | — | extra metadata on every document written by the integration, e.g. `{"repo": "{gitProject}"}` |
| `projectScope` | `"bank"` | `"bank"` keeps existing behavior; `"tags"` scopes ordinary reflect within a shared bank |
| `projectTagTemplate` | `"project:{gitProject}"` | project tag automatically retained and used for scoped reflect |
| `globalTags` | — | shared tags included alongside the current project during scoped reflect |
| `disabled` | `false` | hard off-switch (inert plugin/hook — a no-memory baseline) |
| `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) |
| `pageRefreshEveryTurns` | `10` | refetch the knowledge pages and re-inject the page roster + tool guide every N user turns |
Expand Down Expand Up @@ -441,6 +444,29 @@ of. Both accept the same placeholders as `bankIdTemplate` — `{gitProject}`, `{
The plugin's own `source:` and `harness:` tags are reserved: entries in those namespaces are ignored
with a warning, so a document's agent attribution always reflects the agent that actually wrote it.

### Project-scoped reflect inside one shared bank

Per-repository banks remain the default. To keep one static bank while making ordinary reflect
project-specific, opt into strict tag scope:

```jsonc
{
"bankId": "shared",
"projectScope": "tags",
"projectTagTemplate": "project:{gitProject}",
"globalTags": ["scope:global"],
}
```

This automatically stamps the project tag on every document written by the integration. Automatic
reflect and `hindsight_reflect` then search only the current project's tag plus any `globalTags`.
Untagged and unrelated-project memories are excluded. For an intentional cross-project comparison,
or while migrating legacy untagged documents, use `hindsight_reflect_all_projects` to query the full
bank explicitly.

Knowledge Pages remain bank-wide in this mode. Their search endpoint does not currently accept tag
filters, so project scoping is not presented as covering page search, listing, or page generation.

## Ingestion internals (no CLI)

There is no user-facing ingest command — the deepen engine (`dist/deepen.js`) is spawned by every
Expand Down
7 changes: 7 additions & 0 deletions hindsight-integrations/coding-agents/src/cline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import { applyBankConfig, loadConfig } from "./core/config";
import { deriveBankId } from "./core/bank";
import { HindsightClient } from "./core/hindsight";
import { resolveProjectScope } from "./core/project-scope";
import { RuntimeCore } from "./core/runtime";
import type { TransportTurn } from "./core/chat";
import { diag } from "./core/diag";
Expand Down Expand Up @@ -197,6 +198,12 @@ function createRuntime(workspaceRoot: string | undefined): RuntimeCore | undefin
apiToken: cfg.apiToken,
bank: resolved.bankId,
maxParallelRetains: cfg.maxParallelRetains,
projectScope: resolveProjectScope(
cfg,
workspaceRoot || process.cwd(),
HARNESS,
resolved.bankId
),
});
return new RuntimeCore(client, resolved.bankId, cfg, HARNESS, workspaceRoot || process.cwd());
}
Expand Down
23 changes: 23 additions & 0 deletions hindsight-integrations/coding-agents/src/core/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,29 @@ describe("retainTags / retainMetadata", () => {
expect(cfg.retainTags).toEqual(["ok"]);
expect(cfg.retainMetadata).toEqual({ good: "x" });
});

it("keeps configured tags separate from the derived project scope", () => {
expect(resolveConfig({}).projectScope).toBe("bank");
const cfg = resolveConfig({
projectScope: "tags",
projectTagTemplate: "repo:{gitProject}",
retainTags: ["kind:transcript"],
globalTags: ["scope:global", 42 as unknown as string],
});
expect(cfg.projectScope).toBe("tags");
expect(cfg.retainTags).toEqual(["kind:transcript"]);
expect(cfg.globalTags).toEqual(["scope:global"]);
});

it("allows a bank override to disable tag scope without leaving an implicit retain tag", () => {
const cfg = resolveConfig({
projectScope: "tags",
banks: { shared: { projectScope: "bank" } },
});
const scoped = applyBankConfig(cfg, "shared").cfg;
expect(scoped.projectScope).toBe("bank");
expect(scoped.retainTags).toEqual([]);
});
});

describe("HINDSIGHT_RETAIN_TAGS", () => {
Expand Down
14 changes: 14 additions & 0 deletions hindsight-integrations/coding-agents/src/core/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,12 @@ export interface RawConfig {
/** Extra metadata stamped on every session write-back, e.g. {"repo": "{gitProject}"}. Same
* placeholders as retainTags; built-in metadata (harness attribution) wins on conflict. */
retainMetadata?: Record<string, string>;
/** Opt into project separation by strict tags inside one shared/static bank. */
projectScope?: "bank" | "tags";
/** Tag template identifying the current project when projectScope is "tags". */
projectTagTemplate?: string;
/** Shared tags included alongside the current project during scoped reflect. */
globalTags?: string[];
/** Per-harness overrides of any of the fields above, keyed by harness name ("opencode",
* "claude-code", ...). Lets one config file give each agent its own bank/settings. */
harnesses?: Record<string, Omit<RawConfig, "harnesses">>;
Expand Down Expand Up @@ -154,6 +160,9 @@ export interface Config {
gitIngest: "message" | "full" | "none";
retainTags: string[];
retainMetadata: Record<string, string>;
projectScope: "bank" | "tags";
projectTagTemplate: string;
globalTags: string[];
banks: Record<string, Omit<RawConfig, "banks" | "harnesses"> & { bank?: string }>;
logLevel: "debug" | "info" | "warn" | "error";
}
Expand Down Expand Up @@ -216,6 +225,11 @@ export function resolveConfig(raw: RawConfig = {}): Config {
Object.entries(raw.retainMetadata).filter(([, v]) => typeof v === "string")
)
: {},
projectScope: raw.projectScope === "tags" ? "tags" : "bank",
projectTagTemplate: raw.projectTagTemplate || "project:{gitProject}",
globalTags: Array.isArray(raw.globalTags)
? raw.globalTags.filter((tag): tag is string => typeof tag === "string" && tag.trim() !== "")
: [],
banks: raw.banks && typeof raw.banks === "object" ? raw.banks : {},
logLevel: ["debug", "info", "warn", "error"].includes(raw.logLevel as string)
? (raw.logLevel as "debug" | "info" | "warn" | "error")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { HindsightClient } from "./hindsight";

afterEach(() => vi.restoreAllMocks());

describe("HindsightClient project-scoped reflect", () => {
it("scopes ordinary reflect and supports explicit bank-wide reflect", async () => {
const bodies: unknown[] = [];
vi.stubGlobal(
"fetch",
vi.fn(async (_url: string, init: RequestInit) => {
bodies.push(JSON.parse(String(init.body)));
return { ok: true, status: 200, json: async () => ({ text: "answer" }) } as Response;
})
);
const client = new HindsightClient({
apiUrl: "http://x",
bank: "default",
projectScope: { projectTag: "project:potcodev", globalTags: ["scope:global"] },
});
await client.reflect("why?", { budget: "high" });
await client.reflect("compare projects", { budget: "high", unscoped: true });
expect(bodies[0]).toEqual({
query: "why?",
budget: "high",
tag_groups: [
{
or: [
{ tags: ["project:potcodev"], match: "any_strict" },
{ tags: ["scope:global"], match: "any_strict" },
],
},
],
});
expect(bodies[1]).toEqual({ query: "compare projects", budget: "high" });
});
});
14 changes: 12 additions & 2 deletions hindsight-integrations/coding-agents/src/core/hindsight.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
} from "./missions";
import { pool, semverGte, sleep } from "./util";
import type { RetainStamp } from "./retain-stamp";
import { scopeTagGroups, type ProjectScope } from "./project-scope";

/** One node of GET /knowledge-base/tree. Only the fields this client reads. */
export interface KnowledgeNode {
Expand All @@ -32,6 +33,7 @@ export interface ClientOpts {
log?: (msg: string) => void;
/** Cap on concurrent retain-related requests (drain op polls, deepen pools). Default 10. */
maxParallelRetains?: number;
projectScope?: ProjectScope;
}

export interface RetainOpts {
Expand Down Expand Up @@ -120,13 +122,15 @@ export class HindsightClient {
private idempotentRetain: boolean | undefined;
private readonly log: (msg: string) => void;
readonly maxParallelRetains: number;
private readonly projectScope?: ProjectScope;

constructor(o: ClientOpts) {
this.apiUrl = o.apiUrl.replace(/\/$/, "");
this.apiToken = o.apiToken;
this.bank = o.bank;
this.log = o.log ?? (() => {});
this.maxParallelRetains = o.maxParallelRetains || DEFAULT_MAX_PARALLEL_RETAINS;
this.projectScope = o.projectScope;
}

private headers(): Record<string, string> {
Expand Down Expand Up @@ -364,15 +368,21 @@ export class HindsightClient {
/** Reflect: synthesized, root-cause answer over the bank. Bounded so a slow server never hangs a caller. */
async reflect(
query: string,
opts: { budget?: string; timeoutMs?: number } = {}
opts: { budget?: string; timeoutMs?: number; unscoped?: boolean } = {}
): Promise<string> {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), opts.timeoutMs ?? 120000);
try {
const resp = await fetch(this.bankUrl("/reflect"), {
method: "POST",
headers: this.headers(),
body: JSON.stringify({ query, budget: opts.budget ?? "high" }),
body: JSON.stringify({
query,
budget: opts.budget ?? "high",
...(!opts.unscoped && this.projectScope
? { tag_groups: scopeTagGroups(this.projectScope) }
: {}),
}),
signal: ctrl.signal,
});
if (!resp.ok) throw new Error(`reflect ${resp.status}`);
Expand Down
2 changes: 2 additions & 0 deletions hindsight-integrations/coding-agents/src/core/hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import { brandWord } from "./brand";
import { buildReflectQuery, buildSystemInjection } from "./inject";
import type { PageRef } from "./knowledge-injection";
import { buildRosterRefresh, parsePageList } from "./knowledge-injection";
import { resolveProjectScope } from "./project-scope";
import {
readSessionCache,
sessionCacheFile,
Expand Down Expand Up @@ -240,6 +241,7 @@ export async function runHook(
apiToken: cfg.apiToken,
bank: bankId,
maxParallelRetains: cfg.maxParallelRetains,
projectScope: resolveProjectScope(cfg, cwd, spec.harness, bankId),
});
const cacheFile = sessionCacheFile(spec.harness, sessionId || "no-session");

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,21 @@ describe("buildKnowledgeTools", () => {
expect(JSON.parse(result.content[0].text)).toBe("the decided rule is X=3");
});

it("adds an explicit unscoped reflect tool only in project tag-scope mode", async () => {
const client = stubClient({ reflect: vi.fn(async () => "cross-project answer") });
expect(buildKnowledgeTools(client, "repo-a").map((tool) => tool.name)).not.toContain(
"hindsight_reflect_all_projects"
);
const tools = buildKnowledgeTools(client, "repo-a", {
projectScope: { projectTag: "project:a", globalTags: [] },
});
await findTool(tools, "hindsight_reflect_all_projects").handler({ query: "compare A and B" });
expect(client.reflect).toHaveBeenCalledWith("compare A and B", {
budget: "high",
unscoped: true,
});
});

it("hindsight_capture_initiative calls client.captureInitiative({title, summary, relatesToPageId}) and returns the page id", async () => {
const client = stubClient({
captureInitiative: vi.fn(async (_a: unknown) => ({ page_id: "initiative-retry-backoff" })),
Expand Down
28 changes: 26 additions & 2 deletions hindsight-integrations/coding-agents/src/core/knowledge-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import type { HindsightClient } from "./hindsight";
import { syncStatus } from "./status";
import { loadConfig } from "./config";
import type { RetainStamp } from "./retain-stamp";
import type { ProjectScope } from "./project-scope";

export interface ToolResult {
// Index signature so this structurally satisfies the MCP SDK's CallToolResult (which carries
Expand Down Expand Up @@ -62,9 +63,14 @@ function guarded(fn: (args: any) => Promise<unknown>): (args: any) => Promise<To
export function buildKnowledgeTools(
client: HindsightClient,
bankId: string,
opts: { repoDir?: string; harness?: string; stampFor?: () => RetainStamp } = {}
opts: {
repoDir?: string;
harness?: string;
stampFor?: () => RetainStamp;
projectScope?: ProjectScope;
} = {}
): ToolSpec[] {
return [
const tools: ToolSpec[] = [
{
name: "hindsight_sync_status",
description:
Expand Down Expand Up @@ -250,4 +256,22 @@ export function buildKnowledgeTools(
}),
},
];
if (opts.projectScope) {
tools.splice(
tools.findIndex((tool) => tool.name === "hindsight_capture_initiative"),
0,
{
name: "hindsight_reflect_all_projects",
description:
"Deep memory reasoning across the entire shared bank without the current project's tag " +
"scope. Use only for explicit cross-project comparisons or legacy untagged memory; " +
"prefer hindsight_reflect for ordinary work in the current project.",
inputSchema: { query: z.string().describe("the cross-project question to reason about") },
handler: guarded(async ({ query }: { query: string }) =>
client.reflect(query, { budget: "high", unscoped: true })
),
}
);
}
return tools;
}
Loading