gitmem-mcp 1.4.4 → 1.5.1

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.
@@ -2297,6 +2297,92 @@ export const TOOLS = [
2297
2297
  required: ["type", "tool", "description", "severity"],
2298
2298
  },
2299
2299
  },
2300
+ {
2301
+ name: "index_docs",
2302
+ description: "Scan a directory of markdown files, chunk them, embed them, and store them in a local doc index for semantic search. Supports incremental indexing: only re-processes changed files. Use search_docs to query the indexed docs.",
2303
+ inputSchema: {
2304
+ type: "object",
2305
+ properties: {
2306
+ directory: {
2307
+ type: "string",
2308
+ description: "Absolute path to directory containing .md files to index",
2309
+ },
2310
+ project: {
2311
+ type: "string",
2312
+ description: "Project namespace (e.g., 'my-project'). Scopes sessions and searches.",
2313
+ },
2314
+ exclude: {
2315
+ type: "array",
2316
+ items: { type: "string" },
2317
+ description: "Directory names to exclude (default: ['_archive', 'node_modules', '.git'])",
2318
+ },
2319
+ force: {
2320
+ type: "boolean",
2321
+ description: "Force re-index all files even if unchanged (default: false)",
2322
+ },
2323
+ clear: {
2324
+ type: "boolean",
2325
+ description: "Clear the doc index for this project before indexing (default: false)",
2326
+ },
2327
+ },
2328
+ required: ["directory"],
2329
+ },
2330
+ },
2331
+ {
2332
+ name: "search_docs",
2333
+ description: "Search indexed repository documentation using semantic similarity (pro/dev tier) or BM25 keyword search (free tier). Returns relevant chunks with file paths for targeted reading. Index docs first with index_docs.",
2334
+ inputSchema: {
2335
+ type: "object",
2336
+ properties: {
2337
+ query: {
2338
+ type: "string",
2339
+ description: "Natural language search query (e.g., 'how does authentication work', 'database schema')",
2340
+ },
2341
+ project: {
2342
+ type: "string",
2343
+ description: "Project namespace (e.g., 'my-project'). Scopes sessions and searches.",
2344
+ },
2345
+ category: {
2346
+ type: "string",
2347
+ description: "Filter results to a specific category (directory name, e.g., 'architecture', 'research')",
2348
+ },
2349
+ match_count: {
2350
+ type: "number",
2351
+ description: "Maximum number of results to return (default: 5)",
2352
+ },
2353
+ },
2354
+ required: ["query"],
2355
+ },
2356
+ },
2357
+ {
2358
+ name: "gitmem-idx",
2359
+ description: "Alias for index_docs. Scan a directory of markdown files and index them for semantic search.",
2360
+ inputSchema: {
2361
+ type: "object",
2362
+ properties: {
2363
+ directory: { type: "string", description: "Absolute path to directory containing .md files" },
2364
+ project: { type: "string", description: "Project namespace" },
2365
+ exclude: { type: "array", items: { type: "string" }, description: "Directory names to exclude" },
2366
+ force: { type: "boolean", description: "Force re-index all files" },
2367
+ clear: { type: "boolean", description: "Clear index before indexing" },
2368
+ },
2369
+ required: ["directory"],
2370
+ },
2371
+ },
2372
+ {
2373
+ name: "gitmem-sd",
2374
+ description: "Alias for search_docs. Search indexed repository documentation.",
2375
+ inputSchema: {
2376
+ type: "object",
2377
+ properties: {
2378
+ query: { type: "string", description: "Natural language search query" },
2379
+ project: { type: "string", description: "Project namespace" },
2380
+ category: { type: "string", description: "Filter by category" },
2381
+ match_count: { type: "number", description: "Max results (default: 5)" },
2382
+ },
2383
+ required: ["query"],
2384
+ },
2385
+ },
2300
2386
  ];
2301
2387
  /**
2302
2388
  * Alias tool names — filtered out by default to reduce context window cost.
@@ -2312,6 +2398,7 @@ export const ALIAS_TOOL_NAMES = new Set([
2312
2398
  "gitmem-pc", "gitmem-ao",
2313
2399
  "gitmem-lt", "gitmem-rt", "gitmem-ct", "gitmem-ps", "gitmem-ds",
2314
2400
  "gitmem-cleanup", "gitmem-health", "gitmem-al", "gitmem-graph", "gitmem-fb",
2401
+ "gitmem-idx", "gitmem-sd",
2315
2402
  // gm-* aliases
2316
2403
  "gm-open", "gm-confirm", "gm-reflect", "gm-refresh", "gm-close",
2317
2404
  "gm-scar", "gm-search", "gm-log", "gm-analyze",
@@ -0,0 +1,30 @@
1
+ /**
2
+ * index_docs Tool
3
+ *
4
+ * Scans a directory of markdown files, chunks them, embeds them,
5
+ * and stores them in the doc index for semantic search.
6
+ *
7
+ * Supports incremental indexing: only re-processes changed files.
8
+ */
9
+ import type { Project } from "../types/index.js";
10
+ export interface IndexDocsParams {
11
+ directory: string;
12
+ project?: Project;
13
+ exclude?: string[];
14
+ force?: boolean;
15
+ clear?: boolean;
16
+ }
17
+ export interface IndexDocsResult {
18
+ directory: string;
19
+ project: string;
20
+ files_scanned: number;
21
+ files_changed: number;
22
+ files_unchanged: number;
23
+ chunks_indexed: number;
24
+ chunks_embedded: number;
25
+ errors: number;
26
+ categories: Record<string, number>;
27
+ display: string;
28
+ }
29
+ export declare function indexDocs(params: IndexDocsParams): Promise<IndexDocsResult>;
30
+ //# sourceMappingURL=index-docs.d.ts.map
@@ -0,0 +1,163 @@
1
+ /**
2
+ * index_docs Tool
3
+ *
4
+ * Scans a directory of markdown files, chunks them, embeds them,
5
+ * and stores them in the doc index for semantic search.
6
+ *
7
+ * Supports incremental indexing: only re-processes changed files.
8
+ */
9
+ import * as fs from "fs";
10
+ import { scanDirectory, chunkDocument } from "../services/doc-chunker.js";
11
+ import { indexChunks, getChangedFiles, getIndexStats, clearDocIndex, } from "../services/doc-index.js";
12
+ import { getProject } from "../services/session-state.js";
13
+ import { wrapDisplay, productLine, dimText } from "../services/display-protocol.js";
14
+ export async function indexDocs(params) {
15
+ const directory = params.directory;
16
+ const project = params.project || getProject() || "default";
17
+ const exclude = params.exclude || ["_archive", "node_modules", ".git"];
18
+ const force = params.force || false;
19
+ // Validate directory exists
20
+ if (!fs.existsSync(directory)) {
21
+ const display = wrapDisplay([
22
+ productLine("index_docs", "error"),
23
+ "",
24
+ `Directory not found: ${directory}`,
25
+ "",
26
+ "Provide an absolute path to a directory containing .md files.",
27
+ ].join("\n"));
28
+ return {
29
+ directory,
30
+ project,
31
+ files_scanned: 0,
32
+ files_changed: 0,
33
+ files_unchanged: 0,
34
+ chunks_indexed: 0,
35
+ chunks_embedded: 0,
36
+ errors: 1,
37
+ categories: {},
38
+ display,
39
+ };
40
+ }
41
+ // Handle clear request
42
+ if (params.clear) {
43
+ const removed = clearDocIndex(project);
44
+ const display = wrapDisplay([
45
+ productLine("index_docs", `cleared ${removed} chunks for project="${project}"`),
46
+ ].join("\n"));
47
+ return {
48
+ directory,
49
+ project,
50
+ files_scanned: 0,
51
+ files_changed: 0,
52
+ files_unchanged: 0,
53
+ chunks_indexed: 0,
54
+ chunks_embedded: 0,
55
+ errors: 0,
56
+ categories: {},
57
+ display,
58
+ };
59
+ }
60
+ // Scan directory for .md files
61
+ const files = scanDirectory(directory, { exclude });
62
+ if (files.length === 0) {
63
+ const display = wrapDisplay([
64
+ productLine("index_docs", "no markdown files found"),
65
+ "",
66
+ `Scanned: ${directory}`,
67
+ `Excluded: ${exclude.join(", ")}`,
68
+ ].join("\n"));
69
+ return {
70
+ directory,
71
+ project,
72
+ files_scanned: 0,
73
+ files_changed: 0,
74
+ files_unchanged: 0,
75
+ chunks_indexed: 0,
76
+ chunks_embedded: 0,
77
+ errors: 0,
78
+ categories: {},
79
+ display,
80
+ };
81
+ }
82
+ // Incremental indexing: check which files changed
83
+ const fileHashes = new Map(files.map((f) => [f.relative_path, f.hash]));
84
+ let filesToProcess = files;
85
+ let filesUnchanged = 0;
86
+ if (!force) {
87
+ const changes = getChangedFiles(fileHashes, project);
88
+ const changedSet = new Set([...changes.changed, ...changes.new_files]);
89
+ if (changedSet.size === 0) {
90
+ const stats = getIndexStats(project);
91
+ const display = wrapDisplay([
92
+ productLine("index_docs", "up to date — no changes detected"),
93
+ "",
94
+ `${files.length} files scanned, all unchanged`,
95
+ `${stats.total_chunks} chunks in index`,
96
+ "",
97
+ dimText("Use force=true to re-index all files"),
98
+ ].join("\n"));
99
+ return {
100
+ directory,
101
+ project,
102
+ files_scanned: files.length,
103
+ files_changed: 0,
104
+ files_unchanged: files.length,
105
+ chunks_indexed: 0,
106
+ chunks_embedded: 0,
107
+ errors: 0,
108
+ categories: stats.categories,
109
+ display,
110
+ };
111
+ }
112
+ filesToProcess = files.filter((f) => changedSet.has(f.relative_path));
113
+ filesUnchanged = files.length - filesToProcess.length;
114
+ }
115
+ // Chunk the changed files
116
+ const allChunks = [];
117
+ for (const file of filesToProcess) {
118
+ allChunks.push(...chunkDocument(file));
119
+ }
120
+ // Index chunks (embed + store)
121
+ const result = await indexChunks(allChunks, project);
122
+ // Get updated stats
123
+ const stats = getIndexStats(project);
124
+ // Build display
125
+ const lines = [];
126
+ lines.push(productLine("index_docs", `${filesToProcess.length} files → ${result.indexed} chunks`));
127
+ lines.push("");
128
+ lines.push(`Directory: ${directory}`);
129
+ lines.push(`Project: ${project}`);
130
+ lines.push("");
131
+ lines.push(`Files scanned: ${files.length}`);
132
+ lines.push(`Files changed: ${filesToProcess.length}`);
133
+ lines.push(`Files unchanged: ${filesUnchanged}`);
134
+ lines.push(`Chunks indexed: ${result.indexed}`);
135
+ lines.push(`Chunks embedded: ${result.embedded}`);
136
+ if (result.errors > 0) {
137
+ lines.push(`Errors: ${result.errors}`);
138
+ }
139
+ lines.push("");
140
+ // Category breakdown
141
+ if (Object.keys(stats.categories).length > 0) {
142
+ lines.push("Categories:");
143
+ for (const [cat, count] of Object.entries(stats.categories).sort((a, b) => b[1] - a[1])) {
144
+ lines.push(` ${cat.padEnd(20)} ${count} chunks`);
145
+ }
146
+ lines.push("");
147
+ }
148
+ lines.push(`Total index: ${stats.total_chunks} chunks across ${stats.total_files} files`);
149
+ const display = wrapDisplay(lines.join("\n"));
150
+ return {
151
+ directory,
152
+ project,
153
+ files_scanned: files.length,
154
+ files_changed: filesToProcess.length,
155
+ files_unchanged: filesUnchanged,
156
+ chunks_indexed: result.indexed,
157
+ chunks_embedded: result.embedded,
158
+ errors: result.errors,
159
+ categories: stats.categories,
160
+ display,
161
+ };
162
+ }
163
+ //# sourceMappingURL=index-docs.js.map
@@ -40,6 +40,13 @@ Proceed with caution — this may be new territory without documented lessons.`;
40
40
  formatNudgeHeader(scars.length),
41
41
  "",
42
42
  ];
43
+ // Citation protocol — provenance enforcement for any downstream claims
44
+ // Placed BEFORE results so agents see it before processing scars
45
+ lines.push("───────────────────────────────────────────────────");
46
+ lines.push("CITATION RULE: When referencing facts from these scars, cite the record ID.");
47
+ lines.push("Example: \"Edge improved to 3.07 [id:48ebca14]\" — not paraphrased numbers.");
48
+ lines.push("If you cannot cite a specific record for a claim, say \"not in institutional memory.\"");
49
+ lines.push("");
43
50
  // Blocking verification requirements first
44
51
  const blockingScars = scars.filter((s) => s.required_verification?.blocking);
45
52
  if (blockingScars.length > 0) {
@@ -48,6 +48,13 @@ No past lessons match this plan closely enough. Scars accumulate as you work —
48
48
  lines.push(`${dimText("No project-specific lessons yet. Use create_learning to capture your first.")}`);
49
49
  lines.push("");
50
50
  }
51
+ // Citation protocol — provenance enforcement for any downstream claims
52
+ // Placed BEFORE results so agents see it before processing scars
53
+ lines.push("───────────────────────────────────────────────────");
54
+ lines.push("CITATION RULE: When referencing facts from these scars, cite the record ID.");
55
+ lines.push("Example: \"Edge improved to 3.07 [id:48ebca14]\" — not paraphrased numbers.");
56
+ lines.push("If you cannot cite a specific record for a claim, say \"not in institutional memory.\"");
57
+ lines.push("");
51
58
  // Display blocking verification requirements FIRST and prominently
52
59
  if (scarsWithVerification.length > 0) {
53
60
  lines.push(`${ANSI.yellow}VERIFICATION REQUIRED${ANSI.reset}`);
@@ -73,7 +80,9 @@ No past lessons match this plan closely enough. Scars accumulate as you work —
73
80
  for (const scar of scars) {
74
81
  const sev = SEV[scar.severity] || "[?]";
75
82
  const starterTag = scar.is_starter ? ` ${dimText("[starter]")}` : "";
76
- lines.push(`${sev} **${scar.title}** (${scar.severity}, ${scar.similarity.toFixed(2)}) ${dimText(`id:${scar.id.slice(0, 8)}`)}${starterTag}`);
83
+ // Confidence tier: marginal matches (< 0.55) get flagged — 66% N/A rate in this range
84
+ const confidenceTag = scar.similarity < 0.55 ? ` ${dimText("[low confidence]")}` : "";
85
+ lines.push(`${sev} **${scar.title}** (${scar.severity}, ${scar.similarity.toFixed(2)}) ${dimText(`id:${scar.id.slice(0, 8)}`)}${starterTag}${confidenceTag}`);
77
86
  // Inline archival hint: scars with high dismiss rates get annotated
78
87
  if (dismissals) {
79
88
  const counts = dismissals.get(scar.id);
@@ -0,0 +1,38 @@
1
+ /**
2
+ * search_docs Tool
3
+ *
4
+ * Semantic search over indexed repository documentation.
5
+ * Returns relevant chunks with file paths for targeted reading.
6
+ *
7
+ * Like search (for scars), but for docs.
8
+ */
9
+ import type { Project } from "../types/index.js";
10
+ export interface SearchDocsParams {
11
+ query: string;
12
+ project?: Project;
13
+ category?: string;
14
+ match_count?: number;
15
+ }
16
+ export interface SearchDocsResultEntry {
17
+ id: string;
18
+ file_path: string;
19
+ chunk_index: number;
20
+ title: string;
21
+ section_title: string;
22
+ category: string;
23
+ content: string;
24
+ similarity: number;
25
+ }
26
+ export interface SearchDocsResult {
27
+ query: string;
28
+ project: string;
29
+ results: SearchDocsResultEntry[];
30
+ total_found: number;
31
+ index_stats: {
32
+ total_chunks: number;
33
+ total_files: number;
34
+ };
35
+ display: string;
36
+ }
37
+ export declare function searchDocsHandler(params: SearchDocsParams): Promise<SearchDocsResult>;
38
+ //# sourceMappingURL=search-docs.d.ts.map
@@ -0,0 +1,94 @@
1
+ /**
2
+ * search_docs Tool
3
+ *
4
+ * Semantic search over indexed repository documentation.
5
+ * Returns relevant chunks with file paths for targeted reading.
6
+ *
7
+ * Like search (for scars), but for docs.
8
+ */
9
+ import { searchDocs as doSearch, getIndexStats } from "../services/doc-index.js";
10
+ import { getProject } from "../services/session-state.js";
11
+ import { wrapDisplay, productLine, dimText, truncate } from "../services/display-protocol.js";
12
+ export async function searchDocsHandler(params) {
13
+ const query = params.query;
14
+ const project = params.project || getProject() || "default";
15
+ const category = params.category;
16
+ const matchCount = params.match_count || 5;
17
+ // Check if index exists
18
+ const stats = getIndexStats(project);
19
+ if (stats.total_chunks === 0) {
20
+ const display = wrapDisplay([
21
+ productLine("search_docs", "no docs indexed"),
22
+ "",
23
+ `No documents indexed for project="${project}".`,
24
+ "",
25
+ "Index docs first:",
26
+ ' index_docs({ directory: "/path/to/docs", project: "my-project" })',
27
+ ].join("\n"));
28
+ return {
29
+ query,
30
+ project,
31
+ results: [],
32
+ total_found: 0,
33
+ index_stats: {
34
+ total_chunks: stats.total_chunks,
35
+ total_files: stats.total_files,
36
+ },
37
+ display,
38
+ };
39
+ }
40
+ // Search
41
+ const results = await doSearch(query, {
42
+ project,
43
+ category,
44
+ match_count: matchCount,
45
+ });
46
+ // Build display
47
+ const lines = [];
48
+ lines.push(productLine("search_docs", `${results.length} results · "${truncate(query, 60)}"`));
49
+ if (category) {
50
+ lines.push(`Category filter: ${category}`);
51
+ }
52
+ lines.push("");
53
+ if (results.length === 0) {
54
+ lines.push("No matching docs found.");
55
+ lines.push("");
56
+ lines.push(`Index contains ${stats.total_chunks} chunks across ${stats.total_files} files.`);
57
+ lines.push("Available categories: " + Object.keys(stats.categories).join(", "));
58
+ }
59
+ else {
60
+ // Show results with file paths prominently
61
+ for (const r of results) {
62
+ const sim = `(${r.similarity.toFixed(2)})`;
63
+ const conf = r.similarity < 0.55 ? ` ${dimText("[low confidence]")}` : "";
64
+ const section = r.section_title ? ` > ${r.section_title}` : "";
65
+ lines.push(`${r.file_path}${section} ${sim}${conf}`);
66
+ lines.push(` ${truncate(r.title, 60)} [${r.category}]`);
67
+ lines.push(` ${truncate(r.content, 120)}`);
68
+ lines.push("");
69
+ }
70
+ lines.push(dimText(`Searched ${stats.total_chunks} chunks across ${stats.total_files} files`));
71
+ }
72
+ const display = wrapDisplay(lines.join("\n"));
73
+ return {
74
+ query,
75
+ project,
76
+ results: results.map((r) => ({
77
+ id: r.id,
78
+ file_path: r.file_path,
79
+ chunk_index: r.chunk_index,
80
+ title: r.title,
81
+ section_title: r.section_title,
82
+ category: r.category,
83
+ content: r.content,
84
+ similarity: r.similarity,
85
+ })),
86
+ total_found: results.length,
87
+ index_stats: {
88
+ total_chunks: stats.total_chunks,
89
+ total_files: stats.total_files,
90
+ },
91
+ display,
92
+ };
93
+ }
94
+ //# sourceMappingURL=search-docs.js.map
@@ -30,6 +30,14 @@ function buildSearchDisplay(results, total_found, query, filters) {
30
30
  if (fp.length > 0)
31
31
  lines.push(`Filters: ${fp.join(", ")}`);
32
32
  lines.push("");
33
+ // Citation protocol — provenance enforcement for any downstream claims
34
+ // Placed BEFORE results so agents see it before processing results
35
+ if (results.length > 0) {
36
+ lines.push("───────────────────────────────────────────────────");
37
+ lines.push("CITATION RULE: When referencing facts from these results, cite the record ID.");
38
+ lines.push("If you cannot cite a specific record for a claim, say \"not in institutional memory.\"");
39
+ lines.push("");
40
+ }
33
41
  if (results.length === 0) {
34
42
  lines.push("No results found.");
35
43
  return wrapDisplay(lines.join("\n"));
@@ -40,7 +48,9 @@ function buildSearchDisplay(results, total_found, query, filters) {
40
48
  const t = truncate(r.title, 50);
41
49
  const sim = `(${r.similarity.toFixed(2)})`;
42
50
  const issue = r.source_linear_issue ? ` ${r.source_linear_issue}` : "";
43
- lines.push(`${te} ${se} ${t.padEnd(52)} ${sim}${issue}`);
51
+ // Confidence tier: marginal matches (< 0.55) get flagged
52
+ const conf = r.similarity < 0.55 ? ` ${dimText("[low confidence]")}` : "";
53
+ lines.push(`${te} ${se} ${t.padEnd(52)} ${sim}${issue}${conf}`);
44
54
  const starterTag = r.is_starter ? ` ${dimText("[starter]")}` : "";
45
55
  lines.push(` ${truncate(r.description, 72)} id:${r.id.slice(0, 8)}${starterTag}`);
46
56
  }
@@ -12,7 +12,7 @@ import * as supabase from "../services/supabase-client.js";
12
12
  import { embed, isEmbeddingAvailable } from "../services/embedding.js";
13
13
  import { hasSupabase, getTableName } from "../services/tier.js";
14
14
  import { getStorage } from "../services/storage.js";
15
- import { clearCurrentSession, getSurfacedScars, getConfirmations, getReflections, getObservations, getChildren, getThreads, getSessionActivity } from "../services/session-state.js";
15
+ import { clearCurrentSession, getSurfacedScars, getConfirmations, getReflections, getObservations, getChildren, getThreads, getSessionActivity, isRecallCalled } from "../services/session-state.js";
16
16
  import { normalizeThreads, mergeThreadStates, migrateStringThread, saveThreadsFile } from "../services/thread-manager.js"; //
17
17
  import { deduplicateThreadList } from "../services/thread-dedup.js";
18
18
  import { syncThreadsToSupabase, loadOpenThreadEmbeddings } from "../services/thread-supabase.js";
@@ -858,13 +858,56 @@ export async function sessionClose(params) {
858
858
  `(${Math.round(activity.duration_min)} min, no substantive activity). ` +
859
859
  `Proceeding with standard as requested.`);
860
860
  }
861
+ // Hard gate: quick close requires session under 30 minutes
862
+ if (params.close_type === "quick" && activity.duration_min >= 30) {
863
+ return {
864
+ success: false,
865
+ session_id: params.session_id || "",
866
+ close_compliance: {
867
+ close_type: "quick",
868
+ agent: detectAgent().agent,
869
+ checklist_displayed: false,
870
+ questions_answered_by_agent: false,
871
+ human_asked_for_corrections: false,
872
+ learnings_stored: 0,
873
+ scars_applied: 0,
874
+ },
875
+ validation_errors: [
876
+ `Session has been active for ${Math.round(activity.duration_min)} minutes. ` +
877
+ `Quick close requires sessions under 30 minutes. Use close_type: "standard".`,
878
+ ],
879
+ performance: buildPerformanceData("session_close", timer.stop(), 0),
880
+ };
881
+ }
861
882
  if (params.close_type === "quick" && recommendedLevel === "full") {
862
- // Warn but don't reject — agent chose quick on a substantive session
883
+ // Warn but don't reject — agent chose quick on a substantive session under 30 min
863
884
  console.error(`[session_close] Warning: "quick" close on substantive session ` +
864
885
  `(${Math.round(activity.duration_min)} min, ${activity.recall_count} recalls, ` +
865
886
  `${activity.observation_count} observations). Consider "standard" close.`);
866
887
  }
867
888
  }
889
+ // Hard gate: standard close requires at least one recall() call
890
+ // Exemptions: quick (micro sessions), autonomous (CODA-1), hasReflection (agent already wrote full reflection)
891
+ if (params.close_type === "standard" && !isRecallCalled() && !hasReflection) {
892
+ return {
893
+ success: false,
894
+ session_id: params.session_id || "",
895
+ close_compliance: {
896
+ close_type: "standard",
897
+ agent: detectAgent().agent,
898
+ checklist_displayed: false,
899
+ questions_answered_by_agent: false,
900
+ human_asked_for_corrections: false,
901
+ learnings_stored: 0,
902
+ scars_applied: 0,
903
+ },
904
+ validation_errors: [
905
+ `No recall() was run this session. Standard close requires at least one recall. ` +
906
+ `Run recall("session close ceremony") first, then retry session_close.`,
907
+ ],
908
+ performance: buildPerformanceData("session_close", timer.stop(), 0),
909
+ };
910
+ }
868
911
  // Free tier: simple local persistence, skip Supabase recovery and compliance
869
912
  if (!hasSupabase()) {
870
913
  return sessionCloseFree(params, timer);
@@ -676,6 +676,20 @@ function formatStartDisplay(result, displayInfoMap, isFirstSession) {
676
676
  if (result.project)
677
677
  parts.push(result.project);
678
678
  visual.push(dimText(parts.join(" · ")));
679
+ // Line 3: duration + surfaced scars for resumed/refreshed sessions
680
+ if (result.resumed || result.refreshed) {
681
+ const session = getCurrentSession();
682
+ if (session?.startedAt) {
683
+ const durationMs = Date.now() - session.startedAt.getTime();
684
+ const totalMin = Math.floor(durationMs / 60000);
685
+ const hours = Math.floor(totalMin / 60);
686
+ const mins = totalMin % 60;
687
+ const durationStr = hours > 0 ? `${hours}h ${mins}m` : `${mins}m`;
688
+ const scarCount = session.surfacedScars?.length || 0;
689
+ const scarSuffix = scarCount > 0 ? ` · ${scarCount} scars loaded from earlier` : "";
690
+ visual.push(dimText(`Session active for: ${durationStr}${scarSuffix}`));
691
+ }
692
+ }
679
693
  // Threads section — top 5 by vitality, truncated to 60 chars
680
694
  const hasThreads = result.open_threads && result.open_threads.length > 0;
681
695
  const hasDecisions = result.recent_decisions && result.recent_decisions.length > 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitmem-mcp",
3
- "version": "1.4.4",
3
+ "version": "1.5.1",
4
4
  "mcpName": "io.github.gitmem-dev/gitmem",
5
5
  "description": "Persistent learning memory for AI coding agents. Memory that compounds.",
6
6
  "type": "module",