Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5bca83432b |
@@ -359,6 +359,7 @@ 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}"}` |
|
||||
| `observationScopes` | `"shared"` | how consolidation groups observations: `"shared"` (default) = ONE global scope per bank, so every agent on a repo builds one set of beliefs; also `"combined"` (the server default), `"per_tag"`, `"all_combinations"`, `[["t"]]` |
|
||||
| `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 |
|
||||
@@ -479,6 +480,35 @@ 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.
|
||||
|
||||
### One set of beliefs per repo
|
||||
|
||||
Every document this integration writes carries provenance tags — `source:chat`, `harness:<id>`,
|
||||
`knowledge:<kind>`, plus anything from `retainTags`. Those tags say **who wrote** a memory; they are
|
||||
what filters recall and draws each document's agent logo, and they stay on the facts.
|
||||
|
||||
They are not, however, a good boundary for [observations](/developer/observations). Consolidation's own
|
||||
default (`combined`) builds one observation set per distinct tag set, so the same repository
|
||||
worked on by two agents would grow two parallel sets of beliefs — one per harness — that never
|
||||
merge, each blind to the other, at double the consolidation cost. Which agent happened to be typing
|
||||
does not change whether a convention or a decision is true.
|
||||
|
||||
So the integration retains with `observationScopes: "shared"`: one global, untagged observation
|
||||
scope per bank, which is what a bank already is — one project's memory. Set the field to change it:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"observationScopes": "combined", // one observation set per distinct tag set (server default)
|
||||
"banks": {
|
||||
"coding-agent::mono": { "observationScopes": "per_tag" }, // per-repo, like any behavioral field
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
`"per_tag"` and `"all_combinations"` split further still, and an explicit `[["project:demo"], …]`
|
||||
declares the scopes literally. `HINDSIGHT_OBSERVATION_SCOPES` sets the scalar modes; a scope list is
|
||||
file-only. Changing this does not rewrite observations already consolidated under the old scoping —
|
||||
they stay where they were built, and new work accrues under the new setting.
|
||||
|
||||
## Diagnostics & logging
|
||||
|
||||
Two files, two audiences:
|
||||
|
||||
@@ -352,6 +352,7 @@ 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}"}` |
|
||||
| `observationScopes` | `"shared"` | how consolidation groups observations: `"shared"` (default) = ONE global scope per bank, so every agent on a repo builds one set of beliefs; also `"combined"` (the server default), `"per_tag"`, `"all_combinations"`, `[["t"]]` |
|
||||
| `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 |
|
||||
@@ -472,6 +473,36 @@ 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.
|
||||
|
||||
### One set of beliefs per repo
|
||||
|
||||
Every document this integration writes carries provenance tags — `source:chat`, `harness:<id>`,
|
||||
`knowledge:<kind>`, plus anything from `retainTags`. Those tags say **who wrote** a memory; they are
|
||||
what filters recall and draws each document's agent logo, and they stay on the facts.
|
||||
|
||||
They are not, however, a good boundary for
|
||||
[observations](https://hindsight.vectorize.io/developer/observations). Consolidation's own
|
||||
default (`combined`) builds one observation set per distinct tag set, so the same repository
|
||||
worked on by two agents would grow two parallel sets of beliefs — one per harness — that never
|
||||
merge, each blind to the other, at double the consolidation cost. Which agent happened to be typing
|
||||
does not change whether a convention or a decision is true.
|
||||
|
||||
So the integration retains with `observationScopes: "shared"`: one global, untagged observation
|
||||
scope per bank, which is what a bank already is — one project's memory. Set the field to change it:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"observationScopes": "combined", // one observation set per distinct tag set (server default)
|
||||
"banks": {
|
||||
"coding-agent::mono": { "observationScopes": "per_tag" }, // per-repo, like any behavioral field
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
`"per_tag"` and `"all_combinations"` split further still, and an explicit `[["project:demo"], …]`
|
||||
declares the scopes literally. `HINDSIGHT_OBSERVATION_SCOPES` sets the scalar modes; a scope list is
|
||||
file-only. Changing this does not rewrite observations already consolidated under the old scoping —
|
||||
they stay where they were built, and new work accrues under the new setting.
|
||||
|
||||
## Ingestion internals (no CLI)
|
||||
|
||||
There is no user-facing ingest command — the deepen engine (`dist/deepen.js`) is spawned by every
|
||||
|
||||
@@ -201,6 +201,7 @@ function createRuntime(workspaceRoot: string | undefined): RuntimeCore | undefin
|
||||
apiToken: cfg.apiToken,
|
||||
bank: resolved.bankId,
|
||||
maxParallelRetains: cfg.maxParallelRetains,
|
||||
observationScopes: cfg.observationScopes,
|
||||
});
|
||||
return new RuntimeCore(client, resolved.bankId, cfg, HARNESS, workspaceRoot || process.cwd());
|
||||
}
|
||||
|
||||
@@ -290,3 +290,50 @@ describe("HINDSIGHT_RETAIN_TAGS", () => {
|
||||
expect(readEnvConfig({ HINDSIGHT_RETAIN_METADATA: "repo=x" }).retainMetadata).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("observationScopes", () => {
|
||||
it("defaults to one global scope per bank, so two agents on one repo share its beliefs (#3564)", () => {
|
||||
expect(loadConfig({ path: join(root, "nope.json") }).observationScopes).toBe("shared");
|
||||
});
|
||||
|
||||
it("takes any of the server's scalar modes verbatim", () => {
|
||||
for (const mode of ["shared", "combined", "per_tag", "all_combinations"] as const) {
|
||||
writeJson(globalCfg, { observationScopes: mode });
|
||||
expect(loadConfig({ path: globalCfg }).observationScopes).toBe(mode);
|
||||
}
|
||||
});
|
||||
|
||||
it("takes an explicit scope list, dropping non-string entries", () => {
|
||||
expect(
|
||||
resolveConfig({ observationScopes: [["project:demo"], ["team:eng", "x"]] }).observationScopes
|
||||
).toEqual([["project:demo"], ["team:eng", "x"]]);
|
||||
expect(
|
||||
resolveConfig({ observationScopes: [["a", 7, ""], "nope"] as never }).observationScopes
|
||||
).toEqual([["a"]]);
|
||||
});
|
||||
|
||||
it("falls back to the default on an unusable value rather than sending it to the API", () => {
|
||||
// `[]` in particular: the API reads zero scopes as no spec and silently applies `combined`,
|
||||
// which is the opposite of what writing the field was meant to say.
|
||||
expect(resolveConfig({ observationScopes: [] }).observationScopes).toBe("shared");
|
||||
expect(resolveConfig({ observationScopes: "per-tag" as never }).observationScopes).toBe(
|
||||
"shared"
|
||||
);
|
||||
expect(resolveConfig({ observationScopes: 3 as never }).observationScopes).toBe("shared");
|
||||
});
|
||||
|
||||
it("is overridable per bank, since whether agents should share beliefs is a per-repo call", () => {
|
||||
const cfg = resolveConfig({
|
||||
banks: { "coding-agent::mono": { observationScopes: "combined" } },
|
||||
});
|
||||
expect(applyBankConfig(cfg, "coding-agent::mono").cfg.observationScopes).toBe("combined");
|
||||
expect(applyBankConfig(cfg, "coding-agent::other").cfg.observationScopes).toBe("shared");
|
||||
});
|
||||
|
||||
it("reads HINDSIGHT_OBSERVATION_SCOPES for the scalar modes; a scope LIST stays file-only", () => {
|
||||
expect(readEnvConfig({ HINDSIGHT_OBSERVATION_SCOPES: "per_tag" }).observationScopes).toBe(
|
||||
"per_tag"
|
||||
);
|
||||
expect(readEnvConfig({}).observationScopes).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,6 +17,7 @@ import { join } from "node:path";
|
||||
import { DEFAULT_SEED_LIMIT } from "./seed";
|
||||
import { isOptedIn } from "./bank";
|
||||
import { log } from "./log";
|
||||
import { DEFAULT_OBSERVATION_SCOPES, type ObservationScopes } from "./hindsight";
|
||||
|
||||
/** Default config-file path: ~/.hindsight/coding-agent.json */
|
||||
export // HINDSIGHT_CONFIG joins the two env exceptions (diag/log files): it points at THE config file,
|
||||
@@ -118,6 +119,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>;
|
||||
/** How consolidation groups the observations this plugin's memories feed (default "shared" — one
|
||||
* global scope per bank, so every agent working a repo builds ONE set of beliefs; see
|
||||
* DEFAULT_OBSERVATION_SCOPES). "combined" restores the server default of one scope per distinct
|
||||
* tag set, "per_tag" one per tag, "all_combinations" one per subset; a string[][] declares the
|
||||
* scopes literally. Anything else falls back to the default. */
|
||||
observationScopes?: ObservationScopes;
|
||||
/** 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">>;
|
||||
@@ -170,6 +177,7 @@ export interface Config {
|
||||
gitIngest: "message" | "full" | "none";
|
||||
retainTags: string[];
|
||||
retainMetadata: Record<string, string>;
|
||||
observationScopes: ObservationScopes;
|
||||
banks: Record<string, Omit<RawConfig, "banks" | "harnesses"> & { bank?: string }>;
|
||||
logLevel: "debug" | "info" | "warn" | "error";
|
||||
}
|
||||
@@ -194,6 +202,35 @@ function resolvePageTriggerType(raw: RawConfig): "auto-refresh" | "cron" | "manu
|
||||
return "auto-refresh";
|
||||
}
|
||||
|
||||
/** The server's scalar scoping modes; anything else in this field has to be an explicit scope list. */
|
||||
const OBSERVATION_SCOPE_MODES = ["shared", "combined", "per_tag", "all_combinations"] as const;
|
||||
|
||||
/**
|
||||
* Validate `observationScopes`, falling back to the default on anything unrecognized.
|
||||
*
|
||||
* A typo here would otherwise reach the API as an unknown scoping mode and change how a whole
|
||||
* bank's observations are grouped, so an unusable value takes the default rather than travelling.
|
||||
* An empty list is unusable too, and specifically so: `[]` declares ZERO scopes, which the server
|
||||
* reads as "no spec" and silently treats as `combined` — the opposite of what writing this field
|
||||
* at all was meant to express.
|
||||
*/
|
||||
function resolveObservationScopes(raw: RawConfig["observationScopes"]): ObservationScopes {
|
||||
// Widened deliberately: this arrives from a hand-edited JSON file, so the declared type says what
|
||||
// is meant, not what is there.
|
||||
const value: unknown = raw;
|
||||
if (typeof value === "string")
|
||||
return (OBSERVATION_SCOPE_MODES as readonly string[]).includes(value)
|
||||
? (value as ObservationScopes)
|
||||
: DEFAULT_OBSERVATION_SCOPES;
|
||||
if (Array.isArray(value)) {
|
||||
const scopes = (value as unknown[])
|
||||
.filter((scope): scope is unknown[] => Array.isArray(scope))
|
||||
.map((scope) => scope.filter((t): t is string => typeof t === "string" && t.trim() !== ""));
|
||||
if (scopes.length) return scopes;
|
||||
}
|
||||
return DEFAULT_OBSERVATION_SCOPES;
|
||||
}
|
||||
|
||||
/** Apply defaults to a raw (file) config. Pure — the single place the defaults live. */
|
||||
export function resolveConfig(raw: RawConfig = {}): Config {
|
||||
const serverMode = ["cloud", "self-hosted", "daemon"].includes(raw.serverMode as string)
|
||||
@@ -254,6 +291,7 @@ export function resolveConfig(raw: RawConfig = {}): Config {
|
||||
Object.entries(raw.retainMetadata).filter(([, v]) => typeof v === "string")
|
||||
)
|
||||
: {},
|
||||
observationScopes: resolveObservationScopes(raw.observationScopes),
|
||||
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")
|
||||
@@ -346,6 +384,9 @@ const ENV_KEYS = {
|
||||
surveyRefreshCommits: "HINDSIGHT_SURVEY_REFRESH_COMMITS",
|
||||
logLevel: "HINDSIGHT_LOG_LEVEL",
|
||||
gitIngest: "HINDSIGHT_GIT_INGEST",
|
||||
// Scalar modes only ("shared", "combined", "per_tag", "all_combinations"). An explicit scope
|
||||
// list is a list OF lists, which does not survive flattening into one variable — file-only.
|
||||
observationScopes: "HINDSIGHT_OBSERVATION_SCOPES",
|
||||
// Comma-separated, e.g. HINDSIGHT_RETAIN_TAGS="project:{gitProject},env:work". A LIST rather than
|
||||
// a map, so it flattens cleanly; its sibling retainMetadata stays file-only for the reason above.
|
||||
retainTags: "HINDSIGHT_RETAIN_TAGS",
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { DEFAULT_MAX_PARALLEL_RETAINS, HindsightClient, retryAfterMs } from "./hindsight";
|
||||
import {
|
||||
DEFAULT_MAX_PARALLEL_RETAINS,
|
||||
DEFAULT_OBSERVATION_SCOPES,
|
||||
HindsightClient,
|
||||
retryAfterMs,
|
||||
} from "./hindsight";
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
@@ -168,3 +176,74 @@ describe("retryAfterMs", () => {
|
||||
expect(retryAfterMs("soon")).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("HindsightClient.retain — observation scoping", () => {
|
||||
async function retainItem(client: HindsightClient): Promise<Record<string, unknown>> {
|
||||
let sent: string | undefined;
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (_url: string, init: RequestInit) => {
|
||||
sent = String(init.body);
|
||||
return jsonResponse(200, { operation_id: "op-1" });
|
||||
})
|
||||
);
|
||||
await client.retain(
|
||||
"c",
|
||||
"ctx",
|
||||
"doc-1",
|
||||
["source:chat", "harness:claude-code"],
|
||||
"conversation"
|
||||
);
|
||||
const body = JSON.parse(String(sent)) as { items: Record<string, unknown>[] };
|
||||
return body.items[0];
|
||||
}
|
||||
|
||||
it("defaults every retain to the single global scope, so two agents on one repo build ONE set of observations (#3564)", async () => {
|
||||
expect(DEFAULT_OBSERVATION_SCOPES).toBe("shared");
|
||||
const item = await retainItem(new HindsightClient({ apiUrl: "http://x", bank: "b" }));
|
||||
expect(item.observation_scopes).toBe("shared");
|
||||
// The harness tag still travels: it is what the documents list filters and draws its logo from.
|
||||
expect(item.tags).toEqual(["source:chat", "harness:claude-code"]);
|
||||
});
|
||||
|
||||
it("sends a configured scoping instead, including the server's own default", async () => {
|
||||
const combined = await retainItem(
|
||||
new HindsightClient({ apiUrl: "http://x", bank: "b", observationScopes: "combined" })
|
||||
);
|
||||
expect(combined.observation_scopes).toBe("combined");
|
||||
const explicit = await retainItem(
|
||||
new HindsightClient({ apiUrl: "http://x", bank: "b", observationScopes: [["project:demo"]] })
|
||||
);
|
||||
expect(explicit.observation_scopes).toEqual([["project:demo"]]);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The scoping default lives in the client, so an entrypoint that forgets to forward the config
|
||||
* fails SOFTLY — it keeps writing correct memories and just ignores the user's `observationScopes`.
|
||||
* Nothing would notice, and the next harness added would copy the site that forgot. So assert it
|
||||
* over the whole family instead of per entrypoint, the way daemon.test.ts guards `ensureDaemon`.
|
||||
*/
|
||||
describe("every client-building entrypoint forwards observationScopes", () => {
|
||||
const SRC = fileURLToPath(new URL("..", import.meta.url));
|
||||
|
||||
function sourceFiles(dir: string, prefix = ""): string[] {
|
||||
return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
|
||||
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
|
||||
if (entry.isDirectory())
|
||||
return entry.name === "e2e" ? [] : sourceFiles(join(dir, entry.name), rel);
|
||||
return entry.name.endsWith(".ts") && !entry.name.includes(".test.") ? [rel] : [];
|
||||
});
|
||||
}
|
||||
|
||||
it("has no module that builds a client without passing cfg.observationScopes", () => {
|
||||
const dropped = sourceFiles(SRC).filter((rel) => {
|
||||
const src = readFileSync(join(SRC, rel), "utf8");
|
||||
// `makeClient({` is the hook/session-start seam: the ClientOpts are built there even though
|
||||
// the constructor call itself is the injected default further up the file.
|
||||
const buildsClient = src.includes("new HindsightClient({") || src.includes("makeClient({");
|
||||
return buildsClient && !src.includes("observationScopes:");
|
||||
});
|
||||
expect(dropped).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,6 +26,27 @@ export interface KnowledgeNode {
|
||||
children?: KnowledgeNode[];
|
||||
}
|
||||
|
||||
/**
|
||||
* How consolidation scopes the observations a retained memory feeds (`observation_scopes` on the
|
||||
* retain API). The scalar modes are the server's; a `string[][]` declares the scopes explicitly.
|
||||
*/
|
||||
export type ObservationScopes = "shared" | "combined" | "per_tag" | "all_combinations" | string[][];
|
||||
|
||||
/**
|
||||
* One global scope for everything this plugin writes.
|
||||
*
|
||||
* The server default (`combined`) scopes an observation to the memory's WHOLE tag set, and every
|
||||
* document we write carries provenance tags — `source:chat`, `harness:<id>`, `knowledge:<kind>`,
|
||||
* anything from `retainTags`. That splits one repository's knowledge into a separate observation
|
||||
* set per tag combination: work the same repo with two agents and the harness tag alone gives two
|
||||
* parallel sets of beliefs that never merge, each blind to the other, at double the consolidation
|
||||
* cost (#3564). Those tags are provenance — they say who wrote a memory, not which project the
|
||||
* belief is about — so they belong on the facts (where they still filter recall) and not on the
|
||||
* consolidation boundary. `shared` keeps them on the facts and consolidates into ONE untagged
|
||||
* scope per bank, which is what a bank already is: one project's memory.
|
||||
*/
|
||||
export const DEFAULT_OBSERVATION_SCOPES: ObservationScopes = "shared";
|
||||
|
||||
export interface ClientOpts {
|
||||
apiUrl: string;
|
||||
apiToken?: string;
|
||||
@@ -33,6 +54,8 @@ export interface ClientOpts {
|
||||
log?: (msg: string) => void;
|
||||
/** Cap on concurrent retain-related requests (drain op polls, deepen pools). Default 10. */
|
||||
maxParallelRetains?: number;
|
||||
/** Observation scoping for every retain this client sends. Default `DEFAULT_OBSERVATION_SCOPES`. */
|
||||
observationScopes?: ObservationScopes;
|
||||
}
|
||||
|
||||
export interface RetainOpts {
|
||||
@@ -121,6 +144,7 @@ export class HindsightClient {
|
||||
private idempotentRetain: boolean | undefined;
|
||||
private readonly log: (msg: string) => void;
|
||||
readonly maxParallelRetains: number;
|
||||
readonly observationScopes: ObservationScopes;
|
||||
|
||||
constructor(o: ClientOpts) {
|
||||
this.apiUrl = o.apiUrl.replace(/\/$/, "");
|
||||
@@ -128,6 +152,7 @@ export class HindsightClient {
|
||||
this.bank = o.bank;
|
||||
this.log = o.log ?? (() => {});
|
||||
this.maxParallelRetains = o.maxParallelRetains || DEFAULT_MAX_PARALLEL_RETAINS;
|
||||
this.observationScopes = o.observationScopes ?? DEFAULT_OBSERVATION_SCOPES;
|
||||
}
|
||||
|
||||
private headers(): Record<string, string> {
|
||||
@@ -178,6 +203,10 @@ export class HindsightClient {
|
||||
document_id: documentId,
|
||||
tags,
|
||||
strategy,
|
||||
// Sent on EVERY retain, including the server default `combined`, so the scoping a bank's
|
||||
// observations were built under is a property of the write rather than of whichever server
|
||||
// version happened to process it. Servers older than 0.4.15 ignore the field.
|
||||
observation_scopes: this.observationScopes,
|
||||
};
|
||||
if (opts.timestamp) item.timestamp = opts.timestamp;
|
||||
if (opts.metadata) item.metadata = opts.metadata;
|
||||
|
||||
@@ -251,6 +251,7 @@ export async function runHook(
|
||||
apiToken: cfg.apiToken,
|
||||
bank: bankId,
|
||||
maxParallelRetains: cfg.maxParallelRetains,
|
||||
observationScopes: cfg.observationScopes,
|
||||
});
|
||||
const cacheFile = sessionCacheFile(spec.harness, sessionId || "no-session");
|
||||
|
||||
|
||||
@@ -148,6 +148,7 @@ export async function runRetainHook(
|
||||
apiToken: cfg.apiToken,
|
||||
bank: bankId,
|
||||
maxParallelRetains: cfg.maxParallelRetains,
|
||||
observationScopes: cfg.observationScopes,
|
||||
});
|
||||
|
||||
await buildRetain({
|
||||
|
||||
@@ -358,6 +358,7 @@ export async function runSessionStartHook(
|
||||
apiToken: cfg.apiToken,
|
||||
bank: bankId,
|
||||
maxParallelRetains: cfg.maxParallelRetains,
|
||||
observationScopes: cfg.observationScopes,
|
||||
});
|
||||
|
||||
const out = await buildSessionStartContext({ cwd, bankId, cfg, client, harness });
|
||||
|
||||
@@ -134,6 +134,7 @@ async function main() {
|
||||
apiToken: API_TOKEN,
|
||||
bank: FINAL_BANK!,
|
||||
maxParallelRetains: cfg.maxParallelRetains,
|
||||
observationScopes: cfg.observationScopes,
|
||||
log,
|
||||
});
|
||||
log(`deepen -> ${client.apiUrl} bank=${FINAL_BANK} harness=${harness.name}`);
|
||||
|
||||
@@ -158,6 +158,7 @@ function workspaceFor(root: string): Workspace | undefined {
|
||||
apiUrl: cfg.apiUrl,
|
||||
apiToken: cfg.apiToken,
|
||||
bank: resolved.bankId,
|
||||
observationScopes: cfg.observationScopes,
|
||||
});
|
||||
// The 5th argument is this host's whole reason for existing here: RuntimeCore binds the
|
||||
// knowledge tools (and retain stamps) to the workspace it is given, and dsh's process cwd is the
|
||||
|
||||
@@ -57,6 +57,7 @@ export function createPluginEntry(harness: string): Plugin {
|
||||
apiToken: cfg.apiToken,
|
||||
bank: bankId,
|
||||
maxParallelRetains: cfg.maxParallelRetains,
|
||||
observationScopes: cfg.observationScopes,
|
||||
});
|
||||
const core = new RuntimeCore(client, bankId, cfg, harness, projectDir || process.cwd());
|
||||
// Visible presence via the host's own notice API (POST /tui/show-toast) — never stderr, which
|
||||
|
||||
@@ -54,6 +54,7 @@ async function main() {
|
||||
apiToken: cfg.apiToken,
|
||||
bank: bankId,
|
||||
maxParallelRetains: cfg.maxParallelRetains,
|
||||
observationScopes: cfg.observationScopes,
|
||||
});
|
||||
|
||||
const server = new McpServer({ name: "hindsight", version: "0.1.0" });
|
||||
|
||||
@@ -153,6 +153,7 @@ function createRuntime(repoPath: string): RuntimeCore | undefined {
|
||||
apiUrl: cfg.apiUrl,
|
||||
apiToken: cfg.apiToken,
|
||||
bank: resolved.bankId,
|
||||
observationScopes: cfg.observationScopes,
|
||||
});
|
||||
return new RuntimeCore(client, resolved.bankId, cfg, HARNESS, repoPath);
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ const client = new HindsightClient({
|
||||
apiToken: arg("api-token") ?? cfg.apiToken,
|
||||
bank: FINAL_BANK!,
|
||||
maxParallelRetains: cfg.maxParallelRetains,
|
||||
observationScopes: cfg.observationScopes,
|
||||
});
|
||||
|
||||
syncStatus(client, FINAL_BANK!, REPO)
|
||||
|
||||
@@ -354,6 +354,7 @@ 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}"}` |
|
||||
| `observationScopes` | `"shared"` | how consolidation groups observations: `"shared"` (default) = ONE global scope per bank, so every agent on a repo builds one set of beliefs; also `"combined"` (the server default), `"per_tag"`, `"all_combinations"`, `[["t"]]` |
|
||||
| `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 |
|
||||
@@ -474,6 +475,35 @@ 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.
|
||||
|
||||
### One set of beliefs per repo
|
||||
|
||||
Every document this integration writes carries provenance tags — `source:chat`, `harness:<id>`,
|
||||
`knowledge:<kind>`, plus anything from `retainTags`. Those tags say **who wrote** a memory; they are
|
||||
what filters recall and draws each document's agent logo, and they stay on the facts.
|
||||
|
||||
They are not, however, a good boundary for [observations](../../developer/observations.md). Consolidation's own
|
||||
default (`combined`) builds one observation set per distinct tag set, so the same repository
|
||||
worked on by two agents would grow two parallel sets of beliefs — one per harness — that never
|
||||
merge, each blind to the other, at double the consolidation cost. Which agent happened to be typing
|
||||
does not change whether a convention or a decision is true.
|
||||
|
||||
So the integration retains with `observationScopes: "shared"`: one global, untagged observation
|
||||
scope per bank, which is what a bank already is — one project's memory. Set the field to change it:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"observationScopes": "combined", // one observation set per distinct tag set (server default)
|
||||
"banks": {
|
||||
"coding-agent::mono": { "observationScopes": "per_tag" }, // per-repo, like any behavioral field
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
`"per_tag"` and `"all_combinations"` split further still, and an explicit `[["project:demo"], …]`
|
||||
declares the scopes literally. `HINDSIGHT_OBSERVATION_SCOPES` sets the scalar modes; a scope list is
|
||||
file-only. Changing this does not rewrite observations already consolidated under the old scoping —
|
||||
they stay where they were built, and new work accrues under the new setting.
|
||||
|
||||
## Diagnostics & logging
|
||||
|
||||
Two files, two audiences:
|
||||
|
||||
Reference in New Issue
Block a user