@grindxp/cli 0.1.7 → 0.1.9

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.
Files changed (57) hide show
  1. package/dist/index.js +766 -134
  2. package/dist/web/client/assets/Copy.es-Bs4NgJu-.js +1 -0
  3. package/dist/web/client/assets/Sword.es-2Xm7T3t2.js +1 -0
  4. package/dist/web/client/assets/geist-cyrillic-wght-normal-CHSlOQsW.woff2 +0 -0
  5. package/dist/web/client/assets/geist-latin-ext-wght-normal-DMtmJ5ZE.woff2 +0 -0
  6. package/dist/web/client/assets/geist-latin-wght-normal-Dm3htQBi.woff2 +0 -0
  7. package/dist/web/client/assets/index-6XDcqRbL.js +42 -0
  8. package/dist/web/client/assets/index-BXM1N6tm.js +1 -0
  9. package/dist/web/client/assets/index-B_KMiE38.js +1 -0
  10. package/dist/web/client/assets/index-CGj2rOLm.js +1 -0
  11. package/dist/web/client/assets/index-CS5BuFbt.js +1 -0
  12. package/dist/web/client/assets/index-CYsASiu-.js +1 -0
  13. package/dist/web/client/assets/index-DAvwM0SX.js +1 -0
  14. package/dist/web/client/assets/index-DCBFp5DJ.js +1 -0
  15. package/dist/web/client/assets/index-DjKt1qNz.js +1 -0
  16. package/dist/web/client/assets/index-PIcFs1vr.js +1 -0
  17. package/dist/web/client/assets/instrument-serif-latin-400-italic-DKMiL14s.woff2 +0 -0
  18. package/dist/web/client/assets/instrument-serif-latin-400-italic-u__WvvIK.woff +0 -0
  19. package/dist/web/client/assets/instrument-serif-latin-400-normal-BVbkICAY.woff +0 -0
  20. package/dist/web/client/assets/instrument-serif-latin-400-normal-DnYpCC2O.woff2 +0 -0
  21. package/dist/web/client/assets/instrument-serif-latin-ext-400-italic-C9HzH3YL.woff2 +0 -0
  22. package/dist/web/client/assets/instrument-serif-latin-ext-400-italic-D7-lnxEk.woff +0 -0
  23. package/dist/web/client/assets/instrument-serif-latin-ext-400-normal-C2je3j2s.woff2 +0 -0
  24. package/dist/web/client/assets/instrument-serif-latin-ext-400-normal-CFCUzsTy.woff +0 -0
  25. package/dist/web/client/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
  26. package/dist/web/client/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
  27. package/dist/web/client/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
  28. package/dist/web/client/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
  29. package/dist/web/client/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
  30. package/dist/web/client/assets/main-BI1EOhmt.js +18 -0
  31. package/dist/web/client/assets/styles-7TpWqjrh.css +1 -0
  32. package/dist/web/client/favicon.ico +0 -0
  33. package/dist/web/server/assets/_tanstack-start-manifest_v-B_rvI8DG.js +4 -0
  34. package/dist/web/server/assets/agent.functions-BL3upUNr.js +19541 -0
  35. package/dist/web/server/assets/data.functions-DZmdFOMQ.js +285 -0
  36. package/dist/web/server/assets/index-4SxmUYH6.js +14 -0
  37. package/dist/web/server/assets/index-B2ULpkv2.js +4587 -0
  38. package/dist/web/server/assets/index-BGBMycx-.js +2275 -0
  39. package/dist/web/server/assets/index-BL8u2X7w.js +14 -0
  40. package/dist/web/server/assets/index-BQUCDamI.js +5924 -0
  41. package/dist/web/server/assets/index-BRRsXrOi.js +14 -0
  42. package/dist/web/server/assets/index-BiD7uOOh.js +14 -0
  43. package/dist/web/server/assets/index-CB8UtTN8.js +66 -0
  44. package/dist/web/server/assets/index-D2yaimYL.js +14 -0
  45. package/dist/web/server/assets/index-D3RUqTdb.js +14 -0
  46. package/dist/web/server/assets/index-DTB2dYCz.js +1426 -0
  47. package/dist/web/server/assets/index-DfU25rnD.js +477 -0
  48. package/dist/web/server/assets/index-SHH7zSKt.js +66 -0
  49. package/dist/web/server/assets/router-CXyGzWDS.js +589 -0
  50. package/dist/web/server/assets/sessions-UCWtijHE.js +438 -0
  51. package/dist/web/server/assets/start-HYkvq4Ni.js +4 -0
  52. package/dist/web/server/assets/token-DGoahKjI.js +86 -0
  53. package/dist/web/server/assets/token-util-BopJPy-I.js +451 -0
  54. package/dist/web/server/assets/token-util-Bw35afYM.js +30 -0
  55. package/dist/web/server/assets/vault.server-CscY5Z8e.js +19357 -0
  56. package/dist/web/server/server.js +4889 -0
  57. package/package.json +53 -51
package/dist/index.js CHANGED
@@ -5,25 +5,43 @@ var __getProtoOf = Object.getPrototypeOf;
5
5
  var __defProp = Object.defineProperty;
6
6
  var __getOwnPropNames = Object.getOwnPropertyNames;
7
7
  var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ function __accessProp(key) {
9
+ return this[key];
10
+ }
11
+ var __toESMCache_node;
12
+ var __toESMCache_esm;
8
13
  var __toESM = (mod, isNodeMode, target) => {
14
+ var canCache = mod != null && typeof mod === "object";
15
+ if (canCache) {
16
+ var cache = isNodeMode ? __toESMCache_node ??= new WeakMap : __toESMCache_esm ??= new WeakMap;
17
+ var cached = cache.get(mod);
18
+ if (cached)
19
+ return cached;
20
+ }
9
21
  target = mod != null ? __create(__getProtoOf(mod)) : {};
10
22
  const to = isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target;
11
23
  for (let key of __getOwnPropNames(mod))
12
24
  if (!__hasOwnProp.call(to, key))
13
25
  __defProp(to, key, {
14
- get: () => mod[key],
26
+ get: __accessProp.bind(mod, key),
15
27
  enumerable: true
16
28
  });
29
+ if (canCache)
30
+ cache.set(mod, to);
17
31
  return to;
18
32
  };
19
33
  var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports);
34
+ var __returnValue = (v) => v;
35
+ function __exportSetter(name, newValue) {
36
+ this[name] = __returnValue.bind(null, newValue);
37
+ }
20
38
  var __export = (target, all) => {
21
39
  for (var name in all)
22
40
  __defProp(target, name, {
23
41
  get: all[name],
24
42
  enumerable: true,
25
43
  configurable: true,
26
- set: (newValue) => all[name] = () => newValue
44
+ set: __exportSetter.bind(all, name)
27
45
  });
28
46
  };
29
47
  var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
@@ -28273,8 +28291,7 @@ async function flushCompanionMemory(params) {
28273
28291
  messages: [...messages, { role: "user", content: MEMORY_FLUSH_PROMPT }],
28274
28292
  tools,
28275
28293
  activeTools: ["list_insights", "store_insight", "update_user_context"],
28276
- stopWhen: stepCountIs(4),
28277
- maxOutputTokens: 128,
28294
+ stopWhen: stepCountIs(6),
28278
28295
  maxRetries: 1,
28279
28296
  ...abortSignal ? { abortSignal } : {}
28280
28297
  });
@@ -28332,15 +28349,16 @@ What was planned or in progress.
28332
28349
 
28333
28350
  Be concise. This summary replaces the full conversation history.`, MEMORY_FLUSH_PROMPT = `Session is nearing compaction.
28334
28351
 
28335
- Before summarization, persist any durable user memory via tools:
28336
- - store_insight for concise durable facts (pattern, preference, goal, context)
28337
- - update_user_context only for high-level narrative context that should stay in a user notes blob
28352
+ Step 1: Call list_insights to see what is already stored.
28353
+ Step 2: Identify any durable facts from this session that are NOT already covered by existing insights.
28354
+ Step 3: Only call store_insight for genuinely new information. Do not re-store facts already captured \u2014 store_insight will merge exact duplicates but semantic duplicates waste space.
28355
+ Step 4: If everything worth keeping is already stored, reply exactly: NO_REPLY
28338
28356
 
28339
28357
  Rules:
28340
28358
  - Store only durable information likely to matter in future sessions.
28341
28359
  - Do not store transient chatter.
28342
28360
  - Keep insights short and factual.
28343
- - If there is nothing useful to store, reply exactly: NO_REPLY`;
28361
+ - Prefer updating an existing insight over creating a new one when the content overlaps.`;
28344
28362
  var init_compaction = __esm(() => {
28345
28363
  init_dist5();
28346
28364
  });
@@ -40820,8 +40838,16 @@ TOOL USAGE:
40820
40838
  - When asked whether integrations/channels are connected or available (Telegram, WhatsApp, Discord, Google Calendar), call get_integrations_status first. Do not guess.
40821
40839
  - If the user asks to send or test a Telegram message, call send_telegram_message immediately. Never ask the user for their chat ID \u2014 it is resolved automatically.
40822
40840
  - If send_telegram_message fails because no chat ID was found yet, tell the user to send any message to the bot from Telegram (not /start specifically) and offer to try again immediately after.
40823
- - When the user asks to automate, schedule reminders, or set recurring workflows, use forge tools (create_forge_rule, list_forge_rules, update_forge_rule, run_forge_rule, delete_forge_rule) instead of telling them to use CLI manually.
40824
- - Before updating or deleting a forge rule, call list_forge_rules to confirm the target. Use list_forge_runs to diagnose failures.
40841
+ - When the user asks to automate, schedule reminders, or set recurring workflows, use forge tools directly \u2014 never tell the user to use the CLI manually.
40842
+ - Before updating, deleting, or running a specific rule, call list_forge_rules to confirm the target and read its xpImpact field.
40843
+ - xpImpact: false rules (notifications, reminders, monitors): act fully autonomously \u2014 no explanation needed beyond confirming what you did.
40844
+ - xpImpact: true rules (log-to-vault, update-skill): proceed autonomously and briefly mention in your reply that XP will be awarded automatically.
40845
+ - Deleting a rule is permanent \u2014 tell the user this before calling delete_forge_rule.
40846
+ - run-script rules execute shell scripts as automations \u2014 always show the full script in your reply when creating or updating one.
40847
+ - Use list_forge_runs to diagnose failures.
40848
+ - When the user names a specific calendar (anything other than 'primary'), always call list_calendars first to resolve the name to its id, then pass that id to create_calendar_event or get_calendar_events. Never assume the id \u2014 always look it up.
40849
+ - If list_calendars does not return the named calendar and the user wants to create it, call create_calendar first, then use the returned id immediately for any subsequent event creation.
40850
+ - Never ask the user for a calendar ID \u2014 always resolve it yourself via list_calendars.
40825
40851
  - Keep responses concise. 1-3 sentences for simple actions. No walls of text.
40826
40852
 
40827
40853
  WEB & FILE ACCESS:
@@ -42711,6 +42737,16 @@ async function listCalendars(serviceConfig) {
42711
42737
  const data = await resp.json();
42712
42738
  return data.items ?? [];
42713
42739
  }
42740
+ async function createCalendar(serviceConfig, summary, timeZone) {
42741
+ const body = { summary };
42742
+ if (timeZone)
42743
+ body.timeZone = timeZone;
42744
+ const resp = await googleFetch(`${BASE}/calendars`, serviceConfig, {
42745
+ method: "POST",
42746
+ body: JSON.stringify(body)
42747
+ });
42748
+ return resp.json();
42749
+ }
42714
42750
  function getEventDateKey(event, tz) {
42715
42751
  if (event.start.date)
42716
42752
  return event.start.date;
@@ -46056,6 +46092,12 @@ async function findCompanionInsightByContent(db2, userId, category, content) {
46056
46092
  });
46057
46093
  return row ?? null;
46058
46094
  }
46095
+ async function updateCompanionMode(db2, userId, mode) {
46096
+ const [updated] = await db2.update(companionSettings).set({ mode, updatedAt: Date.now() }).where(eq(companionSettings.userId, userId)).returning();
46097
+ if (!updated)
46098
+ throw new Error("Companion not found. Run `grindxp init` first.");
46099
+ return updated;
46100
+ }
46059
46101
  async function deleteCompanionInsight(db2, insightId, userId) {
46060
46102
  const row = await db2.delete(companionInsights).where(and(eq(companionInsights.id, insightId), eq(companionInsights.userId, userId))).returning({ id: companionInsights.id });
46061
46103
  return row.length > 0;
@@ -46176,6 +46218,16 @@ async function deleteForgeRule(db2, userId, ruleId) {
46176
46218
  const rows = await db2.delete(forgeRules).where(and(eq(forgeRules.id, ruleId), eq(forgeRules.userId, userId))).returning({ id: forgeRules.id });
46177
46219
  return rows.length > 0;
46178
46220
  }
46221
+ async function listSignals(db2, userId, options = {}) {
46222
+ const { limit = 20, source } = options;
46223
+ const where = source ? and(eq(signals.userId, userId), eq(signals.source, source)) : eq(signals.userId, userId);
46224
+ const rows = await db2.query.signals.findMany({
46225
+ where,
46226
+ orderBy: [desc(signals.detectedAt)],
46227
+ limit
46228
+ });
46229
+ return rows.map(rowToSignal);
46230
+ }
46179
46231
  async function recordSignal(db2, input) {
46180
46232
  const valid = createSignalInputSchema.parse(input);
46181
46233
  const [row] = await db2.insert(signals).values(valid).returning();
@@ -46483,6 +46535,36 @@ async function completeQuest(db2, input) {
46483
46535
  });
46484
46536
  return { xpEarned: xpResult.totalXp, skillGains };
46485
46537
  }
46538
+ async function updateQuest(db2, questId, userId, patch) {
46539
+ const set2 = { updatedAt: Date.now() };
46540
+ if (patch.title !== undefined)
46541
+ set2.title = patch.title;
46542
+ if (patch.description !== undefined)
46543
+ set2.description = patch.description;
46544
+ if (patch.type !== undefined)
46545
+ set2.type = patch.type;
46546
+ if (patch.difficulty !== undefined)
46547
+ set2.difficulty = patch.difficulty;
46548
+ if (patch.skillTags !== undefined)
46549
+ set2.skillTags = patch.skillTags;
46550
+ if (patch.baseXp !== undefined)
46551
+ set2.baseXp = patch.baseXp;
46552
+ if (patch.scheduleCron !== undefined)
46553
+ set2.scheduleCron = patch.scheduleCron;
46554
+ if (patch.deadlineAt !== undefined)
46555
+ set2.deadlineAt = patch.deadlineAt;
46556
+ const [row] = await db2.update(quests).set(set2).where(and(eq(quests.id, questId), eq(quests.userId, userId))).returning();
46557
+ return row ? rowToQuest(row) : null;
46558
+ }
46559
+ async function listQuestLogs(db2, userId, options = {}) {
46560
+ const { limit = 20, since } = options;
46561
+ const where = since ? and(eq(questLogs.userId, userId), gte(questLogs.completedAt, since)) : eq(questLogs.userId, userId);
46562
+ return db2.query.questLogs.findMany({
46563
+ where,
46564
+ orderBy: [desc(questLogs.completedAt)],
46565
+ limit
46566
+ });
46567
+ }
46486
46568
  var init_quests = __esm(() => {
46487
46569
  init_drizzle_orm();
46488
46570
  init_schema();
@@ -46554,6 +46636,7 @@ var init_repositories = __esm(() => {
46554
46636
  // ../core/src/forge/runtime.ts
46555
46637
  import { spawnSync } from "child_process";
46556
46638
  import { statSync } from "fs";
46639
+ import { homedir as homedir2 } from "os";
46557
46640
  import { isAbsolute, resolve as resolve2 } from "path";
46558
46641
  async function runForgeTick(options) {
46559
46642
  const at3 = options.at ?? Date.now();
@@ -46719,6 +46802,8 @@ async function executeForgeAction(db2, userId, plan) {
46719
46802
  return executeLogToVault(db2, userId, plan);
46720
46803
  case "send-notification":
46721
46804
  return executeSendNotification(plan);
46805
+ case "run-script":
46806
+ return executeRunScript(plan);
46722
46807
  default:
46723
46808
  return {
46724
46809
  status: "skipped",
@@ -46911,6 +46996,53 @@ async function executeSendNotification(plan) {
46911
46996
  error: `Unsupported notification channel '${channel}'.`
46912
46997
  };
46913
46998
  }
46999
+ async function executeRunScript(plan) {
47000
+ const script = asString2(plan.actionConfig.script);
47001
+ if (!script) {
47002
+ return {
47003
+ status: "failed",
47004
+ actionPayload: {},
47005
+ error: "run-script requires actionConfig.script."
47006
+ };
47007
+ }
47008
+ const timeoutMs = parsePositiveInt(plan.actionConfig.timeout) ?? 30000;
47009
+ const rawWorkdir = asString2(plan.actionConfig.workdir);
47010
+ const workdir = rawWorkdir ? rawWorkdir.replace(/^~/, homedir2()) : undefined;
47011
+ const result = spawnSync("sh", ["-c", script], {
47012
+ encoding: "utf8",
47013
+ stdio: ["ignore", "pipe", "pipe"],
47014
+ timeout: timeoutMs,
47015
+ ...workdir ? { cwd: workdir } : {}
47016
+ });
47017
+ const errCode = result.error?.code;
47018
+ if (errCode === "ENOENT" && workdir) {
47019
+ return {
47020
+ status: "failed",
47021
+ actionPayload: { script, exitCode: null },
47022
+ error: `Working directory does not exist: ${workdir}`
47023
+ };
47024
+ }
47025
+ if (errCode === "ETIMEDOUT") {
47026
+ return {
47027
+ status: "failed",
47028
+ actionPayload: { script, exitCode: null },
47029
+ error: `Script timed out after ${timeoutMs}ms.`
47030
+ };
47031
+ }
47032
+ if (result.status !== 0) {
47033
+ const stderr = result.stderr?.trim().slice(0, 500) ?? "";
47034
+ return {
47035
+ status: "failed",
47036
+ actionPayload: { script, exitCode: result.status },
47037
+ error: `Script exited ${result.status ?? "null"}${stderr ? `: ${stderr}` : ""}`
47038
+ };
47039
+ }
47040
+ const stdout = result.stdout?.trim().slice(0, 2000) ?? "";
47041
+ return {
47042
+ status: "success",
47043
+ actionPayload: { script, exitCode: 0, ...stdout ? { stdout } : {} }
47044
+ };
47045
+ }
46914
47046
  async function executeQueueQuest(db2, userId, plan) {
46915
47047
  const eventPayload = asRecord(plan.actionConfig.eventPayload);
46916
47048
  const questId = asString2(plan.actionConfig.questId) ?? asString2(eventPayload?.questId) ?? asString2(plan.actionConfig.targetQuestId);
@@ -47589,6 +47721,21 @@ var init_google = __esm(() => {
47589
47721
  import * as fs from "fs/promises";
47590
47722
  import * as path from "path";
47591
47723
  import os from "os";
47724
+ function requireTrust(ctx, toolName) {
47725
+ const required2 = TOOL_TRUST_REQUIREMENTS[toolName];
47726
+ if (required2 === undefined)
47727
+ return { denied: false };
47728
+ const current = ctx.trustLevel ?? 0;
47729
+ if (current < required2) {
47730
+ const requiredName = TRUST_LEVEL_NAMES[required2] ?? `Lv.${required2}`;
47731
+ const currentName = TRUST_LEVEL_NAMES[current] ?? `Lv.${current}`;
47732
+ return {
47733
+ denied: true,
47734
+ error: `Action requires trust level ${required2} (${requiredName}). Current level: ${current} (${currentName}). Grant trust with: grindxp companion trust ${required2}`
47735
+ };
47736
+ }
47737
+ return { denied: false };
47738
+ }
47592
47739
  function expandPath(p) {
47593
47740
  if (p.startsWith("~/"))
47594
47741
  return path.join(os.homedir(), p.slice(2));
@@ -47646,6 +47793,38 @@ async function requirePermission(ctx, toolName, detail) {
47646
47793
  return { denied: true, error: "Permission denied by user" };
47647
47794
  return { denied: false };
47648
47795
  }
47796
+ function classifyGoogleError(err) {
47797
+ if (err instanceof GoogleNotConnectedError || err instanceof GoogleTokenExpiredError) {
47798
+ return "Google account not connected or session expired. Run `grindxp integrations connect google`.";
47799
+ }
47800
+ if (err instanceof GoogleApiError) {
47801
+ switch (err.status) {
47802
+ case 401:
47803
+ return "Google account disconnected. Run `grindxp integrations connect google`.";
47804
+ case 403:
47805
+ return "No write access to this calendar. Check the calendar's sharing settings.";
47806
+ case 404:
47807
+ return "Calendar or event not found. Use list_calendars to verify available calendar IDs.";
47808
+ case 409:
47809
+ return "Conflict \u2014 this event may already exist on the calendar.";
47810
+ case 410:
47811
+ return "Sync token expired \u2014 a full re-sync will happen on the next poll.";
47812
+ }
47813
+ if (err.status >= 500) {
47814
+ return "Google Calendar is temporarily unavailable. Try again in a moment.";
47815
+ }
47816
+ if (err.status === 400) {
47817
+ try {
47818
+ const parsed = JSON.parse(err.body);
47819
+ const msg = parsed?.error?.message;
47820
+ if (msg)
47821
+ return `Invalid request: ${msg}`;
47822
+ } catch {}
47823
+ return "Invalid request \u2014 check the calendar ID and date format.";
47824
+ }
47825
+ }
47826
+ return err instanceof Error ? err.message : String(err);
47827
+ }
47649
47828
  async function extractText(html) {
47650
47829
  let text4 = "";
47651
47830
  let skip = false;
@@ -47826,14 +48005,6 @@ async function resolveTelegramChatId(ctx, token) {
47826
48005
  return { chatId: candidate, source: "recent-signal" };
47827
48006
  }
47828
48007
  }
47829
- const webhookActive = Boolean(ctx.config?.gateway?.telegramWebhookSecret ?? freshConfig?.gateway?.telegramWebhookSecret);
47830
- if (webhookActive) {
47831
- return {
47832
- chatId: null,
47833
- source: "none",
47834
- detail: "Send any message to your Telegram bot and I'll respond automatically. The chat ID will be captured on first contact."
47835
- };
47836
- }
47837
48008
  const updatesResponse = await fetch(`https://api.telegram.org/bot${token}/getUpdates?limit=50&timeout=0`, {
47838
48009
  method: "GET"
47839
48010
  });
@@ -47898,7 +48069,10 @@ function persistTelegramDefaultChatId(ctx, chatId) {
47898
48069
  telegramDefaultChatId: chatId
47899
48070
  }
47900
48071
  };
47901
- writeGrindConfig(ctx.config);
48072
+ const onDisk = readGrindConfig();
48073
+ if (!onDisk?.gateway)
48074
+ return;
48075
+ writeGrindConfig({ ...onDisk, gateway: { ...onDisk.gateway, telegramDefaultChatId: chatId } });
47902
48076
  }
47903
48077
  function asRecord2(value) {
47904
48078
  if (!value || typeof value !== "object" || Array.isArray(value))
@@ -48210,6 +48384,37 @@ async function normalizeForgeRuleDefinition(ctx, input) {
48210
48384
  }
48211
48385
  break;
48212
48386
  }
48387
+ case "run-script": {
48388
+ const script = asNonEmptyString(actionConfig.script);
48389
+ if (!script) {
48390
+ return {
48391
+ ok: false,
48392
+ error: "run-script requires actionConfig.script (shell command to execute)."
48393
+ };
48394
+ }
48395
+ actionConfig.script = script;
48396
+ if (actionConfig.timeout !== undefined) {
48397
+ const timeout = parsePositiveInt2(actionConfig.timeout);
48398
+ if (!timeout) {
48399
+ return {
48400
+ ok: false,
48401
+ error: "run-script actionConfig.timeout must be a positive integer (milliseconds)."
48402
+ };
48403
+ }
48404
+ actionConfig.timeout = timeout;
48405
+ }
48406
+ if (actionConfig.workdir !== undefined) {
48407
+ const workdir = asNonEmptyString(actionConfig.workdir);
48408
+ if (!workdir) {
48409
+ return {
48410
+ ok: false,
48411
+ error: "run-script actionConfig.workdir must be a non-empty string when provided."
48412
+ };
48413
+ }
48414
+ actionConfig.workdir = workdir;
48415
+ }
48416
+ break;
48417
+ }
48213
48418
  }
48214
48419
  return {
48215
48420
  ok: true,
@@ -48275,7 +48480,7 @@ function createGrindTools(ctx) {
48275
48480
  const calendars = await listCalendars(googleConfig);
48276
48481
  return { ok: true, calendars, count: calendars.length };
48277
48482
  } catch (err) {
48278
- return { ok: false, error: err instanceof Error ? err.message : String(err) };
48483
+ return { ok: false, error: classifyGoogleError(err) };
48279
48484
  }
48280
48485
  }
48281
48486
  }),
@@ -48362,12 +48567,12 @@ function createGrindTools(ctx) {
48362
48567
  });
48363
48568
  return { ok: true, events: result.events, count: result.events.length };
48364
48569
  } catch (err) {
48365
- return { ok: false, error: err instanceof Error ? err.message : String(err) };
48570
+ return { ok: false, error: classifyGoogleError(err) };
48366
48571
  }
48367
48572
  }
48368
48573
  }),
48369
48574
  create_calendar_event: tool({
48370
- description: "Create a new event in Google Calendar. Creates in the primary calendar by default. Use list_calendars to find other calendar IDs if needed.",
48575
+ description: "Create a new event in Google Calendar. " + "If the user names a specific calendar (anything other than 'primary'), call list_calendars first to resolve its id, then pass that id as calendarId. " + "If the calendar does not exist yet, call create_calendar first, then use the returned id.",
48371
48576
  inputSchema: exports_external.object({
48372
48577
  summary: exports_external.string().min(1).max(500).describe("Event title"),
48373
48578
  startDateTime: exports_external.string().describe("Start time (ISO 8601, e.g. 2026-02-21T09:00:00)"),
@@ -48376,7 +48581,8 @@ function createGrindTools(ctx) {
48376
48581
  location: exports_external.string().optional().describe("Event location"),
48377
48582
  attendees: exports_external.array(exports_external.string().email()).optional().describe("List of attendee email addresses"),
48378
48583
  allDay: exports_external.boolean().optional().describe("If true, treats startDateTime/endDateTime as dates (YYYY-MM-DD)"),
48379
- timeZone: exports_external.string().optional().describe("IANA timezone name (e.g. America/New_York)")
48584
+ timeZone: exports_external.string().optional().describe("IANA timezone name (e.g. America/New_York)"),
48585
+ calendarId: exports_external.string().optional().describe("Calendar ID to create the event in. Use list_calendars to resolve a calendar name to its id. Defaults to the primary calendar.")
48380
48586
  }),
48381
48587
  execute: async ({
48382
48588
  summary,
@@ -48386,7 +48592,8 @@ function createGrindTools(ctx) {
48386
48592
  location,
48387
48593
  attendees,
48388
48594
  allDay,
48389
- timeZone
48595
+ timeZone,
48596
+ calendarId
48390
48597
  }) => {
48391
48598
  const googleConfig = ctx.config?.services?.google;
48392
48599
  if (!googleConfig) {
@@ -48405,10 +48612,32 @@ function createGrindTools(ctx) {
48405
48612
  ...attendees ? { attendees } : {},
48406
48613
  ...allDay ? { allDay } : {},
48407
48614
  ...timeZone ? { timeZone } : {}
48408
- });
48615
+ }, calendarId ?? "primary");
48409
48616
  return { ok: true, event };
48410
48617
  } catch (err) {
48411
- return { ok: false, error: err instanceof Error ? err.message : String(err) };
48618
+ return { ok: false, error: classifyGoogleError(err) };
48619
+ }
48620
+ }
48621
+ }),
48622
+ create_calendar: tool({
48623
+ description: "Create a new Google Calendar. After creation, use the returned id with create_calendar_event to add events to it. " + "Use this when the user asks to create a calendar that does not yet exist in their list.",
48624
+ inputSchema: exports_external.object({
48625
+ summary: exports_external.string().min(1).max(255).describe("Calendar name (e.g. 'Work', 'God', 'Study')"),
48626
+ timeZone: exports_external.string().optional().describe("IANA timezone name for the calendar (e.g. America/Sao_Paulo)")
48627
+ }),
48628
+ execute: async ({ summary, timeZone }) => {
48629
+ const googleConfig = ctx.config?.services?.google;
48630
+ if (!googleConfig) {
48631
+ return {
48632
+ ok: false,
48633
+ error: "Google account not connected. Run `grindxp integrations connect google`."
48634
+ };
48635
+ }
48636
+ try {
48637
+ const calendar2 = await createCalendar(googleConfig, summary, timeZone);
48638
+ return { ok: true, calendar: calendar2 };
48639
+ } catch (err) {
48640
+ return { ok: false, error: classifyGoogleError(err) };
48412
48641
  }
48413
48642
  }
48414
48643
  }),
@@ -48456,7 +48685,7 @@ function createGrindTools(ctx) {
48456
48685
  const event = await updateCalendarEvent(googleConfig, eventId, patch, calendarId ?? "primary");
48457
48686
  return { ok: true, event };
48458
48687
  } catch (err) {
48459
- return { ok: false, error: err instanceof Error ? err.message : String(err) };
48688
+ return { ok: false, error: classifyGoogleError(err) };
48460
48689
  }
48461
48690
  }
48462
48691
  }),
@@ -48478,7 +48707,7 @@ function createGrindTools(ctx) {
48478
48707
  await deleteCalendarEvent(googleConfig, eventId, calendarId ?? "primary");
48479
48708
  return { ok: true, eventId };
48480
48709
  } catch (err) {
48481
- return { ok: false, error: err instanceof Error ? err.message : String(err) };
48710
+ return { ok: false, error: classifyGoogleError(err) };
48482
48711
  }
48483
48712
  }
48484
48713
  }),
@@ -48593,7 +48822,7 @@ function createGrindTools(ctx) {
48593
48822
  }
48594
48823
  }),
48595
48824
  list_forge_rules: tool({
48596
- description: "List forge automation rules. Use this before updating, deleting, or running a specific rule.",
48825
+ description: "List forge automation rules. Each rule includes xpImpact: true/false \u2014 use this to decide how to communicate the action to the user. Always call this before updating, deleting, or running a specific rule.",
48597
48826
  inputSchema: exports_external.object({
48598
48827
  enabledOnly: exports_external.boolean().optional().describe("When true, return only enabled rules."),
48599
48828
  includeRecentRuns: exports_external.boolean().default(true).describe("When true, include recent execution history."),
@@ -48611,6 +48840,7 @@ function createGrindTools(ctx) {
48611
48840
  enabled: rule.enabled,
48612
48841
  triggerType: rule.triggerType,
48613
48842
  actionType: rule.actionType,
48843
+ xpImpact: ["log-to-vault", "update-skill"].includes(rule.actionType),
48614
48844
  triggerConfig: redactForgeValue(rule.triggerConfig),
48615
48845
  actionConfig: redactForgeValue(rule.actionConfig),
48616
48846
  updatedAt: rule.updatedAt
@@ -48685,13 +48915,13 @@ function createGrindTools(ctx) {
48685
48915
  }
48686
48916
  }),
48687
48917
  create_forge_rule: tool({
48688
- description: "Create a forge automation rule. This supports currently implemented actions only: queue-quest, log-to-vault, send-notification.",
48918
+ description: "Create a forge automation rule (queue-quest, log-to-vault, send-notification, run-script). send-notification and queue-quest have no XP impact \u2014 create autonomously. log-to-vault auto-awards XP \u2014 create it and mention that in your reply. run-script executes a shell script \u2014 always include the full script in your reply so the user can verify it.",
48689
48919
  inputSchema: exports_external.object({
48690
48920
  name: exports_external.string().min(2).max(128).describe("Human-readable rule name."),
48691
48921
  triggerType: exports_external.enum(FORGE_TRIGGER_TYPES).describe("Trigger type (cron, event, signal, webhook, manual)."),
48692
48922
  triggerConfig: exports_external.record(exports_external.string(), exports_external.unknown()).default({}).describe("Trigger configuration object."),
48693
48923
  actionType: exports_external.enum(FORGE_ACTION_TYPES).describe("Action type (queue-quest, log-to-vault, send-notification)."),
48694
- actionConfig: exports_external.record(exports_external.string(), exports_external.unknown()).default({}).describe("Action configuration object. " + "send-notification: required 'message' (static string) or 'script' (shell command whose stdout becomes the message), plus 'channel' (telegram/console/webhook/whatsapp) and channel credentials. " + "queue-quest: required 'questId'. " + "log-to-vault: required 'activityType', 'durationMinutes'."),
48924
+ actionConfig: exports_external.record(exports_external.string(), exports_external.unknown()).default({}).describe("Action configuration object. " + "send-notification: required 'message' (static string) or 'script' (shell command whose stdout becomes the message), plus 'channel' (telegram/console/webhook/whatsapp) and channel credentials. " + "queue-quest: required 'questId'. " + "log-to-vault: required 'activityType', 'durationMinutes'. " + "run-script: required 'script' (shell command), optional 'timeout' (ms, default 30000), optional 'workdir' (supports ~)."),
48695
48925
  enabled: exports_external.boolean().default(true).describe("Whether the rule starts enabled.")
48696
48926
  }),
48697
48927
  execute: async ({ name: name21, triggerType, triggerConfig, actionType, actionConfig, enabled }) => {
@@ -48731,7 +48961,7 @@ function createGrindTools(ctx) {
48731
48961
  }
48732
48962
  }),
48733
48963
  update_forge_rule: tool({
48734
- description: "Update a forge rule by ID prefix or name. Use list_forge_rules first to confirm the target.",
48964
+ description: "Update a forge rule by ID prefix or name. Call list_forge_rules first to confirm the target. Act autonomously \u2014 no permission needed unless changing to a log-to-vault action, in which case mention the XP impact in your reply.",
48735
48965
  inputSchema: exports_external.object({
48736
48966
  ruleSearch: exports_external.string().min(1).describe("Rule ID prefix or rule name substring."),
48737
48967
  name: exports_external.string().min(2).max(128).optional().describe("New rule name."),
@@ -48798,6 +49028,7 @@ function createGrindTools(ctx) {
48798
49028
  nextTriggerConfig = normalized.triggerConfig;
48799
49029
  nextActionConfig = normalized.actionConfig;
48800
49030
  }
49031
+ const effectiveActionType = actionType ?? rule.actionType;
48801
49032
  const updated = await updateForgeRule(ctx.db, ctx.userId, rule.id, {
48802
49033
  ...name21 !== undefined ? { name: name21 } : {},
48803
49034
  ...triggerType !== undefined ? { triggerType } : {},
@@ -48827,7 +49058,7 @@ function createGrindTools(ctx) {
48827
49058
  }
48828
49059
  }),
48829
49060
  delete_forge_rule: tool({
48830
- description: "Delete a forge rule by ID prefix or name. Related run history is removed as part of rule deletion.",
49061
+ description: "Delete a forge rule by ID prefix or name. This is permanent \u2014 warn the user before calling this. Run history is also removed.",
48831
49062
  inputSchema: exports_external.object({
48832
49063
  ruleSearch: exports_external.string().min(1).describe("Rule ID prefix or rule name substring.")
48833
49064
  }),
@@ -48836,6 +49067,9 @@ function createGrindTools(ctx) {
48836
49067
  if (!rule) {
48837
49068
  return { ok: false, error: `No forge rule matching "${ruleSearch}".` };
48838
49069
  }
49070
+ const perm = await requirePermission(ctx, "delete_forge_rule", `Permanently delete forge rule "${rule.name}"?`);
49071
+ if (perm.denied)
49072
+ return { ok: false, error: "Deletion cancelled." };
48839
49073
  const deleted = await deleteForgeRule(ctx.db, ctx.userId, rule.id);
48840
49074
  if (!deleted) {
48841
49075
  return { ok: false, error: "Failed to delete forge rule." };
@@ -48852,7 +49086,7 @@ function createGrindTools(ctx) {
48852
49086
  }
48853
49087
  }),
48854
49088
  run_forge_rule: tool({
48855
- description: "Run a forge rule immediately by ID prefix or name. Useful for testing automation behavior without waiting for its trigger.",
49089
+ description: "Run a forge rule immediately by ID prefix or name. Check xpImpact from list_forge_rules first: if false (notifications, reminders), run autonomously; if true (log-to-vault), run and mention the XP award in your reply.",
48856
49090
  inputSchema: exports_external.object({
48857
49091
  ruleSearch: exports_external.string().min(1).describe("Rule ID prefix or rule name substring."),
48858
49092
  eventPayload: exports_external.record(exports_external.string(), exports_external.unknown()).optional().describe("Optional payload passed as event context for this run-now execution."),
@@ -48957,6 +49191,9 @@ function createGrindTools(ctx) {
48957
49191
  baseXp: exports_external.number().int().positive().default(10).describe("Base XP before multipliers. 10=small, 25=medium, 50=large, 100=epic")
48958
49192
  }),
48959
49193
  execute: async ({ title, description, type, difficulty, skillTags, baseXp }) => {
49194
+ const trust2 = requireTrust(ctx, "create_quest");
49195
+ if (trust2.denied)
49196
+ return { error: trust2.error };
48960
49197
  const active = await listQuestsByUser(ctx.db, ctx.userId, ["active"]);
48961
49198
  if (active.length >= 5) {
48962
49199
  return { error: "Max 5 active quests. Complete or abandon one first." };
@@ -48990,6 +49227,9 @@ function createGrindTools(ctx) {
48990
49227
  durationMinutes: exports_external.number().int().positive().optional().describe("Duration in minutes if timer proof")
48991
49228
  }),
48992
49229
  execute: async ({ questSearch, proofType, durationMinutes }) => {
49230
+ const trust2 = requireTrust(ctx, "complete_quest");
49231
+ if (trust2.denied)
49232
+ return { error: trust2.error };
48993
49233
  const quest2 = await findQuestByPrefix(ctx.db, ctx.userId, questSearch);
48994
49234
  if (!quest2)
48995
49235
  return { error: `No quest matching "${questSearch}"` };
@@ -49029,6 +49269,9 @@ function createGrindTools(ctx) {
49029
49269
  questSearch: exports_external.string().describe("Quest ID prefix or title substring")
49030
49270
  }),
49031
49271
  execute: async ({ questSearch }) => {
49272
+ const trust2 = requireTrust(ctx, "abandon_quest");
49273
+ if (trust2.denied)
49274
+ return { error: trust2.error };
49032
49275
  const quest2 = await findQuestByPrefix(ctx.db, ctx.userId, questSearch);
49033
49276
  if (!quest2)
49034
49277
  return { error: `No quest matching "${questSearch}"` };
@@ -49048,6 +49291,9 @@ function createGrindTools(ctx) {
49048
49291
  questSearch: exports_external.string().describe("Quest ID prefix or title substring")
49049
49292
  }),
49050
49293
  execute: async ({ questSearch }) => {
49294
+ const trust2 = requireTrust(ctx, "start_timer");
49295
+ if (trust2.denied)
49296
+ return { error: trust2.error };
49051
49297
  const existing = readTimer(ctx.timerPath);
49052
49298
  if (existing) {
49053
49299
  const elapsed = formatElapsed(existing.startedAt);
@@ -49075,6 +49321,9 @@ function createGrindTools(ctx) {
49075
49321
  complete: exports_external.boolean().default(false).describe("Whether to also complete the quest with timer-duration proof")
49076
49322
  }),
49077
49323
  execute: async ({ complete }) => {
49324
+ const trust2 = requireTrust(ctx, "stop_timer");
49325
+ if (trust2.denied)
49326
+ return { error: trust2.error };
49078
49327
  const timer = readTimer(ctx.timerPath);
49079
49328
  if (!timer)
49080
49329
  return { error: "No timer running" };
@@ -49812,6 +50061,182 @@ ${normalized}` : normalized;
49812
50061
  return { pattern, matches, totalMatches: matches.length, truncated };
49813
50062
  }
49814
50063
  }),
50064
+ list_skills: tool({
50065
+ description: "List all skills with current XP, level, and category. Use this to see the full skill breakdown before making quest suggestions or updates.",
50066
+ inputSchema: exports_external.object({}),
50067
+ execute: async () => {
50068
+ const allSkills = await listSkillsByUser(ctx.db, ctx.userId);
50069
+ return allSkills.map((s) => ({
50070
+ id: s.id.slice(0, 8),
50071
+ name: s.name,
50072
+ category: s.category,
50073
+ xp: s.xp,
50074
+ level: s.level
50075
+ }));
50076
+ }
50077
+ }),
50078
+ list_quest_logs: tool({
50079
+ description: "List recent quest completion history. Returns completions with XP earned, duration, and proof type. Use this to see what the user has actually done recently.",
50080
+ inputSchema: exports_external.object({
50081
+ limit: exports_external.number().int().min(1).max(100).default(20).describe("Maximum number of logs to return"),
50082
+ sinceDaysAgo: exports_external.number().int().min(1).max(90).optional().describe("Only return logs from this many days ago")
50083
+ }),
50084
+ execute: async ({ limit, sinceDaysAgo }) => {
50085
+ const since = sinceDaysAgo ? Date.now() - sinceDaysAgo * 24 * 60 * 60 * 1000 : undefined;
50086
+ const logs = await listQuestLogs(ctx.db, ctx.userId, {
50087
+ limit,
50088
+ ...since ? { since } : {}
50089
+ });
50090
+ const questIds = [...new Set(logs.map((l) => l.questId))];
50091
+ const questTitles = {};
50092
+ for (const qid of questIds) {
50093
+ const q3 = await getQuestById(ctx.db, qid);
50094
+ if (q3)
50095
+ questTitles[qid] = q3.title;
50096
+ }
50097
+ return logs.map((l) => ({
50098
+ questTitle: questTitles[l.questId] ?? l.questId.slice(0, 8),
50099
+ completedAt: new Date(l.completedAt).toLocaleDateString(),
50100
+ xpEarned: l.xpEarned,
50101
+ durationMinutes: l.durationMinutes ?? null,
50102
+ proofType: l.proofType,
50103
+ streakDay: l.streakDay
50104
+ }));
50105
+ }
50106
+ }),
50107
+ list_signals: tool({
50108
+ description: "List recently detected signals (git commits, file changes, process observations, webhook events). Use this to understand what the user has been doing passively.",
50109
+ inputSchema: exports_external.object({
50110
+ limit: exports_external.number().int().min(1).max(50).default(20).describe("Maximum number of signals to return"),
50111
+ source: exports_external.enum(["git", "file", "process", "webhook"]).optional().describe("Filter by signal source")
50112
+ }),
50113
+ execute: async ({ limit, source }) => {
50114
+ const sigs = await listSignals(ctx.db, ctx.userId, {
50115
+ limit,
50116
+ ...source ? { source } : {}
50117
+ });
50118
+ return sigs.map((s) => ({
50119
+ source: s.source,
50120
+ type: s.type,
50121
+ confidence: s.confidence,
50122
+ detectedAt: new Date(s.detectedAt).toLocaleString(),
50123
+ payload: s.payload
50124
+ }));
50125
+ }
50126
+ }),
50127
+ update_quest: tool({
50128
+ description: "Update quest details: title, description, difficulty, type, baseXp, skillTags, or schedule. Does NOT change quest status \u2014 use abandon_quest or complete_quest for that.",
50129
+ inputSchema: exports_external.object({
50130
+ questSearch: exports_external.string().describe("Quest ID prefix or title substring"),
50131
+ title: exports_external.string().min(1).max(256).optional().describe("New quest title"),
50132
+ description: exports_external.string().max(2000).nullable().optional().describe("New description"),
50133
+ type: exports_external.enum(["daily", "weekly", "epic", "bounty", "chain", "ritual"]).optional().describe("New quest type"),
50134
+ difficulty: exports_external.enum(["easy", "medium", "hard", "epic"]).optional().describe("New difficulty"),
50135
+ skillTags: exports_external.array(exports_external.string()).optional().describe("Replace skill tags"),
50136
+ baseXp: exports_external.number().int().positive().optional().describe("New base XP (before multipliers)"),
50137
+ scheduleCron: exports_external.string().nullable().optional().describe("New cron schedule (null to remove)")
50138
+ }).refine((v) => v.title !== undefined || v.description !== undefined || v.type !== undefined || v.difficulty !== undefined || v.skillTags !== undefined || v.baseXp !== undefined || v.scheduleCron !== undefined, { message: "Provide at least one field to update." }),
50139
+ execute: async ({
50140
+ questSearch,
50141
+ title,
50142
+ description,
50143
+ type,
50144
+ difficulty,
50145
+ skillTags,
50146
+ baseXp,
50147
+ scheduleCron
50148
+ }) => {
50149
+ const trust2 = requireTrust(ctx, "update_quest");
50150
+ if (trust2.denied)
50151
+ return { error: trust2.error };
50152
+ const quest2 = await findQuestByPrefix(ctx.db, ctx.userId, questSearch);
50153
+ if (!quest2)
50154
+ return { error: `No quest matching "${questSearch}"` };
50155
+ const updated = await updateQuest(ctx.db, quest2.id, ctx.userId, {
50156
+ ...title !== undefined ? { title } : {},
50157
+ ...description !== undefined ? { description } : {},
50158
+ ...type !== undefined ? { type } : {},
50159
+ ...difficulty !== undefined ? { difficulty } : {},
50160
+ ...skillTags !== undefined ? { skillTags } : {},
50161
+ ...baseXp !== undefined ? { baseXp } : {},
50162
+ ...scheduleCron !== undefined ? { scheduleCron } : {}
50163
+ });
50164
+ if (!updated)
50165
+ return { error: "Failed to update quest." };
50166
+ return {
50167
+ ok: true,
50168
+ id: updated.id.slice(0, 8),
50169
+ title: updated.title,
50170
+ type: updated.type,
50171
+ difficulty: updated.difficulty,
50172
+ skillTags: updated.skillTags,
50173
+ baseXp: updated.baseXp
50174
+ };
50175
+ }
50176
+ }),
50177
+ activate_quest: tool({
50178
+ description: "Move a quest from 'available' to 'active'. Use when the user is ready to start working on a quest that isn't active yet. Subject to the 5-quest active limit.",
50179
+ inputSchema: exports_external.object({
50180
+ questSearch: exports_external.string().describe("Quest ID prefix or title substring")
50181
+ }),
50182
+ execute: async ({ questSearch }) => {
50183
+ const trust2 = requireTrust(ctx, "activate_quest");
50184
+ if (trust2.denied)
50185
+ return { error: trust2.error };
50186
+ const quest2 = await findQuestByPrefix(ctx.db, ctx.userId, questSearch);
50187
+ if (!quest2)
50188
+ return { error: `No quest matching "${questSearch}"` };
50189
+ if (quest2.status === "active")
50190
+ return { error: "Quest is already active." };
50191
+ if (quest2.status === "completed")
50192
+ return { error: "Quest is completed \u2014 cannot reactivate." };
50193
+ if (quest2.status === "abandoned")
50194
+ return { error: "Quest is abandoned \u2014 cannot reactivate." };
50195
+ const active = await listQuestsByUser(ctx.db, ctx.userId, ["active"]);
50196
+ if (active.length >= 5) {
50197
+ return { error: "Max 5 active quests. Complete or abandon one first." };
50198
+ }
50199
+ await updateQuestStatus(ctx.db, quest2.id, ctx.userId, "active");
50200
+ return { ok: true, quest: quest2.title, status: "active" };
50201
+ }
50202
+ }),
50203
+ delete_insight: tool({
50204
+ description: "Delete a stored companion insight. Only AI-observed insights can be deleted. User-stated insights are permanent and cannot be removed by the companion.",
50205
+ inputSchema: exports_external.object({
50206
+ insightId: exports_external.string().min(1).describe("Insight ID (full UUID or short ID prefix)")
50207
+ }),
50208
+ execute: async ({ insightId }) => {
50209
+ const trust2 = requireTrust(ctx, "delete_insight");
50210
+ if (trust2.denied)
50211
+ return { error: trust2.error };
50212
+ const insights = await listCompanionInsights(ctx.db, ctx.userId, 200);
50213
+ const match = insights.find((i) => i.id === insightId || i.id.startsWith(insightId));
50214
+ if (!match)
50215
+ return { error: `No insight matching "${insightId}"` };
50216
+ if (match.source === "user-stated") {
50217
+ return {
50218
+ error: "Cannot delete user-stated insights. These are facts you provided \u2014 they can only be updated, not removed."
50219
+ };
50220
+ }
50221
+ const deleted = await deleteCompanionInsight(ctx.db, match.id, ctx.userId);
50222
+ if (!deleted)
50223
+ return { error: "Failed to delete insight." };
50224
+ return { ok: true, deleted: true, content: match.content };
50225
+ }
50226
+ }),
50227
+ update_companion_mode: tool({
50228
+ description: "Change the companion operating mode. 'suggest' = propose only, 'assist' = act on explicit requests, 'auto' = act proactively.",
50229
+ inputSchema: exports_external.object({
50230
+ mode: exports_external.enum(["off", "suggest", "assist", "auto"]).describe("New companion mode")
50231
+ }),
50232
+ execute: async ({ mode }) => {
50233
+ const trust2 = requireTrust(ctx, "update_companion_mode");
50234
+ if (trust2.denied)
50235
+ return { error: trust2.error };
50236
+ const updated = await updateCompanionMode(ctx.db, ctx.userId, mode);
50237
+ return { ok: true, mode: updated.mode };
50238
+ }
50239
+ }),
49815
50240
  bash: tool({
49816
50241
  description: "Execute a shell command. Returns stdout, stderr, and exit code. Both stdout and stderr are capped at 50KB.",
49817
50242
  inputSchema: exports_external.object({
@@ -49896,7 +50321,7 @@ ${normalized}` : normalized;
49896
50321
  })
49897
50322
  };
49898
50323
  }
49899
- var MAX_FETCH_SIZE, MAX_READ_SIZE, MAX_BASH_OUTPUT, DEFAULT_READ_LIMIT = 2000, MAX_LINE_LENGTH = 2000, FORGE_TRIGGER_TYPES, FORGE_ACTION_TYPES, FORGE_NOTIFICATION_CHANNELS, FORGE_WEBHOOK_CHANNEL_TAGS, SIMPLE_CRON_FIELD_REGEX, FORGE_SENSITIVE_KEYS;
50324
+ var TRUST_LEVEL_NAMES, TOOL_TRUST_REQUIREMENTS, MAX_FETCH_SIZE, MAX_READ_SIZE, MAX_BASH_OUTPUT, DEFAULT_READ_LIMIT = 2000, MAX_LINE_LENGTH = 2000, FORGE_TRIGGER_TYPES, FORGE_ACTION_TYPES, FORGE_NOTIFICATION_CHANNELS, FORGE_WEBHOOK_CHANNEL_TAGS, SIMPLE_CRON_FIELD_REGEX, FORGE_SENSITIVE_KEYS;
49900
50325
  var init_tools = __esm(() => {
49901
50326
  init_dist5();
49902
50327
  init_drizzle_orm();
@@ -49911,11 +50336,28 @@ var init_tools = __esm(() => {
49911
50336
  init_schema2();
49912
50337
  init_repositories();
49913
50338
  init_constants();
50339
+ TRUST_LEVEL_NAMES = ["Watcher", "Advisor", "Scribe", "Agent", "Sovereign"];
50340
+ TOOL_TRUST_REQUIREMENTS = {
50341
+ complete_quest: 2,
50342
+ abandon_quest: 2,
50343
+ activate_quest: 2,
50344
+ start_timer: 2,
50345
+ stop_timer: 2,
50346
+ update_companion_mode: 2,
50347
+ create_quest: 3,
50348
+ update_quest: 3,
50349
+ delete_insight: 4
50350
+ };
49914
50351
  MAX_FETCH_SIZE = 50 * 1024;
49915
50352
  MAX_READ_SIZE = 50 * 1024;
49916
50353
  MAX_BASH_OUTPUT = 50 * 1024;
49917
50354
  FORGE_TRIGGER_TYPES = ["cron", "event", "signal", "webhook", "manual"];
49918
- FORGE_ACTION_TYPES = ["queue-quest", "log-to-vault", "send-notification"];
50355
+ FORGE_ACTION_TYPES = [
50356
+ "queue-quest",
50357
+ "log-to-vault",
50358
+ "send-notification",
50359
+ "run-script"
50360
+ ];
49919
50361
  FORGE_NOTIFICATION_CHANNELS = ["console", "telegram", "webhook", "whatsapp"];
49920
50362
  FORGE_WEBHOOK_CHANNEL_TAGS = ["webhook", "telegram", "discord", "whatsapp"];
49921
50363
  SIMPLE_CRON_FIELD_REGEX = /^[\d*/,\-]+$/;
@@ -83124,9 +83566,10 @@ function ChatScreen(props) {
83124
83566
  const [pasteBuffers, setPasteBuffers] = import_react16.useState([]);
83125
83567
  const pasteCountRef = import_react16.useRef(0);
83126
83568
  const [pastePreviewOpen, setPastePreviewOpen] = import_react16.useState(false);
83127
- const promptHistoryRef = import_react16.useRef(props.initialPromptHistory ?? []);
83569
+ const [hasPromptText, setHasPromptText] = import_react16.useState(false);
83570
+ const historyRef = import_react16.useRef(props.initialPromptHistory ?? []);
83128
83571
  const historyDraftRef = import_react16.useRef("");
83129
- const [historyIndex, setHistoryIndex] = import_react16.useState(-1);
83572
+ const [historyNavPos, setHistoryNavPos] = import_react16.useState(null);
83130
83573
  const suggestions = import_react16.useMemo(() => matchCommands(cmdBuffer), [cmdBuffer]);
83131
83574
  const ghostText = import_react16.useMemo(() => getGhostCompletion(cmdBuffer), [cmdBuffer]);
83132
83575
  const effectiveMode = permissionPrompt ? "permission" : sessionList && sessionList.length > 0 ? "sessions" : commandMode;
@@ -83170,18 +83613,59 @@ function ChatScreen(props) {
83170
83613
  setPickerSearch("");
83171
83614
  setPickerSelectedIndex(0);
83172
83615
  }, []);
83616
+ const clearPromptAll = import_react16.useCallback(() => {
83617
+ textareaRef.current?.clear();
83618
+ textareaRef.current?.extmarks?.clear();
83619
+ for (const att of attachments)
83620
+ onRemoveAttachment(att.id);
83621
+ setPasteBuffers([]);
83622
+ setPastePreviewOpen(false);
83623
+ setHasPromptText(false);
83624
+ historyDraftRef.current = "";
83625
+ setHistoryNavPos(null);
83626
+ }, [attachments, onRemoveAttachment]);
83627
+ function navigateHistory(direction) {
83628
+ const textarea = textareaRef.current;
83629
+ if (!textarea)
83630
+ return;
83631
+ const history = historyRef.current;
83632
+ if (direction === "up") {
83633
+ if (history.length === 0)
83634
+ return;
83635
+ const curIdx = historyNavPos !== null ? historyNavPos.i - 1 : -1;
83636
+ const nextIdx = curIdx === -1 ? history.length - 1 : Math.max(0, curIdx - 1);
83637
+ if (nextIdx === curIdx)
83638
+ return;
83639
+ if (curIdx === -1)
83640
+ historyDraftRef.current = textarea.plainText;
83641
+ setHistoryNavPos({ i: nextIdx + 1, n: history.length });
83642
+ textarea.setText(history[nextIdx]);
83643
+ textarea.cursorOffset = 0;
83644
+ setHasPromptText(true);
83645
+ } else {
83646
+ if (historyNavPos === null)
83647
+ return;
83648
+ const nextIdx = historyNavPos.i;
83649
+ if (nextIdx >= history.length) {
83650
+ setHistoryNavPos(null);
83651
+ const draft = historyDraftRef.current;
83652
+ textarea.setText(draft);
83653
+ textarea.cursorOffset = draft.length;
83654
+ setHasPromptText(draft.length > 0);
83655
+ } else {
83656
+ setHistoryNavPos({ i: nextIdx + 1, n: history.length });
83657
+ textarea.setText(history[nextIdx]);
83658
+ textarea.cursorOffset = history[nextIdx].length;
83659
+ setHasPromptText(true);
83660
+ }
83661
+ }
83662
+ }
83173
83663
  const imageTypeIdRef = import_react16.useRef(null);
83174
83664
  const handleContentChange = useEffectEvent2(() => {
83175
83665
  if (commandMode !== "idle")
83176
83666
  return;
83177
83667
  const value = textareaRef.current?.plainText ?? "";
83178
- if (historyIndex !== -1) {
83179
- const expected = promptHistoryRef.current[historyIndex];
83180
- if (value !== expected) {
83181
- setHistoryIndex(-1);
83182
- }
83183
- return;
83184
- }
83668
+ setHasPromptText(value.length > 0);
83185
83669
  if (value === "/") {
83186
83670
  textareaRef.current?.clear();
83187
83671
  setCommandMode("suggesting");
@@ -83237,18 +83721,22 @@ function ChatScreen(props) {
83237
83721
  if (!text4 && !hasImages)
83238
83722
  return;
83239
83723
  if (text4) {
83240
- const h = promptHistoryRef.current;
83724
+ const h = historyRef.current;
83241
83725
  if (h[h.length - 1] !== text4) {
83242
- promptHistoryRef.current = [...h, text4].slice(-PROMPT_HISTORY_MAX2);
83726
+ historyRef.current = [...h, text4].slice(-PROMPT_HISTORY_MAX2);
83243
83727
  }
83244
83728
  }
83245
- setHistoryIndex(-1);
83246
83729
  historyDraftRef.current = "";
83730
+ setHistoryNavPos(null);
83247
83731
  onSend(text4);
83248
83732
  textareaRef.current?.extmarks?.clear();
83249
83733
  textareaRef.current?.clear();
83250
83734
  }, [onSend, pasteBuffers]);
83251
83735
  const handlePaste = import_react16.useCallback((event) => {
83736
+ if (historyNavPos !== null) {
83737
+ historyDraftRef.current = "";
83738
+ setHistoryNavPos(null);
83739
+ }
83252
83740
  const normalized = event.text.replace(/\r\n/g, `
83253
83741
  `).replace(/\r/g, `
83254
83742
  `);
@@ -83303,6 +83791,13 @@ function ChatScreen(props) {
83303
83791
  }, [onSessionSelect]);
83304
83792
  useKeyboard((key) => {
83305
83793
  if (key.ctrl && key.name === "c") {
83794
+ if (effectiveMode === "idle") {
83795
+ const text4 = textareaRef.current?.plainText ?? "";
83796
+ if (text4 || attachments.length > 0 || pasteBuffers.length > 0) {
83797
+ clearPromptAll();
83798
+ return;
83799
+ }
83800
+ }
83306
83801
  onExit();
83307
83802
  return;
83308
83803
  }
@@ -83430,51 +83925,6 @@ function ChatScreen(props) {
83430
83925
  key.preventDefault();
83431
83926
  return;
83432
83927
  }
83433
- if (mode === "idle" && key.name === "up") {
83434
- const plain = textareaRef.current?.plainText ?? "";
83435
- const cursor = textareaRef.current?.cursorOffset ?? 0;
83436
- const onFirstLine = !plain.slice(0, cursor).includes(`
83437
- `);
83438
- const history = promptHistoryRef.current;
83439
- if (onFirstLine && history.length > 0) {
83440
- key.preventDefault();
83441
- const curIdx = historyIndex;
83442
- const nextIdx = curIdx === -1 ? history.length - 1 : Math.max(0, curIdx - 1);
83443
- if (nextIdx !== curIdx) {
83444
- if (curIdx === -1)
83445
- historyDraftRef.current = plain;
83446
- const entry = history[nextIdx];
83447
- if (entry !== undefined) {
83448
- setHistoryIndex(nextIdx);
83449
- textareaRef.current?.clear();
83450
- textareaRef.current?.extmarks?.clear();
83451
- textareaRef.current?.insertText(entry);
83452
- }
83453
- }
83454
- return;
83455
- }
83456
- }
83457
- if (mode === "idle" && key.name === "down" && historyIndex !== -1) {
83458
- key.preventDefault();
83459
- const history = promptHistoryRef.current;
83460
- const nextIdx = historyIndex + 1;
83461
- if (nextIdx >= history.length) {
83462
- setHistoryIndex(-1);
83463
- textareaRef.current?.clear();
83464
- textareaRef.current?.extmarks?.clear();
83465
- if (historyDraftRef.current)
83466
- textareaRef.current?.insertText(historyDraftRef.current);
83467
- } else {
83468
- const entry = history[nextIdx];
83469
- if (entry !== undefined) {
83470
- setHistoryIndex(nextIdx);
83471
- textareaRef.current?.clear();
83472
- textareaRef.current?.extmarks?.clear();
83473
- textareaRef.current?.insertText(entry);
83474
- }
83475
- }
83476
- return;
83477
- }
83478
83928
  if (mode === "idle" && attachments.length > 0 && (key.meta && key.name === "backspace" || key.ctrl && key.name === "backspace" || key.ctrl && key.name === "w" || key.meta && key.name === "delete")) {
83479
83929
  const textarea = textareaRef.current;
83480
83930
  const extmarks = textarea?.extmarks;
@@ -83518,11 +83968,36 @@ function ChatScreen(props) {
83518
83968
  setPastePreviewOpen((open) => !open);
83519
83969
  return;
83520
83970
  }
83971
+ if (mode === "idle" && historyNavPos !== null) {
83972
+ const isChar = !key.ctrl && !key.meta && key.sequence && key.sequence.length === 1 && key.sequence.charCodeAt(0) >= 32;
83973
+ if (isChar || key.name === "backspace" || key.name === "delete") {
83974
+ historyDraftRef.current = "";
83975
+ setHistoryNavPos(null);
83976
+ }
83977
+ }
83978
+ if (mode === "idle" && key.name === "up" && !key.ctrl && !key.meta) {
83979
+ const offset = textareaRef.current?.cursorOffset ?? 0;
83980
+ if (offset === 0 && historyRef.current.length > 0) {
83981
+ key.preventDefault();
83982
+ navigateHistory("up");
83983
+ return;
83984
+ }
83985
+ }
83986
+ if (mode === "idle" && key.name === "down" && !key.ctrl && !key.meta && historyNavPos !== null) {
83987
+ key.preventDefault();
83988
+ navigateHistory("down");
83989
+ return;
83990
+ }
83521
83991
  if (key.name === "escape") {
83522
83992
  if (isStreaming) {
83523
83993
  onAbort();
83524
83994
  } else {
83525
- onExit();
83995
+ const text4 = textareaRef.current?.plainText ?? "";
83996
+ if (text4 || attachments.length > 0 || pasteBuffers.length > 0) {
83997
+ clearPromptAll();
83998
+ } else {
83999
+ onExit();
84000
+ }
83526
84001
  }
83527
84002
  }
83528
84003
  });
@@ -83846,9 +84321,13 @@ function ChatScreen(props) {
83846
84321
  focused: textareaFocused,
83847
84322
  minHeight: 1,
83848
84323
  maxHeight: 6,
84324
+ flexShrink: 0,
83849
84325
  keyBindings: [
83850
84326
  { name: "return", action: "submit" },
83851
- { name: "return", meta: true, action: "newline" }
84327
+ { name: "return", meta: true, action: "newline" },
84328
+ { name: "return", ctrl: true, action: "newline" },
84329
+ { name: "return", shift: true, action: "newline" },
84330
+ { name: "j", ctrl: true, action: "newline" }
83852
84331
  ]
83853
84332
  }, undefined, false, undefined, this)
83854
84333
  }, undefined, false, undefined, this)
@@ -84053,7 +84532,43 @@ function ChatScreen(props) {
84053
84532
  }, undefined, true, undefined, this)
84054
84533
  ]
84055
84534
  }, undefined, true, undefined, this),
84056
- effectiveMode === "idle" && !isStreaming && /* @__PURE__ */ import_jsx_dev_runtime2.jsxDEV(import_jsx_dev_runtime2.Fragment, {
84535
+ effectiveMode === "idle" && !isStreaming && historyNavPos !== null && /* @__PURE__ */ import_jsx_dev_runtime2.jsxDEV(import_jsx_dev_runtime2.Fragment, {
84536
+ children: [
84537
+ /* @__PURE__ */ import_jsx_dev_runtime2.jsxDEV("text", {
84538
+ fg: colors.muted,
84539
+ children: [
84540
+ "\u2191\u2193",
84541
+ " history ",
84542
+ historyNavPos.i,
84543
+ "/",
84544
+ historyNavPos.n
84545
+ ]
84546
+ }, undefined, true, undefined, this),
84547
+ /* @__PURE__ */ import_jsx_dev_runtime2.jsxDEV("text", {
84548
+ fg: colors.textDim,
84549
+ children: "\xB7"
84550
+ }, undefined, false, undefined, this),
84551
+ /* @__PURE__ */ import_jsx_dev_runtime2.jsxDEV("text", {
84552
+ fg: colors.textDim,
84553
+ children: [
84554
+ "\u2193",
84555
+ " newer"
84556
+ ]
84557
+ }, undefined, true, undefined, this),
84558
+ /* @__PURE__ */ import_jsx_dev_runtime2.jsxDEV("text", {
84559
+ fg: colors.textDim,
84560
+ children: [
84561
+ "[",
84562
+ /* @__PURE__ */ import_jsx_dev_runtime2.jsxDEV("span", {
84563
+ fg: colors.accent,
84564
+ children: "Esc"
84565
+ }, undefined, false, undefined, this),
84566
+ "] cancel"
84567
+ ]
84568
+ }, undefined, true, undefined, this)
84569
+ ]
84570
+ }, undefined, true, undefined, this),
84571
+ effectiveMode === "idle" && !isStreaming && historyNavPos === null && /* @__PURE__ */ import_jsx_dev_runtime2.jsxDEV(import_jsx_dev_runtime2.Fragment, {
84057
84572
  children: [
84058
84573
  /* @__PURE__ */ import_jsx_dev_runtime2.jsxDEV("text", {
84059
84574
  fg: colors.muted,
@@ -84105,43 +84620,44 @@ function ChatScreen(props) {
84105
84620
  }, undefined, true, undefined, this),
84106
84621
  !zenMode && /* @__PURE__ */ import_jsx_dev_runtime2.jsxDEV(import_jsx_dev_runtime2.Fragment, {
84107
84622
  children: [
84108
- /* @__PURE__ */ import_jsx_dev_runtime2.jsxDEV("text", {
84623
+ historyRef.current.length > 0 && /* @__PURE__ */ import_jsx_dev_runtime2.jsxDEV("text", {
84109
84624
  fg: colors.textDim,
84110
84625
  children: [
84111
84626
  "[",
84112
84627
  /* @__PURE__ */ import_jsx_dev_runtime2.jsxDEV("span", {
84113
84628
  fg: colors.accent,
84114
- children: "/"
84629
+ children: "\u2191"
84115
84630
  }, undefined, false, undefined, this),
84116
- "] cmds"
84631
+ "] history"
84117
84632
  ]
84118
84633
  }, undefined, true, undefined, this),
84119
- historyIndex !== -1 ? /* @__PURE__ */ import_jsx_dev_runtime2.jsxDEV("text", {
84120
- fg: colors.accent,
84634
+ /* @__PURE__ */ import_jsx_dev_runtime2.jsxDEV("text", {
84635
+ fg: colors.textDim,
84121
84636
  children: [
84122
- "history [",
84123
- historyIndex + 1,
84124
- "/",
84125
- promptHistoryRef.current.length,
84126
- "]",
84127
- " ",
84637
+ "[",
84128
84638
  /* @__PURE__ */ import_jsx_dev_runtime2.jsxDEV("span", {
84129
- fg: colors.textDim,
84130
- children: "[\u2191\u2193] nav"
84131
- }, undefined, false, undefined, this)
84639
+ fg: colors.accent,
84640
+ children: "/"
84641
+ }, undefined, false, undefined, this),
84642
+ "] cmds"
84132
84643
  ]
84133
- }, undefined, true, undefined, this) : promptHistoryRef.current.length > 0 && /* @__PURE__ */ import_jsx_dev_runtime2.jsxDEV("text", {
84644
+ }, undefined, true, undefined, this),
84645
+ hasPromptText || attachments.length > 0 || pasteBuffers.length > 0 ? /* @__PURE__ */ import_jsx_dev_runtime2.jsxDEV("text", {
84134
84646
  fg: colors.textDim,
84135
84647
  children: [
84136
84648
  "[",
84137
84649
  /* @__PURE__ */ import_jsx_dev_runtime2.jsxDEV("span", {
84138
84650
  fg: colors.accent,
84139
- children: "\u2191\u2193"
84651
+ children: "Esc"
84140
84652
  }, undefined, false, undefined, this),
84141
- "] history"
84653
+ "/",
84654
+ /* @__PURE__ */ import_jsx_dev_runtime2.jsxDEV("span", {
84655
+ fg: colors.accent,
84656
+ children: "^C"
84657
+ }, undefined, false, undefined, this),
84658
+ "] clear"
84142
84659
  ]
84143
- }, undefined, true, undefined, this),
84144
- /* @__PURE__ */ import_jsx_dev_runtime2.jsxDEV("text", {
84660
+ }, undefined, true, undefined, this) : /* @__PURE__ */ import_jsx_dev_runtime2.jsxDEV("text", {
84145
84661
  fg: colors.textDim,
84146
84662
  children: [
84147
84663
  "[",
@@ -84149,7 +84665,7 @@ function ChatScreen(props) {
84149
84665
  fg: colors.accent,
84150
84666
  children: "Esc"
84151
84667
  }, undefined, false, undefined, this),
84152
- "] back"
84668
+ "] exit"
84153
84669
  ]
84154
84670
  }, undefined, true, undefined, this)
84155
84671
  ]
@@ -84193,7 +84709,7 @@ function ChatScreen(props) {
84193
84709
  ]
84194
84710
  }, undefined, true, undefined, this);
84195
84711
  }
84196
- var import_react16, IMAGE_EXTS2, IMAGE_LABEL_RE, IMAGE_LABEL_SPLIT, COLLAPSED_LINES = 8, TOOL_ICONS, BLOCK_TOOLS, CMD_NAME_COL = 14, PROMPT_HISTORY_MAX2 = 100;
84712
+ var import_react16, IMAGE_EXTS2, IMAGE_LABEL_RE, IMAGE_LABEL_SPLIT, COLLAPSED_LINES = 8, PROMPT_HISTORY_MAX2 = 100, TOOL_ICONS, BLOCK_TOOLS, CMD_NAME_COL = 14;
84197
84713
  var init_ChatScreen = __esm(async () => {
84198
84714
  init_use_effect_event();
84199
84715
  init_clipboard();
@@ -85290,7 +85806,8 @@ function renderChat(root, params) {
85290
85806
  db: params.db,
85291
85807
  userId: params.userId,
85292
85808
  timerPath: params.timerPath,
85293
- config: params.config
85809
+ config: params.config,
85810
+ trustLevel: params.companion?.trustLevel ?? 0
85294
85811
  };
85295
85812
  const promptCtx = {
85296
85813
  user: params.user,
@@ -86865,6 +87382,85 @@ async function companionMemoryEditCommand(ctx, idPrefix) {
86865
87382
  });
86866
87383
  R2.success(`Updated insight ${updated.id.slice(0, 8)}.`);
86867
87384
  }
87385
+ var TRUST_LEVELS = [
87386
+ {
87387
+ level: 0,
87388
+ name: "Watcher",
87389
+ minScore: 0,
87390
+ description: "Read-only. Manages forge rules by risk (notifications/reminders freely)."
87391
+ },
87392
+ {
87393
+ level: 1,
87394
+ name: "Advisor",
87395
+ minScore: 10,
87396
+ description: "Can suggest quests."
87397
+ },
87398
+ {
87399
+ level: 2,
87400
+ name: "Scribe",
87401
+ minScore: 25,
87402
+ description: "Can complete/abandon quests, start/stop timers, update companion mode."
87403
+ },
87404
+ {
87405
+ level: 3,
87406
+ name: "Agent",
87407
+ minScore: 50,
87408
+ description: "Can create and update quests."
87409
+ },
87410
+ {
87411
+ level: 4,
87412
+ name: "Sovereign",
87413
+ minScore: 100,
87414
+ description: "Can delete insights."
87415
+ }
87416
+ ];
87417
+ async function companionTrustCommand(ctx, levelArg) {
87418
+ const companion4 = await requireCompanion(ctx);
87419
+ if (levelArg === undefined) {
87420
+ const currentName = TRUST_LEVELS.find((t) => t.level === companion4.trustLevel)?.name ?? `Lv.${companion4.trustLevel}`;
87421
+ Ve([
87422
+ `Current: ${currentName} (level ${companion4.trustLevel}, score ${companion4.trustScore})`,
87423
+ "",
87424
+ "Available levels:",
87425
+ ...TRUST_LEVELS.map((t) => {
87426
+ const marker21 = t.level === companion4.trustLevel ? "\u25B6" : " ";
87427
+ return ` ${marker21} ${t.level} ${t.name.padEnd(10)} ${t.description}`;
87428
+ }),
87429
+ "",
87430
+ "Grant trust: grindxp companion trust <0-4>"
87431
+ ].join(`
87432
+ `), "Companion Trust");
87433
+ return;
87434
+ }
87435
+ const parsed = Number.parseInt(levelArg, 10);
87436
+ if (!Number.isInteger(parsed) || parsed < 0 || parsed > 4) {
87437
+ R2.error("Trust level must be 0\u20134.");
87438
+ return;
87439
+ }
87440
+ const target = TRUST_LEVELS[parsed];
87441
+ if (!target) {
87442
+ R2.error("Invalid trust level.");
87443
+ return;
87444
+ }
87445
+ if (parsed === companion4.trustLevel) {
87446
+ R2.info(`Companion is already at ${target.name} (level ${parsed}).`);
87447
+ return;
87448
+ }
87449
+ const action = parsed > companion4.trustLevel ? "Grant" : "Revoke to";
87450
+ const confirm = await Re({
87451
+ message: `${action} trust level ${parsed} (${target.name})? ${target.description}`
87452
+ });
87453
+ if (Ct(confirm) || !confirm) {
87454
+ Ne("Cancelled.");
87455
+ return;
87456
+ }
87457
+ await upsertCompanion(ctx.db, {
87458
+ ...companion4,
87459
+ trustLevel: parsed,
87460
+ trustScore: Math.max(companion4.trustScore, target.minScore)
87461
+ });
87462
+ R2.success(`Trust updated to ${target.name} (level ${parsed}).`);
87463
+ }
86868
87464
  async function companionMemoryDeleteCommand(ctx, idPrefix) {
86869
87465
  await requireCompanion(ctx);
86870
87466
  const insightId = await chooseInsightId(ctx, idPrefix);
@@ -87423,6 +88019,7 @@ async function handleTelegramEvent(options) {
87423
88019
  userId: options.userId,
87424
88020
  timerPath: getTimerPath(),
87425
88021
  config: options.config,
88022
+ trustLevel: companion4?.trustLevel ?? 0,
87426
88023
  requestPermission: async (toolName, detail) => {
87427
88024
  if (options.alwaysAllowedTools.has(toolName)) {
87428
88025
  return "once";
@@ -87587,17 +88184,12 @@ function parsePermissionCallbackData(data) {
87587
88184
  }
87588
88185
  function ensureTrustedTelegramChat(options, chatId) {
87589
88186
  if (!options.trustedChatId) {
87590
- if (!options.config.gateway) {
88187
+ const onDisk = readGrindConfig() ?? options.config;
88188
+ const gatewayBase = onDisk.gateway ?? options.config.gateway;
88189
+ if (!gatewayBase) {
87591
88190
  return false;
87592
88191
  }
87593
- const nextConfig = {
87594
- ...options.config,
87595
- gateway: {
87596
- ...options.config.gateway,
87597
- telegramDefaultChatId: chatId
87598
- }
87599
- };
87600
- writeGrindConfig(nextConfig);
88192
+ writeGrindConfig({ ...onDisk, gateway: { ...gatewayBase, telegramDefaultChatId: chatId } });
87601
88193
  options.setTrustedChatId(chatId);
87602
88194
  options.onWarn?.(`Auto-set telegramDefaultChatId to ${chatId}.`);
87603
88195
  return true;
@@ -88050,7 +88642,7 @@ import { join as join6 } from "path";
88050
88642
  // src/gateway/autostart.ts
88051
88643
  await init_src();
88052
88644
  import { existsSync as existsSync6, mkdirSync as mkdirSync3, unlinkSync as unlinkSync3, writeFileSync as writeFileSync6 } from "fs";
88053
- import { homedir as homedir2 } from "os";
88645
+ import { homedir as homedir3 } from "os";
88054
88646
  import { join as join5, resolve as resolve4 } from "path";
88055
88647
  var SYSTEMD_UNIT_NAME = "grind-gateway.service";
88056
88648
  var LAUNCHD_LABEL = "com.grind.gateway";
@@ -88237,7 +88829,7 @@ async function runSystemdCommand(args, options) {
88237
88829
  return runCommand(["systemctl", "--user", ...args], options);
88238
88830
  }
88239
88831
  function getSystemdUnitDir() {
88240
- return join5(homedir2(), ".config", "systemd", "user");
88832
+ return join5(homedir3(), ".config", "systemd", "user");
88241
88833
  }
88242
88834
  function getSystemdUnitPath() {
88243
88835
  return join5(getSystemdUnitDir(), SYSTEMD_UNIT_NAME);
@@ -88361,7 +88953,7 @@ function buildLaunchdPlist(launchSpec) {
88361
88953
  `);
88362
88954
  }
88363
88955
  function getLaunchdDir() {
88364
- return join5(homedir2(), "Library", "LaunchAgents");
88956
+ return join5(homedir3(), "Library", "LaunchAgents");
88365
88957
  }
88366
88958
  function getLaunchdPlistPath() {
88367
88959
  return join5(getLaunchdDir(), `${LAUNCHD_LABEL}.plist`);
@@ -90079,9 +90671,14 @@ async function forgeCreateCommand(ctx) {
90079
90671
  const actionType = await Je({
90080
90672
  message: "Action type:",
90081
90673
  options: [
90082
- { value: "queue-quest", label: "queue-quest", hint: "Low risk" },
90083
- { value: "log-to-vault", label: "log-to-vault", hint: "Medium risk" },
90084
- { value: "send-notification", label: "send-notification", hint: "Low risk" }
90674
+ {
90675
+ value: "send-notification",
90676
+ label: "send-notification",
90677
+ hint: "Send a message or run a script whose stdout becomes the message"
90678
+ },
90679
+ { value: "run-script", label: "run-script", hint: "Execute a shell script silently" },
90680
+ { value: "queue-quest", label: "queue-quest", hint: "Activate a quest" },
90681
+ { value: "log-to-vault", label: "log-to-vault", hint: "Auto-log an activity and award XP" }
90085
90682
  ]
90086
90683
  });
90087
90684
  if (Ct(actionType))
@@ -90237,6 +90834,38 @@ async function forgeCreateCommand(ctx) {
90237
90834
  }
90238
90835
  }
90239
90836
  }
90837
+ if (actionType === "run-script") {
90838
+ const script = await Ze({
90839
+ message: "Shell script to execute:",
90840
+ placeholder: "curl -s https://example.com/data | jq .price",
90841
+ validate: (value) => !value?.trim() ? "Script is required." : undefined
90842
+ });
90843
+ if (Ct(script))
90844
+ return bail();
90845
+ actionConfig.script = script.trim();
90846
+ const timeoutRaw = await Ze({
90847
+ message: "Timeout in seconds (default 30):",
90848
+ placeholder: "30",
90849
+ defaultValue: "30",
90850
+ validate: (value) => {
90851
+ const parsed = Number.parseInt(value ?? "", 10);
90852
+ if (!Number.isInteger(parsed) || parsed <= 0)
90853
+ return "Enter a positive integer.";
90854
+ return;
90855
+ }
90856
+ });
90857
+ if (Ct(timeoutRaw))
90858
+ return bail();
90859
+ actionConfig.timeout = Number.parseInt(String(timeoutRaw), 10) * 1000;
90860
+ const workdir = await Ze({
90861
+ message: "Working directory (optional):",
90862
+ placeholder: "~/projects/myapp"
90863
+ });
90864
+ if (Ct(workdir))
90865
+ return bail();
90866
+ if (workdir)
90867
+ actionConfig.workdir = workdir;
90868
+ }
90240
90869
  const rule = await insertForgeRule(ctx.db, {
90241
90870
  userId: ctx.user.id,
90242
90871
  name: name21,
@@ -91774,6 +92403,7 @@ function showCommandHelp(command, sub) {
91774
92403
  "Usage: grindxp companion <subcommand>",
91775
92404
  "",
91776
92405
  cmd("(none)", "Show companion settings"),
92406
+ cmd("trust [level]", "Show or set companion trust level (0\u20134)"),
91777
92407
  cmd("soul", "Edit companion personality in $EDITOR"),
91778
92408
  cmd("context [--refresh]", "View or refresh your user context"),
91779
92409
  cmd("memory, insights", "List stored insights"),
@@ -92076,7 +92706,9 @@ async function main() {
92076
92706
  }
92077
92707
  break;
92078
92708
  case "companion":
92079
- if (sub === "soul") {
92709
+ if (sub === "trust") {
92710
+ await companionTrustCommand(ctx, rest[0]);
92711
+ } else if (sub === "soul") {
92080
92712
  await companionSoulCommand(ctx);
92081
92713
  } else if (sub === "context") {
92082
92714
  await companionContextCommand(ctx, rest.includes("--refresh"));