switchroom 0.21.16 → 0.21.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-scheduler/index.js +5 -0
- package/dist/auth-broker/index.js +5 -0
- package/dist/cli/notion-write-pretool.mjs +5 -0
- package/dist/cli/switchroom.js +163 -31
- package/dist/host-control/main.js +6 -1
- package/dist/vault/approvals/kernel-server.js +5 -0
- package/dist/vault/broker/server.js +5 -0
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +11 -0
- package/telegram-plugin/dist/gateway/gateway.js +9 -4
- package/vendor/hindsight-memory/hooks/hooks.json +9 -0
- package/vendor/hindsight-memory/scripts/directive_verify.py +43 -1
- package/vendor/hindsight-memory/scripts/lib/client.py +35 -0
- package/vendor/hindsight-memory/scripts/lib/config.py +47 -0
- package/vendor/hindsight-memory/scripts/lib/orientation.py +248 -0
- package/vendor/hindsight-memory/scripts/lib/recall_buffer.py +29 -0
- package/vendor/hindsight-memory/scripts/orientation.py +195 -0
- package/vendor/hindsight-memory/scripts/prefetch.py +10 -0
- package/vendor/hindsight-memory/scripts/recall.py +144 -11
- package/vendor/hindsight-memory/scripts/setup_hooks.py +10 -1
- package/vendor/hindsight-memory/scripts/tests/fixtures/rules-block.golden.md +9 -0
- package/vendor/hindsight-memory/scripts/tests/test_orientation_hook.py +283 -0
- package/vendor/hindsight-memory/scripts/tests/test_orientation_logic.py +176 -0
- package/vendor/hindsight-memory/scripts/tests/test_prefetch_invalidation.py +329 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_directive_suppression.py +328 -0
|
@@ -11144,6 +11144,10 @@ var init_schema = __esm(() => {
|
|
|
11144
11144
|
}).optional().describe("Operator-declared, per-specialist Hindsight mental models (RFC " + "Phase 5). Named, opt-in curated reflections this agent's bank should " + "carry — e.g. a coach's 'training-plan-state' or a lawyer's " + "'open-matters'. Ensured idempotently at scaffold/reconcile: NOTHING " + "is created unless declared here (zero declarations = zero models, " + "matching post-#2447 behaviour), and no fixed identity model is " + "reintroduced — 'who the user is' stays owned by dedicated profile " + "banks (users.*.profile_bank), never a per-agent model. Per-agent " + "ONLY: intentionally not accepted at the defaults/profile tier, so a " + "model can never be fleet-seeded — each specialist opts in on its own " + "(the invariant-clean inverse of the retired blind auto-seeding)."),
|
|
11145
11145
|
observations_mission: exports_external.string().optional().describe("Steers what the observation-consolidation LLM synthesises from raw " + "facts (the higher-order 'what patterns matter' lens). Cascade: override."),
|
|
11146
11146
|
rules_block: exports_external.boolean().default(false).describe("Memory v2 M3 go-live flag (M1 only DEFINES this, default false; " + "M3 flips it per agent). When true, the sanctioned rules/index " + "blocks render live in this agent's CLAUDE.md (marker-delimited, " + "below the `# --- Yours ---` line) AND the permission deny for " + "direct Edit/Write of the agent's own CLAUDE.md is seeded — the " + "flag couples deny + tools together so a live deny never orphans " + "the invited free-text edit path (the `memory_edit_yours` verb is " + "the only sanctioned writer once flipped). Unset/false ⇒ byte-" + "identical to pre-M1 behaviour: no blocks, no deny, dark build. " + "Cascade: override (per-agent wins over default; never fleet-" + "seeded — each agent's flip is a deliberate M3 rollout step)."),
|
|
11147
|
+
orientation: exports_external.boolean().optional().describe("Memory v2 M5 go-live flag (Surface B: orientation-at-boot), " + "effective default false — dark build (unset ⇒ OFF, applied by the " + "scaffold resolver, NOT a Zod .default() — a hard default here would " + "shadow the per-agent value in the cascade the way it does every other " + "memory knob). When true, the orientation SessionStart " + "hook injects this agent's cron-refreshed `orientation` mental model " + "into context at boot AND re-seats it after every compaction (E-88), " + "deterministically and with zero tool call. Unset/false ⇒ byte-" + "identical to pre-M5 behaviour: the hook no-ops before any bank " + "resolve or network call. Per-agent ONLY (never fleet-seeded — a " + "flip is a deliberate canary/rollout step gated on the agent's " + "m2-residue measurement + operator-created model, carve-M5 §7). " + "Cascade: override (per-agent wins over default)."),
|
|
11148
|
+
orientation_reinject_turns: exports_external.number().int().min(0).optional().describe("Memory v2 M5 optional per-turn re-inject cadence, effective default " + "0 = off (unset ⇒ 0, applied by the scaffold resolver, not a Zod " + ".default() — same cascade-shadowing reason as `orientation`). At 0 the " + "briefing is injected only at SessionStart + compaction (the cheap " + "path). N>0 " + "would additionally re-inject the orientation briefing every N turns; " + "priced at ~55M tok/30d at every-turn, which is WHY it defaults off " + "(carve-M5 §2/§4). Only late-session context loss should ever set it, " + "per-agent, measured. A stripped/absent value fails to 0 (fail-safe " + "AND fail-cheap). Per-agent ONLY. Cascade: override."),
|
|
11149
|
+
orientation_cadence_hours: exports_external.number().int().min(1).optional().describe("Memory v2 M5 per-agent refresh cadence tier (hours), effective " + "default 48 (unset ⇒ 48, applied by the scaffold resolver, not a Zod " + ".default() — a hard default would shadow a `defaults.memory." + "orientation_cadence_hours` fleet value in the cascade). " + "The fast-churning agents (klanker, overlord) set 24; everyone else " + "inherits 48 (carve-M5 §6, Ken's locked tiered cadence). Drives BOTH " + "the out-of-session refresh scheduler AND the staleness guard's " + "per-tier thresholds (stale prefix at 1.5× cadence, cold degrade at " + "3×) — so a fixed 36h can never mislabel a 48h-tier agent as stale a " + "day early. Modelled as an explicit int (not a hard-coded name list) " + "so the tiering is data-driven and auditable. A cost knob, not an " + "enablement knob: a stripped/absent value failing to 48 is safe " + "(the cheaper tier). Cascade: override (per-agent wins over default); " + "unlike `orientation`/`_reinject_turns` it IS accepted at the " + "defaults/profile tier."),
|
|
11150
|
+
inject_directives: exports_external.boolean().optional().describe("Memory v2 M3 Surface-A directive-injection switch (default TRUE — " + "on-by-default via the plugin settings.json stamp + config.py " + "DEFAULTS). When left unset the agent keeps injecting the " + "`<active_directives>` block on every turn, byte-identical to pre-M3. " + "Set FALSE to SUPPRESS that injection for this agent — its standing " + "rules are then served from the live rules block (memory.rules_block) " + "instead of re-injected directives, which is the change that collapses " + "the always-on directive-token spend (E-41). The suppression is " + "fail-safe and re-checked every turn in recall.py: it fires ONLY when " + "a non-empty rules block is physically present in the agent's " + "CLAUDE.md — a false flag with an empty/absent block keeps injecting " + "AND emits a degraded-canary notice, so a zero-standing-rules turn is " + "unreachable. Requires memory.rules_block=true first (the block must " + "be live before injection is turned off — enforce the ordering with " + "`switchroom memory flip`, never a bare flag flip). Coordinates with " + "M4's memory.recall prefetch (memoryPrefetchEnabled): the same guard " + "gates all three recall.py emit sites so a flipped agent leaks no " + "directive on a cache/prefetch hit. Cascade: override, per-agent ONLY " + "(never fleet-seeded from the defaults/profile tier — each flip is a " + "deliberate staggered M3 rollout step, same darkness safeguard as " + "rules_block). Default-true is the fail-safe direction under the " + "Zod-strip footgun: a stripped/misread key keeps the agent injecting."),
|
|
11147
11151
|
disposition: exports_external.object({
|
|
11148
11152
|
skepticism: exports_external.number().int().min(1).max(5).optional().describe("How much the bank doubts unverified claims (1-5; engine default 3)."),
|
|
11149
11153
|
literalism: exports_external.number().int().min(1).max(5).optional().describe("How literally the bank reads statements vs inferring intent (1-5; engine default 3)."),
|
|
@@ -11589,6 +11593,7 @@ var init_schema = __esm(() => {
|
|
|
11589
11593
|
profile: exports_external.string().optional(),
|
|
11590
11594
|
directive_capture_nudge: exports_external.boolean().optional(),
|
|
11591
11595
|
profile_capture_nudge: exports_external.boolean().optional(),
|
|
11596
|
+
orientation_cadence_hours: exports_external.number().int().min(1).optional(),
|
|
11592
11597
|
anti_confabulation_directive: AntiConfabulationDirectiveSchema,
|
|
11593
11598
|
observation_scopes: ObservationScopesSchema,
|
|
11594
11599
|
observation_scope_strategy: ObservationScopeStrategySchema,
|
|
@@ -21593,7 +21598,7 @@ function allocateAgentUid(name) {
|
|
|
21593
21598
|
}
|
|
21594
21599
|
|
|
21595
21600
|
// src/build-info.ts
|
|
21596
|
-
var VERSION = "0.21.
|
|
21601
|
+
var VERSION = "0.21.17";
|
|
21597
21602
|
|
|
21598
21603
|
// src/setup/hindsight-recall-passthrough.ts
|
|
21599
21604
|
var HINDSIGHT_RECALL_TAG_WEIGHT_SEED = Object.freeze({ sidechain: 0.8 });
|
|
@@ -4584,6 +4584,10 @@ var init_schema = __esm(() => {
|
|
|
4584
4584
|
}).optional().describe("Operator-declared, per-specialist Hindsight mental models (RFC " + "Phase 5). Named, opt-in curated reflections this agent's bank should " + "carry — e.g. a coach's 'training-plan-state' or a lawyer's " + "'open-matters'. Ensured idempotently at scaffold/reconcile: NOTHING " + "is created unless declared here (zero declarations = zero models, " + "matching post-#2447 behaviour), and no fixed identity model is " + "reintroduced — 'who the user is' stays owned by dedicated profile " + "banks (users.*.profile_bank), never a per-agent model. Per-agent " + "ONLY: intentionally not accepted at the defaults/profile tier, so a " + "model can never be fleet-seeded — each specialist opts in on its own " + "(the invariant-clean inverse of the retired blind auto-seeding)."),
|
|
4585
4585
|
observations_mission: exports_external.string().optional().describe("Steers what the observation-consolidation LLM synthesises from raw " + "facts (the higher-order 'what patterns matter' lens). Cascade: override."),
|
|
4586
4586
|
rules_block: exports_external.boolean().default(false).describe("Memory v2 M3 go-live flag (M1 only DEFINES this, default false; " + "M3 flips it per agent). When true, the sanctioned rules/index " + "blocks render live in this agent's CLAUDE.md (marker-delimited, " + "below the `# --- Yours ---` line) AND the permission deny for " + "direct Edit/Write of the agent's own CLAUDE.md is seeded — the " + "flag couples deny + tools together so a live deny never orphans " + "the invited free-text edit path (the `memory_edit_yours` verb is " + "the only sanctioned writer once flipped). Unset/false ⇒ byte-" + "identical to pre-M1 behaviour: no blocks, no deny, dark build. " + "Cascade: override (per-agent wins over default; never fleet-" + "seeded — each agent's flip is a deliberate M3 rollout step)."),
|
|
4587
|
+
orientation: exports_external.boolean().optional().describe("Memory v2 M5 go-live flag (Surface B: orientation-at-boot), " + "effective default false — dark build (unset ⇒ OFF, applied by the " + "scaffold resolver, NOT a Zod .default() — a hard default here would " + "shadow the per-agent value in the cascade the way it does every other " + "memory knob). When true, the orientation SessionStart " + "hook injects this agent's cron-refreshed `orientation` mental model " + "into context at boot AND re-seats it after every compaction (E-88), " + "deterministically and with zero tool call. Unset/false ⇒ byte-" + "identical to pre-M5 behaviour: the hook no-ops before any bank " + "resolve or network call. Per-agent ONLY (never fleet-seeded — a " + "flip is a deliberate canary/rollout step gated on the agent's " + "m2-residue measurement + operator-created model, carve-M5 §7). " + "Cascade: override (per-agent wins over default)."),
|
|
4588
|
+
orientation_reinject_turns: exports_external.number().int().min(0).optional().describe("Memory v2 M5 optional per-turn re-inject cadence, effective default " + "0 = off (unset ⇒ 0, applied by the scaffold resolver, not a Zod " + ".default() — same cascade-shadowing reason as `orientation`). At 0 the " + "briefing is injected only at SessionStart + compaction (the cheap " + "path). N>0 " + "would additionally re-inject the orientation briefing every N turns; " + "priced at ~55M tok/30d at every-turn, which is WHY it defaults off " + "(carve-M5 §2/§4). Only late-session context loss should ever set it, " + "per-agent, measured. A stripped/absent value fails to 0 (fail-safe " + "AND fail-cheap). Per-agent ONLY. Cascade: override."),
|
|
4589
|
+
orientation_cadence_hours: exports_external.number().int().min(1).optional().describe("Memory v2 M5 per-agent refresh cadence tier (hours), effective " + "default 48 (unset ⇒ 48, applied by the scaffold resolver, not a Zod " + ".default() — a hard default would shadow a `defaults.memory." + "orientation_cadence_hours` fleet value in the cascade). " + "The fast-churning agents (klanker, overlord) set 24; everyone else " + "inherits 48 (carve-M5 §6, Ken's locked tiered cadence). Drives BOTH " + "the out-of-session refresh scheduler AND the staleness guard's " + "per-tier thresholds (stale prefix at 1.5× cadence, cold degrade at " + "3×) — so a fixed 36h can never mislabel a 48h-tier agent as stale a " + "day early. Modelled as an explicit int (not a hard-coded name list) " + "so the tiering is data-driven and auditable. A cost knob, not an " + "enablement knob: a stripped/absent value failing to 48 is safe " + "(the cheaper tier). Cascade: override (per-agent wins over default); " + "unlike `orientation`/`_reinject_turns` it IS accepted at the " + "defaults/profile tier."),
|
|
4590
|
+
inject_directives: exports_external.boolean().optional().describe("Memory v2 M3 Surface-A directive-injection switch (default TRUE — " + "on-by-default via the plugin settings.json stamp + config.py " + "DEFAULTS). When left unset the agent keeps injecting the " + "`<active_directives>` block on every turn, byte-identical to pre-M3. " + "Set FALSE to SUPPRESS that injection for this agent — its standing " + "rules are then served from the live rules block (memory.rules_block) " + "instead of re-injected directives, which is the change that collapses " + "the always-on directive-token spend (E-41). The suppression is " + "fail-safe and re-checked every turn in recall.py: it fires ONLY when " + "a non-empty rules block is physically present in the agent's " + "CLAUDE.md — a false flag with an empty/absent block keeps injecting " + "AND emits a degraded-canary notice, so a zero-standing-rules turn is " + "unreachable. Requires memory.rules_block=true first (the block must " + "be live before injection is turned off — enforce the ordering with " + "`switchroom memory flip`, never a bare flag flip). Coordinates with " + "M4's memory.recall prefetch (memoryPrefetchEnabled): the same guard " + "gates all three recall.py emit sites so a flipped agent leaks no " + "directive on a cache/prefetch hit. Cascade: override, per-agent ONLY " + "(never fleet-seeded from the defaults/profile tier — each flip is a " + "deliberate staggered M3 rollout step, same darkness safeguard as " + "rules_block). Default-true is the fail-safe direction under the " + "Zod-strip footgun: a stripped/misread key keeps the agent injecting."),
|
|
4587
4591
|
disposition: exports_external.object({
|
|
4588
4592
|
skepticism: exports_external.number().int().min(1).max(5).optional().describe("How much the bank doubts unverified claims (1-5; engine default 3)."),
|
|
4589
4593
|
literalism: exports_external.number().int().min(1).max(5).optional().describe("How literally the bank reads statements vs inferring intent (1-5; engine default 3)."),
|
|
@@ -5029,6 +5033,7 @@ var init_schema = __esm(() => {
|
|
|
5029
5033
|
profile: exports_external.string().optional(),
|
|
5030
5034
|
directive_capture_nudge: exports_external.boolean().optional(),
|
|
5031
5035
|
profile_capture_nudge: exports_external.boolean().optional(),
|
|
5036
|
+
orientation_cadence_hours: exports_external.number().int().min(1).optional(),
|
|
5032
5037
|
anti_confabulation_directive: AntiConfabulationDirectiveSchema,
|
|
5033
5038
|
observation_scopes: ObservationScopesSchema,
|
|
5034
5039
|
observation_scope_strategy: ObservationScopeStrategySchema,
|
|
@@ -4180,6 +4180,10 @@ var init_schema = __esm(() => {
|
|
|
4180
4180
|
}).optional().describe("Operator-declared, per-specialist Hindsight mental models (RFC " + "Phase 5). Named, opt-in curated reflections this agent's bank should " + "carry — e.g. a coach's 'training-plan-state' or a lawyer's " + "'open-matters'. Ensured idempotently at scaffold/reconcile: NOTHING " + "is created unless declared here (zero declarations = zero models, " + "matching post-#2447 behaviour), and no fixed identity model is " + "reintroduced — 'who the user is' stays owned by dedicated profile " + "banks (users.*.profile_bank), never a per-agent model. Per-agent " + "ONLY: intentionally not accepted at the defaults/profile tier, so a " + "model can never be fleet-seeded — each specialist opts in on its own " + "(the invariant-clean inverse of the retired blind auto-seeding)."),
|
|
4181
4181
|
observations_mission: exports_external.string().optional().describe("Steers what the observation-consolidation LLM synthesises from raw " + "facts (the higher-order 'what patterns matter' lens). Cascade: override."),
|
|
4182
4182
|
rules_block: exports_external.boolean().default(false).describe("Memory v2 M3 go-live flag (M1 only DEFINES this, default false; " + "M3 flips it per agent). When true, the sanctioned rules/index " + "blocks render live in this agent's CLAUDE.md (marker-delimited, " + "below the `# --- Yours ---` line) AND the permission deny for " + "direct Edit/Write of the agent's own CLAUDE.md is seeded — the " + "flag couples deny + tools together so a live deny never orphans " + "the invited free-text edit path (the `memory_edit_yours` verb is " + "the only sanctioned writer once flipped). Unset/false ⇒ byte-" + "identical to pre-M1 behaviour: no blocks, no deny, dark build. " + "Cascade: override (per-agent wins over default; never fleet-" + "seeded — each agent's flip is a deliberate M3 rollout step)."),
|
|
4183
|
+
orientation: exports_external.boolean().optional().describe("Memory v2 M5 go-live flag (Surface B: orientation-at-boot), " + "effective default false — dark build (unset ⇒ OFF, applied by the " + "scaffold resolver, NOT a Zod .default() — a hard default here would " + "shadow the per-agent value in the cascade the way it does every other " + "memory knob). When true, the orientation SessionStart " + "hook injects this agent's cron-refreshed `orientation` mental model " + "into context at boot AND re-seats it after every compaction (E-88), " + "deterministically and with zero tool call. Unset/false ⇒ byte-" + "identical to pre-M5 behaviour: the hook no-ops before any bank " + "resolve or network call. Per-agent ONLY (never fleet-seeded — a " + "flip is a deliberate canary/rollout step gated on the agent's " + "m2-residue measurement + operator-created model, carve-M5 §7). " + "Cascade: override (per-agent wins over default)."),
|
|
4184
|
+
orientation_reinject_turns: exports_external.number().int().min(0).optional().describe("Memory v2 M5 optional per-turn re-inject cadence, effective default " + "0 = off (unset ⇒ 0, applied by the scaffold resolver, not a Zod " + ".default() — same cascade-shadowing reason as `orientation`). At 0 the " + "briefing is injected only at SessionStart + compaction (the cheap " + "path). N>0 " + "would additionally re-inject the orientation briefing every N turns; " + "priced at ~55M tok/30d at every-turn, which is WHY it defaults off " + "(carve-M5 §2/§4). Only late-session context loss should ever set it, " + "per-agent, measured. A stripped/absent value fails to 0 (fail-safe " + "AND fail-cheap). Per-agent ONLY. Cascade: override."),
|
|
4185
|
+
orientation_cadence_hours: exports_external.number().int().min(1).optional().describe("Memory v2 M5 per-agent refresh cadence tier (hours), effective " + "default 48 (unset ⇒ 48, applied by the scaffold resolver, not a Zod " + ".default() — a hard default would shadow a `defaults.memory." + "orientation_cadence_hours` fleet value in the cascade). " + "The fast-churning agents (klanker, overlord) set 24; everyone else " + "inherits 48 (carve-M5 §6, Ken's locked tiered cadence). Drives BOTH " + "the out-of-session refresh scheduler AND the staleness guard's " + "per-tier thresholds (stale prefix at 1.5× cadence, cold degrade at " + "3×) — so a fixed 36h can never mislabel a 48h-tier agent as stale a " + "day early. Modelled as an explicit int (not a hard-coded name list) " + "so the tiering is data-driven and auditable. A cost knob, not an " + "enablement knob: a stripped/absent value failing to 48 is safe " + "(the cheaper tier). Cascade: override (per-agent wins over default); " + "unlike `orientation`/`_reinject_turns` it IS accepted at the " + "defaults/profile tier."),
|
|
4186
|
+
inject_directives: exports_external.boolean().optional().describe("Memory v2 M3 Surface-A directive-injection switch (default TRUE — " + "on-by-default via the plugin settings.json stamp + config.py " + "DEFAULTS). When left unset the agent keeps injecting the " + "`<active_directives>` block on every turn, byte-identical to pre-M3. " + "Set FALSE to SUPPRESS that injection for this agent — its standing " + "rules are then served from the live rules block (memory.rules_block) " + "instead of re-injected directives, which is the change that collapses " + "the always-on directive-token spend (E-41). The suppression is " + "fail-safe and re-checked every turn in recall.py: it fires ONLY when " + "a non-empty rules block is physically present in the agent's " + "CLAUDE.md — a false flag with an empty/absent block keeps injecting " + "AND emits a degraded-canary notice, so a zero-standing-rules turn is " + "unreachable. Requires memory.rules_block=true first (the block must " + "be live before injection is turned off — enforce the ordering with " + "`switchroom memory flip`, never a bare flag flip). Coordinates with " + "M4's memory.recall prefetch (memoryPrefetchEnabled): the same guard " + "gates all three recall.py emit sites so a flipped agent leaks no " + "directive on a cache/prefetch hit. Cascade: override, per-agent ONLY " + "(never fleet-seeded from the defaults/profile tier — each flip is a " + "deliberate staggered M3 rollout step, same darkness safeguard as " + "rules_block). Default-true is the fail-safe direction under the " + "Zod-strip footgun: a stripped/misread key keeps the agent injecting."),
|
|
4183
4187
|
disposition: exports_external.object({
|
|
4184
4188
|
skepticism: exports_external.number().int().min(1).max(5).optional().describe("How much the bank doubts unverified claims (1-5; engine default 3)."),
|
|
4185
4189
|
literalism: exports_external.number().int().min(1).max(5).optional().describe("How literally the bank reads statements vs inferring intent (1-5; engine default 3)."),
|
|
@@ -4625,6 +4629,7 @@ var init_schema = __esm(() => {
|
|
|
4625
4629
|
profile: exports_external.string().optional(),
|
|
4626
4630
|
directive_capture_nudge: exports_external.boolean().optional(),
|
|
4627
4631
|
profile_capture_nudge: exports_external.boolean().optional(),
|
|
4632
|
+
orientation_cadence_hours: exports_external.number().int().min(1).optional(),
|
|
4628
4633
|
anti_confabulation_directive: AntiConfabulationDirectiveSchema,
|
|
4629
4634
|
observation_scopes: ObservationScopesSchema,
|
|
4630
4635
|
observation_scope_strategy: ObservationScopeStrategySchema,
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "switchroom",
|
|
3
3
|
"//version": "NOT the release version — source of truth is the git tag, resolved by scripts/build.mjs:resolveVersion() (see CLAUDE.md > Standard release process). This field is stale by design and only the Layer-4 dev/non-tag fallback for build.mjs + src/cli/resolve-version.ts; do NOT bump it expecting a release to pick it up. npm-pack tarball naming needs a real version — do that as an UNCOMMITTED pack-time bump (see release step 6), never a committed one.",
|
|
4
|
-
"version": "0.21.
|
|
4
|
+
"version": "0.21.17",
|
|
5
5
|
"description": "Run Claude Code 24/7 on your Claude Pro/Max subscription over Telegram. Open-source alternative to OpenClaw and NanoClaw — no API keys.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
|
@@ -1198,6 +1198,17 @@ export HINDSIGHT_DIRECTIVE_CAPTURE_NUDGE={{hindsightDirectiveCaptureNudge}}
|
|
|
1198
1198
|
{{#if hindsightProfileCaptureNudge}}
|
|
1199
1199
|
export HINDSIGHT_PROFILE_CAPTURE_NUDGE={{hindsightProfileCaptureNudge}}
|
|
1200
1200
|
{{/if}}
|
|
1201
|
+
# Memory v2 M3 Surface-A — directive-injection switch (memory.inject_directives
|
|
1202
|
+
# cascade). On by default (plugin settings.json): recall.py injects the
|
|
1203
|
+
# <active_directives> block every turn. Export only when the operator overrode
|
|
1204
|
+
# it; set false to SUPPRESS injection for this agent (a flipped M3 canary) — its
|
|
1205
|
+
# standing rules then come from the live rules block instead. recall.py keeps
|
|
1206
|
+
# the suppression fail-safe: it drops injection ONLY when a non-empty rules
|
|
1207
|
+
# block is physically present in CLAUDE.md, else it keeps injecting and emits a
|
|
1208
|
+
# degraded-canary notice. Flip via `switchroom memory flip`, never a bare edit.
|
|
1209
|
+
{{#if hindsightInjectDirectives}}
|
|
1210
|
+
export HINDSIGHT_INJECT_DIRECTIVES={{hindsightInjectDirectives}}
|
|
1211
|
+
{{/if}}
|
|
1201
1212
|
# Per-row observation scope (memory.observation_scopes cascade). Stamped on
|
|
1202
1213
|
# every retain — Stop hook, sidechain, boot reconcile, queue drain, backfill.
|
|
1203
1214
|
# "shared" makes consolidation pool this agent's observations into ONE global
|
|
@@ -21798,6 +21798,10 @@ var init_schema = __esm(() => {
|
|
|
21798
21798
|
}).optional().describe("Operator-declared, per-specialist Hindsight mental models (RFC " + "Phase 5). Named, opt-in curated reflections this agent's bank should " + "carry \u2014 e.g. a coach's 'training-plan-state' or a lawyer's " + "'open-matters'. Ensured idempotently at scaffold/reconcile: NOTHING " + "is created unless declared here (zero declarations = zero models, " + "matching post-#2447 behaviour), and no fixed identity model is " + "reintroduced \u2014 'who the user is' stays owned by dedicated profile " + "banks (users.*.profile_bank), never a per-agent model. Per-agent " + "ONLY: intentionally not accepted at the defaults/profile tier, so a " + "model can never be fleet-seeded \u2014 each specialist opts in on its own " + "(the invariant-clean inverse of the retired blind auto-seeding)."),
|
|
21799
21799
|
observations_mission: exports_external.string().optional().describe("Steers what the observation-consolidation LLM synthesises from raw " + "facts (the higher-order 'what patterns matter' lens). Cascade: override."),
|
|
21800
21800
|
rules_block: exports_external.boolean().default(false).describe("Memory v2 M3 go-live flag (M1 only DEFINES this, default false; " + "M3 flips it per agent). When true, the sanctioned rules/index " + "blocks render live in this agent's CLAUDE.md (marker-delimited, " + "below the `# --- Yours ---` line) AND the permission deny for " + "direct Edit/Write of the agent's own CLAUDE.md is seeded \u2014 the " + "flag couples deny + tools together so a live deny never orphans " + "the invited free-text edit path (the `memory_edit_yours` verb is " + "the only sanctioned writer once flipped). Unset/false \u21d2 byte-" + "identical to pre-M1 behaviour: no blocks, no deny, dark build. " + "Cascade: override (per-agent wins over default; never fleet-" + "seeded \u2014 each agent's flip is a deliberate M3 rollout step)."),
|
|
21801
|
+
orientation: exports_external.boolean().optional().describe("Memory v2 M5 go-live flag (Surface B: orientation-at-boot), " + "effective default false \u2014 dark build (unset \u21d2 OFF, applied by the " + "scaffold resolver, NOT a Zod .default() \u2014 a hard default here would " + "shadow the per-agent value in the cascade the way it does every other " + "memory knob). When true, the orientation SessionStart " + "hook injects this agent's cron-refreshed `orientation` mental model " + "into context at boot AND re-seats it after every compaction (E-88), " + "deterministically and with zero tool call. Unset/false \u21d2 byte-" + "identical to pre-M5 behaviour: the hook no-ops before any bank " + "resolve or network call. Per-agent ONLY (never fleet-seeded \u2014 a " + "flip is a deliberate canary/rollout step gated on the agent's " + "m2-residue measurement + operator-created model, carve-M5 \u00a77). " + "Cascade: override (per-agent wins over default)."),
|
|
21802
|
+
orientation_reinject_turns: exports_external.number().int().min(0).optional().describe("Memory v2 M5 optional per-turn re-inject cadence, effective default " + "0 = off (unset \u21d2 0, applied by the scaffold resolver, not a Zod " + ".default() \u2014 same cascade-shadowing reason as `orientation`). At 0 the " + "briefing is injected only at SessionStart + compaction (the cheap " + "path). N>0 " + "would additionally re-inject the orientation briefing every N turns; " + "priced at ~55M tok/30d at every-turn, which is WHY it defaults off " + "(carve-M5 \u00a72/\u00a74). Only late-session context loss should ever set it, " + "per-agent, measured. A stripped/absent value fails to 0 (fail-safe " + "AND fail-cheap). Per-agent ONLY. Cascade: override."),
|
|
21803
|
+
orientation_cadence_hours: exports_external.number().int().min(1).optional().describe("Memory v2 M5 per-agent refresh cadence tier (hours), effective " + "default 48 (unset \u21d2 48, applied by the scaffold resolver, not a Zod " + ".default() \u2014 a hard default would shadow a `defaults.memory." + "orientation_cadence_hours` fleet value in the cascade). " + "The fast-churning agents (klanker, overlord) set 24; everyone else " + "inherits 48 (carve-M5 \u00a76, Ken's locked tiered cadence). Drives BOTH " + "the out-of-session refresh scheduler AND the staleness guard's " + "per-tier thresholds (stale prefix at 1.5\u00d7 cadence, cold degrade at " + "3\u00d7) \u2014 so a fixed 36h can never mislabel a 48h-tier agent as stale a " + "day early. Modelled as an explicit int (not a hard-coded name list) " + "so the tiering is data-driven and auditable. A cost knob, not an " + "enablement knob: a stripped/absent value failing to 48 is safe " + "(the cheaper tier). Cascade: override (per-agent wins over default); " + "unlike `orientation`/`_reinject_turns` it IS accepted at the " + "defaults/profile tier."),
|
|
21804
|
+
inject_directives: exports_external.boolean().optional().describe("Memory v2 M3 Surface-A directive-injection switch (default TRUE \u2014 " + "on-by-default via the plugin settings.json stamp + config.py " + "DEFAULTS). When left unset the agent keeps injecting the " + "`<active_directives>` block on every turn, byte-identical to pre-M3. " + "Set FALSE to SUPPRESS that injection for this agent \u2014 its standing " + "rules are then served from the live rules block (memory.rules_block) " + "instead of re-injected directives, which is the change that collapses " + "the always-on directive-token spend (E-41). The suppression is " + "fail-safe and re-checked every turn in recall.py: it fires ONLY when " + "a non-empty rules block is physically present in the agent's " + "CLAUDE.md \u2014 a false flag with an empty/absent block keeps injecting " + "AND emits a degraded-canary notice, so a zero-standing-rules turn is " + "unreachable. Requires memory.rules_block=true first (the block must " + "be live before injection is turned off \u2014 enforce the ordering with " + "`switchroom memory flip`, never a bare flag flip). Coordinates with " + "M4's memory.recall prefetch (memoryPrefetchEnabled): the same guard " + "gates all three recall.py emit sites so a flipped agent leaks no " + "directive on a cache/prefetch hit. Cascade: override, per-agent ONLY " + "(never fleet-seeded from the defaults/profile tier \u2014 each flip is a " + "deliberate staggered M3 rollout step, same darkness safeguard as " + "rules_block). Default-true is the fail-safe direction under the " + "Zod-strip footgun: a stripped/misread key keeps the agent injecting."),
|
|
21801
21805
|
disposition: exports_external.object({
|
|
21802
21806
|
skepticism: exports_external.number().int().min(1).max(5).optional().describe("How much the bank doubts unverified claims (1-5; engine default 3)."),
|
|
21803
21807
|
literalism: exports_external.number().int().min(1).max(5).optional().describe("How literally the bank reads statements vs inferring intent (1-5; engine default 3)."),
|
|
@@ -22243,6 +22247,7 @@ var init_schema = __esm(() => {
|
|
|
22243
22247
|
profile: exports_external.string().optional(),
|
|
22244
22248
|
directive_capture_nudge: exports_external.boolean().optional(),
|
|
22245
22249
|
profile_capture_nudge: exports_external.boolean().optional(),
|
|
22250
|
+
orientation_cadence_hours: exports_external.number().int().min(1).optional(),
|
|
22246
22251
|
anti_confabulation_directive: AntiConfabulationDirectiveSchema,
|
|
22247
22252
|
observation_scopes: ObservationScopesSchema,
|
|
22248
22253
|
observation_scope_strategy: ObservationScopeStrategySchema,
|
|
@@ -105871,10 +105876,10 @@ function startOutboxSweep(deps) {
|
|
|
105871
105876
|
}
|
|
105872
105877
|
|
|
105873
105878
|
// ../src/build-info.ts
|
|
105874
|
-
var VERSION2 = "0.21.
|
|
105875
|
-
var COMMIT_SHA = "
|
|
105876
|
-
var COMMIT_DATE = "2026-08-
|
|
105877
|
-
var LATEST_PR =
|
|
105879
|
+
var VERSION2 = "0.21.17";
|
|
105880
|
+
var COMMIT_SHA = "ac61272c";
|
|
105881
|
+
var COMMIT_DATE = "2026-08-17T16:42:58Z";
|
|
105882
|
+
var LATEST_PR = 4768;
|
|
105878
105883
|
var COMMITS_AHEAD_OF_TAG = 0;
|
|
105879
105884
|
|
|
105880
105885
|
// gateway/boot-version.ts
|
|
@@ -64,6 +64,7 @@ from lib.directives import ( # noqa: E402
|
|
|
64
64
|
parse_active_directives_block,
|
|
65
65
|
rule_already_captured,
|
|
66
66
|
)
|
|
67
|
+
from lib import recall_buffer # noqa: E402
|
|
67
68
|
|
|
68
69
|
# Reuse Stage B's pleasantry scrub so "as always" / "always happy to help"
|
|
69
70
|
# can't trip the high-confidence detector either. Imported lazily-safe: if
|
|
@@ -387,6 +388,37 @@ def invalidate_cache_on_directive_write(messages: list, config: dict) -> None:
|
|
|
387
388
|
debug_log(config, f"Directives cache invalidation skipped (error): {e}")
|
|
388
389
|
|
|
389
390
|
|
|
391
|
+
def invalidate_prefetch_buffer_on_directive_write(messages: list, config: dict, session_id: str) -> None:
|
|
392
|
+
"""Drop this session's pending M4 prefetch buffer if this turn wrote a
|
|
393
|
+
directive (create/update/delete/retire).
|
|
394
|
+
|
|
395
|
+
red-team-M3 R2 (BLOCKER): the A4 cache invalidation above keeps the fresh
|
|
396
|
+
<active_directives> block current, but the M4 prefetch buffer carries a
|
|
397
|
+
RECALLED memories block captured at a prior turn's Stop — and a rule/
|
|
398
|
+
directive that was just retired can survive in that snapshot as recalled
|
|
399
|
+
text. Without this, a buffer prefetched while the rule was active would
|
|
400
|
+
re-inject the retired rule on the next turn (the exact §4.4 resurrection
|
|
401
|
+
failure), and `run_prefetch` bailing on an empty recall would leave that
|
|
402
|
+
stale buffer to be served on later turns too. Deleting the buffer + sentinel
|
|
403
|
+
here forces the consumer to fall to the synchronous, always-current path
|
|
404
|
+
(or the explicitly stale-marked fallback) instead of resurrecting it.
|
|
405
|
+
|
|
406
|
+
Only fires when `memoryPrefetchEnabled` is on (the buffer only exists then)
|
|
407
|
+
— a cheap no-op statting two absent files otherwise, so gated to avoid it.
|
|
408
|
+
Self-guarded; never raises (a Stop hook must not wedge a turn).
|
|
409
|
+
"""
|
|
410
|
+
try:
|
|
411
|
+
if not messages or not config.get("memoryPrefetchEnabled", False):
|
|
412
|
+
return
|
|
413
|
+
idx, _text = find_last_human_turn(messages)
|
|
414
|
+
start = idx if idx is not None else -1
|
|
415
|
+
if turn_contains_directive_write(messages, start):
|
|
416
|
+
recall_buffer.invalidate(session_id or "unknown")
|
|
417
|
+
debug_log(config, "Prefetch buffer invalidated — turn wrote a directive (R2 resurrection guard)")
|
|
418
|
+
except Exception as e: # pragma: no cover - defensive; Stop must not wedge
|
|
419
|
+
debug_log(config, f"Prefetch-buffer invalidation skipped (error): {e}")
|
|
420
|
+
|
|
421
|
+
|
|
390
422
|
def read_transcript(transcript_path: str) -> list:
|
|
391
423
|
"""Read a JSONL transcript into a list of message dicts (role/content).
|
|
392
424
|
|
|
@@ -508,6 +540,7 @@ def main():
|
|
|
508
540
|
# and this is not an already-blocked re-fire.
|
|
509
541
|
ttl = config.get("directivesCacheTtlSeconds", DIRECTIVES_CACHE_TTL_SECONDS)
|
|
510
542
|
cache_on = isinstance(ttl, (int, float)) and ttl > 0
|
|
543
|
+
prefetch_on = bool(config.get("memoryPrefetchEnabled", False))
|
|
511
544
|
verify_maybe = (
|
|
512
545
|
config.get("directiveCaptureNudge", True)
|
|
513
546
|
and config.get("directiveCaptureVerify", True)
|
|
@@ -515,7 +548,7 @@ def main():
|
|
|
515
548
|
)
|
|
516
549
|
|
|
517
550
|
messages: list = []
|
|
518
|
-
if cache_on or verify_maybe:
|
|
551
|
+
if cache_on or verify_maybe or prefetch_on:
|
|
519
552
|
messages = read_transcript(hook_input.get("transcript_path", ""))
|
|
520
553
|
|
|
521
554
|
# A4: invalidate the directives cache when this turn wrote a directive, so
|
|
@@ -524,6 +557,15 @@ def main():
|
|
|
524
557
|
if cache_on:
|
|
525
558
|
invalidate_cache_on_directive_write(messages, config)
|
|
526
559
|
|
|
560
|
+
# R2 (BLOCKER): invalidate this session's M4 prefetch buffer when this turn
|
|
561
|
+
# wrote/retired a directive, so a stale pre-retire snapshot can never
|
|
562
|
+
# resurrect the retired rule on a later turn. Self-guarded no-op when
|
|
563
|
+
# prefetch is off.
|
|
564
|
+
if prefetch_on:
|
|
565
|
+
invalidate_prefetch_buffer_on_directive_write(
|
|
566
|
+
messages, config, hook_input.get("session_id") or "unknown"
|
|
567
|
+
)
|
|
568
|
+
|
|
527
569
|
try:
|
|
528
570
|
reason = evaluate(hook_input, config, messages=messages)
|
|
529
571
|
except Exception as e: # never wedge a turn on a verify bug
|
|
@@ -519,3 +519,38 @@ class HindsightClient:
|
|
|
519
519
|
if retain_mission:
|
|
520
520
|
updates["retain_mission"] = retain_mission
|
|
521
521
|
return self._request("PATCH", path, {"updates": updates}, timeout=timeout)
|
|
522
|
+
|
|
523
|
+
def list_mental_models(self, bank_id: str, timeout: int = 5) -> dict:
|
|
524
|
+
"""List the mental models for a bank (Memory v2 M5 — orientation-at-boot).
|
|
525
|
+
|
|
526
|
+
The orientation SessionStart hook knows the orientation model by NAME
|
|
527
|
+
(the configured `memoryOrientationModel`), not by its `mm-…` id, so it
|
|
528
|
+
lists the bank's models and matches on name → id before reading content
|
|
529
|
+
(carve-M5 §0d/§3). Read-only GET on the ungated engine REST surface;
|
|
530
|
+
m5-0-probe measured the own-bank read at ~4ms on klanker.
|
|
531
|
+
|
|
532
|
+
REST: ``GET /v1/default/banks/{bank_id}/mental-models``. Returns the raw
|
|
533
|
+
response dict, expected to carry an ``items`` list where each item has at
|
|
534
|
+
least ``id`` and ``name``.
|
|
535
|
+
"""
|
|
536
|
+
path = f"/v1/default/banks/{urllib.parse.quote(bank_id, safe='')}/mental-models"
|
|
537
|
+
return self._request("GET", path, timeout=timeout)
|
|
538
|
+
|
|
539
|
+
def get_mental_model(
|
|
540
|
+
self, bank_id: str, model_id: str, detail: str = "full", timeout: int = 5
|
|
541
|
+
) -> dict:
|
|
542
|
+
"""Read one mental model's content + freshness watermark (M5).
|
|
543
|
+
|
|
544
|
+
REST: ``GET /v1/default/banks/{bank_id}/mental-models/{model_id}?detail=full``
|
|
545
|
+
(m5-0-probe Q3/Q4 proved this returns ``content`` + the
|
|
546
|
+
``last_refreshed_at`` watermark the staleness guard keys on, ~4ms warm).
|
|
547
|
+
Read-only; ungated. The caller wraps this in a generous read-timeout and
|
|
548
|
+
degrades to the cold notice on any error — a slow/failed read must never
|
|
549
|
+
block boot (carve §3 fail-safe).
|
|
550
|
+
"""
|
|
551
|
+
bank = urllib.parse.quote(bank_id, safe="")
|
|
552
|
+
model = urllib.parse.quote(model_id, safe="")
|
|
553
|
+
path = f"/v1/default/banks/{bank}/mental-models/{model}"
|
|
554
|
+
if detail:
|
|
555
|
+
path = f"{path}?{urllib.parse.urlencode({'detail': detail})}"
|
|
556
|
+
return self._request("GET", path, timeout=timeout)
|
|
@@ -130,6 +130,20 @@ DEFAULTS = {
|
|
|
130
130
|
# opt out per-agent via memory.profile_capture_nudge=false →
|
|
131
131
|
# HINDSIGHT_PROFILE_CAPTURE_NUDGE.
|
|
132
132
|
"profileCaptureNudge": True,
|
|
133
|
+
# Switchroom Memory v2 M3 Surface-A — directive-injection switch. When True
|
|
134
|
+
# (default), recall.py injects the `<active_directives>` block on every
|
|
135
|
+
# UserPromptSubmit at all three emit sites (main + prefetch/cache fast
|
|
136
|
+
# paths). When False (a flipped M3 canary), that injection is SUPPRESSED —
|
|
137
|
+
# the agent's standing rules come from the live rules block instead, which
|
|
138
|
+
# is the change that collapses the always-on directive-token spend (E-41).
|
|
139
|
+
# Suppression is fail-safe and re-checked every turn: it fires ONLY when a
|
|
140
|
+
# non-empty rules block is physically present in CLAUDE.md; a False flag
|
|
141
|
+
# with an empty/absent block keeps injecting AND emits a degraded-canary
|
|
142
|
+
# notice, so a zero-standing-rules turn is unreachable. Operators flip a
|
|
143
|
+
# canary per-agent via memory.inject_directives=false →
|
|
144
|
+
# HINDSIGHT_INJECT_DIRECTIVES; the ordered flip (rules_block live → migrate
|
|
145
|
+
# → this flag off) is enforced by `switchroom memory flip`.
|
|
146
|
+
"injectDirectives": True,
|
|
133
147
|
# Switchroom #2873/#2903 Fix 6.2 — the BLOCKING half (Stage C
|
|
134
148
|
# directive_verify.py Stop hook) split out from the advisory nudge. When
|
|
135
149
|
# True (default) the verifier may block the stop once to re-prompt capture;
|
|
@@ -140,6 +154,23 @@ DEFAULTS = {
|
|
|
140
154
|
# per-agent via memory.directive_capture_verify=false →
|
|
141
155
|
# HINDSIGHT_DIRECTIVE_CAPTURE_VERIFY.
|
|
142
156
|
"directiveCaptureVerify": True,
|
|
157
|
+
# Memory v2 M5 — orientation-at-boot (Surface B). All four fail-safe by
|
|
158
|
+
# default so a stripped/absent key (the Zod-strip footgun, carve §1) boots
|
|
159
|
+
# exactly as pre-M5. `memoryOrientationEnabled` is the per-agent kill switch,
|
|
160
|
+
# default OFF (dark build — the orientation SessionStart hook no-ops before
|
|
161
|
+
# any bank resolve or network call when off). `memoryOrientationModel` is the
|
|
162
|
+
# mental-model NAME the hook resolves to an id (the agent's OWN bank).
|
|
163
|
+
# `memoryOrientationCadenceHours` is the per-agent refresh cadence tier
|
|
164
|
+
# (klanker/overlord 24, everyone else 48) the staleness guard's per-tier
|
|
165
|
+
# thresholds (1.5×/3×) key on. `memoryOrientationReinjectTurns` is the
|
|
166
|
+
# epic's optional per-turn re-inject knob, default 0 = SessionStart+compaction
|
|
167
|
+
# only (the cheap path; N>0 is the ~55M/30d-at-every-turn expensive variant,
|
|
168
|
+
# which is why it defaults off). Delivered per-agent via the scaffold
|
|
169
|
+
# settings.json stamp and overridable via the HINDSIGHT_ORIENTATION_* env.
|
|
170
|
+
"memoryOrientationEnabled": False,
|
|
171
|
+
"memoryOrientationModel": "orientation",
|
|
172
|
+
"memoryOrientationCadenceHours": 48,
|
|
173
|
+
"memoryOrientationReinjectTurns": 0,
|
|
143
174
|
# Switchroom hindsight-leverage A4 — TTL (seconds) for the directives-list
|
|
144
175
|
# cache on the recall critical path (see lib/directives.py). The list is
|
|
145
176
|
# re-fetched at most once per TTL window for no-write turns; in-session
|
|
@@ -510,11 +541,27 @@ ENV_OVERRIDES = {
|
|
|
510
541
|
# it; the switchroom default is on (settings.json pins true; recall.py falls
|
|
511
542
|
# back to True).
|
|
512
543
|
"HINDSIGHT_PROFILE_CAPTURE_NUDGE": ("profileCaptureNudge", bool),
|
|
544
|
+
# Switchroom Memory v2 M3 Surface-A: directive-injection switch on/off. Set
|
|
545
|
+
# by start.sh from agents.<name>.memory.inject_directives only when the
|
|
546
|
+
# operator overrode it; the switchroom default is on (settings.json pins
|
|
547
|
+
# true; recall.py falls back to True). False SUPPRESSES the
|
|
548
|
+
# <active_directives> injection for a flipped canary, fail-safe on a live
|
|
549
|
+
# rules block (see recall.py directive_injection_decision).
|
|
550
|
+
"HINDSIGHT_INJECT_DIRECTIVES": ("injectDirectives", bool),
|
|
513
551
|
# Switchroom #2873/#2903 Fix 6.2: the Stage C block on/off, independent of
|
|
514
552
|
# the Stage B nudge. Set by start.sh from
|
|
515
553
|
# agents.<name>.memory.directive_capture_verify only when the operator
|
|
516
554
|
# overrode it; the switchroom default is on.
|
|
517
555
|
"HINDSIGHT_DIRECTIVE_CAPTURE_VERIFY": ("directiveCaptureVerify", bool),
|
|
556
|
+
# Memory v2 M5 — orientation-at-boot. Env override channel for the four
|
|
557
|
+
# orientation knobs (settings.json carries the per-agent value; env wins for
|
|
558
|
+
# a docker-exec'd hook or an agent `env:` map). `memoryOrientationEnabled`
|
|
559
|
+
# is the per-agent kill switch (default off); the model NAME, cadence tier,
|
|
560
|
+
# and reinject count follow. See DEFAULTS above and carve-M5 §4.
|
|
561
|
+
"HINDSIGHT_ORIENTATION_ENABLED": ("memoryOrientationEnabled", bool),
|
|
562
|
+
"HINDSIGHT_ORIENTATION_MODEL": ("memoryOrientationModel", str),
|
|
563
|
+
"HINDSIGHT_ORIENTATION_CADENCE_HOURS": ("memoryOrientationCadenceHours", int),
|
|
564
|
+
"HINDSIGHT_ORIENTATION_REINJECT_TURNS": ("memoryOrientationReinjectTurns", int),
|
|
518
565
|
# Switchroom hindsight-leverage A4 — directives-list cache TTL (seconds).
|
|
519
566
|
# 0 disables the cache (rollback lever).
|
|
520
567
|
"HINDSIGHT_DIRECTIVES_CACHE_TTL_SECONDS": ("directivesCacheTtlSeconds", int),
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"""Memory v2 M5 — Surface B: orientation-at-boot (pure logic).
|
|
2
|
+
|
|
3
|
+
carve-M5.md §3/§5/§0c. This module holds the network-free, deterministically
|
|
4
|
+
testable half of the orientation SessionStart hook: staleness classification
|
|
5
|
+
(per-tier thresholds, NOT a fixed 36h), rule-aware truncation to the content
|
|
6
|
+
token budget, and the `additionalContext` rendering. The hook entry
|
|
7
|
+
(`orientation.py`) does the I/O — resolve own bank, list mental models, match
|
|
8
|
+
the orientation model by name, GET its content — and calls into here so the
|
|
9
|
+
budget/staleness/render logic can be unit-tested without a live engine.
|
|
10
|
+
|
|
11
|
+
Why the split mirrors the fleet's other deterministic memory surfaces
|
|
12
|
+
(recall.py's nudge regexes, prefetch.py's buffer contract): the value is a
|
|
13
|
+
DETERMINISTIC mechanism, not model discretion, so every branch that decides
|
|
14
|
+
"inject / prefix-as-stale / degrade-to-cold / truncate" must be pinned by a
|
|
15
|
+
test that fails on the bug it guards (carve §8 tautology-guard discipline).
|
|
16
|
+
|
|
17
|
+
TOKEN BUDGET (carve §0c). The epic caps the injected briefing at 2048 tokens,
|
|
18
|
+
but M0 measured real mental models at ~1,766–1,962 tokens of CONTENT alone —
|
|
19
|
+
so a naive "cap at 2048" overflows the moment a stale prefix or framing is
|
|
20
|
+
prepended. The real number to design to is a CONTENT budget below the cap,
|
|
21
|
+
leaving headroom for the prefix + framing. Truncation is rule-aware: whole
|
|
22
|
+
markdown sections are kept, trailing sections dropped, and a visible marker is
|
|
23
|
+
emitted so the loss is never silent.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import math
|
|
29
|
+
from datetime import datetime, timezone
|
|
30
|
+
|
|
31
|
+
#: Hard ceiling on the total injected `additionalContext` (epic line 143).
|
|
32
|
+
ORIENTATION_TOTAL_TOKEN_CAP = 2048
|
|
33
|
+
#: Budget for the model CONTENT alone, below the cap so the stale prefix +
|
|
34
|
+
#: framing tags fit inside 2048 (carve §0c: ~1,800, reserving ~250).
|
|
35
|
+
ORIENTATION_CONTENT_TOKEN_BUDGET = 1800
|
|
36
|
+
#: Chars-per-token estimate M0 used to convert measured char sizes to tokens
|
|
37
|
+
#: (7,063 chars ≈ 1,766 tokens). Deterministic proxy — no tokenizer dependency
|
|
38
|
+
#: on the boot critical path.
|
|
39
|
+
CHARS_PER_TOKEN = 4
|
|
40
|
+
|
|
41
|
+
#: Emitted (once) when trailing sections were dropped to fit the budget, so the
|
|
42
|
+
#: loss is visible to the model rather than a silent mid-sentence byte-cut.
|
|
43
|
+
TRUNCATION_MARKER = "(orientation truncated to fit context budget)"
|
|
44
|
+
|
|
45
|
+
# Framing tags. Kept intentionally tiny — they count against the 2048 cap.
|
|
46
|
+
_OPEN_TAG = "<orientation source=\"memory\">"
|
|
47
|
+
_CLOSE_TAG = "</orientation>"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def estimate_tokens(text: str) -> int:
|
|
51
|
+
"""Deterministic token estimate (ceil of chars / CHARS_PER_TOKEN).
|
|
52
|
+
|
|
53
|
+
A proxy, not a real tokenizer — but the SAME proxy M0 measured content
|
|
54
|
+
sizes with, so the budget math is internally consistent, and it needs no
|
|
55
|
+
model/tokenizer on the boot path. Empty string is 0 tokens.
|
|
56
|
+
"""
|
|
57
|
+
if not text:
|
|
58
|
+
return 0
|
|
59
|
+
return math.ceil(len(text) / CHARS_PER_TOKEN)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def classify_staleness(
|
|
63
|
+
last_refreshed_at: str | None,
|
|
64
|
+
cadence_hours: int,
|
|
65
|
+
now: datetime | None = None,
|
|
66
|
+
) -> tuple[str, float | None]:
|
|
67
|
+
"""Classify an orientation model's freshness against its agent's TIER.
|
|
68
|
+
|
|
69
|
+
Returns ``(state, hours_ago)`` where state is one of:
|
|
70
|
+
- ``"fresh"`` — refreshed < 1.5× cadence ago: inject plainly.
|
|
71
|
+
- ``"stale"`` — 1.5×–3× cadence ago: inject WITH a visible prefix.
|
|
72
|
+
- ``"degraded"`` — > 3× cadence ago: do NOT inject stale-as-fresh; the
|
|
73
|
+
hook emits the cold notice + enqueues a refresh.
|
|
74
|
+
- ``"unknown"`` — no/unparseable ``last_refreshed_at``: treat as cold
|
|
75
|
+
(fail closed — never present an un-dated model as
|
|
76
|
+
fresh). ``hours_ago`` is None.
|
|
77
|
+
|
|
78
|
+
The thresholds are PER-TIER (carve §5): a fixed 36h would mislabel every
|
|
79
|
+
48h-cadence agent as stale a full day early. 1.5× / 3× of the resolved
|
|
80
|
+
cadence is the tier-correct boundary — 36h/72h at cadence 24, 72h/144h at
|
|
81
|
+
cadence 48. Keyed on ``last_refreshed_at`` (the freshness watermark the
|
|
82
|
+
persisting refresh advances — m5-0-probe Q3), NOT ``updated_at`` (returned
|
|
83
|
+
null on the single-model read) and NOT the engine's own ``is_stale`` (its
|
|
84
|
+
threshold is engine-defined, not the agent's tier).
|
|
85
|
+
"""
|
|
86
|
+
if now is None:
|
|
87
|
+
now = datetime.now(timezone.utc)
|
|
88
|
+
if not last_refreshed_at:
|
|
89
|
+
return ("unknown", None)
|
|
90
|
+
ts = _parse_iso(last_refreshed_at)
|
|
91
|
+
if ts is None:
|
|
92
|
+
return ("unknown", None)
|
|
93
|
+
# A naive timestamp is assumed UTC (the engine stores tz-aware UTC; this is
|
|
94
|
+
# belt-and-braces so an aware/naive mismatch can never raise on subtract).
|
|
95
|
+
if ts.tzinfo is None:
|
|
96
|
+
ts = ts.replace(tzinfo=timezone.utc)
|
|
97
|
+
hours_ago = (now - ts).total_seconds() / 3600.0
|
|
98
|
+
# Guard a clock-skew future timestamp: treat as fresh, never negative-stale.
|
|
99
|
+
if hours_ago < 0:
|
|
100
|
+
hours_ago = 0.0
|
|
101
|
+
stale_at = 1.5 * cadence_hours
|
|
102
|
+
degrade_at = 3.0 * cadence_hours
|
|
103
|
+
if hours_ago < stale_at:
|
|
104
|
+
return ("fresh", hours_ago)
|
|
105
|
+
if hours_ago < degrade_at:
|
|
106
|
+
return ("stale", hours_ago)
|
|
107
|
+
return ("degraded", hours_ago)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _parse_iso(value: str) -> datetime | None:
|
|
111
|
+
"""Parse an engine ISO-8601 timestamp, tolerating a trailing ``Z``."""
|
|
112
|
+
try:
|
|
113
|
+
# datetime.fromisoformat handles "+00:00" and fractional seconds; map a
|
|
114
|
+
# trailing Z (some engine surfaces emit it) to the offset form first.
|
|
115
|
+
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
116
|
+
except (ValueError, AttributeError):
|
|
117
|
+
return None
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def truncate_to_budget(content: str, token_budget: int) -> tuple[str, bool]:
|
|
121
|
+
"""Rule-aware truncation to ``token_budget`` tokens.
|
|
122
|
+
|
|
123
|
+
Returns ``(text, truncated)``. Prefers WHOLE markdown sections (a section
|
|
124
|
+
starts at a line beginning with ``#``): accumulates sections while they fit,
|
|
125
|
+
drops trailing sections that don't, and signals ``truncated=True`` so the
|
|
126
|
+
caller can append :data:`TRUNCATION_MARKER`. If the very first section
|
|
127
|
+
already exceeds the budget, hard-cuts at a whitespace boundary (never
|
|
128
|
+
mid-word) rather than emitting nothing.
|
|
129
|
+
|
|
130
|
+
Never returns more than the budget; an empty/whitespace input returns
|
|
131
|
+
``("", False)``.
|
|
132
|
+
"""
|
|
133
|
+
content = (content or "").strip()
|
|
134
|
+
if not content:
|
|
135
|
+
return ("", False)
|
|
136
|
+
if estimate_tokens(content) <= token_budget:
|
|
137
|
+
return (content, False)
|
|
138
|
+
|
|
139
|
+
sections = _split_sections(content)
|
|
140
|
+
kept: list[str] = []
|
|
141
|
+
used = 0
|
|
142
|
+
for sec in sections:
|
|
143
|
+
sec_tokens = estimate_tokens(sec)
|
|
144
|
+
# +1 token slack for the join newline between kept sections.
|
|
145
|
+
if kept and used + sec_tokens + 1 > token_budget:
|
|
146
|
+
break
|
|
147
|
+
if not kept and sec_tokens > token_budget:
|
|
148
|
+
# First section alone overflows — hard-cut on a word boundary.
|
|
149
|
+
return (_hard_cut(sec, token_budget), True)
|
|
150
|
+
kept.append(sec)
|
|
151
|
+
used += sec_tokens + (1 if len(kept) > 1 else 0)
|
|
152
|
+
|
|
153
|
+
if not kept:
|
|
154
|
+
return (_hard_cut(sections[0], token_budget), True)
|
|
155
|
+
return ("\n".join(kept).strip(), True)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _split_sections(content: str) -> list[str]:
|
|
159
|
+
"""Split markdown into whole sections at lines beginning with ``#``.
|
|
160
|
+
|
|
161
|
+
Leading preamble before the first header is its own section. A document
|
|
162
|
+
with no headers is a single section (the hard-cut path handles overflow).
|
|
163
|
+
"""
|
|
164
|
+
lines = content.split("\n")
|
|
165
|
+
sections: list[str] = []
|
|
166
|
+
cur: list[str] = []
|
|
167
|
+
for line in lines:
|
|
168
|
+
if line.startswith("#") and cur:
|
|
169
|
+
sections.append("\n".join(cur))
|
|
170
|
+
cur = [line]
|
|
171
|
+
else:
|
|
172
|
+
cur.append(line)
|
|
173
|
+
if cur:
|
|
174
|
+
sections.append("\n".join(cur))
|
|
175
|
+
return sections
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _hard_cut(text: str, token_budget: int) -> str:
|
|
179
|
+
"""Cut ``text`` to fit ``token_budget`` at a whitespace boundary."""
|
|
180
|
+
char_budget = max(0, token_budget * CHARS_PER_TOKEN)
|
|
181
|
+
if len(text) <= char_budget:
|
|
182
|
+
return text.strip()
|
|
183
|
+
cut = text[:char_budget]
|
|
184
|
+
# Back up to the last whitespace so we never slice a word in half.
|
|
185
|
+
ws = cut.rfind(" ")
|
|
186
|
+
nl = cut.rfind("\n")
|
|
187
|
+
boundary = max(ws, nl)
|
|
188
|
+
if boundary > 0:
|
|
189
|
+
cut = cut[:boundary]
|
|
190
|
+
return cut.strip()
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def stale_prefix(hours_ago: float | None) -> str:
|
|
194
|
+
"""One-line visible staleness prefix (carve §5). Never presents stale as fresh."""
|
|
195
|
+
if hours_ago is None:
|
|
196
|
+
return "(orientation staleness unknown — may be stale)"
|
|
197
|
+
return f"(orientation last refreshed {int(round(hours_ago))}h ago — may be stale)"
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def cold_notice() -> str:
|
|
201
|
+
"""One-line cold notice (carve §3-4): no usable model, boot un-blocked.
|
|
202
|
+
|
|
203
|
+
Visible by design — a missing/degraded briefing degrades to a NOTICE the
|
|
204
|
+
model can see, never a silently memoryless boot.
|
|
205
|
+
"""
|
|
206
|
+
return (
|
|
207
|
+
f"{_OPEN_TAG}\n"
|
|
208
|
+
"orientation model not yet built or refreshed — booting without a "
|
|
209
|
+
"briefing (a background refresh has been requested)\n"
|
|
210
|
+
f"{_CLOSE_TAG}"
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def render_orientation(
|
|
215
|
+
content: str,
|
|
216
|
+
staleness: str,
|
|
217
|
+
hours_ago: float | None,
|
|
218
|
+
) -> str:
|
|
219
|
+
"""Render the injected `additionalContext` for a usable model.
|
|
220
|
+
|
|
221
|
+
Applies the stale prefix when warranted, truncates the CONTENT to
|
|
222
|
+
:data:`ORIENTATION_CONTENT_TOKEN_BUDGET` (rule-aware), wraps in the tiny
|
|
223
|
+
framing tags, and guarantees the WHOLE rendered block (prefix + framing +
|
|
224
|
+
content) stays within :data:`ORIENTATION_TOTAL_TOKEN_CAP` — if the prefix
|
|
225
|
+
pushes it over, the content budget is trimmed further so the cap holds.
|
|
226
|
+
|
|
227
|
+
Callers pass only ``fresh`` or ``stale`` here; ``degraded``/``unknown`` go
|
|
228
|
+
to :func:`cold_notice` in the hook (never rendered as fresh).
|
|
229
|
+
"""
|
|
230
|
+
prefix = stale_prefix(hours_ago) if staleness == "stale" else ""
|
|
231
|
+
# Reserve budget for the framing tags + prefix so the TOTAL stays under the
|
|
232
|
+
# hard cap, not just the content.
|
|
233
|
+
framing_tokens = estimate_tokens(_OPEN_TAG) + estimate_tokens(_CLOSE_TAG) + 2
|
|
234
|
+
prefix_tokens = estimate_tokens(prefix) + (1 if prefix else 0)
|
|
235
|
+
content_budget = min(
|
|
236
|
+
ORIENTATION_CONTENT_TOKEN_BUDGET,
|
|
237
|
+
ORIENTATION_TOTAL_TOKEN_CAP - framing_tokens - prefix_tokens,
|
|
238
|
+
)
|
|
239
|
+
body, truncated = truncate_to_budget(content, content_budget)
|
|
240
|
+
if truncated:
|
|
241
|
+
body = f"{body}\n\n{TRUNCATION_MARKER}"
|
|
242
|
+
|
|
243
|
+
parts = [_OPEN_TAG]
|
|
244
|
+
if prefix:
|
|
245
|
+
parts.append(prefix)
|
|
246
|
+
parts.append(body)
|
|
247
|
+
parts.append(_CLOSE_TAG)
|
|
248
|
+
return "\n".join(parts)
|