@compr/opscontext-mcp 2.8.1 → 2.8.2

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/CHANGELOG.md CHANGED
@@ -4,6 +4,16 @@ All notable changes to OpsContext for AI Agents (previously ContextEngine — MC
4
4
 
5
5
  > Entries for 2.2.0 through 2.4.0 were not backfilled here; see `docs/sessions/SESSION_19` through `SESSION_21` for those releases.
6
6
 
7
+ ## [2.8.2] 2026-09-08: every source says what it is
8
+
9
+ ### Added
10
+
11
+ - **One-line summary per source in `list_sources` and `contextengine list-sources`**
12
+ (`src/source-summary.ts`): frontmatter `description` when the file has one, else H1 title
13
+ plus first prose line, else the module docstring or header comment for code. Read from the
14
+ first 4 KB of each file only. Why: with 800+ sources an agent opened two or three whole
15
+ documents to find the right one; the summary lets it pick once.
16
+
7
17
  ## [2.8.1] 2026-09-07: a push is not done until its CI is read
8
18
 
9
19
  ### Added
package/dist/cli.js CHANGED
@@ -641,6 +641,7 @@ async function runInit() {
641
641
  // ---------------------------------------------------------------------------
642
642
  import { loadSources, loadProjectDirs, loadConfig, resolveProjectDir, findProjectRoot, looksLikePath } from "./config.js";
643
643
  import { ingestSources } from "./ingest.js";
644
+ import { summarizeSource } from "./source-summary.js";
644
645
  import { searchChunks } from "./search.js";
645
646
  import { SERVER_COMMANDS, suggestCommands } from "./cli-commands.js";
646
647
  import { collectProjectOps, collectSystemOps } from "./collectors.js";
@@ -743,6 +744,9 @@ async function cliListSources() {
743
744
  const count = chunks.filter((c) => c.source === s.name).length;
744
745
  const status = exists ? `✅ ${count} chunks` : "⚠ file not found";
745
746
  console.log(` ${s.name}: ${status}`);
747
+ const summary = exists ? summarizeSource(s) : "";
748
+ if (summary)
749
+ console.log(` ${summary}`);
746
750
  console.log(` ${s.path}`);
747
751
  }
748
752
  console.log("");
package/dist/index.js CHANGED
@@ -4,6 +4,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
4
4
  import { z } from "zod";
5
5
  import { loadSources, loadProjectDirs, loadConfig, resolveProjectDir } from "./config.js";
6
6
  import { ingestSources } from "./ingest.js";
7
+ import { summarizeSource } from "./source-summary.js";
7
8
  import { searchChunks } from "./search.js";
8
9
  import { initEmbeddings, embedChunks, embedKeyOf, vectorSearch, isEmbeddingsReady, } from "./embeddings.js";
9
10
  import { collectProjectOps, collectSystemOps } from "./collectors.js";
@@ -575,7 +576,7 @@ server.tool("search_context", "Search across all indexed project knowledge (copi
575
576
  // ---------------------------------------------------------------------------
576
577
  // Tool: list_sources
577
578
  // ---------------------------------------------------------------------------
578
- server.tool("list_sources", "List all knowledge sources indexed by ContextEngine, with their status (found/missing) and chunk counts.", {}, async () => {
579
+ server.tool("list_sources", "List all knowledge sources indexed by ContextEngine, each with a one-line summary (from the file's own head: frontmatter description, title plus first sentence, or module docstring), status (found/missing) and chunk counts. Read the summary to pick the right source before calling read_source.", {}, async () => {
579
580
  const lines = sources.map((s) => {
580
581
  const exists = existsSync(s.path);
581
582
  const count = chunks.filter((c) => c.source === s.name).length;
@@ -583,7 +584,8 @@ server.tool("list_sources", "List all knowledge sources indexed by ContextEngine
583
584
  const status = exists
584
585
  ? `✅ ${count} chunks${embeddedCount > 0 ? ` (${embeddedCount} embedded)` : ""}`
585
586
  : "⚠ file not found";
586
- return `${s.name}: ${status}\n ${s.path}`;
587
+ const summary = exists ? summarizeSource(s) : "";
588
+ return `${s.name}: ${status}${summary ? `\n ${summary}` : ""}\n ${s.path}`;
587
589
  });
588
590
  const embStatus = isEmbeddingsReady()
589
591
  ? `✅ ${embeddedChunks.length} vectors`
@@ -0,0 +1,9 @@
1
+ import type { KnowledgeSource } from "./config.js";
2
+ export declare const HEAD_BYTES = 4096;
3
+ export declare const SUMMARY_MAX = 110;
4
+ /** Read at most `bytes` from the start of a file. Empty string on any error. */
5
+ export declare function readHead(path: string, bytes?: number): string;
6
+ /** Summary for a configured source; "" when the file is unreadable or says nothing. */
7
+ export declare function summarizeSource(source: KnowledgeSource): string;
8
+ export declare function summarizeText(text: string, type: "markdown" | "code"): string;
9
+ //# sourceMappingURL=source-summary.d.ts.map
@@ -0,0 +1,150 @@
1
+ /**
2
+ * One-line summary per knowledge source, for list_sources (MCP) and
3
+ * `contextengine list-sources` (CLI).
4
+ *
5
+ * Why: with 800+ sources, an agent reading a bare name plus a path opens two
6
+ * or three files to find the right one. Each open puts a whole document into
7
+ * its context (see CLAUDE.md, multi-agent cost). A summary line derived from
8
+ * the first few KB of the file lets it pick once.
9
+ *
10
+ * Reads only the head of the file (HEAD_BYTES), never the whole document, so
11
+ * the cost is bounded no matter how many sources are configured.
12
+ */
13
+ import { openSync, readSync, closeSync } from "fs";
14
+ export const HEAD_BYTES = 4096;
15
+ export const SUMMARY_MAX = 110;
16
+ /** Read at most `bytes` from the start of a file. Empty string on any error. */
17
+ export function readHead(path, bytes = HEAD_BYTES) {
18
+ let fd;
19
+ try {
20
+ fd = openSync(path, "r");
21
+ const buf = Buffer.alloc(bytes);
22
+ const n = readSync(fd, buf, 0, bytes, 0);
23
+ return buf.subarray(0, n).toString("utf-8");
24
+ }
25
+ catch {
26
+ return "";
27
+ }
28
+ finally {
29
+ if (fd !== undefined)
30
+ closeSync(fd);
31
+ }
32
+ }
33
+ /** Summary for a configured source; "" when the file is unreadable or says nothing. */
34
+ export function summarizeSource(source) {
35
+ return summarizeText(readHead(source.path), source.type);
36
+ }
37
+ export function summarizeText(text, type) {
38
+ if (!text)
39
+ return "";
40
+ return clip(type === "code" ? summarizeCode(text) : summarizeMarkdown(text));
41
+ }
42
+ function clip(s) {
43
+ const one = s.replace(/\s+/g, " ").trim();
44
+ if (one.length <= SUMMARY_MAX)
45
+ return one;
46
+ return one.slice(0, SUMMARY_MAX - 3).trimEnd() + "...";
47
+ }
48
+ /** Strip markdown decoration so the line reads as plain text. */
49
+ function plain(line) {
50
+ return line
51
+ .replace(/!\[[^\]]*\]\([^)]*\)/g, "") // images
52
+ .replace(/\[([^\]]+)\]\([^)]*\)/g, "$1") // links -> text
53
+ .replace(/`([^`]*)`/g, "$1") // inline code
54
+ .replace(/\*\*|__/g, "") // bold
55
+ .replace(/^\s*(?:[-*+]|\d+[.)])\s+/, "") // list bullet
56
+ .replace(/^\s*>\s?/, "") // blockquote
57
+ .trim();
58
+ }
59
+ /** Header metadata lines that say nothing about the content. */
60
+ const META_LINE = /^(updated|last updated|created|date|status|author|owner|version|scope|audience)\b\s*[:\-]/i;
61
+ function summarizeMarkdown(text) {
62
+ let body = text;
63
+ // YAML frontmatter: prefer its description, it IS a one-line summary.
64
+ if (body.startsWith("---\n") || body.startsWith("---\r\n")) {
65
+ const end = body.indexOf("\n---", 4);
66
+ if (end !== -1) {
67
+ const fm = body.slice(4, end);
68
+ const desc = fm.match(/^description:\s*(.+)$/m);
69
+ if (desc)
70
+ return desc[1].trim().replace(/^["']|["']$/g, "");
71
+ body = body.slice(end + 4);
72
+ }
73
+ }
74
+ let title = "";
75
+ let prose = "";
76
+ let inFence = false;
77
+ for (const raw of body.split("\n")) {
78
+ const line = raw.trim();
79
+ if (line.startsWith("```") || line.startsWith("~~~")) {
80
+ inFence = !inFence;
81
+ continue;
82
+ }
83
+ if (inFence || !line)
84
+ continue;
85
+ if (line.startsWith("<!--") || line.startsWith("|") || line.startsWith("![") || line.startsWith("[!"))
86
+ continue;
87
+ if (/^[-=*_]{3,}$/.test(line))
88
+ continue;
89
+ if (line.startsWith("#")) {
90
+ if (!title)
91
+ title = plain(line.replace(/^#+\s*/, ""));
92
+ continue;
93
+ }
94
+ const p = plain(line);
95
+ if (p.length < 12)
96
+ continue; // lone dates, "v2.1", etc.
97
+ if (META_LINE.test(p))
98
+ continue; // "Updated: 2026-03-13", "Status: draft"
99
+ prose = p;
100
+ break;
101
+ }
102
+ if (title && prose)
103
+ return `${title}: ${prose}`;
104
+ return title || prose;
105
+ }
106
+ const CODE_SKIP = /^(#!|\/\/\s*eslint|\/\*\s*eslint|@ts-|#\s*-\*-|#\s*coding[:=]|SPDX|Copyright|\[LOCK|\[LOCKED\]|\[NEVER\]|WHY:|FIX:|import |from |export |const |let |var |use strict|"use strict")/i;
107
+ function summarizeCode(text) {
108
+ const lines = text.split("\n");
109
+ let inBlock = false;
110
+ for (const raw of lines) {
111
+ let line = raw.trim();
112
+ if (!line)
113
+ continue;
114
+ if (!inBlock) {
115
+ const opensBlock = line.startsWith("/*") || line.startsWith('"""') || line.startsWith("'''");
116
+ const closesOnSameLine = (line.startsWith("/*") && /\*\/$/.test(line)) ||
117
+ (/^("""|''')/.test(line) && line.length > 3 && /("""|''')$/.test(line));
118
+ if (CODE_SKIP.test(line)) {
119
+ // Pragma, shebang or marker on the raw line: skip it. A one-line
120
+ // block comment ("/* eslint-disable */") must not leave us in a block.
121
+ if (opensBlock && !closesOnSameLine)
122
+ inBlock = true;
123
+ continue;
124
+ }
125
+ if (opensBlock) {
126
+ inBlock = !closesOnSameLine;
127
+ line = line.replace(/^(\/\*+|"""|''')\s*/, "").replace(/(\*\/|"""|''')\s*$/, "").trim();
128
+ }
129
+ else if (line.startsWith("//") || line.startsWith("#")) {
130
+ line = line.replace(/^(\/\/|#)+\s*/, "").trim();
131
+ }
132
+ else {
133
+ // Code before any comment: nothing worth saying about this file.
134
+ return "";
135
+ }
136
+ }
137
+ else {
138
+ if (/(\*\/|"""|''')$/.test(line))
139
+ inBlock = false;
140
+ line = line.replace(/^\*+\s*/, "").replace(/(\*\/|"""|''')\s*$/, "").trim();
141
+ }
142
+ if (!line || /^[-=*_#]{3,}$/.test(line) || CODE_SKIP.test(line))
143
+ continue;
144
+ if (line.length < 12)
145
+ continue;
146
+ return line;
147
+ }
148
+ return "";
149
+ }
150
+ //# sourceMappingURL=source-summary.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@compr/opscontext-mcp",
3
- "version": "2.8.1",
3
+ "version": "2.8.2",
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",