shmakk 1.2.0 → 1.2.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.
@@ -0,0 +1,217 @@
1
+ // Markdown-to-ANSI renderer for terminal output.
2
+ // Converts common markdown syntax to ANSI escape sequences for
3
+ // bold, italic, headers, lists, blockquotes, links, code, etc.
4
+ //
5
+ // Designed for streaming-friendly line-by-line rendering:
6
+ // renderLine(line, opts) — renders a single line
7
+ // renderBlock(text, opts) — renders a full text block (calls renderLine)
8
+
9
+ // ── ANSI helpers ────────────────────────────────────────────────────────────
10
+
11
+ const BOLD = '\x1b[1m';
12
+ const DIM = '\x1b[2m';
13
+ const ITALIC = '\x1b[3m';
14
+ const UNDERLINE = '\x1b[4m';
15
+ const RESET = '\x1b[0m';
16
+
17
+ const COLORS = {
18
+ black: '\x1b[30m',
19
+ red: '\x1b[31m',
20
+ green: '\x1b[32m',
21
+ yellow: '\x1b[33m',
22
+ blue: '\x1b[34m',
23
+ magenta: '\x1b[35m',
24
+ cyan: '\x1b[36m',
25
+ white: '\x1b[37m',
26
+ brightBlack: '\x1b[90m',
27
+ brightRed: '\x1b[91m',
28
+ brightGreen: '\x1b[92m',
29
+ brightYellow: '\x1b[93m',
30
+ brightBlue: '\x1b[94m',
31
+ brightCyan: '\x1b[96m',
32
+ };
33
+
34
+ // ── Inline formatting ───────────────────────────────────────────────────────
35
+
36
+ function renderInline(text, enabled) {
37
+ if (!enabled) {
38
+ // Strip markdown markers when colors are off
39
+ return text
40
+ .replace(/\*\*\*(.+?)\*\*\*/g, '$1')
41
+ .replace(/\*\*(.+?)\*\*/g, '$1')
42
+ .replace(/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, '$1')
43
+ .replace(/`(.+?)`/g, '$1');
44
+ }
45
+
46
+ let result = text;
47
+
48
+ // Bold + italic: ***text***
49
+ result = result.replace(/\*\*\*(.+?)\*\*\*/g, `${BOLD}${ITALIC}$1${RESET}`);
50
+
51
+ // Bold: **text**
52
+ result = result.replace(/\*\*(.+?)\*\*/g, `${BOLD}$1${RESET}`);
53
+
54
+ // Italic: *text* (single asterisk, not part of **)
55
+ result = result.replace(/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, `${ITALIC}$1${RESET}`);
56
+
57
+ // Inline code: `text`
58
+ result = result.replace(/`([^`]+)`/g, `${COLORS.brightBlack}${BOLD}$1${RESET}`);
59
+
60
+ // Links: [text](url) — keep text, show URL dimmed
61
+ result = result.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, label, url) => {
62
+ if (label === url) return `${UNDERLINE}${url}${RESET}`;
63
+ return `${UNDERLINE}${label}${RESET} ${DIM}(${url})${RESET}`;
64
+ });
65
+
66
+ return result;
67
+ }
68
+
69
+ // ── Block-level rendering ───────────────────────────────────────────────────
70
+
71
+ const HORIZONTAL_RULE_RE = /^[-*_]{3,}\s*$/;
72
+ const HEADER_RE = /^(#{1,6})\s+(.+)$/;
73
+ const UNORDERED_LIST_RE = /^(\s*)[-*+]\s+(.+)$/;
74
+ const ORDERED_LIST_RE = /^(\s*)(\d+)\.\s+(.+)$/;
75
+ const BLOCKQUOTE_RE = /^>\s?(.*)$/;
76
+ const CODE_FENCE_RE = /^```(\S*)$/;
77
+
78
+ function renderLine(line, opts = {}) {
79
+ const { enabled = true, colors = true } = opts;
80
+ const trim = line.trimEnd();
81
+ if (!trim) return '';
82
+
83
+ // Code fence — pass through as-is (handled by renderBlock)
84
+ if (CODE_FENCE_RE.test(trim)) {
85
+ if (!enabled || !colors) return line;
86
+ return `${COLORS.brightBlack}${trim}${RESET}`;
87
+ }
88
+
89
+ // Horizontal rule
90
+ if (HORIZONTAL_RULE_RE.test(trim)) {
91
+ if (!enabled || !colors) return '---';
92
+ const width = Math.min(process.stdout.columns || 80, 80);
93
+ return DIM + '\u2500'.repeat(width - 1) + RESET;
94
+ }
95
+
96
+ // Headers
97
+ const hMatch = trim.match(HEADER_RE);
98
+ if (hMatch) {
99
+ const level = hMatch[1].length;
100
+ const text = renderInline(hMatch[2], enabled && colors);
101
+ if (!enabled || !colors) {
102
+ // Structural only: uppercase headers
103
+ return level <= 2 ? text.toUpperCase() : text;
104
+ }
105
+ if (level <= 2) {
106
+ return `${BOLD}${UNDERLINE}${text}${RESET}`;
107
+ }
108
+ return `${BOLD}${text}${RESET}`;
109
+ }
110
+
111
+ // Blockquote
112
+ const bqMatch = trim.match(BLOCKQUOTE_RE);
113
+ if (bqMatch) {
114
+ const text = renderInline(bqMatch[1], enabled && colors);
115
+ if (!enabled || !colors) return ` ${text}`;
116
+ return `${COLORS.brightBlack}\u2502 ${text}${RESET}`;
117
+ }
118
+
119
+ // Unordered list
120
+ const ulMatch = trim.match(UNORDERED_LIST_RE);
121
+ if (ulMatch) {
122
+ const indent = ulMatch[1];
123
+ const text = renderInline(ulMatch[2], enabled && colors);
124
+ if (!enabled || !colors) return ` ${indent}\u2022 ${text}`;
125
+ return `${indent}${COLORS.cyan}\u2022${RESET} ${text}`;
126
+ }
127
+
128
+ // Ordered list
129
+ const olMatch = trim.match(ORDERED_LIST_RE);
130
+ if (olMatch) {
131
+ const indent = olMatch[1];
132
+ const num = olMatch[2];
133
+ const text = renderInline(olMatch[3], enabled && colors);
134
+ if (!enabled || !colors) return ` ${indent}${num}. ${text}`;
135
+ return `${indent}${COLORS.cyan}${num}.${RESET} ${text}`;
136
+ }
137
+
138
+ // Regular line — apply inline formatting
139
+ return renderInline(trim, enabled && colors);
140
+ }
141
+
142
+ function renderBlock(text, opts = {}) {
143
+ const { enabled = true, colors = true } = opts;
144
+ const src = String(text || '');
145
+ if (!src) return src;
146
+
147
+ const lines = src.split('\n');
148
+ const out = [];
149
+ let inCodeBlock = false;
150
+ let codeLang = '';
151
+ let codeLines = [];
152
+
153
+ function flushCode() {
154
+ if (!codeLines.length) return;
155
+ if (!enabled || !colors) {
156
+ out.push(`[${codeLang || 'code'}]`);
157
+ for (const l of codeLines) out.push(` ${l}`);
158
+ codeLines = [];
159
+ return;
160
+ }
161
+ const head = `${COLORS.brightCyan}${BOLD}${codeLang || 'code'}${RESET}`;
162
+ out.push(head);
163
+ for (const l of codeLines) {
164
+ out.push(`${COLORS.brightBlack}${l}${RESET}`);
165
+ }
166
+ codeLines = [];
167
+ }
168
+
169
+ for (let i = 0; i < lines.length; i++) {
170
+ const raw = lines[i];
171
+ const fenceMatch = raw.match(CODE_FENCE_RE);
172
+
173
+ if (fenceMatch && !inCodeBlock) {
174
+ // Opening fence
175
+ flushCode();
176
+ // Flush any accumulated regular lines first is handled above
177
+ inCodeBlock = true;
178
+ codeLang = fenceMatch[1] || '';
179
+ continue;
180
+ }
181
+
182
+ if (fenceMatch && inCodeBlock) {
183
+ // Closing fence
184
+ flushCode();
185
+ inCodeBlock = false;
186
+ codeLang = '';
187
+ continue;
188
+ }
189
+
190
+ if (inCodeBlock) {
191
+ codeLines.push(raw);
192
+ continue;
193
+ }
194
+
195
+ // Code fences might contain ``` with trailing text that isn't a fence
196
+ // Handle ``` that appears mid-line (unusual, but possible)
197
+ if (raw.startsWith('```') && !fenceMatch) {
198
+ // This is caught by the regex above, but just in case:
199
+ codeLines.push(raw);
200
+ continue;
201
+ }
202
+
203
+ const rendered = renderLine(raw, { enabled, colors });
204
+ out.push(rendered);
205
+ }
206
+
207
+ // If file ends inside a code block, still flush it
208
+ flushCode();
209
+
210
+ return out.join('\n');
211
+ }
212
+
213
+ module.exports = {
214
+ renderInline,
215
+ renderLine,
216
+ renderBlock,
217
+ };
package/src/notify.js ADDED
@@ -0,0 +1,34 @@
1
+ // Desktop notification support via notify-send (libnotify).
2
+ // Falls back silently if notify-send is not available or no notification
3
+ // daemon is running.
4
+
5
+ const { execFile } = require('child_process');
6
+
7
+ const NOTIFY_BIN = 'notify-send';
8
+
9
+ function available() {
10
+ try {
11
+ const { execFileSync } = require('child_process');
12
+ execFileSync('which', [NOTIFY_BIN], { stdio: 'ignore' });
13
+ return true;
14
+ } catch {
15
+ return false;
16
+ }
17
+ }
18
+
19
+ // Fire a desktop notification. Never throws — failures are silent.
20
+ // Delegates to a subprocess so the main loop isn't blocked.
21
+ function notify(summary, body, urgency) {
22
+ if (!available()) return;
23
+ const args = [summary || 'shmakk'];
24
+ if (body) args.push(body);
25
+ args.push('--app-name=shmakk', '--category=im.received');
26
+ if (urgency === 'critical') args.push('--urgency=critical');
27
+ if (urgency === 'low') args.push('--urgency=low');
28
+ execFile(NOTIFY_BIN, args, (err) => {
29
+ // Silently ignore — notification daemon may not be running.
30
+ void err;
31
+ });
32
+ }
33
+
34
+ module.exports = { notify, available };
package/src/pty.js CHANGED
@@ -1,4 +1,4 @@
1
- const pty = require('node-pty');
1
+ const pty = require('@lydell/node-pty');
2
2
  const { EventEmitter } = require('events');
3
3
  const { detectShell } = require('./shell');
4
4
  const { configureForShell } = require('./hooks');
package/src/review.js CHANGED
@@ -1,9 +1,16 @@
1
1
  // Y/n prompt with cooperative cancellation. The returned `ask` accepts an
2
2
  // optional `{ onCancel }` callback that fires when the user hits Ctrl-C.
3
3
 
4
- function makePrompter(pty, write) {
4
+ function makePrompter(pty, write, opts = {}) {
5
+ const { onNotify } = opts;
5
6
  return function ask(question, defaultYes, { onCancel, onWhy } = {}) {
6
7
  return new Promise((resolve) => {
8
+ if (onNotify) {
9
+ try {
10
+ const body = typeof question === 'string' ? question.replace(/\x1b\[[0-9;]*m/g, '') : String(question || '');
11
+ onNotify('shmakk needs your attention', body.slice(0, 120));
12
+ } catch {}
13
+ }
7
14
  const tag = defaultYes ? '[Y/n/?]' : '[y/N/?]';
8
15
  write(`${question} ${tag} `);
9
16
  let buf = '';
@@ -80,6 +80,35 @@ const SELF_COMMANDS = [
80
80
  action: 'show-plan',
81
81
  },
82
82
 
83
+ // ── Agent overview ──
84
+ {
85
+ patterns: [
86
+ /^(?:show\s+)?(?:agent\s+)?overview$/i,
87
+ /^what\s+(?:agents?|specialists?)\s+(?:are|is)\s+(?:working|running|active)[\s?]*$/i,
88
+ /^who\s+(?:is\s+)?working[\s?]*$/i,
89
+ /^team\s+(?:status|overview)$/i,
90
+ ],
91
+ action: 'agent-overview',
92
+ },
93
+ // "agent skills" — show skills used by agents
94
+ {
95
+ patterns: [
96
+ /^agent\s+skills?$/i,
97
+ /^(?:show\s+)?agent\s+skills?$/i,
98
+ ],
99
+ action: 'agent-skills',
100
+ },
101
+ // "agent <role|id>" — drill into a specific agent
102
+ {
103
+ patterns: [
104
+ /^agent\s+(\S[\s\S]*)$/i,
105
+ /^show\s+agent\s+(\S[\s\S]*)$/i,
106
+ /^(?:detail|inspect|follow)\s+agent\s+(\S[\s\S]*)$/i,
107
+ ],
108
+ action: 'agent-detail',
109
+ needsArg: true,
110
+ },
111
+
83
112
  // ── Session ──
84
113
  {
85
114
  patterns: [/^(?:shmakk\s+)?status$/i],
@@ -355,6 +384,19 @@ const SELF_COMMANDS = [
355
384
  ],
356
385
  action: 'review-edits',
357
386
  },
387
+
388
+ // ── Sidebar (meta / out-of-band query) ──
389
+ // "Sidebar: what files did you touch?" runs the agent with full context
390
+ // but the query and response are never added to conversation history.
391
+ // Use this for meta-questions, status checks, and side-channel queries.
392
+ {
393
+ patterns: [
394
+ /^Sidebar:\s*(.+)$/i,
395
+ /^sidebar\s+(.+)$/i,
396
+ ],
397
+ action: 'sidebar-query',
398
+ needsArg: true,
399
+ },
358
400
  ];
359
401
 
360
402
  function matchSelfCommand(input) {
@@ -404,6 +446,70 @@ function executeSelfCommand(match, write, ctx = {}) {
404
446
  // ── Plan ──
405
447
  case 'show-plan': ctl.showPlan(); break;
406
448
 
449
+ // ── Agent overview ──
450
+ case 'agent-overview': {
451
+ const overview = require('./agent-overview');
452
+ const agents = overview.getAll();
453
+ const lines = overview.formatOverview(agents);
454
+ for (const line of lines) write(line + '\r\n');
455
+ break;
456
+ }
457
+ case 'agent-detail': {
458
+ const overview = require('./agent-overview');
459
+ const query = (match.arg || '').trim();
460
+ // Try exact id match first, then role match
461
+ let agent = overview.get(query);
462
+ if (!agent) {
463
+ const byRole = overview.findByRole(query);
464
+ if (byRole.length === 1) {
465
+ agent = byRole[0];
466
+ } else if (byRole.length > 1) {
467
+ write(`\x1b[36m[shmakk]\x1b[0m ${byRole.length} agents match "\x1b[1m${query}\x1b[0m":\r\n`);
468
+ for (const a of byRole) {
469
+ const icon = overview.statusIcon(a.status);
470
+ write(` ${icon} \x1b[36m${a.id}\x1b[0m \x1b[2m${a.role} · ${a.status}\x1b[0m\r\n`);
471
+ }
472
+ write(`\r\n\x1b[2mUse "agent <id>" to drill into a specific one.\x1b[0m\r\n`);
473
+ break;
474
+ }
475
+ }
476
+ if (!agent) {
477
+ write(`\x1b[33m[shmakk] no agent found matching "\x1b[1m${query}\x1b[0m"\r\n`);
478
+ write(`\x1b[2mTry "agent overview" to see all agents.\x1b[0m\r\n`);
479
+ break;
480
+ }
481
+ const lines = overview.formatAgentDetail(agent);
482
+ for (const line of lines) write(line + '\r\n');
483
+ break;
484
+ }
485
+ case 'agent-skills': {
486
+ const overview = require('./agent-overview');
487
+ const agents = overview.getAll();
488
+ if (!agents.length) {
489
+ write('\x1b[2mNo agents registered.\x1b[0m\r\n');
490
+ break;
491
+ }
492
+ const skillMap = new Map();
493
+ for (const a of agents) {
494
+ if (!a.skill) continue;
495
+ if (!skillMap.has(a.skill)) {
496
+ skillMap.set(a.skill, { skill: a.skill, source: a.skillSource, agents: [] });
497
+ }
498
+ skillMap.get(a.skill).agents.push(a.role);
499
+ }
500
+ if (!skillMap.size) {
501
+ write('\x1b[2mNo skills used by agents (all using roster hints).\x1b[0m\r\n');
502
+ break;
503
+ }
504
+ write(`\x1b[1mAgent Skills\x1b[0m\r\n\r\n`);
505
+ for (const [name, info] of skillMap) {
506
+ const roles = [...new Set(info.agents)].join(', ');
507
+ write(` \x1b[36m${name}\x1b[0m \x1b[2mused by: ${roles}\x1b[0m\r\n`);
508
+ if (info.source) write(` \x1b[2msource: ${info.source}\x1b[0m\r\n`);
509
+ }
510
+ break;
511
+ }
512
+
407
513
  // ── Session ──
408
514
  case 'status': ctl.status(); break;
409
515
  case 'stats': ctl.stats(); break;
@@ -637,10 +743,10 @@ function executeSelfCommand(match, write, ctx = {}) {
637
743
  const list = listEndpoints(opts.workspace || process.cwd());
638
744
  const current = getCurrentEndpointName();
639
745
  if (!list.length) {
640
- write('[shmakk] no endpoints configured in ~/.config/shmakk/endpoints.js\r\n');
746
+ write('[shmakk] no endpoints configured in ~/.config/shmakk/endpoints.json\r\n');
641
747
  break;
642
748
  }
643
- write('[shmakk] available endpoints:\r\n');
749
+ write('[shmakk] available model endpoints:\r\n');
644
750
  for (const ep of list) {
645
751
  const marker = ep === current ? ' \x1b[1m*\x1b[0m ' : ' ';
646
752
  write(`${marker}${ep}\r\n`);
package/src/session.js CHANGED
@@ -11,6 +11,7 @@ const { clearIndex } = require('./workspace-index');
11
11
  const { loadGlossary } = require('./glossary');
12
12
  const { isConfigured } = require('./llm');
13
13
  const { makePrompter, decisionBanner } = require('./review');
14
+ const { notify } = require('./notify');
14
15
  const { workspaceWarning } = require('./safety');
15
16
  const { createMCPManager } = require('./mcp-client');
16
17
  const { clearEdits } = require('./edit-tracker');
@@ -164,8 +165,11 @@ function makeToolConfirm(opts, ask, out, getAbort) {
164
165
  async function runOneSession(opts, registerSession) {
165
166
  const session = startSession({ debug: opts.debug, voiceEnabled: !!opts.voice });
166
167
  let colorsEnabled = opts.colors !== false;
168
+ let markdownEnabled = opts.markdown !== false;
167
169
  const out = (s) => session.stdoutWrite(colorsEnabled ? s : stripAnsi(s));
168
- const ask = makePrompter(session, out);
170
+ const ask = makePrompter(session, out, {
171
+ onNotify: opts.notify ? (summary, body) => notify(summary, body) : null,
172
+ });
169
173
  const glossary = loadGlossary();
170
174
  // Workspace tracking: explicit --workspace is "pinned"; otherwise cwd
171
175
  // floats with the inner shell's `cd`. When both pinned and cwd differ,
@@ -392,6 +396,7 @@ async function runOneSession(opts, registerSession) {
392
396
  history,
393
397
  profile: opts.profile || voiceRouting.profile || 'balanced',
394
398
  colors: colorsEnabled,
399
+ markdown: markdownEnabled,
395
400
  voiceMode: true,
396
401
  specialistHint: voiceRouting.specialistHint,
397
402
  mcpManager,
@@ -622,6 +627,44 @@ async function runOneSession(opts, registerSession) {
622
627
  audit.append({ kind: 'self-command', cmd: lastCmd, action: selfCmd.action, cwd });
623
628
  // Clear any stale correction state so it doesn't leak into next command
624
629
  correctionOrigin = null;
630
+
631
+ // ── Sidebar query: run the agent with context but don't persist to history ──
632
+ if (selfCmd.action === 'sidebar-query' && selfCmd.arg) {
633
+ if (!isConfigured()) {
634
+ out('\r\n\x1b[33m[shmakk] LLM not configured — sidebar query ignored\x1b[0m\r\n');
635
+ session.childWrite('\r');
636
+ return;
637
+ }
638
+ await withAI(async (ctrl) => {
639
+ const sidebarRouting = routeToSpecialist(selfCmd.arg, [...history, { role: 'user', content: selfCmd.arg }]);
640
+ out('\x1b[36m[shmakk sidebar] (Ctrl-C to interrupt)\x1b[0m\r\n');
641
+ try {
642
+ const updated = await runAgent({
643
+ input: selfCmd.arg,
644
+ roots: currentRoots(),
645
+ glossary,
646
+ confirmTool: makeToolConfirm(opts, ask, out, () => ctrl.abort()),
647
+ write: out,
648
+ signal: ctrl.signal,
649
+ history,
650
+ profile: opts.profile || sidebarRouting.profile || 'balanced',
651
+ colors: colorsEnabled,
652
+ markdown: markdownEnabled,
653
+ specialistHint: sidebarRouting.specialistHint,
654
+ mcpManager,
655
+ });
656
+ // Don't update history — sidebar queries are out-of-band.
657
+ // (runAgent already records turns in session search internally.)
658
+ } catch (e) {
659
+ if (!isAbortError(e)) {
660
+ out(`\x1b[31m[shmakk sidebar] error: ${e.message}\x1b[0m\r\n`);
661
+ }
662
+ }
663
+ });
664
+ session.childWrite('\r');
665
+ return;
666
+ }
667
+
625
668
  if (selfCmd.confirm) {
626
669
  const go = await ask(`Run ${selfCmd.action}?`, true, { onCancel: () => {} });
627
670
  if (!go) { session.childWrite('\r'); return; }
@@ -641,11 +684,18 @@ async function runOneSession(opts, registerSession) {
641
684
  // - Failed (code != 0): give agent the original command the user typed, not any
642
685
  // corrected variant — if a correction was applied but also failed, correctionOrigin
643
686
  // still holds the user's original input and that's what the agent should reason about.
687
+ //
688
+ // After a correction was applied (whether it succeeded or failed), we MUST skip
689
+ // re-running correction on the original — otherwise it would propose the same fix
690
+ // again and enter an infinite loop (correct → feed corrected → succeed/fail →
691
+ // agent sees original → correction proposes same thing → feed again → ...).
644
692
  let cmd = lastCmd;
693
+ let correctionAlreadyTried = false;
645
694
  if (code === 0) {
646
695
  if (correctionOrigin && !opts.noAi) {
647
696
  cmd = correctionOrigin;
648
697
  correctionOrigin = null;
698
+ correctionAlreadyTried = true;
649
699
  } else {
650
700
  flushPending();
651
701
  return;
@@ -657,14 +707,19 @@ async function runOneSession(opts, registerSession) {
657
707
  if (correctionOrigin) {
658
708
  cmd = correctionOrigin;
659
709
  correctionOrigin = null;
710
+ correctionAlreadyTried = true;
660
711
  }
661
712
  }
662
713
 
663
714
  audit.append({ kind: 'failed-command', cmd, exit: code, cwd });
664
715
 
665
716
  // ── Correction runs standalone (no LLM needed) ──
717
+ // Skip correction if one was already applied for this input —
718
+ // re-running correction on the same original would just propose the same fix.
666
719
  let decision;
667
- if (opts.noCorrection) {
720
+ if (correctionAlreadyTried) {
721
+ decision = { category: 'not_a_correction', proposed: null, safety: 'uncertain', reason: 'correction already tried — routing to agent' };
722
+ } else if (opts.noCorrection) {
668
723
  decision = { category: 'not_a_correction', proposed: null, safety: 'uncertain', reason: 'correction disabled' };
669
724
  } else {
670
725
  try {
@@ -761,6 +816,7 @@ async function runOneSession(opts, registerSession) {
761
816
  signal: ctrl.signal,
762
817
  profile: agentProfile,
763
818
  colors: colorsEnabled,
819
+ markdown: markdownEnabled,
764
820
  specialistHint: routing.specialistHint,
765
821
  mcpManager,
766
822
  };