diff --git a/hindsight-docs/docs-integrations/coding-agents.md b/hindsight-docs/docs-integrations/coding-agents.md index 044d10a3bd..25e7a24ea5 100644 --- a/hindsight-docs/docs-integrations/coding-agents.md +++ b/hindsight-docs/docs-integrations/coding-agents.md @@ -500,7 +500,7 @@ hook by Codex...), so one shared config serves several agents side by side: | `reflectBudget` | `"high"` | reflect budget for the `hindsight_reflect` tool: `"low"`, `"mid"` or `"high"`. Drop it on a large bank where high-budget synthesis exceeds the server's wall timeout. The automatic session-start reflect always uses `"low"` to fit its hook window and is unaffected | | `autoReflect` | `true` | inject a one-time reflect synthesis on the session's **first prompt**. `false` = tool-only reflect: nothing is injected; the agent searches knowledge pages first and reflects only when they are too shallow | | `pageRefreshEveryTurns` | `10` | refetch the knowledge pages and re-inject the page roster + tool guide every N user turns | -| `pageTriggerType` | `"auto-refresh"` | when NEW knowledge pages refresh, i.e. what keeping them current costs — `"auto-refresh"` after every consolidation that produced new material, `"cron"` on `pageTriggerCron` only, `"manual"` never on their own. Auto-refresh is the most current and the most expensive: one synthesis per page per consolidation. Maps to the page's `trigger.refresh_after_consolidation` in the Hindsight API (`true` for auto-refresh, `false` for manual) | +| `pageTriggerType` | `"auto-refresh"` | when NEW knowledge pages refresh, i.e. what keeping them current costs — `"auto-refresh"` after every consolidation that produced new material, `"cron"` on `pageTriggerCron` only, `"daily-staggered"` once daily at a stable minute per bank/page, `"manual"` never on their own. Auto-refresh is the most current and the most expensive: one synthesis per page per consolidation. Maps to the page's `trigger.refresh_after_consolidation` in the Hindsight API (`true` for auto-refresh, `false` for manual) | | `pageTriggerCron` | — | schedule for `pageTriggerType: "cron"` — UTC, standard 5-field cron, e.g. `"0 3 * * *"`. Sets the page's `trigger.refresh_cron`, which the API treats as mutually exclusive with `refresh_after_consolidation`; a scheduled refresh is skipped when nothing changed | | `autoSeed` | `true` | SessionStart: auto-seed a cold repo's bank from git history | | `seedLimit` | `300` | auto-seed: most-recent-N-commits cap | @@ -521,6 +521,13 @@ belongs to the server: Hindsight creates a knowledge page with a delta refresh ( page instead of rebuilding it) that doesn't reflect over sibling pages, and these settings merge over those defaults rather than replacing them. +To spread daily refreshes across the day, set `"pageTriggerType": "daily-staggered"`. +It needs no `pageTriggerCron` (an existing value is ignored). Each new taxonomy or initiative page +gets a standard UTC cron derived from its bank id and page path. The schedule is saved on the +server and survives restarts; retries and concurrent seeders choose the same time. Hashing spreads +work approximately, so some pages can share a minute. This does not reserve ingestion capacity or +change queued work. Enable it in each coding-agent client that creates pages. + **These settings apply to pages created from here on.** Changing them does not migrate the pages a repo already has: a page keeps the trigger it was created with, so a bank seeded before you set `"manual"` keeps refreshing on every consolidation. To move an existing page, change its trigger diff --git a/hindsight-integrations/coding-agents/README.md b/hindsight-integrations/coding-agents/README.md index 22569c2a61..27350d415a 100644 --- a/hindsight-integrations/coding-agents/README.md +++ b/hindsight-integrations/coding-agents/README.md @@ -503,7 +503,7 @@ hook by Codex...), so one shared config serves several agents side by side: | `reflectBudget` | `"high"` | reflect budget for the `hindsight_reflect` tool: `"low"`, `"mid"` or `"high"`. Drop it on a large bank where high-budget synthesis exceeds the server's wall timeout. The automatic session-start reflect always uses `"low"` to fit its hook window and is unaffected | | `autoReflect` | `true` | inject a one-time reflect synthesis on the session's **first prompt**. `false` = tool-only reflect: nothing is injected; the agent searches knowledge pages first and reflects only when they are too shallow | | `pageRefreshEveryTurns` | `10` | refetch the knowledge pages and re-inject the page roster + tool guide every N user turns | -| `pageTriggerType` | `"auto-refresh"` | when NEW knowledge pages refresh, i.e. what keeping them current costs — `"auto-refresh"` after every consolidation that produced new material, `"cron"` on `pageTriggerCron` only, `"manual"` never on their own. Auto-refresh is the most current and the most expensive: one synthesis per page per consolidation. Maps to the page's `trigger.refresh_after_consolidation` in the Hindsight API (`true` for auto-refresh, `false` for manual) | +| `pageTriggerType` | `"auto-refresh"` | when NEW knowledge pages refresh, i.e. what keeping them current costs — `"auto-refresh"` after every consolidation that produced new material, `"cron"` on `pageTriggerCron` only, `"daily-staggered"` once daily at a stable minute per bank/page, `"manual"` never on their own. Auto-refresh is the most current and the most expensive: one synthesis per page per consolidation. Maps to the page's `trigger.refresh_after_consolidation` in the Hindsight API (`true` for auto-refresh, `false` for manual) | | `pageTriggerCron` | — | schedule for `pageTriggerType: "cron"` — UTC, standard 5-field cron, e.g. `"0 3 * * *"`. Sets the page's `trigger.refresh_cron`, which the API treats as mutually exclusive with `refresh_after_consolidation`; a scheduled refresh is skipped when nothing changed | | `autoSeed` | `true` | SessionStart: auto-seed a cold repo's bank from git history | | `seedLimit` | `300` | auto-seed: most-recent-N-commits cap | @@ -524,6 +524,13 @@ belongs to the server: Hindsight creates a knowledge page with a delta refresh ( page instead of rebuilding it) that doesn't reflect over sibling pages, and these settings merge over those defaults rather than replacing them. +To spread daily refreshes across the day, set `"pageTriggerType": "daily-staggered"`. +It needs no `pageTriggerCron` (an existing value is ignored). Each new taxonomy or initiative page +gets a standard UTC cron derived from its bank id and page path. The schedule is saved on the +server and survives restarts; retries and concurrent seeders choose the same time. Hashing spreads +work approximately, so some pages can share a minute. This does not reserve ingestion capacity or +change queued work. Enable it in each coding-agent client that creates pages. + **These settings apply to pages created from here on.** Changing them does not migrate the pages a repo already has: a page keeps the trigger it was created with, so a bank seeded before you set `"manual"` keeps refreshing on every consolidation. To move an existing page, change its trigger diff --git a/hindsight-integrations/coding-agents/skill/SKILL.md b/hindsight-integrations/coding-agents/skill/SKILL.md index 93add576b4..5054388108 100644 --- a/hindsight-integrations/coding-agents/skill/SKILL.md +++ b/hindsight-integrations/coding-agents/skill/SKILL.md @@ -209,7 +209,7 @@ hook by Codex...), so one shared config serves several agents side by side: | `reflectBudget` | `"high"` | reflect budget for the `hindsight_reflect` tool: `"low"`, `"mid"` or `"high"`. Drop it on a large bank where high-budget synthesis exceeds the server's wall timeout. The automatic session-start reflect always uses `"low"` to fit its hook window and is unaffected | | `autoReflect` | `true` | inject a one-time reflect synthesis on the session's **first prompt**. `false` = tool-only reflect: nothing is injected; the agent searches knowledge pages first and reflects only when they are too shallow | | `pageRefreshEveryTurns` | `10` | refetch the knowledge pages and re-inject the page roster + tool guide every N user turns | -| `pageTriggerType` | `"auto-refresh"` | when NEW knowledge pages refresh, i.e. what keeping them current costs — `"auto-refresh"` after every consolidation that produced new material, `"cron"` on `pageTriggerCron` only, `"manual"` never on their own. Auto-refresh is the most current and the most expensive: one synthesis per page per consolidation. Maps to the page's `trigger.refresh_after_consolidation` in the Hindsight API (`true` for auto-refresh, `false` for manual) | +| `pageTriggerType` | `"auto-refresh"` | when NEW knowledge pages refresh, i.e. what keeping them current costs — `"auto-refresh"` after every consolidation that produced new material, `"cron"` on `pageTriggerCron` only, `"daily-staggered"` once daily at a stable minute per bank/page, `"manual"` never on their own. Auto-refresh is the most current and the most expensive: one synthesis per page per consolidation. Maps to the page's `trigger.refresh_after_consolidation` in the Hindsight API (`true` for auto-refresh, `false` for manual) | | `pageTriggerCron` | — | schedule for `pageTriggerType: "cron"` — UTC, standard 5-field cron, e.g. `"0 3 * * *"`. Sets the page's `trigger.refresh_cron`, which the API treats as mutually exclusive with `refresh_after_consolidation`; a scheduled refresh is skipped when nothing changed | | `autoSeed` | `true` | SessionStart: auto-seed a cold repo's bank from git history | | `seedLimit` | `300` | auto-seed: most-recent-N-commits cap | @@ -230,6 +230,13 @@ belongs to the server: Hindsight creates a knowledge page with a delta refresh ( page instead of rebuilding it) that doesn't reflect over sibling pages, and these settings merge over those defaults rather than replacing them. +To spread daily refreshes across the day, set `"pageTriggerType": "daily-staggered"`. +It needs no `pageTriggerCron` (an existing value is ignored). Each new taxonomy or initiative page +gets a standard UTC cron derived from its bank id and page path. The schedule is saved on the +server and survives restarts; retries and concurrent seeders choose the same time. Hashing spreads +work approximately, so some pages can share a minute. This does not reserve ingestion capacity or +change queued work. Enable it in each coding-agent client that creates pages. + **These settings apply to pages created from here on.** Changing them does not migrate the pages a repo already has: a page keeps the trigger it was created with, so a bank seeded before you set `"manual"` keeps refreshing on every consolidation. To move an existing page, change its trigger diff --git a/hindsight-integrations/coding-agents/src/core/config.ts b/hindsight-integrations/coding-agents/src/core/config.ts index cf9e0866e9..e25c0bf6a7 100644 --- a/hindsight-integrations/coding-agents/src/core/config.ts +++ b/hindsight-integrations/coding-agents/src/core/config.ts @@ -109,11 +109,12 @@ export interface RawConfig { * page this plugin creates (the seeded taxonomy and each captured initiative): * "auto-refresh" (default) — refresh after every consolidation that produced new material * "cron" — refresh on `pageTriggerCron` only, and only when actually stale + * "daily-staggered" — once daily at a stable minute chosen for each bank/page * "manual" — never refresh on its own; the tools and control plane still can * Auto-refresh is both the most current and the most expensive: one LLM synthesis per page per * consolidation, which adds up fast across auto-surveyed repos (#3506). Existing pages keep the * trigger they were created with — this changes what NEW pages get. */ - pageTriggerType?: "auto-refresh" | "cron" | "manual"; + pageTriggerType?: "auto-refresh" | "cron" | "daily-staggered" | "manual"; /** Schedule for `pageTriggerType: "cron"` — UTC, standard 5-field cron, e.g. "0 3 * * *". */ pageTriggerCron?: string; autoSeed?: boolean; // SessionStart: auto-seed a cold repo's bank from git history (default true) @@ -206,7 +207,7 @@ export interface Config { reflectBudget: "low" | "mid" | "high"; autoReflect: boolean; pageRefreshEveryTurns: number; - pageTriggerType: "auto-refresh" | "cron" | "manual"; + pageTriggerType: "auto-refresh" | "cron" | "daily-staggered" | "manual"; pageTriggerCron?: string; autoSeed: boolean; seedLimit: number; @@ -231,7 +232,10 @@ export interface Config { * API rejects a cron trigger with no expression, which would fail page creation outright. Fall * back to the default and say so — a user who wants pages to stop refreshing writes "manual". */ -function resolvePageTriggerType(raw: RawConfig): "auto-refresh" | "cron" | "manual" { +function resolvePageTriggerType( + raw: RawConfig +): "auto-refresh" | "cron" | "daily-staggered" | "manual" { + if (raw.pageTriggerType === "daily-staggered") return "daily-staggered"; if (raw.pageTriggerType === "manual") return "manual"; if (raw.pageTriggerType === "cron") { if (raw.pageTriggerCron?.trim()) return "cron"; diff --git a/hindsight-integrations/coding-agents/src/core/hindsight.pages.test.ts b/hindsight-integrations/coding-agents/src/core/hindsight.pages.test.ts index 1db08fdc31..f5a28bec33 100644 --- a/hindsight-integrations/coding-agents/src/core/hindsight.pages.test.ts +++ b/hindsight-integrations/coding-agents/src/core/hindsight.pages.test.ts @@ -295,6 +295,41 @@ describe("HindsightClient.seedPages", () => { }); }); + it("stamps different daily schedules when seeding and preserves them on later runs", async () => { + const calls: any[] = []; + let roots: unknown[] = []; + stubFetchRouted(calls, [ + { + match: (m, u) => m === "GET" && u.endsWith("/knowledge-base/tree"), + get json() { + return { roots }; + }, + }, + ]); + const c = new HindsightClient({ apiUrl: "http://x", bank: "repo-a" }); + const cfg = resolveConfig({ pageTriggerType: "daily-staggered" }); + const pageTrigger = vi.fn((page) => buildPageTrigger(cfg, page)); + await c.configureBank({ pageTrigger }); + const posts = calls.filter( + (k) => k.method === "POST" && k.url.endsWith("/knowledge-base/pages") + ); + expect(posts).toHaveLength(PAGES.length); + expect(new Set(posts.map((p) => p.body.trigger.refresh_cron)).size).toBeGreaterThan(1); + expect(pageTrigger.mock.calls.map(([page]) => page)).toEqual( + PAGES.map((page) => ({ bank: "repo-a", path: [page.name] })) + ); + roots = posts.map((post, i) => ({ + id: `kp-${i}`, + kind: "page", + name: post.body.name, + description: post.body.source_query, + trigger: { ...post.body.trigger, refresh_cron: "17 9 * * *" }, + })); + calls.length = 0; + await c.seedPages(pageTrigger); + expect(calls.filter((k) => k.method !== "GET")).toEqual([]); + }); + // A page seeded before `tags_match` existed keeps the server's `all_strict` default, which // excludes the untagged shared observations these pages are meant to synthesize from. The // source query is unchanged on such a bank, so the trigger has to be its own drift signal. @@ -309,7 +344,7 @@ describe("HindsightClient.seedPages", () => { kind: "page", name: p.name, description: p.source_query, - trigger: { tags_match: "all_strict" }, + trigger: { tags_match: "all_strict", refresh_cron: "17 9 * * *" }, })), }, }, @@ -322,7 +357,7 @@ describe("HindsightClient.seedPages", () => { for (const patch of patches) { // ONLY the trigger: sending `source_query` would schedule a full rebuild of every page on // a bank whose question never changed. - expect(patch.body).toEqual({ trigger: buildPageTrigger() }); + expect(patch.body).toEqual({ trigger: { tags_match: "all" } }); } }); @@ -564,6 +599,36 @@ describe("HindsightClient.captureInitiative", () => { expect(item.context).toContain(`[[page:${result.page_id}]]`); }); + it("schedules new initiatives by bank and folder path, leaving recaptures untouched", async () => { + const calls: any[] = []; + stubFetchRouted(calls, [ + { match: (m, u) => m === "GET" && u.endsWith("/knowledge-base/tree"), json: { roots: [] } }, + { + match: (m, u) => m === "POST" && u.endsWith("/knowledge-base/folders"), + json: { id: "folder-abc" }, + }, + { + match: (m, u) => m === "POST" && u.endsWith("/knowledge-base/pages"), + json: { page_id: "pg" }, + }, + { match: (m, u) => m === "POST" && u.endsWith("/memories"), json: { operation_id: "op-1" } }, + ]); + const c = new HindsightClient({ apiUrl: "http://x", bank: "repo-a" }); + const cfg = resolveConfig({ pageTriggerType: "daily-staggered" }); + const pageTrigger = vi.fn((page) => buildPageTrigger(cfg, page)); + const args = { title: "Upload retries", summary: "Retry transient failures.", pageTrigger }; + await c.captureInitiative(args); + expect(pageTrigger).toHaveBeenCalledWith({ bank: "repo-a", path: ["Initiatives", args.title] }); + const post = calls.find((k) => k.url.endsWith("/knowledge-base/pages")); + expect(post.body.trigger.refresh_cron).toMatch(/^\d+ \d+ \* \* \*$/); + expect(post.body.trigger.refresh_after_consolidation).toBeUndefined(); + calls.length = 0; + pageTrigger.mockClear(); + await c.captureInitiative({ ...args, relatesToPageId: "pg" }); + expect(pageTrigger).not.toHaveBeenCalled(); + expect(calls.every((k) => k.url.endsWith("/memories"))).toBe(true); + }); + it("enhancement (relatesToPageId): NO page POST; marker names the existing page id", async () => { const calls: any[] = []; stubFetchRouted(calls, [ diff --git a/hindsight-integrations/coding-agents/src/core/hindsight.ts b/hindsight-integrations/coding-agents/src/core/hindsight.ts index 99dfbbe781..d3ba8c9a3a 100644 --- a/hindsight-integrations/coding-agents/src/core/hindsight.ts +++ b/hindsight-integrations/coding-agents/src/core/hindsight.ts @@ -12,6 +12,7 @@ import { PAGE_MAX_TOKENS, pagesFor, type PageTrigger, + type PageTriggerSource, } from "./missions"; import { pool, semverGte, sleep } from "./util"; import type { RetainStamp } from "./retain-stamp"; @@ -391,7 +392,7 @@ export class HindsightClient { * `conversation`, `document`, `survey`): an unknown strategy name is not an error server-side, * it just falls back to the bank's own config, so the miss is silent. */ async configureBank( - opts: { reset?: boolean; pageTrigger?: PageTrigger; manage?: boolean } = {} + opts: { reset?: boolean; pageTrigger?: PageTriggerSource; manage?: boolean } = {} ): Promise { if (opts.reset) { await this.req("DELETE", this.bankUrl()); @@ -624,7 +625,7 @@ export class HindsightClient { * `source_query` re-syncs onto the live page instead of orphaning its synthesized content — * which is how `pageScopeRule`'s repo name reaches banks seeded by an earlier version. */ - async seedPages(pageTrigger: PageTrigger = buildPageTrigger()): Promise { + async seedPages(pageTrigger: PageTriggerSource = buildPageTrigger()): Promise { // The bank id is the fallback subject, not a degraded one: for a bank no single repository // owns it is the only name that stays put across sessions, and under the default // `coding-agent::{gitProject}` template `project` is always set, so it never applies there. @@ -646,13 +647,17 @@ export class HindsightClient { let created = 0; let updated = 0; for (const page of pages) { + const trigger = + typeof pageTrigger === "function" + ? pageTrigger({ bank: this.bank, path: [page.name] }) + : pageTrigger; const hit = existing.get(page.name.toLowerCase()); const body = { name: page.name, source_query: page.source_query, tags: page.tags, max_tokens: PAGE_MAX_TOKENS, - trigger: pageTrigger, + trigger, }; if (!hit) { // 409 = another deepen run seeded this name between our tree read and this POST. That is @@ -670,20 +675,21 @@ export class HindsightClient { const sourceDrift = hit.description !== page.source_query; // Older servers omit trigger from the tree, so an absent value means unknown rather // than drift. Those servers also reject a trigger-only PATCH as an empty update. - const triggerDrift = - hit.trigger != null && hit.trigger.tags_match !== pageTrigger.tags_match; + const triggerDrift = hit.trigger != null && hit.trigger.tags_match !== trigger.tags_match; if (!sourceDrift && !triggerDrift) continue; - // The name IS the match key, so it can't drift; the source query and the trigger can. - // The trigger is re-sent when the server reports it drifting, because it is the only way - // a policy change reaches a page that already exists. Servers that do not report a page's - // trigger leave its policy unknown; source-query drift can still be reconciled safely. - const patch: { trigger?: PageTrigger; source_query?: string; tags?: string[] } = {}; + // Reconcile only tag matching. Sending the complete creation trigger here used to + // overwrite a page's manually edited schedule whenever its tag policy drifted. + const patch: { + trigger?: Pick; + source_query?: string; + tags?: string[]; + } = {}; if (sourceDrift) { patch.source_query = page.source_query; patch.tags = page.tags; } - if (triggerDrift) patch.trigger = pageTrigger; + if (triggerDrift) patch.trigger = { tags_match: trigger.tags_match }; const r = await this.req( "PATCH", this.bankUrl(`/knowledge-base/nodes/${encodeURIComponent(hit.id)}`), @@ -737,7 +743,7 @@ export class HindsightClient { stamp?: RetainStamp; /** Same refresh policy as the seeded pages — an initiative page is one of them, and used to * carry its own hardcoded copy of this trigger. */ - pageTrigger?: PageTrigger; + pageTrigger?: PageTriggerSource; }): Promise<{ page_id: string }> { // `/knowledge-base/pages` mints its OWN page id (kp-…); we can't set it. So for a new initiative // we create the page first and adopt the server-assigned id — that id is what the return value @@ -751,7 +757,10 @@ export class HindsightClient { source_query: `Summarize the "${args.title}" initiative: what is being built or changed and why, and its current state — drawn from the project's memory.`, parent_id: folderId, tags: ["knowledge:feature-work"], - trigger: args.pageTrigger ?? buildPageTrigger(), + trigger: + typeof args.pageTrigger === "function" + ? args.pageTrigger({ bank: this.bank, path: ["Initiatives", args.title] }) + : (args.pageTrigger ?? buildPageTrigger()), }); try { const j = (await r.json()) as { page_id?: string; id?: string }; diff --git a/hindsight-integrations/coding-agents/src/core/knowledge-tools.ts b/hindsight-integrations/coding-agents/src/core/knowledge-tools.ts index c547486e10..40e3abbdfc 100644 --- a/hindsight-integrations/coding-agents/src/core/knowledge-tools.ts +++ b/hindsight-integrations/coding-agents/src/core/knowledge-tools.ts @@ -25,7 +25,7 @@ import { syncStatus } from "./status"; import { applyBankConfig, DEFAULT_REFLECT_TOOL_TIMEOUT_MS, loadConfig } from "./config"; import { describeError } from "./log"; import type { RetainStamp } from "./retain-stamp"; -import type { PageTrigger } from "./missions"; +import type { PageTriggerSource } from "./missions"; export interface ToolResult { // Index signature so this structurally satisfies the MCP SDK's CallToolResult (which carries @@ -101,7 +101,7 @@ export function buildKnowledgeTools( harness?: string; stampFor?: () => RetainStamp; /** Refresh policy for a page `hindsight_capture_initiative` creates (core/missions.ts). */ - pageTrigger?: PageTrigger; + pageTrigger?: PageTriggerSource; /** How long `hindsight_reflect` waits on the server (cfg.reflectToolTimeoutMs). Must be * threaded in by every caller: left unset, the client falls back to a 120s deadline that * aborts high-budget synthesis on a populated bank mid-flight (#3590). */ diff --git a/hindsight-integrations/coding-agents/src/core/missions.test.ts b/hindsight-integrations/coding-agents/src/core/missions.test.ts index e39fb7adc0..774b06a036 100644 --- a/hindsight-integrations/coding-agents/src/core/missions.test.ts +++ b/hindsight-integrations/coding-agents/src/core/missions.test.ts @@ -50,6 +50,29 @@ describe("buildPageTrigger", () => { expect(trigger.refresh_after_consolidation).toBeUndefined(); }); + it("assigns stable daily minutes across banks and pages without a configured cron", () => { + const cfg = resolveConfig({ pageTriggerType: "daily-staggered", pageTriggerCron: "0 3 * * *" }); + expect(cfg.pageTriggerType).toBe("daily-staggered"); + const page = { bank: "repo-a", path: ["Component map"] }; + const trigger = buildPageTrigger(cfg, page); + expect(buildPageTrigger(cfg, page)).toEqual(trigger); + expect(trigger.refresh_after_consolidation).toBeUndefined(); + expect(trigger.tags_match).toBe("all"); + const schedules = Array.from( + { length: 50 }, + (_, i) => buildPageTrigger(cfg, { bank: `repo-${i}`, path: ["Component map"] }).refresh_cron + ); + expect(new Set(schedules).size).toBeGreaterThan(40); + for (const cron of schedules) { + expect(cron).toMatch(/^(?:[0-9]|[1-5][0-9]) (?:[0-9]|1[0-9]|2[0-3]) \* \* \*$/); + } + expect(buildPageTrigger(cfg, { ...page, path: ["Core concepts"] })).not.toEqual(trigger); + expect(buildPageTrigger(cfg, { ...page, path: ["Initiatives", "Component map"] })).not.toEqual( + trigger + ); + expect(() => buildPageTrigger(cfg)).toThrow("requires a bank and page identity"); + }); + it("stops refreshing pages on request", () => { const trigger = buildPageTrigger(resolveConfig({ pageTriggerType: "manual" })); expect(trigger.refresh_after_consolidation).toBe(false); @@ -90,7 +113,7 @@ describe("page trigger config resolution", () => { ); }); - it("ignores a value that is not one of the three types", () => { + it("ignores a value that is not a supported type", () => { expect(resolveConfig({ pageTriggerType: "whenever" as never }).pageTriggerType).toBe( "auto-refresh" ); diff --git a/hindsight-integrations/coding-agents/src/core/missions.ts b/hindsight-integrations/coding-agents/src/core/missions.ts index 1321a86d16..d82a72e325 100644 --- a/hindsight-integrations/coding-agents/src/core/missions.ts +++ b/hindsight-integrations/coding-agents/src/core/missions.ts @@ -1,3 +1,5 @@ +import { createHash } from "node:crypto"; + /** * Harness-agnostic Hindsight missions, retain strategies, and knowledge-page taxonomy. * @@ -298,6 +300,14 @@ export interface PageTrigger { refresh_cron?: string; } +/** Creation identity is available before the server assigns a page id. */ +export interface PageIdentity { + bank: string; + path: string[]; +} + +export type PageTriggerSource = PageTrigger | ((page: PageIdentity) => PageTrigger); + /** * `all` — AND over the page's tier tag, but INCLUDING untagged memories. * @@ -320,7 +330,7 @@ export const PAGE_FACT_TYPES = ["world", "experience", "observation"]; /** The config fields that shape the trigger (a subset of Config — see core/config.ts). */ export interface PageTriggerConfig { - pageTriggerType?: "auto-refresh" | "cron" | "manual"; + pageTriggerType?: "auto-refresh" | "cron" | "daily-staggered" | "manual"; pageTriggerCron?: string; } @@ -348,9 +358,20 @@ export interface PageTriggerConfig { * `refresh_after_consolidation` and `refresh_cron` are mutually exclusive server-side, so exactly * one of them is ever set here. */ -export function buildPageTrigger(cfg: PageTriggerConfig = {}): PageTrigger { +export function buildPageTrigger(cfg: PageTriggerConfig = {}, page?: PageIdentity): PageTrigger { const base: PageTrigger = { fact_types: PAGE_FACT_TYPES, tags_match: PAGE_TAGS_MATCH }; switch (cfg.pageTriggerType) { + case "daily-staggered": { + if (!page) throw new Error("daily-staggered refresh requires a bank and page identity"); + // A shared daily cron synchronized every page. Hash creation identity instead, so retries + // and concurrent seeders agree without another API call or a shared slot allocator. + const minute = + createHash("sha256") + .update(JSON.stringify([page.bank, ...page.path])) + .digest() + .readUInt32BE(0) % 1440; + return { ...base, refresh_cron: `${minute % 60} ${Math.floor(minute / 60)} * * *` }; + } case "cron": return { ...base, refresh_cron: cfg.pageTriggerCron }; case "manual": diff --git a/hindsight-integrations/coding-agents/src/core/runtime.ts b/hindsight-integrations/coding-agents/src/core/runtime.ts index 4d4c09a4a7..efb950c4db 100644 --- a/hindsight-integrations/coding-agents/src/core/runtime.ts +++ b/hindsight-integrations/coding-agents/src/core/runtime.ts @@ -79,7 +79,7 @@ export class RuntimeCore { return buildKnowledgeTools(this.client, this.bankId, { repoDir: this.projectDir, harness: this.harness, - pageTrigger: buildPageTrigger(this.cfg), + pageTrigger: (page) => buildPageTrigger(this.cfg, page), reflectTimeoutMs: this.cfg.reflectToolTimeoutMs, reflectBudget: this.cfg.reflectBudget, stampFor: () => diff --git a/hindsight-integrations/coding-agents/src/deepen.ts b/hindsight-integrations/coding-agents/src/deepen.ts index be161bfccb..fc1307ed90 100644 --- a/hindsight-integrations/coding-agents/src/deepen.ts +++ b/hindsight-integrations/coding-agents/src/deepen.ts @@ -163,7 +163,7 @@ async function main() { }); await client.configureBank({ - pageTrigger: buildPageTrigger(cfg), + pageTrigger: (page) => buildPageTrigger(cfg, page), manage: cfg.manageBankConfig, }); if (client.knowledgePagesSupported === false) { diff --git a/hindsight-integrations/coding-agents/src/mcp-server.ts b/hindsight-integrations/coding-agents/src/mcp-server.ts index 1917bafb2a..e64bdd6506 100644 --- a/hindsight-integrations/coding-agents/src/mcp-server.ts +++ b/hindsight-integrations/coding-agents/src/mcp-server.ts @@ -39,7 +39,7 @@ export function selectTools( : buildKnowledgeTools(client, bankId, { repoDir: cwd, harness, - pageTrigger: buildPageTrigger(cfg), + pageTrigger: (page) => buildPageTrigger(cfg, page), reflectTimeoutMs: cfg.reflectToolTimeoutMs, reflectBudget: cfg.reflectBudget, stampFor: () => buildRetainStamp(cfg, { directory: cwd, harness, bankId }), diff --git a/skills/hindsight-docs/references/sdks/integrations/coding-agents.md b/skills/hindsight-docs/references/sdks/integrations/coding-agents.md index a24105dd97..9cfbf1ce03 100644 --- a/skills/hindsight-docs/references/sdks/integrations/coding-agents.md +++ b/skills/hindsight-docs/references/sdks/integrations/coding-agents.md @@ -495,7 +495,7 @@ hook by Codex...), so one shared config serves several agents side by side: | `reflectBudget` | `"high"` | reflect budget for the `hindsight_reflect` tool: `"low"`, `"mid"` or `"high"`. Drop it on a large bank where high-budget synthesis exceeds the server's wall timeout. The automatic session-start reflect always uses `"low"` to fit its hook window and is unaffected | | `autoReflect` | `true` | inject a one-time reflect synthesis on the session's **first prompt**. `false` = tool-only reflect: nothing is injected; the agent searches knowledge pages first and reflects only when they are too shallow | | `pageRefreshEveryTurns` | `10` | refetch the knowledge pages and re-inject the page roster + tool guide every N user turns | -| `pageTriggerType` | `"auto-refresh"` | when NEW knowledge pages refresh, i.e. what keeping them current costs — `"auto-refresh"` after every consolidation that produced new material, `"cron"` on `pageTriggerCron` only, `"manual"` never on their own. Auto-refresh is the most current and the most expensive: one synthesis per page per consolidation. Maps to the page's `trigger.refresh_after_consolidation` in the Hindsight API (`true` for auto-refresh, `false` for manual) | +| `pageTriggerType` | `"auto-refresh"` | when NEW knowledge pages refresh, i.e. what keeping them current costs — `"auto-refresh"` after every consolidation that produced new material, `"cron"` on `pageTriggerCron` only, `"daily-staggered"` once daily at a stable minute per bank/page, `"manual"` never on their own. Auto-refresh is the most current and the most expensive: one synthesis per page per consolidation. Maps to the page's `trigger.refresh_after_consolidation` in the Hindsight API (`true` for auto-refresh, `false` for manual) | | `pageTriggerCron` | — | schedule for `pageTriggerType: "cron"` — UTC, standard 5-field cron, e.g. `"0 3 * * *"`. Sets the page's `trigger.refresh_cron`, which the API treats as mutually exclusive with `refresh_after_consolidation`; a scheduled refresh is skipped when nothing changed | | `autoSeed` | `true` | SessionStart: auto-seed a cold repo's bank from git history | | `seedLimit` | `300` | auto-seed: most-recent-N-commits cap | @@ -516,6 +516,13 @@ belongs to the server: Hindsight creates a knowledge page with a delta refresh ( page instead of rebuilding it) that doesn't reflect over sibling pages, and these settings merge over those defaults rather than replacing them. +To spread daily refreshes across the day, set `"pageTriggerType": "daily-staggered"`. +It needs no `pageTriggerCron` (an existing value is ignored). Each new taxonomy or initiative page +gets a standard UTC cron derived from its bank id and page path. The schedule is saved on the +server and survives restarts; retries and concurrent seeders choose the same time. Hashing spreads +work approximately, so some pages can share a minute. This does not reserve ingestion capacity or +change queued work. Enable it in each coding-agent client that creates pages. + **These settings apply to pages created from here on.** Changing them does not migrate the pages a repo already has: a page keeps the trigger it was created with, so a bank seeded before you set `"manual"` keeps refreshing on every consolidation. To move an existing page, change its trigger