clementine-agent 1.1.11 → 1.1.13

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.
@@ -1517,18 +1517,31 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
1517
1517
  const { detectFrustrationSignals, detectRepeatedTopics } = require('./insight-engine.js');
1518
1518
  const since24h = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
1519
1519
  const since7d = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString();
1520
- const recent = this.getRecentActivity(since24h, 50);
1521
- const week = this.getRecentActivity(since7d, 200);
1520
+ let recent = this.getRecentActivity(since24h, 50);
1521
+ let week = this.getRecentActivity(since7d, 200);
1522
+ // Phase 10c: per-agent scope filter. Per-agent bot session keys
1523
+ // embed the agent slug (e.g. dm:ross-the-sdr:userId), so when this
1524
+ // prompt is for a specific agent profile we only consider sessions
1525
+ // that involved THAT agent. Without this filter, Nate's frustration
1526
+ // chatting with Sasha would leak into Ross's prompt — wrong signal.
1527
+ if (profile?.slug) {
1528
+ const slugMarker = `:${profile.slug}:`;
1529
+ recent = recent.filter(e => e.sessionKey.includes(slugMarker));
1530
+ week = week.filter(e => e.sessionKey.includes(slugMarker));
1531
+ }
1522
1532
  const frustration = detectFrustrationSignals(recent);
1523
1533
  const topics = detectRepeatedTopics(week);
1524
1534
  const allSignals = [...frustration, ...topics];
1525
1535
  if (allSignals.length > 0) {
1536
+ const scopeNote = profile?.slug
1537
+ ? `\n\n*Scope: signals from sessions with you (${profile.slug}).*`
1538
+ : '';
1526
1539
  const guidance = frustration.length > 0
1527
1540
  ? '\n\n**Adjust your approach:** When friction signals are present, lead with a clarifying question instead of assuming. Acknowledge the prior misunderstanding briefly without over-apologizing. Confirm understanding before acting.'
1528
1541
  : '\n\n**Use this context naturally:** Recurring topics may indicate an unresolved thread — if relevant, offer to close the loop or summarize current state. Do not force callbacks if not directly applicable.';
1529
1542
  volatileParts.push(`## Conversational Context\n\nSignals from recent sessions:\n` +
1530
1543
  allSignals.map(s => `- ${s}`).join('\n') +
1531
- guidance);
1544
+ guidance + scopeNote);
1532
1545
  }
1533
1546
  }
1534
1547
  catch { /* non-fatal — insight-engine optional */ }
@@ -16981,6 +16981,51 @@ async function refreshToolUsagePanel() {
16981
16981
  }
16982
16982
  html += '</table>';
16983
16983
  }
16984
+
16985
+ // Phase 11e: cost-by-source pivot — aggregates the bySource maps from
16986
+ // every family into a single "top jobs by spend" view. Uses only data
16987
+ // already in the response, no extra API call.
16988
+ const sourceTotals = {};
16989
+ for (const f of (data.families || [])) {
16990
+ const callsPerSource = {};
16991
+ for (const s of (f.bySource || [])) callsPerSource[s.source] = s.count;
16992
+ const familyTotalCalls = f.totalCalls || 0;
16993
+ for (const s of (f.bySource || [])) {
16994
+ const share = familyTotalCalls > 0 ? s.count / familyTotalCalls : 0;
16995
+ const cost = (f.estimatedCostUsd || 0) * share;
16996
+ if (!sourceTotals[s.source]) sourceTotals[s.source] = { source: s.source, cost: 0, calls: 0 };
16997
+ sourceTotals[s.source].cost += cost;
16998
+ sourceTotals[s.source].calls += s.count;
16999
+ }
17000
+ }
17001
+ const sourceList = Object.values(sourceTotals)
17002
+ .filter(s => s.source !== 'unknown' || sourceTotals.unknown.cost > 0)
17003
+ .sort((a, b) => b.cost - a.cost);
17004
+
17005
+ if (sourceList.length > 0) {
17006
+ const maxSrcCost = Math.max.apply(null, sourceList.map(s => s.cost).concat([0.0001]));
17007
+ html += '<div style="margin-top:18px"><div style="font-weight:600;font-size:13px;margin-bottom:8px;color:var(--text-secondary)">Top jobs by cost</div>';
17008
+ html += '<table style="width:100%;font-size:13px"><tr>'
17009
+ + '<th>Job / source</th><th style="text-align:right">Cost</th><th style="text-align:right">Share</th><th style="text-align:right">Calls</th><th>Distribution</th></tr>';
17010
+ const totalSrcCost = sourceList.reduce((sum, s) => sum + s.cost, 0);
17011
+ for (const s of sourceList.slice(0, 10)) {
17012
+ const pct = totalSrcCost > 0 ? ((s.cost / totalSrcCost) * 100).toFixed(1) + '%' : '0.0%';
17013
+ const barW = Math.max(2, Math.round((s.cost / maxSrcCost) * 100));
17014
+ const sourceLabel = s.source === 'unknown'
17015
+ ? '<em style="color:var(--text-muted)">unattributed</em>'
17016
+ : '<strong>' + esc(s.source) + '</strong>';
17017
+ html += '<tr>'
17018
+ + '<td>' + sourceLabel + '</td>'
17019
+ + '<td style="text-align:right;color:var(--green)">$' + s.cost.toFixed(2) + '</td>'
17020
+ + '<td style="text-align:right;color:var(--text-muted)">' + pct + '</td>'
17021
+ + '<td style="text-align:right">' + s.calls.toLocaleString() + '</td>'
17022
+ + '<td><div style="background:var(--bg-elev);height:8px;border-radius:4px;overflow:hidden;width:100%;max-width:160px">'
17023
+ + '<div style="background:var(--blue);height:100%;width:' + barW + '%"></div></div></td>'
17024
+ + '</tr>';
17025
+ }
17026
+ html += '</table></div>';
17027
+ }
17028
+
16984
17029
  html += '</div></div>';
16985
17030
  host.innerHTML = html;
16986
17031
  } catch(e) {
package/dist/cli/index.js CHANGED
@@ -899,7 +899,10 @@ function cmdConfigSet(key, value) {
899
899
  else {
900
900
  content = content.trimEnd() + `\n${upperKey}=${value}\n`;
901
901
  }
902
- writeFileSync(ENV_PATH, content);
902
+ // Mode 0o600 — credentials live here; never world-readable. Phase 12
903
+ // hardening also fixes pre-existing .env files via `clementine config
904
+ // harden-permissions`.
905
+ writeFileSync(ENV_PATH, content, { mode: 0o600 });
903
906
  console.log(` Set ${upperKey}=${value}`);
904
907
  }
905
908
  function cmdConfigGet(key) {
@@ -1217,6 +1220,65 @@ async function cmdConfigMigrateFromKeychain(opts) {
1217
1220
  console.log(` ${GREEN}Migrated ${result.migrated.length} key${result.migrated.length === 1 ? '' : 's'} out of keychain.${RESET}`);
1218
1221
  console.log();
1219
1222
  }
1223
+ // ── Config harden-permissions ────────────────────────────────────────
1224
+ async function cmdConfigHardenPermissions(opts) {
1225
+ const { hardenPermissions } = await import('../config/harden-permissions.js');
1226
+ const report = hardenPermissions(BASE_DIR, { dryRun: opts.dryRun });
1227
+ if (opts.json) {
1228
+ console.log(JSON.stringify(report, null, 2));
1229
+ process.exit(report.failed > 0 ? 1 : 0);
1230
+ }
1231
+ const DIM = '\x1b[0;90m';
1232
+ const BOLD = '\x1b[1m';
1233
+ const GREEN = '\x1b[0;32m';
1234
+ const YELLOW = '\x1b[0;33m';
1235
+ const RED = '\x1b[0;31m';
1236
+ const CYAN = '\x1b[0;36m';
1237
+ const RESET = '\x1b[0m';
1238
+ console.log();
1239
+ console.log(` ${BOLD}Data home:${RESET} ${report.baseDir}`);
1240
+ console.log(` ${BOLD}Mode:${RESET} ${opts.dryRun ? `${YELLOW}dry run${RESET}` : `${GREEN}apply${RESET}`}`);
1241
+ console.log();
1242
+ console.log(` ${BOLD}Scanned:${RESET} ${report.scanned}`);
1243
+ console.log(` ${GREEN}Tightened:${RESET} ${report.tightened}`);
1244
+ console.log(` ${DIM}Already correct:${RESET} ${report.alreadyCorrect}`);
1245
+ console.log(` ${DIM}Skipped:${RESET} ${report.skipped}`);
1246
+ if (report.failed > 0) {
1247
+ console.log(` ${RED}Failed:${RESET} ${report.failed}`);
1248
+ }
1249
+ console.log();
1250
+ // Show first ~20 tightened entries — enough to see the shape without flooding
1251
+ const tightened = report.entries.filter(e => e.status === 'tightened').slice(0, 20);
1252
+ if (tightened.length > 0) {
1253
+ console.log(` ${BOLD}${opts.dryRun ? 'Would tighten' : 'Tightened'} (showing first ${tightened.length}):${RESET}`);
1254
+ for (const e of tightened) {
1255
+ const rel = e.path.startsWith(report.baseDir + '/') ? e.path.slice(report.baseDir.length + 1) : e.path;
1256
+ const kindTag = e.kind === 'directory' ? `${CYAN}d${RESET}` : `${DIM}-${RESET}`;
1257
+ console.log(` ${kindTag} ${e.beforeMode} ${DIM}→${RESET} ${GREEN}${e.afterMode}${RESET} ${rel}`);
1258
+ }
1259
+ if (report.tightened > tightened.length) {
1260
+ console.log(` ${DIM}... ${report.tightened - tightened.length} more${RESET}`);
1261
+ }
1262
+ console.log();
1263
+ }
1264
+ const failed = report.entries.filter(e => e.status === 'failed').slice(0, 10);
1265
+ if (failed.length > 0) {
1266
+ console.log(` ${RED}${BOLD}Failed:${RESET}`);
1267
+ for (const e of failed) {
1268
+ console.log(` ${RED}✗${RESET} ${e.path} — ${e.error ?? 'unknown'}`);
1269
+ }
1270
+ console.log();
1271
+ }
1272
+ if (opts.dryRun) {
1273
+ console.log(` ${DIM}Re-run without --dry-run to apply.${RESET}`);
1274
+ console.log();
1275
+ }
1276
+ else if (report.tightened > 0) {
1277
+ console.log(` ${GREEN}Done.${RESET} ${DIM}Re-run \`clementine config doctor\` to confirm permissions are now correct.${RESET}`);
1278
+ console.log();
1279
+ }
1280
+ process.exit(report.failed > 0 ? 1 : 0);
1281
+ }
1220
1282
  // ── Config keychain-fix-acl ─────────────────────────────────────────
1221
1283
  async function cmdConfigKeychainFixAcl(opts) {
1222
1284
  const { listClementineKeychainEntries, fixAllClementineEntries } = await import('../config/keychain-fix-acl.js');
@@ -2004,6 +2066,14 @@ configCmd
2004
2066
  .action(async (opts) => {
2005
2067
  await cmdConfigKeychainFixAcl(opts);
2006
2068
  });
2069
+ configCmd
2070
+ .command('harden-permissions')
2071
+ .description('Tighten file modes in ~/.clementine/ — files to 0600, directories to 0700')
2072
+ .option('--dry-run', 'Preview without changing anything')
2073
+ .option('--json', 'Emit machine-readable JSON')
2074
+ .action(async (opts) => {
2075
+ await cmdConfigHardenPermissions(opts);
2076
+ });
2007
2077
  configCmd
2008
2078
  .command('edit')
2009
2079
  .description('Open .env in your editor')
@@ -2229,10 +2299,10 @@ async function cmdUpdate(options) {
2229
2299
  console.log(` ${S()} Backing up config...`);
2230
2300
  if (!options.dryRun) {
2231
2301
  mkdirSync(backupDir, { recursive: true });
2232
- // .env
2302
+ // .env (mode 0o600 — backup carries credentials, same protection as live file)
2233
2303
  if (existsSync(ENV_PATH)) {
2234
2304
  const envContent = readFileSync(ENV_PATH, 'utf-8');
2235
- writeFileSync(path.join(backupDir, '.env'), envContent);
2305
+ writeFileSync(path.join(backupDir, '.env'), envContent, { mode: 0o600 });
2236
2306
  }
2237
2307
  // Cron state
2238
2308
  const cronStateFile = path.join(BASE_DIR, '.cron_last_run.json');
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Tighten file modes on the Clementine data home.
3
+ *
4
+ * Walks ~/.clementine/ (or any baseDir) and:
5
+ * - Sets every regular file to mode 0600 (owner read/write only)
6
+ * - Sets every directory to mode 0700 (owner traverse only)
7
+ *
8
+ * Why: state files live alongside `credentials.json` and `.env`. Even
9
+ * after Phase 9b's outbound redaction, contents on disk include
10
+ * conversation transcripts, advisor LLM outputs, cron run logs, full
11
+ * Discord/Slack message snippets, etc. Default umask 022 leaves all of
12
+ * those world-readable; on a single-user laptop the practical risk is
13
+ * Time Machine backup exposure and any process running as the user.
14
+ *
15
+ * Pure: returns a result describing what was tightened, what was already
16
+ * correct, and what failed (e.g. permission denied if user wasn't owner).
17
+ * Caller decides whether to print, JSON-stringify, or act on the result.
18
+ *
19
+ * Idempotent: re-running on an already-hardened tree is a no-op (every
20
+ * entry returns "already correct"). chmod is the cheapest syscall —
21
+ * even a 1000-file tree completes in milliseconds.
22
+ *
23
+ * Skip list: a few entries are intentionally NOT touched:
24
+ * - Symlinks (chmod follows symlinks; could escalate elsewhere). We
25
+ * check via lstat and skip non-files/non-dirs.
26
+ * - Files we don't own (chmod will fail; we capture the failure in
27
+ * the report rather than crashing).
28
+ */
29
+ export type HardenStatus = 'tightened' | 'already-correct' | 'skipped' | 'failed';
30
+ export interface HardenEntry {
31
+ path: string;
32
+ kind: 'file' | 'directory' | 'other';
33
+ beforeMode: string;
34
+ afterMode: string;
35
+ status: HardenStatus;
36
+ error?: string;
37
+ }
38
+ export interface HardenReport {
39
+ baseDir: string;
40
+ scanned: number;
41
+ tightened: number;
42
+ alreadyCorrect: number;
43
+ skipped: number;
44
+ failed: number;
45
+ /** Per-entry detail. Cap at 50 in printable form to keep output sane;
46
+ * full list available in the JSON output. */
47
+ entries: HardenEntry[];
48
+ }
49
+ export declare function hardenPermissions(baseDir: string, opts?: {
50
+ dryRun?: boolean;
51
+ }): HardenReport;
52
+ //# sourceMappingURL=harden-permissions.d.ts.map
@@ -0,0 +1,181 @@
1
+ /**
2
+ * Tighten file modes on the Clementine data home.
3
+ *
4
+ * Walks ~/.clementine/ (or any baseDir) and:
5
+ * - Sets every regular file to mode 0600 (owner read/write only)
6
+ * - Sets every directory to mode 0700 (owner traverse only)
7
+ *
8
+ * Why: state files live alongside `credentials.json` and `.env`. Even
9
+ * after Phase 9b's outbound redaction, contents on disk include
10
+ * conversation transcripts, advisor LLM outputs, cron run logs, full
11
+ * Discord/Slack message snippets, etc. Default umask 022 leaves all of
12
+ * those world-readable; on a single-user laptop the practical risk is
13
+ * Time Machine backup exposure and any process running as the user.
14
+ *
15
+ * Pure: returns a result describing what was tightened, what was already
16
+ * correct, and what failed (e.g. permission denied if user wasn't owner).
17
+ * Caller decides whether to print, JSON-stringify, or act on the result.
18
+ *
19
+ * Idempotent: re-running on an already-hardened tree is a no-op (every
20
+ * entry returns "already correct"). chmod is the cheapest syscall —
21
+ * even a 1000-file tree completes in milliseconds.
22
+ *
23
+ * Skip list: a few entries are intentionally NOT touched:
24
+ * - Symlinks (chmod follows symlinks; could escalate elsewhere). We
25
+ * check via lstat and skip non-files/non-dirs.
26
+ * - Files we don't own (chmod will fail; we capture the failure in
27
+ * the report rather than crashing).
28
+ */
29
+ import { readdirSync, lstatSync, chmodSync } from 'node:fs';
30
+ import path from 'node:path';
31
+ const FILE_TARGET = 0o600;
32
+ const DIR_TARGET = 0o700;
33
+ function octal(mode) {
34
+ return (mode & 0o777).toString(8).padStart(3, '0');
35
+ }
36
+ /**
37
+ * Walk a directory tree iteratively (not recursively — no stack-blow risk
38
+ * on deep vault trees). Returns absolute paths.
39
+ */
40
+ function walk(root) {
41
+ const out = [];
42
+ const stack = [root];
43
+ while (stack.length > 0) {
44
+ const dir = stack.pop();
45
+ let entries;
46
+ try {
47
+ entries = readdirSync(dir);
48
+ }
49
+ catch {
50
+ continue; // unreadable dir — caller will see it as skipped
51
+ }
52
+ for (const name of entries) {
53
+ const full = path.join(dir, name);
54
+ try {
55
+ const st = lstatSync(full);
56
+ if (st.isSymbolicLink())
57
+ continue; // never follow
58
+ if (st.isDirectory()) {
59
+ out.push(full);
60
+ stack.push(full);
61
+ }
62
+ else if (st.isFile()) {
63
+ out.push(full);
64
+ }
65
+ }
66
+ catch {
67
+ // stat failed — skip
68
+ }
69
+ }
70
+ }
71
+ return out;
72
+ }
73
+ export function hardenPermissions(baseDir, opts = {}) {
74
+ const report = {
75
+ baseDir,
76
+ scanned: 0,
77
+ tightened: 0,
78
+ alreadyCorrect: 0,
79
+ skipped: 0,
80
+ failed: 0,
81
+ entries: [],
82
+ };
83
+ // Always include baseDir itself in the walk
84
+ let baseSt;
85
+ try {
86
+ baseSt = lstatSync(baseDir);
87
+ }
88
+ catch (err) {
89
+ report.entries.push({
90
+ path: baseDir,
91
+ kind: 'other',
92
+ beforeMode: '???',
93
+ afterMode: '???',
94
+ status: 'failed',
95
+ error: `baseDir not accessible: ${String(err).slice(0, 100)}`,
96
+ });
97
+ report.failed++;
98
+ return report;
99
+ }
100
+ if (!baseSt.isDirectory()) {
101
+ report.entries.push({
102
+ path: baseDir,
103
+ kind: 'other',
104
+ beforeMode: octal(baseSt.mode),
105
+ afterMode: octal(baseSt.mode),
106
+ status: 'skipped',
107
+ error: 'baseDir is not a directory',
108
+ });
109
+ report.skipped++;
110
+ return report;
111
+ }
112
+ const all = [baseDir, ...walk(baseDir)];
113
+ for (const p of all) {
114
+ let st;
115
+ try {
116
+ st = lstatSync(p);
117
+ }
118
+ catch (err) {
119
+ report.entries.push({
120
+ path: p,
121
+ kind: 'other',
122
+ beforeMode: '???',
123
+ afterMode: '???',
124
+ status: 'failed',
125
+ error: String(err).slice(0, 100),
126
+ });
127
+ report.failed++;
128
+ continue;
129
+ }
130
+ report.scanned++;
131
+ const beforeMode = octal(st.mode);
132
+ let kind = 'other';
133
+ let target = null;
134
+ if (st.isDirectory()) {
135
+ kind = 'directory';
136
+ target = DIR_TARGET;
137
+ }
138
+ else if (st.isFile()) {
139
+ kind = 'file';
140
+ target = FILE_TARGET;
141
+ }
142
+ if (target === null) {
143
+ // Sockets, FIFOs, devices, etc. — leave alone.
144
+ report.entries.push({
145
+ path: p, kind, beforeMode, afterMode: beforeMode, status: 'skipped',
146
+ });
147
+ report.skipped++;
148
+ continue;
149
+ }
150
+ if ((st.mode & 0o777) === target) {
151
+ report.entries.push({
152
+ path: p, kind, beforeMode, afterMode: beforeMode, status: 'already-correct',
153
+ });
154
+ report.alreadyCorrect++;
155
+ continue;
156
+ }
157
+ if (opts.dryRun) {
158
+ report.entries.push({
159
+ path: p, kind, beforeMode, afterMode: octal(target), status: 'tightened',
160
+ });
161
+ report.tightened++;
162
+ continue;
163
+ }
164
+ try {
165
+ chmodSync(p, target);
166
+ report.entries.push({
167
+ path: p, kind, beforeMode, afterMode: octal(target), status: 'tightened',
168
+ });
169
+ report.tightened++;
170
+ }
171
+ catch (err) {
172
+ report.entries.push({
173
+ path: p, kind, beforeMode, afterMode: beforeMode, status: 'failed',
174
+ error: String(err).slice(0, 100),
175
+ });
176
+ report.failed++;
177
+ }
178
+ }
179
+ return report;
180
+ }
181
+ //# sourceMappingURL=harden-permissions.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clementine-agent",
3
- "version": "1.1.11",
3
+ "version": "1.1.13",
4
4
  "description": "Clementine — Personal AI Assistant (TypeScript)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",