ispbills-icli 8.1.1 → 8.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.
package/README.md CHANGED
@@ -99,7 +99,8 @@ icli --reasoning "audit the config on olt#1" # stream reasoning inline
99
99
 
100
100
  | Flag | Description |
101
101
  |---|---|
102
- | `--reasoning` | Stream the model's reasoning inline |
102
+ | `--reasoning` | Stream the model's reasoning inline (on by default in the REPL) |
103
+ | `--no-reasoning` | Hide the reasoning trace |
103
104
  | `--loader-style STYLE` | Thinking indicator: `spinner` (default), `gradient`, or `minimal` |
104
105
  | `--ascii` | Force ASCII-safe glyphs (use if symbols show as boxes) |
105
106
  | `--unicode` | Force full Unicode glyphs |
@@ -145,10 +146,29 @@ suggestion (accept with **Tab** or **→**), and runs the highlighted command on
145
146
  |---|---|
146
147
  | `/mac [addr]` | Locate a MAC address on the network |
147
148
  | `/arp [device]` · `/route [device]` | ARP / routing tables |
149
+ | `/overlaps [ip/vlan/bgp/route]` | Find overlapping IP subnets, VLAN ids, interface assignments, BGP prefixes or routes |
150
+ | `/flapping [device]` | List flapping interfaces (frequent up/down transitions) |
148
151
  | `/dhcp` | DHCP leases and pool usage |
149
152
  | `/ping [host]` · `/trace [host]` | Ping / traceroute a host |
150
153
  | `/customer [name/IP]` | Look up a customer |
151
154
 
155
+ **Backups & version control**
156
+
157
+ | Slash | Description |
158
+ |---|---|
159
+ | `/backup [device]` | Back up device config (all devices if omitted). Each answer is archived to your per-user git repo |
160
+ | `/git [args]` | Run git in that backup repo — e.g. `/git status`, `/git log`, `/git commit -m "note"`, `/git remote add origin <url>`, `/git push` |
161
+ | `/gist` | Save the last answer or the whole transcript to a **secret** GitHub gist (uses the `gh` CLI, or `/gist token <TOKEN>`) |
162
+
163
+ **Memory**
164
+
165
+ | Slash | Description |
166
+ |---|---|
167
+ | `/remember <note>` | Save a standing note the AI keeps in mind on every turn |
168
+ | `/memory` | List your saved memory notes |
169
+ | `/forget <n/all>` | Delete a note by number, matching text, or `all` |
170
+ | `/copy` | Copy the last AI answer to the system clipboard |
171
+
152
172
  **Session**
153
173
 
154
174
  | Slash | Description |
@@ -165,7 +185,10 @@ suggestion (accept with **Tab** or **→**), and runs the highlighted command on
165
185
  |---|---|
166
186
  | **Enter** | Send message (or run the highlighted slash command) |
167
187
  | **/** then **↑/↓** | Open the slash menu and move the selection; **Tab** / **→** completes, **Enter** runs |
188
+ | **@** | Mention a device/entity (e.g. `@olt:`, `@onu:`); **Tab** / **→** completes the tag |
168
189
  | **↑/↓** (empty input) | Navigate input history |
190
+ | **Ctrl+Z** / **Ctrl+Y** | Undo / redo the input line |
191
+ | **Paste** | Multi-line pastes are inserted as one line (never auto-submitted) |
169
192
  | **Shift+Tab** | Toggle autopilot mode |
170
193
  | **Ctrl+T** | Toggle reasoning display |
171
194
  | **Ctrl+L** | Clear the transcript |
@@ -194,8 +217,15 @@ iCli connects to the same AI engine as the in-browser terminal:
194
217
  - **Customer lookup** — find customers by name, IP, MAC, username, or invoice
195
218
  - **Config audits** — review running config and get concrete suggestions
196
219
  - **Web search** — looks up vendor CLI docs when it needs to
197
- - **Streaming output** — answers stream live with a thinking indicator, reasoning, and step-by-step server progress
198
- - **Plans & connect cards** — proposed fixes render as a reviewable plan (`apply fix` to confirm); device connections show as a connect card
220
+ - **Streaming output** — reasoning, thoughts, and multi-step plans all stream live as they form, with an animated working indicator and step-by-step server progress
221
+ - **Reasoning on by default** — the model's reasoning trace streams inline (toggle with **Ctrl+T** / `/reasoning`, or start with `--no-reasoning`)
222
+ - **Rich rendering** — Markdown tables, colored lists, headings, and code blocks; `@`-mentions are highlighted; long lists render as aligned tables
223
+ - **Interactive prompts** — when the AI needs a decision, it shows a selectable choice list (↑/↓, **Enter**) with an optional free-text answer (**Tab**)
224
+ - **Plans & connect cards** — proposed fixes stream in live as a reviewable plan (`apply fix` to confirm, or use the Apply/Explain/Cancel prompt); device connections show as a connect card
225
+ - **Bot memory** — `/remember` saves standing operator notes that are injected into every answer (`/memory`, `/forget` to manage)
226
+ - **Backups in git** — `/backup` archives config to a per-user git repo; run `/git status|log|commit|push` from the prompt
227
+ - **Share as a gist** — `/gist` publishes the last answer or transcript to a secret GitHub gist
228
+ - **Copy & paste** — `/copy` puts the last answer on the clipboard; multi-line pastes and **Ctrl+Z / Ctrl+Y** undo/redo work in the input
199
229
  - **Full-screen terminal UI** — a full-height alternate-screen TUI (built on [Ink](https://github.com/vadimdemedes/ink)) with the banner pinned to the top, a bottom-pinned bordered input, a selectable slash-command menu, input history, and a bottom-anchored transcript — the same layout as GitHub Copilot CLI / Gemini CLI. Piped/non-interactive use falls back to plain streaming output
200
230
  - **Terminal-aware rendering** — the layout reflows to your terminal width and resizes live; on classic Windows consoles (`cmd.exe` / legacy PowerShell) it auto-switches to ASCII-safe glyphs and borders so nothing shows as broken boxes
201
231
 
package/bin/icli.js CHANGED
@@ -54,9 +54,9 @@ if (has('--ascii')) process.env.ICLI_ASCII = '1';
54
54
  if (has('--unicode')) process.env.ICLI_UNICODE = '1';
55
55
 
56
56
  const display = {
57
- reasoning: has('--reasoning'),
57
+ reasoning: !has('--no-reasoning'),
58
58
  loader: {
59
- text: 'Thinking',
59
+ text: 'Working',
60
60
  style: flag('--loader-style') || 'spinner',
61
61
  },
62
62
  };
@@ -109,7 +109,8 @@ function printHelp() {
109
109
 
110
110
  console.log(`\n ${BOLD}Flags${RESET}`);
111
111
  [
112
- ['--reasoning', 'Stream the model\'s reasoning inline'],
112
+ ['--reasoning', 'Stream reasoning inline (on by default)'],
113
+ ['--no-reasoning', 'Hide the reasoning trace'],
113
114
  ['--loader-style STYLE', 'spinner | gradient | minimal'],
114
115
  ['--ascii', 'Force ASCII-safe glyphs (classic Windows cmd.exe)'],
115
116
  ['--unicode', 'Force full Unicode glyphs'],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ispbills-icli",
3
- "version": "8.1.1",
3
+ "version": "8.3.0",
4
4
  "description": "iCli — IspBills AI network engineer in your terminal",
5
5
  "keywords": [
6
6
  "ispbills",
package/src/client.js CHANGED
@@ -7,7 +7,11 @@ import { refreshToken } from './auth.js';
7
7
  function parseStreamText(raw) {
8
8
  const m = raw.match(/"text"\s*:\s*"((?:[^"\\]|\\.)*)/);
9
9
  if (!m) return raw.trimStart().startsWith('{') ? null : raw;
10
- return m[1]
10
+ return unescapeJsonStr(m[1]);
11
+ }
12
+
13
+ function unescapeJsonStr(s) {
14
+ return s
11
15
  .replace(/\\n/g, '\n')
12
16
  .replace(/\\t/g, '\t')
13
17
  .replace(/\\r/g, '')
@@ -15,6 +19,53 @@ function parseStreamText(raw) {
15
19
  .replace(/\\\\/g, '\\');
16
20
  }
17
21
 
22
+ /** Pull the value of a top-level string key out of a (possibly partial) JSON blob. */
23
+ function grabString(raw, key) {
24
+ const m = raw.match(new RegExp('"' + key + '"\\s*:\\s*"((?:[^"\\\\]|\\\\.)*)"'));
25
+ return m ? unescapeJsonStr(m[1]) : null;
26
+ }
27
+
28
+ /**
29
+ * Extract a partially-streamed `plan` reply from the raw JSON.
30
+ * Returns { summary, steps:[{cmd, why, risk}] } using only the step objects
31
+ * that have finished streaming (balanced braces), so the plan can grow live.
32
+ */
33
+ export function parsePartialPlan(raw) {
34
+ const summary = grabString(raw, 'summary') || '';
35
+ const steps = [];
36
+ const sm = raw.match(/"steps"\s*:\s*\[/);
37
+ if (sm) {
38
+ let i = sm.index + sm[0].length;
39
+ while (i < raw.length) {
40
+ // find next object start
41
+ while (i < raw.length && raw[i] !== '{' && raw[i] !== ']') i++;
42
+ if (i >= raw.length || raw[i] === ']') break;
43
+ // scan a balanced object, respecting strings
44
+ let depth = 0, inStr = false, esc = false, start = i;
45
+ for (; i < raw.length; i++) {
46
+ const ch = raw[i];
47
+ if (inStr) {
48
+ if (esc) esc = false;
49
+ else if (ch === '\\') esc = true;
50
+ else if (ch === '"') inStr = false;
51
+ } else if (ch === '"') inStr = true;
52
+ else if (ch === '{') depth++;
53
+ else if (ch === '}') { depth--; if (depth === 0) { i++; break; } }
54
+ }
55
+ if (depth !== 0) break; // object still streaming
56
+ const objRaw = raw.slice(start, i);
57
+ const cmd = grabString(objRaw, 'cmd');
58
+ if (cmd === null) continue;
59
+ steps.push({
60
+ cmd,
61
+ why: grabString(objRaw, 'why') || grabString(objRaw, 'reason') || '',
62
+ risk: grabString(objRaw, 'risk') || '',
63
+ });
64
+ }
65
+ }
66
+ return { type: 'plan', summary, steps };
67
+ }
68
+
18
69
  /**
19
70
  * Stream a chat turn from the IspBills Terminal AI backend.
20
71
  *
@@ -82,10 +133,17 @@ export async function streamChat(cfg, messages, options = {}) {
82
133
  onEvent({ type: 'reasoning', delta: ev.reason_delta });
83
134
  } else if (ev.delta !== undefined) {
84
135
  rawAnswer += ev.delta;
85
- const txt = parseStreamText(rawAnswer);
86
- if (txt !== null && txt.length > answerText.length) {
87
- onEvent({ type: 'text', delta: txt.slice(answerText.length) });
88
- answerText = txt;
136
+ const trimmed = rawAnswer.trimStart();
137
+ const isPlan = /"type"\s*:\s*"plan"/.test(rawAnswer) ||
138
+ (trimmed.startsWith('{') && /"steps"\s*:/.test(rawAnswer));
139
+ if (isPlan) {
140
+ onEvent({ type: 'plan', reply: parsePartialPlan(rawAnswer) });
141
+ } else {
142
+ const txt = parseStreamText(rawAnswer);
143
+ if (txt !== null && txt.length > answerText.length) {
144
+ onEvent({ type: 'text', delta: txt.slice(answerText.length) });
145
+ answerText = txt;
146
+ }
89
147
  }
90
148
  } else if (ev.done) {
91
149
  finalResult = ev.result;
@@ -0,0 +1,42 @@
1
+ // Cross-platform "copy to system clipboard" helper. Best-effort: resolves to
2
+ // false (rather than throwing) when no clipboard tool is available so the TUI
3
+ // can show a friendly notice.
4
+ import { spawn } from 'child_process';
5
+
6
+ function candidates() {
7
+ if (process.platform === 'darwin') return [['pbcopy', []]];
8
+ if (process.platform === 'win32') return [['clip', []]];
9
+ // Linux / BSD: prefer Wayland, then X11 tools.
10
+ return [
11
+ ['wl-copy', []],
12
+ ['xclip', ['-selection', 'clipboard']],
13
+ ['xsel', ['--clipboard', '--input']],
14
+ ];
15
+ }
16
+
17
+ function tryCopy(cmd, args, text) {
18
+ return new Promise((resolve) => {
19
+ let child;
20
+ try {
21
+ child = spawn(cmd, args, { stdio: ['pipe', 'ignore', 'ignore'] });
22
+ } catch {
23
+ resolve(false);
24
+ return;
25
+ }
26
+ child.on('error', () => resolve(false));
27
+ child.on('close', (code) => resolve(code === 0));
28
+ try {
29
+ child.stdin.end(text);
30
+ } catch {
31
+ resolve(false);
32
+ }
33
+ });
34
+ }
35
+
36
+ /** Copy `text` to the system clipboard. Returns the tool name on success, else null. */
37
+ export async function copyToClipboard(text) {
38
+ for (const [cmd, args] of candidates()) {
39
+ if (await tryCopy(cmd, args, text)) return cmd;
40
+ }
41
+ return null;
42
+ }
package/src/commands.js CHANGED
@@ -34,11 +34,23 @@ export const SLASH_COMMANDS = [
34
34
  { cmd: '/mac', hint: '[addr]', desc: 'Locate a MAC address on the network', group: 'Lookup' },
35
35
  { cmd: '/arp', hint: '[device]', desc: 'ARP table for a device', group: 'Lookup' },
36
36
  { cmd: '/route', hint: '[device]', desc: 'Routing table for a device', group: 'Lookup' },
37
+ { cmd: '/overlaps', hint: '[ip/vlan/bgp/route]', desc: 'Find overlapping IP, VLAN, interface, BGP or routes', group: 'Lookup' },
38
+ { cmd: '/flapping', hint: '[device]', desc: 'List flapping interfaces (frequent up/down)', group: 'Lookup' },
37
39
  { cmd: '/dhcp', hint: '', desc: 'DHCP leases and pool usage', group: 'Lookup' },
38
40
  { cmd: '/ping', hint: '[host]', desc: 'Ping a host through the network', group: 'Lookup' },
39
41
  { cmd: '/trace', hint: '[host]', desc: 'Traceroute the path to a host', group: 'Lookup' },
40
42
  { cmd: '/customer', hint: '[name/IP]', desc: 'Look up a customer', group: 'Lookup' },
41
43
 
44
+ // ── Backups & version control ──
45
+ { cmd: '/git', hint: '[args]', desc: 'Run git in your backup repo (status/commit/push/log)', group: 'Backups', local: true },
46
+ { cmd: '/gist', hint: '', desc: 'Save the last answer or transcript to a GitHub gist', group: 'Backups', local: true },
47
+
48
+ // ── Memory ──
49
+ { cmd: '/remember', hint: '<note>', desc: 'Save a standing note the AI keeps in mind', group: 'Memory', local: true },
50
+ { cmd: '/memory', hint: '', desc: 'List saved memory notes', group: 'Memory', local: true },
51
+ { cmd: '/forget', hint: '[n/all]', desc: 'Delete a memory note (by number or all)', group: 'Memory', local: true },
52
+ { cmd: '/copy', hint: '', desc: 'Copy the last AI answer to the clipboard', group: 'Memory', local: true },
53
+
42
54
  // ── Session & app ──
43
55
  { cmd: '/autopilot', hint: '', desc: 'Toggle autopilot (auto-apply fixes)', group: 'Session', local: true },
44
56
  { cmd: '/model', hint: '[name]', desc: 'Show or set the AI model', group: 'Session', local: true },
@@ -106,6 +118,16 @@ export function slashToQuestion(line) {
106
118
  case '/mac': return on(`locate MAC address ${arg} — which device and port is it on?`, 'how do I locate a MAC address? ask me for the address');
107
119
  case '/arp': return on(`show the ARP table for ${arg}`, 'show ARP tables and flag any duplicate or suspicious entries');
108
120
  case '/route': return on(`show the routing table for ${arg}`, 'show routing tables across the network');
121
+ case '/overlaps': {
122
+ const t = arg.toLowerCase();
123
+ if (/vlan/.test(t)) return 'scan all devices for duplicate or overlapping VLAN ids and list each conflict with the devices/interfaces involved';
124
+ if (/bgp|prefix|as\b|peer/.test(t)) return 'check BGP for overlapping or conflicting prefixes and duplicate AS announcements across routers and list each conflict';
125
+ if (/route|routing/.test(t)) return 'find overlapping or conflicting routes across the routing tables and list each pair with the devices involved';
126
+ if (/int|port/.test(t)) return 'find interfaces/ports with overlapping or duplicate address or VLAN assignments and list each conflict';
127
+ if (/ip|subnet|network|cidr/.test(t)) return 'find overlapping or conflicting IP subnets and duplicate IP addresses across the network and list each conflict with the devices involved';
128
+ return 'scan the whole network for any overlapping or conflicting IP subnets, duplicate IPs, duplicate/overlapping VLAN ids, interface assignment conflicts, and overlapping BGP prefixes or routes — list each conflict grouped by type with the devices involved';
129
+ }
130
+ case '/flapping': return on(`check ${arg} for flapping interfaces — list ports with frequent up/down transitions, their flap counts and last change time`, 'scan all devices for flapping interfaces (frequent up/down transitions) and list them with flap counts and the last state-change time');
109
131
  case '/dhcp': return 'show DHCP leases and pool usage, and flag pools that are nearly exhausted';
110
132
  case '/ping': return on(`ping ${arg} through the network and report latency and loss`, 'which hosts should I ping? list reachable devices');
111
133
  case '/trace': return on(`traceroute the path to ${arg} and show each hop`, 'what host should I traceroute?');
package/src/gist.js ADDED
@@ -0,0 +1,90 @@
1
+ // Create GitHub gists from the terminal. Prefers the `gh` CLI (uses its stored
2
+ // auth); falls back to the REST API with a token from the icli config
3
+ // (cfg.github_token) or the GITHUB_TOKEN / GH_TOKEN environment variables.
4
+ import { spawn } from 'child_process';
5
+ import { mkdtempSync, writeFileSync, rmSync } from 'fs';
6
+ import { tmpdir } from 'os';
7
+ import { join } from 'path';
8
+
9
+ function sh(cmd, args, { input } = {}) {
10
+ return new Promise((resolve) => {
11
+ let child;
12
+ try {
13
+ child = spawn(cmd, args, { stdio: ['pipe', 'pipe', 'pipe'] });
14
+ } catch (e) {
15
+ resolve({ code: 127, stdout: '', stderr: String(e.message || e) });
16
+ return;
17
+ }
18
+ let stdout = '', stderr = '';
19
+ child.stdout.on('data', (d) => (stdout += d));
20
+ child.stderr.on('data', (d) => (stderr += d));
21
+ child.on('error', (e) => resolve({ code: 127, stdout, stderr: String(e.message || e) }));
22
+ child.on('close', (code) => resolve({ code, stdout: stdout.trim(), stderr: stderr.trim() }));
23
+ if (input != null) { try { child.stdin.end(input); } catch {} }
24
+ else child.stdin.end();
25
+ });
26
+ }
27
+
28
+ async function hasGh() {
29
+ const res = await sh('gh', ['--version']);
30
+ return res.code === 0;
31
+ }
32
+
33
+ function token(cfg) {
34
+ return cfg?.github_token || process.env.GITHUB_TOKEN || process.env.GH_TOKEN || '';
35
+ }
36
+
37
+ /** True if any GitHub auth is available (gh CLI or a token). */
38
+ export async function canGist(cfg) {
39
+ if (token(cfg)) return true;
40
+ return hasGh();
41
+ }
42
+
43
+ async function gistViaGh({ filename, content, description, isPublic }) {
44
+ const dir = mkdtempSync(join(tmpdir(), 'icli-gist-'));
45
+ const path = join(dir, filename);
46
+ try {
47
+ writeFileSync(path, content);
48
+ const args = ['gist', 'create', path, '-d', description];
49
+ if (isPublic) args.push('--public');
50
+ const res = await sh('gh', args);
51
+ if (res.code !== 0) throw new Error(res.stderr || 'gh gist create failed');
52
+ const url = (res.stdout.match(/https?:\/\/\S+/) || [res.stdout])[0];
53
+ return { url };
54
+ } finally {
55
+ rmSync(dir, { recursive: true, force: true });
56
+ }
57
+ }
58
+
59
+ async function gistViaApi({ filename, content, description, isPublic }, tok) {
60
+ const res = await fetch('https://api.github.com/gists', {
61
+ method: 'POST',
62
+ headers: {
63
+ Authorization: 'Bearer ' + tok,
64
+ Accept: 'application/vnd.github+json',
65
+ 'Content-Type': 'application/json',
66
+ 'User-Agent': 'ispbills-icli',
67
+ },
68
+ body: JSON.stringify({ description, public: !!isPublic, files: { [filename]: { content } } }),
69
+ });
70
+ if (!res.ok) {
71
+ const txt = await res.text().catch(() => '');
72
+ throw new Error(`GitHub API ${res.status}: ${txt.slice(0, 160)}`);
73
+ }
74
+ const data = await res.json();
75
+ return { url: data.html_url };
76
+ }
77
+
78
+ /**
79
+ * Create a gist. `opts` = { filename, content, description, isPublic }.
80
+ * Returns { url }. Throws with a helpful message when no auth is available.
81
+ */
82
+ export async function createGist(cfg, opts) {
83
+ const o = { isPublic: false, filename: 'icli.md', description: 'iCli export', ...opts };
84
+ const tok = token(cfg);
85
+ if (tok) return gistViaApi(o, tok);
86
+ if (await hasGh()) return gistViaGh(o);
87
+ throw new Error(
88
+ 'No GitHub auth. Run `gh auth login`, or save a token with `/gist token <TOKEN>` (needs the "gist" scope).',
89
+ );
90
+ }
package/src/gitrepo.js ADDED
@@ -0,0 +1,78 @@
1
+ // Per-user local git repository for archiving device config backups and giving
2
+ // the operator plain /git commands (status, log, commit, push, diff, …). The
3
+ // repo lives under ~/.config/ispbills-icli/backups/<user>/ and is git-init'd on
4
+ // first use with the operator's IspBills identity.
5
+ import { spawn } from 'child_process';
6
+ import { existsSync, writeFileSync } from 'fs';
7
+ import { join } from 'path';
8
+ import { userDir } from './userdir.js';
9
+
10
+ // Sub-commands the operator may run through /git. Read-mostly plus the handful
11
+ // needed to record and publish backups; anything else is refused.
12
+ const ALLOWED = new Set([
13
+ 'status', 'log', 'diff', 'show', 'add', 'commit', 'push', 'pull',
14
+ 'fetch', 'remote', 'branch', 'checkout', 'init', 'config', 'ls-files', 'rev-parse',
15
+ ]);
16
+
17
+ /** Absolute path to the user's backup git repo (created if missing). */
18
+ export function repoDir(cfg) {
19
+ return userDir(cfg, 'backups');
20
+ }
21
+
22
+ function run(cfg, args, { input } = {}) {
23
+ return new Promise((resolve) => {
24
+ let child;
25
+ try {
26
+ child = spawn('git', args, { cwd: repoDir(cfg), stdio: ['pipe', 'pipe', 'pipe'] });
27
+ } catch (e) {
28
+ resolve({ code: 127, stdout: '', stderr: String(e.message || e) });
29
+ return;
30
+ }
31
+ let stdout = '', stderr = '';
32
+ child.stdout.on('data', (d) => (stdout += d));
33
+ child.stderr.on('data', (d) => (stderr += d));
34
+ child.on('error', (e) => resolve({ code: 127, stdout, stderr: String(e.message || e) }));
35
+ child.on('close', (code) => resolve({ code, stdout, stderr }));
36
+ if (input != null) { try { child.stdin.end(input); } catch {} }
37
+ else child.stdin.end();
38
+ });
39
+ }
40
+
41
+ /** Initialise the repo on first use and set the local identity. */
42
+ export async function ensureRepo(cfg) {
43
+ const dir = repoDir(cfg);
44
+ if (!existsSync(join(dir, '.git'))) {
45
+ await run(cfg, ['init', '-q']);
46
+ await run(cfg, ['config', 'user.name', cfg?.user?.name || 'IspBills operator']);
47
+ await run(cfg, ['config', 'user.email', cfg?.user?.email || 'operator@ispbills.local']);
48
+ await run(cfg, ['config', 'commit.gpgsign', 'false']);
49
+ writeFileSync(join(dir, 'README.md'), '# IspBills device backups\n\nManaged by iCli (`/backup`, `/git`).\n');
50
+ await run(cfg, ['add', 'README.md']);
51
+ await run(cfg, ['commit', '-q', '-m', 'Initialise backup repository']);
52
+ }
53
+ return dir;
54
+ }
55
+
56
+ /** Run a whitelisted git sub-command; returns { ok, code, stdout, stderr }. */
57
+ export async function git(cfg, args) {
58
+ const sub = (args[0] || '').toLowerCase();
59
+ if (!sub) return { ok: false, code: 2, stdout: '', stderr: 'Usage: /git <status|log|commit|push|diff|…>' };
60
+ if (!ALLOWED.has(sub)) {
61
+ return { ok: false, code: 2, stdout: '', stderr: `git ${sub} is not allowed from iCli. Allowed: ${[...ALLOWED].join(', ')}` };
62
+ }
63
+ await ensureRepo(cfg);
64
+ const res = await run(cfg, args);
65
+ return { ok: res.code === 0, ...res };
66
+ }
67
+
68
+ /** Save backup content to a file and commit it. Returns { file, committed, stderr }. */
69
+ export async function commitBackup(cfg, label, content) {
70
+ await ensureRepo(cfg);
71
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
72
+ const safe = String(label || 'fleet').toLowerCase().replace(/[^a-z0-9._-]+/g, '-').slice(0, 40) || 'fleet';
73
+ const name = `${stamp}__${safe}.txt`;
74
+ writeFileSync(join(repoDir(cfg), name), String(content));
75
+ await run(cfg, ['add', name]);
76
+ const res = await run(cfg, ['commit', '-q', '-m', `Backup ${safe} @ ${stamp}`]);
77
+ return { file: name, committed: res.code === 0, stderr: res.stderr };
78
+ }
package/src/memory.js ADDED
@@ -0,0 +1,72 @@
1
+ // Per-user persistent "bot memory": short operator notes that are injected
2
+ // into the AI context on every turn so the assistant remembers preferences,
3
+ // naming conventions, standing instructions, etc. Managed with /remember,
4
+ // /forget and /memory.
5
+ import { readFileSync, writeFileSync } from 'fs';
6
+ import { join } from 'path';
7
+ import { userDir } from './userdir.js';
8
+
9
+ const MAX_NOTES = 50;
10
+
11
+ function file(cfg) {
12
+ return join(userDir(cfg, 'memory'), 'notes.json');
13
+ }
14
+
15
+ /** Load the user's notes as an array of { at, text }. */
16
+ export function loadNotes(cfg) {
17
+ try {
18
+ const data = JSON.parse(readFileSync(file(cfg), 'utf8'));
19
+ return Array.isArray(data?.notes) ? data.notes : [];
20
+ } catch {
21
+ return [];
22
+ }
23
+ }
24
+
25
+ function save(cfg, notes) {
26
+ writeFileSync(file(cfg), JSON.stringify({ notes }, null, 2), { mode: 0o600 });
27
+ return notes;
28
+ }
29
+
30
+ /** Append a note. Returns the updated list. */
31
+ export function addNote(cfg, text) {
32
+ const t = String(text || '').trim();
33
+ if (!t) return loadNotes(cfg);
34
+ const notes = loadNotes(cfg);
35
+ notes.push({ at: new Date().toISOString(), text: t });
36
+ return save(cfg, notes.slice(-MAX_NOTES));
37
+ }
38
+
39
+ /**
40
+ * Remove a note. `which` may be a 1-based index, the string 'all', or a
41
+ * substring to match. Returns { notes, removed }.
42
+ */
43
+ export function forgetNote(cfg, which) {
44
+ const notes = loadNotes(cfg);
45
+ if (String(which).toLowerCase() === 'all') {
46
+ save(cfg, []);
47
+ return { notes: [], removed: notes.length };
48
+ }
49
+ const idx = Number(which);
50
+ if (Number.isInteger(idx) && idx >= 1 && idx <= notes.length) {
51
+ const [gone] = notes.splice(idx - 1, 1);
52
+ save(cfg, notes);
53
+ return { notes, removed: gone ? 1 : 0 };
54
+ }
55
+ const needle = String(which || '').toLowerCase();
56
+ const before = notes.length;
57
+ const kept = needle ? notes.filter((n) => !n.text.toLowerCase().includes(needle)) : notes;
58
+ save(cfg, kept);
59
+ return { notes: kept, removed: before - kept.length };
60
+ }
61
+
62
+ /** Build the preamble message injected ahead of the conversation, or null. */
63
+ export function memoryPreamble(cfg) {
64
+ const notes = loadNotes(cfg);
65
+ if (!notes.length) return null;
66
+ const body = notes.map((n) => `- ${n.text}`).join('\n');
67
+ return {
68
+ role: 'user',
69
+ content:
70
+ `[operator memory — standing notes to keep in mind for every answer; do not reply to this message directly]\n${body}`,
71
+ };
72
+ }