@compr/opscontext-mcp 2.5.3 → 2.5.5

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/index.js CHANGED
@@ -10,10 +10,11 @@ import { collectProjectOps, collectSystemOps } from "./collectors.js";
10
10
  import { loadCache, saveCache } from "./cache.js";
11
11
  import { listProjects, checkPorts, runComplianceAudit, formatProjectList, formatPortMap, formatPlan, scoreProject, formatScoreReport, runScoreCanary, } from "./agents.js";
12
12
  import { saveSession, loadSession, listSessions, deleteSession, formatSession, formatSessionList, } from "./sessions.js";
13
- import { verifyChain, readAuditLog, filterByRange } from "./audit.js";
13
+ import { verifyChain, readAuditLog, filterByRange, autoRotateAuditLog } from "./audit.js";
14
14
  import { startEventIngestServer } from "./http-server.js";
15
15
  import { detect } from "./detector.js";
16
- import { saveLearning, searchLearnings, listLearnings, deleteLearning, learningsToChunks, learningsStats, formatLearnings, importLearningsFromFile, autoImportFromSources, LEARNING_CATEGORIES, } from "./learnings.js";
16
+ import { buildCostReport } from "./cost-report.js";
17
+ import { saveLearning, searchLearnings, listLearnings, deleteLearning, learningsToChunks, learningsStats, formatLearnings, importLearningsFromFile, autoImportFromSources, LEARNING_CATEGORIES, parseSince } from "./learnings.js";
17
18
  import { communityRulesToChunks, mergeWithDedup, loadCommunityStore, } from "./community-sync.js";
18
19
  import { readFileSync, existsSync, watch, statSync, writeFileSync, mkdirSync } from "fs";
19
20
  import { basename, join, dirname } from "path";
@@ -609,6 +610,9 @@ server.tool("audit_verify", "Verify the integrity of the local audit log chain.
609
610
  const summary = [];
610
611
  summary.push(`Audit chain: ${report.ok ? "✅ INTACT" : "❌ BROKEN"}`);
611
612
  summary.push(`Total records: ${report.total}`);
613
+ if ((report.redactedIndices ?? []).length > 0) {
614
+ summary.push(`Redacted and acknowledged on the chain: ${report.redactedIndices.length} record(s), not counted as altered`);
615
+ }
612
616
  if (since || until) {
613
617
  summary.push(`Range filter: ${since ?? "start"} → ${until ?? "now"} (${filtered.length} record(s) in range)`);
614
618
  }
@@ -623,6 +627,28 @@ server.tool("audit_verify", "Verify the integrity of the local audit log chain.
623
627
  return respond("audit_verify", summary.join("\n"));
624
628
  });
625
629
  // ---------------------------------------------------------------------------
630
+ // Tool: agent_cost (multi-agent token / cost / capacity report)
631
+ // ---------------------------------------------------------------------------
632
+ // Same renderer as `contextengine cost`. [LOCK] [COST-REPORT-ONE-RENDERER]
633
+ // Free tool: it reads the caller's own Claude Code transcripts on this machine,
634
+ // nothing leaves it. Added 2026-08-21, one day after the CLI (707fcc8).
635
+ server.tool("agent_cost", "Multi-agent cost report from Claude Code's own transcripts on this machine: tokens moved (cache read/write, fresh input, output), valued cost at API list prices (marked NOTIONAL on a subscription, UNPRICED when no rate matches), capacity intensity (subagents, failed, died at window, tool calls per agent, cache reuse), top runs, and context_burn / fanout_without_canary signals. Call it after a fan-out to read what it consumed, or before one to compare with the last. Thresholds come from .contextengine/policy.json agent_cost, else built-in defaults.", {
636
+ days: z.number().int().positive().optional().describe("Only runs started within the last N days"),
637
+ project: z.string().optional().describe("Filter by project slug as it appears in ~/.claude/projects (e.g. -Users-yan-Projects-ContextEngine)"),
638
+ session: z.string().optional().describe("Filter by parent session id"),
639
+ run: z.string().optional().describe("Filter by run id (wf_... or task group id)"),
640
+ top: z.number().int().positive().max(50).optional().describe("How many runs to list (default 10)"),
641
+ json: z.boolean().optional().describe("Return the structured JSON report instead of the text one"),
642
+ policy_dir: z.string().optional().describe("Absolute path of the repo whose .contextengine/policy.json supplies agent_cost thresholds and rates. Default: the MCP server's working directory, which under launchd is the home dir, not a repo; the report names which source it used on its 'thresholds:' line"),
643
+ }, async ({ days, project, session, run, top, json, policy_dir }) => {
644
+ // [COST-POLICY-DIR-IS-EXPLICIT] — the daemon's cwd is not a project. Without this the MCP
645
+ // surface silently priced with built-in defaults while the CLI in the repo read policy.json.
646
+ const report = buildCostReport({ days, project, session, run, top }, policy_dir || process.cwd());
647
+ if (json && report.json)
648
+ return respond("agent_cost", JSON.stringify(report.json, null, 2));
649
+ return respond("agent_cost", report.text);
650
+ });
651
+ // ---------------------------------------------------------------------------
626
652
  // Tool: drift_status (Detector — read current drift signals)
627
653
  // ---------------------------------------------------------------------------
628
654
  // Agents should call this between major task phases. If any 'critical' signal
@@ -893,10 +919,21 @@ server.tool("list_learnings", "List all permanent learnings, optionally filtered
893
919
  .string()
894
920
  .optional()
895
921
  .describe("Filter by category (deployment, api, database, etc.). Omit to show all."),
896
- }, async ({ category }) => {
922
+ since: z
923
+ .string()
924
+ .optional()
925
+ .describe("Only learnings created at or after this boundary: 'today', 'yesterday' (Europe/Zurich calendar days) or an ISO date/instant. Every entry shows its created instant, UTC plus Europe/Zurich."),
926
+ }, async ({ category, since }) => {
897
927
  // Project-scoped: only show learnings for active workspace projects + universal (no project)
928
+ let sinceDate;
929
+ if (since) {
930
+ const parsed = parseSince(since);
931
+ if (!parsed)
932
+ return respond("list_learnings", `❌ since="${since}" is not today, yesterday, or an ISO date. No list rendered, so this is not a zero.`);
933
+ sinceDate = parsed;
934
+ }
898
935
  const learnings = listLearnings(category, activeProjectNames);
899
- const text = formatLearnings(learnings);
936
+ const text = formatLearnings(learnings, { since: sinceDate, sinceSpec: since });
900
937
  return respond("list_learnings", text);
901
938
  });
902
939
  // ---------------------------------------------------------------------------
@@ -1131,6 +1168,24 @@ async function main() {
1131
1168
  const transport = new StdioServerTransport();
1132
1169
  await server.connect(transport);
1133
1170
  console.error("[ContextEngine] 🚀 MCP server running on stdio (keyword search ready)");
1171
+ // 3a. Audit log auto-rotation. Deferred so the first requests are answered before the
1172
+ // synchronous verify + rewrite (a few seconds on a 500k-record chain) blocks the loop.
1173
+ // [LOCK] [AUTO-ROTATE-HYSTERESIS-AND-ONE-RUNNER]
1174
+ // Measured 2026-08-21: ~13k records/hour on this machine, so the 100k trigger is hours
1175
+ // away, not a day; a server that is never restarted must still rotate. Hourly recheck.
1176
+ const runAutoRotate = () => {
1177
+ try {
1178
+ const o = autoRotateAuditLog();
1179
+ if (o.action === "rotated" || o.action === "refused" || o.action === "error" || o.action === "in_progress") {
1180
+ console.error(`[ContextEngine] 📦 audit auto-rotate (${o.action}): ${o.detail}`);
1181
+ }
1182
+ }
1183
+ catch (err) {
1184
+ console.error(`[ContextEngine] ⚠ audit auto-rotate failed: ${err.message}`);
1185
+ }
1186
+ };
1187
+ setTimeout(runAutoRotate, 3_000).unref();
1188
+ setInterval(runAutoRotate, 60 * 60_000).unref();
1134
1189
  // 3b. Write server-meta.json so the VS Code extension can read tool count
1135
1190
  // without needing an active MCP session. Single source of truth =
1136
1191
  // src/tools-manifest.ts (asserted by tests/tools-manifest.test.ts).
@@ -104,5 +104,18 @@ export declare function learningsStats(): {
104
104
  /**
105
105
  * Format learnings for display.
106
106
  */
107
- export declare function formatLearnings(learnings: Learning[]): string;
107
+ export declare const LEARNINGS_LOCAL_TZ = "Europe/Zurich";
108
+ /** `2026-09-05 10:30Z (12:30 CEST)`; `undated` when the record carries no usable instant. */
109
+ export declare function formatLearnedAt(iso: string | undefined, tz?: string): string;
110
+ /** `today` | `yesterday` (calendar days in `tz`) | any ISO date or instant. `null` when unparseable. */
111
+ export declare function parseSince(spec: string, now?: Date, tz?: string): Date | null;
112
+ /** Records created at or after `since`, oldest first. Undated records are excluded and counted by the caller. */
113
+ export declare function filterSince(learnings: Learning[], since: Date): Learning[];
114
+ export interface FormatLearningsOptions {
115
+ /** Already-parsed boundary; the caller resolves the spec so an invalid one errors before rendering. */
116
+ since?: Date;
117
+ /** The spec as typed, echoed in the header so the reader sees which boundary applied. */
118
+ sinceSpec?: string;
119
+ }
120
+ export declare function formatLearnings(learnings: Learning[], opts?: FormatLearningsOptions): string;
108
121
  //# sourceMappingURL=learnings.d.ts.map
package/dist/learnings.js CHANGED
@@ -680,12 +680,76 @@ export function learningsStats() {
680
680
  /**
681
681
  * Format learnings for display.
682
682
  */
683
- export function formatLearnings(learnings) {
683
+ // [LOCKED] [LEARNINGS-LIST-SHOWS-CREATED] 2026-09-05
684
+ // [NEVER] print a learning without its `created` instant, and [NEVER] answer "what was
685
+ // saved since X" by probing learnings.json with a hand-written key.
686
+ // WHY: on 2026-09-05 an agent read a date field the records do not have (`createdAt`),
687
+ // got an empty string for every record, and answered "0 saved today" with full
688
+ // confidence; a second agent made the same mistake the same morning. A probe on a
689
+ // missing key returns a confident zero, never an error. The store had 22 records
690
+ // from that day, under `created`. Until then the listing showed the date only,
691
+ // so "today" was also ambiguous around midnight between UTC and Yan's clock.
692
+ // FIX: one renderer shows `created` as the UTC instant plus the Europe/Zurich wall
693
+ // time, and `--since today|yesterday|ISO` is a first-class filter whose empty
694
+ // result names the boundary it applied. An unparseable spec is an error, not zero.
695
+ export const LEARNINGS_LOCAL_TZ = "Europe/Zurich";
696
+ /** `2026-09-05 10:30Z (12:30 CEST)`; `undated` when the record carries no usable instant. */
697
+ export function formatLearnedAt(iso, tz = LEARNINGS_LOCAL_TZ) {
698
+ if (!iso)
699
+ return "undated";
700
+ const d = new Date(iso);
701
+ if (Number.isNaN(d.getTime()))
702
+ return "undated";
703
+ const utc = d.toISOString().slice(0, 16).replace("T", " ") + "Z";
704
+ const local = new Intl.DateTimeFormat("en-GB", {
705
+ timeZone: tz, hour: "2-digit", minute: "2-digit", hour12: false, timeZoneName: "short",
706
+ }).format(d);
707
+ return `${utc} (${local})`;
708
+ }
709
+ /** Midnight of the given calendar day in `tz`, as a UTC instant. Offset read from Intl, never guessed. */
710
+ function localMidnightUtc(y, m, d, tz) {
711
+ const guess = new Date(Date.UTC(y, m - 1, d, 0, 0, 0));
712
+ const off = new Intl.DateTimeFormat("en-US", { timeZone: tz, timeZoneName: "longOffset" })
713
+ .formatToParts(guess).find((p) => p.type === "timeZoneName")?.value ?? "GMT";
714
+ const mm = /GMT([+-])(\d{2}):(\d{2})/.exec(off);
715
+ const minutes = mm ? (mm[1] === "-" ? -1 : 1) * (parseInt(mm[2], 10) * 60 + parseInt(mm[3], 10)) : 0;
716
+ return new Date(guess.getTime() - minutes * 60_000);
717
+ }
718
+ /** `today` | `yesterday` (calendar days in `tz`) | any ISO date or instant. `null` when unparseable. */
719
+ export function parseSince(spec, now = new Date(), tz = LEARNINGS_LOCAL_TZ) {
720
+ const s = spec.trim().toLowerCase();
721
+ if (s === "today" || s === "yesterday") {
722
+ const parts = new Intl.DateTimeFormat("en-CA", { timeZone: tz, year: "numeric", month: "2-digit", day: "2-digit" })
723
+ .formatToParts(now);
724
+ const get = (t) => parseInt(parts.find((p) => p.type === t)?.value ?? "0", 10);
725
+ const midnight = localMidnightUtc(get("year"), get("month"), get("day"), tz);
726
+ return s === "today" ? midnight : new Date(midnight.getTime() - 86_400_000);
727
+ }
728
+ if (!/^\d{4}-\d{2}-\d{2}/.test(spec.trim()))
729
+ return null;
730
+ const d = new Date(spec.trim());
731
+ return Number.isNaN(d.getTime()) ? null : d;
732
+ }
733
+ /** Records created at or after `since`, oldest first. Undated records are excluded and counted by the caller. */
734
+ export function filterSince(learnings, since) {
735
+ return learnings
736
+ .filter((l) => l.created && !Number.isNaN(new Date(l.created).getTime()) && new Date(l.created).getTime() >= since.getTime())
737
+ .sort((a, b) => new Date(a.created).getTime() - new Date(b.created).getTime());
738
+ }
739
+ export function formatLearnings(learnings, opts = {}) {
740
+ let sinceNote = "";
741
+ if (opts.since) {
742
+ learnings = filterSince(learnings, opts.since);
743
+ sinceNote = ` since ${opts.sinceSpec ?? opts.since.toISOString()} = ${formatLearnedAt(opts.since.toISOString())}`;
744
+ }
684
745
  if (learnings.length === 0) {
746
+ if (opts.since) {
747
+ return `0 learnings${sinceNote}. The boundary above is the one that was applied; if that looks wrong, the store is at ~/.contextengine/learnings.json and its date field is \`created\`.`;
748
+ }
685
749
  return "No learnings stored yet. Use `save_learning` to add operational rules.";
686
750
  }
687
751
  const lines = [];
688
- lines.push(`# 💡 Learnings Store (${learnings.length} rules)\n`);
752
+ lines.push(`# 💡 Learnings Store (${learnings.length} rules${sinceNote})\n`);
689
753
  // Group by category
690
754
  const byCategory = new Map();
691
755
  for (const l of learnings) {
@@ -704,8 +768,7 @@ export function formatLearnings(learnings) {
704
768
  lines.push(`- **Context:** ${l.context}`);
705
769
  if (l.tags?.length)
706
770
  lines.push(`- **Tags:** ${l.tags.join(", ")}`);
707
- if (l.created)
708
- lines.push(`- **Learned:** ${l.created.split("T")[0]}`);
771
+ lines.push(`- **Learned:** ${formatLearnedAt(l.created)}`);
709
772
  lines.push("");
710
773
  }
711
774
  }
@@ -24,14 +24,14 @@
24
24
  * Every tool name registered on the MCP server, in registration order.
25
25
  * Order is not load-bearing — kept stable for easier diffs.
26
26
  */
27
- export declare const ALL_TOOLS: readonly ["search_context", "list_sources", "read_source", "reindex", "list_projects", "check_ports", "run_audit", "score_project", "save_session", "load_session", "list_sessions", "delete_session", "audit_verify", "drift_status", "end_session", "save_learning", "list_learnings", "delete_learning", "import_learnings", "activate", "activation_status"];
27
+ export declare const ALL_TOOLS: readonly ["search_context", "list_sources", "read_source", "reindex", "list_projects", "check_ports", "run_audit", "score_project", "save_session", "load_session", "list_sessions", "delete_session", "audit_verify", "drift_status", "agent_cost", "end_session", "save_learning", "list_learnings", "delete_learning", "import_learnings", "activate", "activation_status"];
28
28
  /**
29
29
  * The 4 tools gated behind PRO activation. Subset of `ALL_TOOLS`.
30
30
  * Must match `PREMIUM_TOOLS` in `src/activation.ts` (asserted by test).
31
31
  */
32
32
  export declare const PREMIUM_TOOL_NAMES: readonly ["score_project", "run_audit", "check_ports", "list_projects"];
33
33
  /** Total count — what users see as "Active on all N MCP tools". */
34
- export declare const TOOL_COUNT: 21;
34
+ export declare const TOOL_COUNT: 22;
35
35
  /** Free-tier tool count — everything except `PREMIUM_TOOL_NAMES`. */
36
36
  export declare const FREE_TOOL_COUNT: number;
37
37
  //# sourceMappingURL=tools-manifest.d.ts.map
@@ -39,6 +39,7 @@ export const ALL_TOOLS = [
39
39
  "delete_session",
40
40
  "audit_verify",
41
41
  "drift_status",
42
+ "agent_cost",
42
43
  "end_session",
43
44
  "save_learning",
44
45
  "list_learnings",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@compr/opscontext-mcp",
3
- "version": "2.5.3",
3
+ "version": "2.5.5",
4
4
  "description": "OpsContext for AI Agents — read-only fleet visibility (PM2/nginx/Docker/git/cron) + tamper-evident audit log + policy-as-code hooks. The ops + compliance layer Claude Code can't grow natively.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",