@causyne/agent 1.10.12 → 1.10.13

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/LICENSE CHANGED
@@ -9,7 +9,7 @@ Parameters
9
9
 
10
10
  Licensor: Ikbal Fadilah
11
11
 
12
- Licensed Work: Causyne Agent 1.10.1
12
+ Licensed Work: Causyne Agent 1.10.13
13
13
  The Licensed Work is (c) 2026 Ikbal Fadilah.
14
14
 
15
15
  Additional Use Grant: You may make use of the Licensed Work, provided that
@@ -36,7 +36,7 @@ are application policies and require adversarial review.
36
36
  | Models | `providers/`, `modelCatalogue.js` |
37
37
  | Context and memory | `memory.js`, `memoryManager.js`, `contextManager.js`, `cortex/` |
38
38
  | Recovery | `checkpoints.js`, `gitSnapshot.js`, `sessionStore.js` |
39
- | Extensions | `mcpClient.js`, `skillsManager.js`, `toolPacks.js`, user skills and Tool Forge |
39
+ | Extensions | `mcp/`, `plugins/`, `hooks.js`, `skillsManager.js`, `toolPacks.js` |
40
40
  | Readable interpreter source | `src/tool-scripts/` |
41
41
 
42
42
  The current implementation remains JavaScript/ESM on Node.js 24+. The accepted
@@ -44,6 +44,51 @@ language direction is incremental strict TypeScript. Runtime validation is
44
44
  still needed at model, filesystem, subprocess and extension boundaries.
45
45
  Rust is a possible narrow native worker later; no Rust rewrite is underway.
46
46
 
47
+ ## Tool and extension boundaries
48
+
49
+ Tool schemas are assembled once from native tools, explicitly enabled plugins,
50
+ and granted MCP servers. Denied tools are removed before the model sees them,
51
+ native names win collisions, and large capability groups remain deferred until
52
+ the task shape or `tool_search` makes them relevant.
53
+
54
+ Local plugins use a static `plugin.json` manifest. Creating a plugin leaves it
55
+ disabled. Enabling it pins a digest of every file the plugin process may read;
56
+ any later manifest, source, helper or asset change revokes trust. Discovery
57
+ validates static manifests and hashes bytes without importing JavaScript, so code cannot run
58
+ during pre-trust initialization. A plugin tool runs in a separate Node process
59
+ with the permission model enabled and only its declared workspace, network, or
60
+ subprocess capabilities. Plugin calls still pass through Causyne's sandbox and
61
+ approval gates.
62
+
63
+ Hooks are user-owned argv arrays stored in the local Causyne configuration.
64
+ They never pass through a shell and project repositories are not searched for
65
+ startup hooks. `PreToolUse` runs after the built-in sandbox and before an
66
+ approval prompt; exit code 2 denies, and any other failure fails closed.
67
+ `PostToolUse` can annotate a result or reject it without rewriting history.
68
+
69
+ Host execution remains the default because Causyne must work in the directory
70
+ the user selected. Optional container isolation can be added as an execution
71
+ profile later, but it must remain explicit: mounting a workspace into Docker is
72
+ a different trust boundary and cannot silently replace native desktop access.
73
+
74
+ ## Plan cache and context pressure
75
+
76
+ Completed multi-step runs teach argument-free plan templates. Lookup tries an
77
+ indexed exact signature first, then bounded lexical similarity only for a task
78
+ shape with enough confidence. Latency, hit type and recent hit rate are recorded;
79
+ lookup temporarily cools down when a populated cache repeatedly misses while
80
+ new successful plans may continue warming it. `/plans import` accepts sanitized
81
+ offline samples for known workloads, and `/plans` exposes the measurements.
82
+ `npm run benchmark:plan-cache` measures exact lookup, fuzzy lookup, misses and
83
+ prewarming at bounded cache sizes in an isolated temporary state directory. It
84
+ is a synthetic cache microbenchmark, not evidence for model accuracy, token
85
+ cost, or end-to-end latency claims from the research paper.
86
+
87
+ Context uses per-tool result budgets, oversized-result snipping, decay, a bounded
88
+ sliding window, and structural summaries. Full tool output is spilled before it
89
+ is shortened. Material compaction is recorded in the audit log, and summary
90
+ collapse emits a quiet terminal notice so context loss is visible.
91
+
47
92
  ## Removed boundaries
48
93
 
49
94
  No active pairing, relay, WebRTC/P2P, mobile command surface, background daemon
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@causyne/agent",
3
- "version": "1.10.12",
3
+ "version": "1.10.13",
4
4
  "description": "Agentic AI for software, tools, and computer workflows",
5
5
  "main": "bin/causyne.js",
6
6
  "type": "module",
@@ -24,6 +24,7 @@
24
24
  "start": "node ./bin/causyne.js",
25
25
  "test": "node --test --import ./tests/helpers/isolateState.js \"tests/**/*.test.js\"",
26
26
  "build": "node scripts/compile_skills.js",
27
+ "benchmark:plan-cache": "node src/benchmarks/planCache.js",
27
28
  "check:release": "node scripts/check_release.js",
28
29
  "check:public": "npm run check:release && node scripts/check_public_release.js",
29
30
  "prepublishOnly": "npm run check:public && npm test && npm run typecheck:strict",
package/src/agentLoop.js CHANGED
@@ -7,6 +7,7 @@ import { TaskBuffer } from './taskBuffer.js';
7
7
  import { TOOL_DEFINITIONS, getDynamicTools, resolveTool, canonicalToolName } from './tools/registry.js';
8
8
  import { Sandbox } from './sandbox.js';
9
9
  import { auditLog } from './auditLog.js';
10
+ import { hasMatchingHook, runConfiguredHooks } from './hooks.js';
10
11
  import { loadConfig } from './config.js';
11
12
  import { isDevEmulator } from './devMode.js';
12
13
  import { TokenTracker } from './tokenTracker.js';
@@ -14,7 +15,7 @@ import { TokenTracker } from './tokenTracker.js';
14
15
  import { getMemoryContextString } from './memory.js';
15
16
  import { MemoryManager } from './memoryManager.js';
16
17
  import { cortexDb } from './cortex/vectorDb.js';
17
- import { condenseAndSave } from './cortex/autoCondenser.js';
18
+ import { buildStructuralSummary, condenseAndSave } from './cortex/autoCondenser.js';
18
19
  import { analyzeTask, buildTaskBrief, packsFromShape } from './taskShape.js';
19
20
  import { lookupPlan, recordPlan, recordMiss, renderPlanHint, toStep } from './planCache.js';
20
21
  import { routedConfig } from './modelRouter.js';
@@ -497,18 +498,9 @@ export class AgentLoop {
497
498
  // once per task rather than once per step.
498
499
  let planHint = '';
499
500
  try {
500
- const match = lookupPlan({
501
- prompt: this.currentTaskPrompt || this.userPrompt,
502
- archetype: this.taskShape ? this.taskShape.archetype : ''
503
- });
504
- if (match) {
505
- planHint = renderPlanHint(match);
506
- this._offeredPlanId = match.template.id;
507
- this._planSimilarity = match.similarity;
508
- this.debugLog(`plan cache hit ${(match.similarity * 100).toFixed(0)}% (${match.template.steps.length} steps)`);
509
- }
501
+ planHint = renderPlanHint(this._planMatch);
510
502
  } catch (e) {
511
- this.debugLog(`plan cache lookup skipped: ${e.message}`);
503
+ this.debugLog(`plan cache hint skipped: ${e.message}`);
512
504
  }
513
505
 
514
506
  return { lessonsLearned, designContext, antiRepeatDirective, universalSkillsContext, planHint };
@@ -601,6 +593,10 @@ export class AgentLoop {
601
593
  for (const toolCall of toolCalls) {
602
594
  const functionName = canonicalToolName(toolCall.function.name);
603
595
  if (!AgentLoop.READ_ONLY_TOOLS.has(functionName)) continue;
596
+ // Prefetch begins before the serial dispatch path. If this tool has a
597
+ // PreToolUse hook, running it here would create a temporal pre-trust
598
+ // window where the read happens before the hook can deny it.
599
+ if (hasMatchingHook(this.config.hooks, 'PreToolUse', functionName)) continue;
604
600
 
605
601
  let args;
606
602
  try {
@@ -800,6 +796,7 @@ export class AgentLoop {
800
796
  this._planSteps = [];
801
797
  this._offeredPlanId = null;
802
798
  this._planSimilarity = 0;
799
+ this._planMatch = null;
803
800
  this._lessonSimilarity = 0;
804
801
  this._lastToolResult = null;
805
802
  this._paradigmShiftPending = false;
@@ -816,6 +813,36 @@ export class AgentLoop {
816
813
  this.unlockPack(pack);
817
814
  }
818
815
 
816
+ // Reuse a known route before the first model call. Exact keyword-set
817
+ // matches are always eligible; fuzzy reuse requires at least medium task
818
+ // classification confidence because APC's threshold study shows that
819
+ // aggressive fuzzy hits trade accuracy for speed.
820
+ try {
821
+ this._planMatch = lookupPlan({
822
+ prompt: this.userPrompt,
823
+ archetype: this.taskShape ? this.taskShape.archetype : '',
824
+ allowFuzzy: this.taskShape?.confidence !== 'low'
825
+ });
826
+ if (this._planMatch) {
827
+ this._offeredPlanId = this._planMatch.template.id;
828
+ this._planSimilarity = this._planMatch.similarity;
829
+ for (const step of this._planMatch.template.steps || []) {
830
+ if (!step.ok || !resolveTool(step.tool)) continue;
831
+ this.unlockedTools.add(step.tool);
832
+ const pack = packForTool(step.tool);
833
+ if (pack) this.unlockPack(pack);
834
+ }
835
+ this.debugLog(
836
+ `plan cache ${this._planMatch.matchType || 'fuzzy'} hit `
837
+ + `${(this._planMatch.similarity * 100).toFixed(0)}% `
838
+ + `(${this._planMatch.template.steps.length} steps, `
839
+ + `${this._planMatch.telemetry?.lookupMs?.toFixed(2) || '0.00'} ms)`
840
+ );
841
+ }
842
+ } catch (e) {
843
+ this.debugLog(`plan cache lookup skipped: ${e.message}`);
844
+ }
845
+
819
846
  this.debugLog(`task shape: ${this.taskShape.archetype} (${this.taskShape.confidence} confidence)`);
820
847
  }
821
848
 
@@ -972,6 +999,13 @@ export class AgentLoop {
972
999
  });
973
1000
  if (compaction.totalReclaimed > 0) {
974
1001
  this.debugLog(`progressive compaction reclaimed ${(compaction.totalReclaimed / 1024).toFixed(1)} KB [${compaction.stagesFired.join(', ')}]`);
1002
+ auditLog.log({
1003
+ event: 'context_compaction', taskId: this.taskId,
1004
+ kind: 'progressive', stages: compaction.stagesFired,
1005
+ reclaimedChars: compaction.totalReclaimed,
1006
+ finalChars: compaction.finalChars,
1007
+ limitChars: MAX_CHAR_POOL
1008
+ });
975
1009
  }
976
1010
 
977
1011
  const { window: slidingWindow, splitIndex } = buildWindow(this.messages, MAX_CHAR_POOL);
@@ -996,9 +1030,17 @@ export class AgentLoop {
996
1030
  if (splitIndex >= 1) {
997
1031
  const dropped = this.messages.slice(1, splitIndex + 1);
998
1032
  if (dropped.length > 0) {
999
- const removed = compactInPlace(this.messages, splitIndex, describeDroppedMessages(dropped));
1033
+ const compactSummary = buildStructuralSummary(dropped) || describeDroppedMessages(dropped);
1034
+ const removed = compactInPlace(this.messages, splitIndex, compactSummary);
1000
1035
  this._lastCompactionIndex = null;
1001
1036
  this.debugLog(`compacted ${removed} messages out of the live context`);
1037
+ auditLog.log({
1038
+ event: 'context_compaction', taskId: this.taskId,
1039
+ kind: 'summary', removedMessages: removed,
1040
+ retainedSummaryChars: compactSummary.length,
1041
+ limitChars: MAX_CHAR_POOL
1042
+ });
1043
+ this.notice(`Context compacted: ${removed} earlier messages condensed into a reviewable summary.`);
1002
1044
 
1003
1045
  // V5 Cortex tiered condensation. Tier 1 (structural, free) always
1004
1046
  // runs so the Digital Brain keeps learning on every provider. Tier 2
@@ -1029,6 +1071,8 @@ export class AgentLoop {
1029
1071
  unlockedTools: this.unlockedTools,
1030
1072
  unlockOrder: this.unlockOrder,
1031
1073
  grantAllTools: this.config.grantAllTools === true,
1074
+ allowedTools: this.sandbox.allowedTools,
1075
+ deniedTools: this.config.deniedTools,
1032
1076
  residentOnly
1033
1077
  });
1034
1078
  // A delegated subagent runs with a declared toolScope. Enforcing it
@@ -1699,58 +1743,10 @@ export class AgentLoop {
1699
1743
 
1700
1744
  this.consecutiveBlockedActions = 0; // Reset counter on allowed action
1701
1745
 
1702
- // Approval flow for dangerous actions (Mode A/B) or ALWAYS for heavy AI downloads
1703
- // The schema name is manage_local_dependencies. Comparing against the
1704
- // old alias meant this consent gate never fired and multi-GB model
1705
- // downloads ran unannounced.
1706
- const isHeavyAiDownload = functionName === 'manage_local_dependencies' &&
1707
- ['pipInst', 'fetchBin', 'fetchMod'].includes(args.action);
1708
- // A standing approval from "Allow always" lifts the gate for this
1709
- // tool until the process restarts. It is checked HERE rather than
1710
-
1711
- // asking and then auto-answering would still interrupt the user,
1712
- // which is the entire thing they were trying to stop.
1713
- //
1714
- // Nothing else is bypassed: the sandbox has already run above, so a
1715
- // FORBIDDEN command is still refused and a path outside the workspace
1716
- // is still blocked. This waives the QUESTION, not the guard.
1717
- const standingApproval = isAlwaysAllowed(functionName);
1718
- if (standingApproval) {
1719
- auditLog.log({
1720
- event: 'approval_auto_granted', taskId: this.taskId,
1721
- tool: functionName, risk: validation.risk, reason: 'allow_always'
1722
- });
1723
- }
1724
-
1725
- if (!standingApproval && (
1726
- validation.risk === 'extreme' ||
1727
- isHeavyAiDownload ||
1728
- (this.config.autonomyMode !== 'C' && validation.risk === 'dangerous') ||
1729
- this.config.autonomyMode === 'A')) {
1730
-
1731
- // Wait for local approval
1732
- this.emitEvent({ type: 'approval_request', toolCallId: toolCall.id, tool: functionName, args, risk: validation.risk });
1733
-
1734
- const approved = await this.waitForApproval(toolCall.id);
1735
-
1736
- if (!approved) {
1737
- this.messages.push({
1738
- role: 'tool',
1739
- tool_call_id: toolCall.id,
1740
- name: functionName,
1741
- content: JSON.stringify({ success: false, error: 'User denied this action' })
1742
- });
1743
- continue;
1744
- }
1745
- }
1746
-
1747
- // Execute
1746
+ // Resolve and validate before asking the user for approval. Unknown or
1747
+ // malformed calls should self-correct without creating approval fatigue.
1748
1748
  const tool = resolveTool(functionName);
1749
-
1750
- // GUARD: Prevent crash if LLM hallucinates a non-existent tool name
1751
1749
  if (!tool || typeof tool.execute !== 'function') {
1752
- // Only advertise what is currently in context. Listing all 69 names
1753
- // here re-bloated the very context progressive disclosure protects.
1754
1750
  this.messages.push({
1755
1751
  role: 'tool',
1756
1752
  tool_call_id: toolCall.id,
@@ -1763,10 +1759,6 @@ export class AgentLoop {
1763
1759
  continue;
1764
1760
  }
1765
1761
 
1766
- // AUTO-UNLOCK RECOVERY: the model named a real tool whose pack is not
1767
- // loaded yet (it remembered it from an earlier task, or guessed well).
1768
- // Unlock the pack and run it rather than dead-ending — capability is
1769
- // deferred, never removed.
1770
1762
  if (!isCoreTool(functionName) && !this.unlockedTools.has(functionName)) {
1771
1763
  const pack = packForTool(functionName);
1772
1764
  if (this.unlockPack(pack)) {
@@ -1775,26 +1767,29 @@ export class AgentLoop {
1775
1767
  this.unlockedTools.add(functionName);
1776
1768
  }
1777
1769
 
1778
- // V5: Tool Call Argument Validation Layer
1779
1770
  const validationErrors = this.validateToolArgs(functionName, args);
1780
1771
  if (validationErrors) {
1781
1772
  this.messages.push({
1782
1773
  role: 'tool',
1783
1774
  tool_call_id: toolCall.id,
1784
1775
  name: functionName,
1785
- content: JSON.stringify({
1786
- success: false,
1787
- error: `Invalid arguments: ${validationErrors}. Please fix and retry.`
1776
+ content: JSON.stringify({
1777
+ success: false,
1778
+ error: `Invalid arguments: ${validationErrors}. Please fix and retry.`
1788
1779
  })
1789
1780
  });
1790
1781
  continue;
1791
1782
  }
1792
1783
 
1793
- // Stage 3 seam: PreToolUse hook. A no-op that always allows today; when
1794
- // hooks are configured it can deny a call before it runs, returned as an
1795
- // is_error tool result so the model self-corrects (never thrown).
1784
+ // A policy hook runs after the built-in sandbox but before any user
1785
+ // approval prompt, so a deterministic denial never asks the user to
1786
+ // approve work that will be rejected anyway.
1796
1787
  const preHook = await this.runPreToolUse(functionName, args, toolCall.id);
1797
1788
  if (preHook && preHook.denied) {
1789
+ auditLog.log({
1790
+ event: 'hook_denied', taskId: this.taskId, hookEvent: 'PreToolUse',
1791
+ tool: functionName, reason: preHook.reason || 'hook denied'
1792
+ });
1798
1793
  this.messages.push({
1799
1794
  role: 'tool',
1800
1795
  tool_call_id: toolCall.id,
@@ -1808,6 +1803,51 @@ export class AgentLoop {
1808
1803
  continue;
1809
1804
  }
1810
1805
 
1806
+ // Approval flow for dangerous actions (Mode A/B) or ALWAYS for heavy AI downloads
1807
+ // The schema name is manage_local_dependencies. Comparing against the
1808
+ // old alias meant this consent gate never fired and multi-GB model
1809
+ // downloads ran unannounced.
1810
+ const isHeavyAiDownload = functionName === 'manage_local_dependencies' &&
1811
+ ['pipInst', 'fetchBin', 'fetchMod'].includes(args.action);
1812
+ // A standing approval from "Allow always" lifts the gate for this
1813
+ // tool until the process restarts. It is checked HERE rather than
1814
+
1815
+ // asking and then auto-answering would still interrupt the user,
1816
+ // which is the entire thing they were trying to stop.
1817
+ //
1818
+ // Nothing else is bypassed: the sandbox has already run above, so a
1819
+ // FORBIDDEN command is still refused and a path outside the workspace
1820
+ // is still blocked. This waives the QUESTION, not the guard.
1821
+ const standingApproval = isAlwaysAllowed(functionName);
1822
+ if (standingApproval) {
1823
+ auditLog.log({
1824
+ event: 'approval_auto_granted', taskId: this.taskId,
1825
+ tool: functionName, risk: validation.risk, reason: 'allow_always'
1826
+ });
1827
+ }
1828
+
1829
+ if (!standingApproval && (
1830
+ validation.risk === 'extreme' ||
1831
+ isHeavyAiDownload ||
1832
+ (this.config.autonomyMode !== 'C' && validation.risk === 'dangerous') ||
1833
+ this.config.autonomyMode === 'A')) {
1834
+
1835
+ // Wait for local approval
1836
+ this.emitEvent({ type: 'approval_request', toolCallId: toolCall.id, tool: functionName, args, risk: validation.risk });
1837
+
1838
+ const approved = await this.waitForApproval(toolCall.id);
1839
+
1840
+ if (!approved) {
1841
+ this.messages.push({
1842
+ role: 'tool',
1843
+ tool_call_id: toolCall.id,
1844
+ name: functionName,
1845
+ content: JSON.stringify({ success: false, error: 'User denied this action' })
1846
+ });
1847
+ continue;
1848
+ }
1849
+ }
1850
+
1811
1851
  // P2: snapshot the files this call is about to change, before it runs.
1812
1852
  // Only the mutating fs tools match; everything else returns immediately.
1813
1853
  // Fail-open by contract — this can never block or fail a tool call.
@@ -2393,26 +2433,32 @@ export class AgentLoop {
2393
2433
  return hints[errorType] || hints['unknown_error'];
2394
2434
  }
2395
2435
 
2396
- // V5: Validate tool call arguments before execution
2397
- /**
2398
- * PreToolUse / PostToolUse seams (Stage 3 fills these in).
2399
- *
2400
- * Today they are no-ops that always allow, so the dispatch behaviour is exactly
2401
- * what it was. When `.causyne/settings.json` gains a `hooks` block (Stage 3),
2402
- * these run the user's shell hooks with the exit-code protocol
2403
- * (0 allow / 2 deny / else warn) and return { denied, messages }. The loop
2404
- * already branches on `denied`, so Stage 3 is a fill-in here, not another edit
2405
- * to the dispatch path. See src/tools/toolDispatch.js for the discipline these
2406
- * plug into.
2407
- */
2408
- async runPreToolUse(_toolName, _args, _toolCallId) {
2409
- return { denied: false, messages: [] };
2436
+ async runPreToolUse(toolName, args, toolCallId) {
2437
+ return runConfiguredHooks({
2438
+ hooks: this.config?.hooks,
2439
+ event: 'PreToolUse',
2440
+ toolName,
2441
+ args,
2442
+ taskId: this.taskId,
2443
+ toolCallId,
2444
+ workspacePaths: this.sandbox?.workspacePaths
2445
+ });
2410
2446
  }
2411
2447
 
2412
- async runPostToolUse(_toolName, _args, _result) {
2413
- return { denied: false, messages: [] };
2448
+ async runPostToolUse(toolName, args, result) {
2449
+ return runConfiguredHooks({
2450
+ hooks: this.config?.hooks,
2451
+ event: 'PostToolUse',
2452
+ toolName,
2453
+ args,
2454
+ result,
2455
+ taskId: this.taskId,
2456
+ workspacePaths: this.sandbox?.workspacePaths
2457
+ });
2414
2458
  }
2415
2459
 
2460
+ // V5: Validate tool call arguments before execution
2461
+
2416
2462
  validateToolArgs(toolName, args) {
2417
2463
  // Check for common LLM mistakes
2418
2464
 
@@ -0,0 +1,159 @@
1
+ #!/usr/bin/env node
2
+
3
+ import fs from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { performance } from 'node:perf_hooks';
7
+
8
+ // Never inspect or mutate a user's real plan cache. utils/paths.js resolves the
9
+ // state root at module initialization, so set it before importing planCache.js.
10
+ const benchmarkHome = fs.mkdtempSync(path.join(os.tmpdir(), 'causyne-plan-cache-bench-'));
11
+ process.env.CAUSYNE_HOME = benchmarkHome;
12
+
13
+ const {
14
+ forgetPlans,
15
+ lookupPlan,
16
+ planStats,
17
+ prewarmPlans
18
+ } = await import('../planCache.js');
19
+
20
+ const sizes = [1, 10, 20, 50, 100, 200];
21
+ const hitIterations = 500;
22
+ // Production deliberately cools down after a sustained low hit rate. Staying
23
+ // below that threshold measures a real miss scan instead of the cheap bypass.
24
+ const missIterations = 16;
25
+
26
+ function sample(index) {
27
+ const key = `taskkey${String(index).padStart(3, '0')}`;
28
+ return {
29
+ prompt: `benchmark workflow ${key} inspect validate artifact`,
30
+ archetype: 'benchmark',
31
+ steps: [
32
+ { tool: 'fs_search', action: 'inspect', ok: true },
33
+ { tool: 'sys_run', action: 'validate', ok: true }
34
+ ]
35
+ };
36
+ }
37
+
38
+ function percentile(values, fraction) {
39
+ const sorted = [...values].sort((left, right) => left - right);
40
+ if (sorted.length === 0) return 0;
41
+ return sorted[Math.min(sorted.length - 1, Math.floor((sorted.length - 1) * fraction))];
42
+ }
43
+
44
+ function measure(iterations, operation) {
45
+ const values = [];
46
+ for (let index = 0; index < iterations; index++) {
47
+ const startedAt = performance.now();
48
+ operation(index);
49
+ values.push(performance.now() - startedAt);
50
+ }
51
+ const total = values.reduce((sum, value) => sum + value, 0);
52
+ return {
53
+ iterations,
54
+ averageMs: total / values.length,
55
+ medianMs: percentile(values, 0.5),
56
+ p95Ms: percentile(values, 0.95),
57
+ maxMs: Math.max(...values)
58
+ };
59
+ }
60
+
61
+ function fixed(value) {
62
+ return Number(value.toFixed(4));
63
+ }
64
+
65
+ function compactMeasurement(measurement) {
66
+ return {
67
+ iterations: measurement.iterations,
68
+ averageMs: fixed(measurement.averageMs),
69
+ medianMs: fixed(measurement.medianMs),
70
+ p95Ms: fixed(measurement.p95Ms),
71
+ maxMs: fixed(measurement.maxMs)
72
+ };
73
+ }
74
+
75
+ function prewarmInBatches(seeds) {
76
+ const combined = { accepted: 0, rejected: 0, errors: [] };
77
+ for (let offset = 0; offset < seeds.length; offset += 100) {
78
+ const batch = prewarmPlans(seeds.slice(offset, offset + 100));
79
+ combined.accepted += batch.accepted;
80
+ combined.rejected += batch.rejected;
81
+ combined.errors.push(...batch.errors);
82
+ }
83
+ return combined;
84
+ }
85
+
86
+ const results = [];
87
+
88
+ try {
89
+ for (const size of sizes) {
90
+ forgetPlans();
91
+ const seeds = Array.from({ length: size }, (_, index) => sample(index));
92
+ const prewarmStartedAt = performance.now();
93
+ const prewarm = prewarmInBatches(seeds);
94
+ const prewarmMs = performance.now() - prewarmStartedAt;
95
+ const target = sample(Math.floor(size / 2));
96
+
97
+ // Warm filesystem and in-memory index before measuring lookup paths.
98
+ lookupPlan({ prompt: target.prompt, archetype: target.archetype, allowFuzzy: true });
99
+
100
+ const exact = measure(hitIterations, () => {
101
+ const match = lookupPlan({
102
+ prompt: target.prompt,
103
+ archetype: target.archetype,
104
+ allowFuzzy: true
105
+ });
106
+ if (!match || match.matchType !== 'exact') {
107
+ throw new Error(`Exact lookup failed at cache size ${size}`);
108
+ }
109
+ });
110
+
111
+ const fuzzyPrompt = target.prompt.replace('validate artifact', 'validate repaired artifact');
112
+ const fuzzy = measure(hitIterations, () => {
113
+ const match = lookupPlan({
114
+ prompt: fuzzyPrompt,
115
+ archetype: target.archetype,
116
+ allowFuzzy: true
117
+ });
118
+ if (!match || match.matchType !== 'fuzzy') {
119
+ throw new Error(`Fuzzy lookup failed at cache size ${size}`);
120
+ }
121
+ });
122
+
123
+ const miss = measure(missIterations, (index) => {
124
+ const match = lookupPlan({
125
+ prompt: `unrelated cold miss ${size} ${index} quantum orchard telescope`,
126
+ archetype: 'different-archetype',
127
+ allowFuzzy: true
128
+ });
129
+ if (match) throw new Error(`Unexpected cache hit at cache size ${size}`);
130
+ });
131
+
132
+ results.push({
133
+ cacheSize: size,
134
+ acceptedSeeds: prewarm.accepted,
135
+ prewarmMs: fixed(prewarmMs),
136
+ exact: compactMeasurement(exact),
137
+ fuzzy: compactMeasurement(fuzzy),
138
+ miss: compactMeasurement(miss),
139
+ telemetry: planStats()
140
+ });
141
+ }
142
+
143
+ const report = {
144
+ benchmark: 'Causyne Agent plan cache lookup',
145
+ generatedAt: new Date().toISOString(),
146
+ runtime: { node: process.version, platform: process.platform, arch: process.arch },
147
+ method: {
148
+ cacheSizes: sizes,
149
+ hitIterations,
150
+ missIterations,
151
+ note: 'Synthetic microbenchmark. It measures Causyne cache operations only; it does not reproduce paper task accuracy, model cost, or end-to-end agent latency.'
152
+ },
153
+ results
154
+ };
155
+
156
+ process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
157
+ } finally {
158
+ fs.rmSync(benchmarkHome, { recursive: true, force: true });
159
+ }
package/src/commands.js CHANGED
@@ -425,6 +425,89 @@ export const COMMANDS = [
425
425
  }
426
426
  },
427
427
 
428
+ {
429
+ name: 'plans',
430
+ scope: 'local',
431
+ usage: '/plans [import <json-file> | forget]',
432
+ description: 'Inspect, pre-warm, or clear reusable task plans',
433
+ dialogTitle: 'Plan cache',
434
+ run: async (args = []) => {
435
+ const cache = await import('./planCache.js');
436
+ const verb = String(args[0] || '').toLowerCase();
437
+
438
+ if (verb === 'forget') {
439
+ const removed = cache.forgetPlans();
440
+ return ok(`Forgot ${removed} cached plan${removed === 1 ? '' : 's'}.`);
441
+ }
442
+
443
+ if (verb === 'import') {
444
+ const requested = args.slice(1).join(' ').trim();
445
+ if (!requested) return fail('Usage: /plans import <json-file>');
446
+ const file = path.resolve(requested.replace(/^~(?=\/)/, os.homedir()));
447
+ try {
448
+ const stat = fs.statSync(file);
449
+ if (!stat.isFile() || stat.size > 1024 * 1024) {
450
+ return fail('Plan seed must be one JSON file no larger than 1 MB.');
451
+ }
452
+ const outcome = cache.prewarmPlans(JSON.parse(fs.readFileSync(file, 'utf8')));
453
+ const detail = outcome.errors.length > 0 ? `\n${outcome.errors.join('\n')}` : '';
454
+ return outcome.accepted > 0
455
+ ? ok(`Imported ${outcome.accepted} plan${outcome.accepted === 1 ? '' : 's'}; rejected ${outcome.rejected}.${detail}`)
456
+ : fail(`No plans were imported.${detail}`);
457
+ } catch (error) {
458
+ return fail(`Could not import plan seeds: ${error.message}`);
459
+ }
460
+ }
461
+
462
+ const stats = cache.planStats();
463
+ return ok([
464
+ `${stats.count} cached plan${stats.count === 1 ? '' : 's'}`,
465
+ `Hits: ${stats.hits}/${stats.lookups} (${(stats.hitRate * 100).toFixed(1)}%) — exact ${stats.exactHits}, fuzzy ${stats.fuzzyHits}`,
466
+ `Average lookup: ${stats.averageLookupMs.toFixed(3)} ms; max ${stats.maxLookupMs.toFixed(3)} ms`,
467
+ stats.disabled
468
+ ? `Lookup cooling down until ${new Date(stats.disabledUntil).toISOString()} after persistent misses.`
469
+ : 'Lookup active.',
470
+ '',
471
+ 'Pre-warm with /plans import <json-file>. Clear with /plans forget.'
472
+ ].join('\n'));
473
+ }
474
+ },
475
+
476
+ {
477
+ name: 'plugins',
478
+ scope: 'local',
479
+ usage: '/plugins [enable <name> | disable <name>]',
480
+ description: 'Inspect or change trust for local plugins',
481
+ dialogTitle: 'Plugins',
482
+ run: async (args = []) => {
483
+ const manager = await import('./plugins/pluginManager.js');
484
+ const verb = String(args[0] || '').toLowerCase();
485
+ const name = String(args[1] || '');
486
+ if (verb === 'enable') {
487
+ if (!name) return fail('Usage: /plugins enable <name>');
488
+ const result = manager.enablePlugin(name);
489
+ return result.ok
490
+ ? ok(`Enabled ${name} at manifest digest ${result.digest}. Changes will revoke trust automatically.`)
491
+ : fail(`Could not enable ${name}: ${result.error}`);
492
+ }
493
+ if (verb === 'disable') {
494
+ if (!name) return fail('Usage: /plugins disable <name>');
495
+ const result = manager.disablePlugin(name);
496
+ return result.ok ? ok(`Disabled ${name}.`) : fail(`Could not disable ${name}: ${result.error}`);
497
+ }
498
+
499
+ const rows = manager.listPlugins();
500
+ if (rows.length === 0) return ok(`No local plugins. Plugin root: ${manager.PLUGIN_ROOT}`);
501
+ return ok(rows.map((plugin) => {
502
+ const state = plugin.invalid ? 'invalid' : plugin.modified ? 'modified; trust revoked' : plugin.enabled ? 'enabled' : 'disabled';
503
+ const capabilities = Array.isArray(plugin.capabilities) && plugin.capabilities.length > 0
504
+ ? plugin.capabilities.join(', ')
505
+ : 'none';
506
+ return ` ${plugin.name} ${plugin.version || ''} — ${state}; capabilities: ${capabilities}`;
507
+ }).join('\n') + `\n\nPlugin root: ${manager.PLUGIN_ROOT}`);
508
+ }
509
+ },
510
+
428
511
  {
429
512
  name: 'sessions',
430
513
  aliases: ['session'],