@compr/opscontext-mcp 2.5.4 → 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/cli.js CHANGED
@@ -646,7 +646,7 @@ import { SERVER_COMMANDS, suggestCommands } from "./cli-commands.js";
646
646
  import { collectProjectOps, collectSystemOps } from "./collectors.js";
647
647
  import { scanCodeDir } from "./code-chunker.js";
648
648
  import { listProjects, runComplianceAudit, formatProjectList, formatPlan, scoreProject, runScoreCanary, formatScoreReport, generateScoreHTML, generateProjectScoreMD, } from "./agents.js";
649
- import { listLearnings, learningsToChunks, learningsStats, formatLearnings, saveLearning, deleteLearning, importLearningsFromFile, autoImportFromSources, LEARNING_CATEGORIES, } from "./learnings.js";
649
+ import { listLearnings, parseSince, learningsToChunks, learningsStats, formatLearnings, saveLearning, deleteLearning, importLearningsFromFile, autoImportFromSources, LEARNING_CATEGORIES, } from "./learnings.js";
650
650
  import { saveSession, loadSession, listSessions, deleteSession, formatSession, formatSessionList, } from "./sessions.js";
651
651
  import { activate, deactivate, getActivationStatus, gateCheck, } from "./activation.js";
652
652
  import { syncTierA, syncTierB, loadCommunityStore, communityRulesToChunks, mergeWithDedup, STORE_PATH as COMMUNITY_STORE_PATH, } from "./community-sync.js";
@@ -755,12 +755,37 @@ async function cliListProjects() {
755
755
  const text = formatProjectList(projects);
756
756
  console.log(`\n${text}`);
757
757
  }
758
- async function cliListLearnings(category) {
758
+ async function cliListLearnings(args) {
759
+ // list-learnings [category] [--since today|yesterday|ISO]. [LOCK] [LEARNINGS-LIST-SHOWS-CREATED]
760
+ let category;
761
+ let sinceSpec;
762
+ for (let i = 0; i < args.length; i++) {
763
+ if (args[i] === "--since") {
764
+ sinceSpec = args[i + 1];
765
+ if (!sinceSpec) {
766
+ console.error("--since needs a value: today, yesterday, or an ISO date");
767
+ process.exit(1);
768
+ }
769
+ i++;
770
+ }
771
+ else if (!category) {
772
+ category = args[i];
773
+ }
774
+ }
775
+ let since;
776
+ if (sinceSpec) {
777
+ const parsed = parseSince(sinceSpec);
778
+ if (!parsed) {
779
+ console.error(`--since "${sinceSpec}" is not today, yesterday, or an ISO date. Refusing to answer with a zero.`);
780
+ process.exit(1);
781
+ }
782
+ since = parsed;
783
+ }
759
784
  // Project-scoped: only show learnings for workspace projects + universal
760
785
  const projectDirs = loadProjectDirs();
761
786
  const projectNames = projectDirs.map((d) => d.name);
762
787
  const learnings = listLearnings(category, projectNames);
763
- const text = formatLearnings(learnings);
788
+ const text = formatLearnings(learnings, { since, sinceSpec });
764
789
  console.log(`\n${text}`);
765
790
  }
766
791
  async function cliSaveLearning(args) {
@@ -2458,7 +2483,8 @@ Usage:
2458
2483
  contextengine search <query> [-n N] Search indexed knowledge (default: top 5)
2459
2484
  contextengine list-sources Show all indexed sources with chunk counts
2460
2485
  contextengine list-projects Discover and analyze all projects (Pro)
2461
- contextengine list-learnings [cat] List all learnings (optional: filter by category)
2486
+ contextengine list-learnings [cat] [--since today|yesterday|ISO]
2487
+ List learnings with their created instant (UTC + Europe/Zurich)
2462
2488
  contextengine save-learning <text> -c <category> Save a learning
2463
2489
  contextengine delete-learning <id> Delete a learning by ID
2464
2490
  contextengine import-learnings <file> [-c cat] [-p project] Bulk-import learnings
@@ -2579,8 +2605,7 @@ else if (command === "list-projects") {
2579
2605
  });
2580
2606
  }
2581
2607
  else if (command === "list-learnings") {
2582
- const category = process.argv[3];
2583
- cliListLearnings(category).catch((err) => {
2608
+ cliListLearnings(process.argv.slice(3)).catch((err) => {
2584
2609
  console.error("Error:", err);
2585
2610
  process.exit(1);
2586
2611
  });
package/dist/index.js CHANGED
@@ -14,7 +14,7 @@ import { verifyChain, readAuditLog, filterByRange, autoRotateAuditLog } from "./
14
14
  import { startEventIngestServer } from "./http-server.js";
15
15
  import { detect } from "./detector.js";
16
16
  import { buildCostReport } from "./cost-report.js";
17
- import { saveLearning, searchLearnings, listLearnings, deleteLearning, learningsToChunks, learningsStats, formatLearnings, importLearningsFromFile, autoImportFromSources, LEARNING_CATEGORIES, } from "./learnings.js";
17
+ import { saveLearning, searchLearnings, listLearnings, deleteLearning, learningsToChunks, learningsStats, formatLearnings, importLearningsFromFile, autoImportFromSources, LEARNING_CATEGORIES, parseSince } from "./learnings.js";
18
18
  import { communityRulesToChunks, mergeWithDedup, loadCommunityStore, } from "./community-sync.js";
19
19
  import { readFileSync, existsSync, watch, statSync, writeFileSync, mkdirSync } from "fs";
20
20
  import { basename, join, dirname } from "path";
@@ -919,10 +919,21 @@ server.tool("list_learnings", "List all permanent learnings, optionally filtered
919
919
  .string()
920
920
  .optional()
921
921
  .describe("Filter by category (deployment, api, database, etc.). Omit to show all."),
922
- }, 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 }) => {
923
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
+ }
924
935
  const learnings = listLearnings(category, activeProjectNames);
925
- const text = formatLearnings(learnings);
936
+ const text = formatLearnings(learnings, { since: sinceDate, sinceSpec: since });
926
937
  return respond("list_learnings", text);
927
938
  });
928
939
  // ---------------------------------------------------------------------------
@@ -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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@compr/opscontext-mcp",
3
- "version": "2.5.4",
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",