@hasna/terminal 4.2.0 → 4.3.0

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.
@@ -67,10 +67,22 @@ function resolvePath(p, cwd) {
67
67
  export function createServer() {
68
68
  const server = new McpServer({
69
69
  name: "terminal",
70
- version: "3.4.0",
70
+ version: "4.2.0",
71
71
  });
72
72
  // Create a session for this MCP server instance
73
73
  const sessionId = createSession(process.cwd(), "mcp");
74
+ // ── Mementos: cross-session project memory ────────────────────────────────
75
+ let mementosProjectId = null;
76
+ try {
77
+ const mementos = require("@hasna/mementos");
78
+ const projectName = process.cwd().split("/").pop() ?? "unknown";
79
+ const project = mementos.registerProject(projectName, process.cwd());
80
+ mementosProjectId = project?.id ?? null;
81
+ mementos.registerAgent("terminal-mcp");
82
+ if (mementosProjectId)
83
+ mementos.setFocus(mementosProjectId);
84
+ }
85
+ catch { } // mementos optional — works without it
74
86
  /** Log a tool call to sessions.db for economy tracking */
75
87
  function logCall(tool, data) {
76
88
  try {
@@ -166,11 +178,12 @@ export function createServer() {
166
178
  command: z.string().describe("Shell command to execute"),
167
179
  cwd: z.string().optional().describe("Working directory"),
168
180
  timeout: z.number().optional().describe("Timeout in ms (default: 30000)"),
169
- }, async ({ command, cwd, timeout }) => {
181
+ verbosity: z.enum(["minimal", "normal", "detailed"]).optional().describe("Summary detail level (default: normal)"),
182
+ }, async ({ command, cwd, timeout, verbosity }) => {
170
183
  const start = Date.now();
171
184
  const result = await exec(command, cwd, timeout ?? 30000, true);
172
185
  const output = (result.stdout + result.stderr).trim();
173
- const processed = await processOutput(command, output);
186
+ const processed = await processOutput(command, output, undefined, verbosity);
174
187
  const detailKey = output.split("\n").length > 15 ? storeOutput(command, output) : undefined;
175
188
  logCall("execute_smart", { command, outputTokens: estimateTokens(output), tokensSaved: processed.tokensSaved, durationMs: Date.now() - start, exitCode: result.exitCode, aiProcessed: processed.aiProcessed });
176
189
  return {
@@ -1214,9 +1227,10 @@ Be specific, not generic. Only flag real problems.`,
1214
1227
  // ── batch: multiple operations in one round trip ───────────────────────────
1215
1228
  server.tool("batch", "Run multiple operations in ONE call. Saves N-1 round trips. Each op can be: execute (run command), read (file read/summarize), search (grep pattern), or symbols (file outline).", {
1216
1229
  ops: z.array(z.object({
1217
- type: z.enum(["execute", "read", "search", "symbols"]).describe("Operation type"),
1230
+ type: z.enum(["execute", "read", "write", "search", "symbols"]).describe("Operation type"),
1218
1231
  command: z.string().optional().describe("Shell command (for execute)"),
1219
- path: z.string().optional().describe("File path (for read/symbols)"),
1232
+ path: z.string().optional().describe("File path (for read/write/symbols)"),
1233
+ content: z.string().optional().describe("File content (for write)"),
1220
1234
  pattern: z.string().optional().describe("Search pattern (for search)"),
1221
1235
  summarize: z.boolean().optional().describe("AI summarize (for read)"),
1222
1236
  format: z.enum(["raw", "summary"]).optional().describe("Output format (for execute)"),
@@ -1258,8 +1272,26 @@ Be specific, not generic. Only flag real problems.`,
1258
1272
  results.push({ op: i, type: "read", path: op.path, content: result.content, lines: result.content.split("\n").length });
1259
1273
  }
1260
1274
  }
1275
+ else if (op.type === "write" && op.path && op.content !== undefined) {
1276
+ const filePath = resolvePath(op.path, workDir);
1277
+ const { writeFileSync, mkdirSync, existsSync } = await import("fs");
1278
+ const { dirname } = await import("path");
1279
+ const dir = dirname(filePath);
1280
+ if (!existsSync(dir))
1281
+ mkdirSync(dir, { recursive: true });
1282
+ writeFileSync(filePath, op.content);
1283
+ results.push({ op: i, type: "write", path: op.path, ok: true, bytes: op.content.length });
1284
+ }
1261
1285
  else if (op.type === "search" && op.pattern) {
1262
- const result = await searchContent(op.pattern, op.path ? resolvePath(op.path, workDir) : workDir, {});
1286
+ // Search accepts both files and directories resolve to parent dir if file
1287
+ let searchPath = op.path ? resolvePath(op.path, workDir) : workDir;
1288
+ try {
1289
+ const { statSync } = await import("fs");
1290
+ if (statSync(searchPath).isFile())
1291
+ searchPath = searchPath.replace(/\/[^/]+$/, "");
1292
+ }
1293
+ catch { }
1294
+ const result = await searchContent(op.pattern, searchPath, {});
1263
1295
  results.push({ op: i, type: "search", pattern: op.pattern, totalMatches: result.totalMatches, files: result.files.slice(0, 10) });
1264
1296
  }
1265
1297
  else if (op.type === "symbols" && op.path) {
@@ -1295,6 +1327,43 @@ Be specific, not generic. Only flag real problems.`,
1295
1327
  logCall("batch", { command: `${ops.length} ops`, durationMs: Date.now() - start, aiProcessed: true });
1296
1328
  return { content: [{ type: "text", text: JSON.stringify({ results, total: results.length, durationMs: Date.now() - start }) }] };
1297
1329
  });
1330
+ // ── Cross-session memory (mementos SDK) ────────────────────────────────────
1331
+ server.tool("remember", "Save a learning about this project for future sessions. Persists across restarts. Use for: project patterns, conventions, toolchain quirks, architectural decisions.", {
1332
+ key: z.string().describe("Short key (e.g., 'test-command', 'deploy-process', 'auth-pattern')"),
1333
+ value: z.string().describe("What to remember"),
1334
+ importance: z.number().optional().describe("1-10, default 7"),
1335
+ }, async ({ key, value, importance }) => {
1336
+ try {
1337
+ const mementos = require("@hasna/mementos");
1338
+ mementos.createMemory({ key, value, scope: "shared", category: "knowledge", importance: importance ?? 7 });
1339
+ logCall("remember", { command: `remember: ${key}` });
1340
+ return { content: [{ type: "text", text: JSON.stringify({ saved: key }) }] };
1341
+ }
1342
+ catch (e) {
1343
+ return { content: [{ type: "text", text: JSON.stringify({ error: e.message?.slice(0, 200) }) }] };
1344
+ }
1345
+ });
1346
+ server.tool("recall", "Recall project memories from previous sessions. Returns all saved learnings, patterns, and decisions for this project.", {
1347
+ search: z.string().optional().describe("Search query to filter memories"),
1348
+ limit: z.number().optional().describe("Max memories to return (default: 20)"),
1349
+ }, async ({ search, limit }) => {
1350
+ try {
1351
+ const mementos = require("@hasna/mementos");
1352
+ let memories;
1353
+ if (search) {
1354
+ memories = mementos.searchMemories(search, { limit: limit ?? 20 });
1355
+ }
1356
+ else {
1357
+ memories = mementos.listMemories({ scope: "shared", limit: limit ?? 20 });
1358
+ }
1359
+ const items = (memories ?? []).map((m) => ({ key: m.key, value: m.value, importance: m.importance }));
1360
+ logCall("recall", { command: `recall${search ? `: ${search}` : ""}` });
1361
+ return { content: [{ type: "text", text: JSON.stringify({ memories: items, total: items.length }) }] };
1362
+ }
1363
+ catch (e) {
1364
+ return { content: [{ type: "text", text: JSON.stringify({ error: e.message?.slice(0, 200), memories: [] }) }] };
1365
+ }
1366
+ });
1298
1367
  return server;
1299
1368
  }
1300
1369
  // ── main: start MCP server via stdio ─────────────────────────────────────────
@@ -102,7 +102,7 @@ RULES:
102
102
  * Process command output through AI summarization.
103
103
  * Cheap AI call (~100 tokens) saves 1000+ tokens downstream.
104
104
  */
105
- export async function processOutput(command, output, originalPrompt) {
105
+ export async function processOutput(command, output, originalPrompt, verbosity) {
106
106
  const lines = output.split("\n");
107
107
  // Fingerprint check — skip AI entirely for known patterns (0ms, $0)
108
108
  const fp = fingerprint(command, output);
@@ -157,10 +157,14 @@ export async function processOutput(command, output, originalPrompt) {
157
157
  // Falls back to main provider if Groq unavailable
158
158
  const provider = getOutputProvider();
159
159
  const outputModel = provider.name === "groq" ? "llama-3.1-8b-instant" : undefined;
160
+ const verbosityHint = verbosity === "minimal" ? "\nBe ULTRA concise — 1-2 lines max. Status + key number only."
161
+ : verbosity === "detailed" ? "\nBe thorough — include all relevant details, up to 15 lines."
162
+ : ""; // normal = default 8 lines from SUMMARIZE_PROMPT
163
+ const maxTok = verbosity === "minimal" ? 100 : verbosity === "detailed" ? 500 : 300;
160
164
  const summary = await provider.complete(`${originalPrompt ? `User asked: ${originalPrompt}\n` : ""}Command: ${command}\nOutput (${lines.length} lines):\n${toSummarize}${hintsBlock}${profileHints}`, {
161
165
  model: outputModel,
162
- system: SUMMARIZE_PROMPT,
163
- maxTokens: 300,
166
+ system: SUMMARIZE_PROMPT + verbosityHint,
167
+ maxTokens: maxTok,
164
168
  temperature: 0.2,
165
169
  });
166
170
  const originalTokens = estimateTokens(output);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hasna/terminal",
3
- "version": "4.2.0",
3
+ "version": "4.3.0",
4
4
  "description": "Smart terminal wrapper for AI agents and humans — structured output, token compression, MCP server, natural language",
5
5
  "type": "module",
6
6
  "files": [
@@ -22,6 +22,7 @@
22
22
  },
23
23
  "dependencies": {
24
24
  "@anthropic-ai/sdk": "^0.39.0",
25
+ "@hasna/mementos": "^0.10.0",
25
26
  "@modelcontextprotocol/sdk": "^1.27.1",
26
27
  "@typescript/vfs": "^1.6.4",
27
28
  "ink": "^5.0.1",
package/src/mcp/server.ts CHANGED
@@ -71,12 +71,23 @@ function resolvePath(p: string, cwd?: string): string {
71
71
  export function createServer(): McpServer {
72
72
  const server = new McpServer({
73
73
  name: "terminal",
74
- version: "3.4.0",
74
+ version: "4.2.0",
75
75
  });
76
76
 
77
77
  // Create a session for this MCP server instance
78
78
  const sessionId = createSession(process.cwd(), "mcp");
79
79
 
80
+ // ── Mementos: cross-session project memory ────────────────────────────────
81
+ let mementosProjectId: string | null = null;
82
+ try {
83
+ const mementos = require("@hasna/mementos");
84
+ const projectName = process.cwd().split("/").pop() ?? "unknown";
85
+ const project = mementos.registerProject(projectName, process.cwd());
86
+ mementosProjectId = project?.id ?? null;
87
+ mementos.registerAgent("terminal-mcp");
88
+ if (mementosProjectId) mementos.setFocus(mementosProjectId);
89
+ } catch {} // mementos optional — works without it
90
+
80
91
  /** Log a tool call to sessions.db for economy tracking */
81
92
  function logCall(tool: string, data: {
82
93
  command?: string;
@@ -194,12 +205,13 @@ export function createServer(): McpServer {
194
205
  command: z.string().describe("Shell command to execute"),
195
206
  cwd: z.string().optional().describe("Working directory"),
196
207
  timeout: z.number().optional().describe("Timeout in ms (default: 30000)"),
208
+ verbosity: z.enum(["minimal", "normal", "detailed"]).optional().describe("Summary detail level (default: normal)"),
197
209
  },
198
- async ({ command, cwd, timeout }) => {
210
+ async ({ command, cwd, timeout, verbosity }) => {
199
211
  const start = Date.now();
200
212
  const result = await exec(command, cwd, timeout ?? 30000, true);
201
213
  const output = (result.stdout + result.stderr).trim();
202
- const processed = await processOutput(command, output);
214
+ const processed = await processOutput(command, output, undefined, verbosity);
203
215
 
204
216
  const detailKey = output.split("\n").length > 15 ? storeOutput(command, output) : undefined;
205
217
  logCall("execute_smart", { command, outputTokens: estimateTokens(output), tokensSaved: processed.tokensSaved, durationMs: Date.now() - start, exitCode: result.exitCode, aiProcessed: processed.aiProcessed });
@@ -1580,9 +1592,10 @@ Be specific, not generic. Only flag real problems.`,
1580
1592
  "Run multiple operations in ONE call. Saves N-1 round trips. Each op can be: execute (run command), read (file read/summarize), search (grep pattern), or symbols (file outline).",
1581
1593
  {
1582
1594
  ops: z.array(z.object({
1583
- type: z.enum(["execute", "read", "search", "symbols"]).describe("Operation type"),
1595
+ type: z.enum(["execute", "read", "write", "search", "symbols"]).describe("Operation type"),
1584
1596
  command: z.string().optional().describe("Shell command (for execute)"),
1585
- path: z.string().optional().describe("File path (for read/symbols)"),
1597
+ path: z.string().optional().describe("File path (for read/write/symbols)"),
1598
+ content: z.string().optional().describe("File content (for write)"),
1586
1599
  pattern: z.string().optional().describe("Search pattern (for search)"),
1587
1600
  summarize: z.boolean().optional().describe("AI summarize (for read)"),
1588
1601
  format: z.enum(["raw", "summary"]).optional().describe("Output format (for execute)"),
@@ -1622,8 +1635,22 @@ Be specific, not generic. Only flag real problems.`,
1622
1635
  } else {
1623
1636
  results.push({ op: i, type: "read", path: op.path, content: result.content, lines: result.content.split("\n").length });
1624
1637
  }
1638
+ } else if (op.type === "write" && op.path && op.content !== undefined) {
1639
+ const filePath = resolvePath(op.path, workDir);
1640
+ const { writeFileSync, mkdirSync, existsSync } = await import("fs");
1641
+ const { dirname } = await import("path");
1642
+ const dir = dirname(filePath);
1643
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
1644
+ writeFileSync(filePath, op.content);
1645
+ results.push({ op: i, type: "write", path: op.path, ok: true, bytes: op.content.length });
1625
1646
  } else if (op.type === "search" && op.pattern) {
1626
- const result = await searchContent(op.pattern, op.path ? resolvePath(op.path, workDir) : workDir, {});
1647
+ // Search accepts both files and directories resolve to parent dir if file
1648
+ let searchPath = op.path ? resolvePath(op.path, workDir) : workDir;
1649
+ try {
1650
+ const { statSync } = await import("fs");
1651
+ if (statSync(searchPath).isFile()) searchPath = searchPath.replace(/\/[^/]+$/, "");
1652
+ } catch {}
1653
+ const result = await searchContent(op.pattern, searchPath, {});
1627
1654
  results.push({ op: i, type: "search", pattern: op.pattern, totalMatches: result.totalMatches, files: result.files.slice(0, 10) });
1628
1655
  } else if (op.type === "symbols" && op.path) {
1629
1656
  const filePath = resolvePath(op.path, workDir);
@@ -1654,6 +1681,53 @@ Be specific, not generic. Only flag real problems.`,
1654
1681
  }
1655
1682
  );
1656
1683
 
1684
+ // ── Cross-session memory (mementos SDK) ────────────────────────────────────
1685
+
1686
+ server.tool(
1687
+ "remember",
1688
+ "Save a learning about this project for future sessions. Persists across restarts. Use for: project patterns, conventions, toolchain quirks, architectural decisions.",
1689
+ {
1690
+ key: z.string().describe("Short key (e.g., 'test-command', 'deploy-process', 'auth-pattern')"),
1691
+ value: z.string().describe("What to remember"),
1692
+ importance: z.number().optional().describe("1-10, default 7"),
1693
+ },
1694
+ async ({ key, value, importance }) => {
1695
+ try {
1696
+ const mementos = require("@hasna/mementos");
1697
+ mementos.createMemory({ key, value, scope: "shared", category: "knowledge", importance: importance ?? 7 });
1698
+ logCall("remember", { command: `remember: ${key}` });
1699
+ return { content: [{ type: "text" as const, text: JSON.stringify({ saved: key }) }] };
1700
+ } catch (e: any) {
1701
+ return { content: [{ type: "text" as const, text: JSON.stringify({ error: e.message?.slice(0, 200) }) }] };
1702
+ }
1703
+ }
1704
+ );
1705
+
1706
+ server.tool(
1707
+ "recall",
1708
+ "Recall project memories from previous sessions. Returns all saved learnings, patterns, and decisions for this project.",
1709
+ {
1710
+ search: z.string().optional().describe("Search query to filter memories"),
1711
+ limit: z.number().optional().describe("Max memories to return (default: 20)"),
1712
+ },
1713
+ async ({ search, limit }) => {
1714
+ try {
1715
+ const mementos = require("@hasna/mementos");
1716
+ let memories;
1717
+ if (search) {
1718
+ memories = mementos.searchMemories(search, { limit: limit ?? 20 });
1719
+ } else {
1720
+ memories = mementos.listMemories({ scope: "shared", limit: limit ?? 20 });
1721
+ }
1722
+ const items = (memories ?? []).map((m: any) => ({ key: m.key, value: m.value, importance: m.importance }));
1723
+ logCall("recall", { command: `recall${search ? `: ${search}` : ""}` });
1724
+ return { content: [{ type: "text" as const, text: JSON.stringify({ memories: items, total: items.length }) }] };
1725
+ } catch (e: any) {
1726
+ return { content: [{ type: "text" as const, text: JSON.stringify({ error: e.message?.slice(0, 200), memories: [] }) }] };
1727
+ }
1728
+ }
1729
+ );
1730
+
1657
1731
  return server;
1658
1732
  }
1659
1733
 
@@ -140,6 +140,7 @@ export async function processOutput(
140
140
  command: string,
141
141
  output: string,
142
142
  originalPrompt?: string,
143
+ verbosity?: "minimal" | "normal" | "detailed",
143
144
  ): Promise<ProcessedOutput> {
144
145
  const lines = output.split("\n");
145
146
 
@@ -201,12 +202,16 @@ export async function processOutput(
201
202
  // Falls back to main provider if Groq unavailable
202
203
  const provider = getOutputProvider();
203
204
  const outputModel = provider.name === "groq" ? "llama-3.1-8b-instant" : undefined;
205
+ const verbosityHint = verbosity === "minimal" ? "\nBe ULTRA concise — 1-2 lines max. Status + key number only."
206
+ : verbosity === "detailed" ? "\nBe thorough — include all relevant details, up to 15 lines."
207
+ : ""; // normal = default 8 lines from SUMMARIZE_PROMPT
208
+ const maxTok = verbosity === "minimal" ? 100 : verbosity === "detailed" ? 500 : 300;
204
209
  const summary = await provider.complete(
205
210
  `${originalPrompt ? `User asked: ${originalPrompt}\n` : ""}Command: ${command}\nOutput (${lines.length} lines):\n${toSummarize}${hintsBlock}${profileHints}`,
206
211
  {
207
212
  model: outputModel,
208
- system: SUMMARIZE_PROMPT,
209
- maxTokens: 300,
213
+ system: SUMMARIZE_PROMPT + verbosityHint,
214
+ maxTokens: maxTok,
210
215
  temperature: 0.2,
211
216
  }
212
217
  );