@grindxp/cli 0.1.8 → 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.
@@ -1,9 +1,10 @@
1
- import { f as createQuest, h as completeQuest, i as getQuestById, u as updateQuestStatus, g as getCompanionByUserId, j as updateCompanionUserContext, k as updateCompanionInsight, m as findCompanionInsightByContent, n as createCompanionInsight, o as listCompanionInsights, l as listQuestsByUser, p as findQuestByPrefix, c as createServerRpc, q as getConversationById, r as createConversation, s as appendMessage, t as getConversationMessages, v as storedToModelMessages } from "./sessions-DOkG47Ex.js";
2
- import { d as desc, f as forgeRules, e as eq, b as and, c as forgeRuleSchema, h as createForgeRuleInputSchema, i as forgeRuns, j as forgeRunSchema, s as sql, k as createForgeRunInputSchema, Z as ZodFirstPartyTypeKind, o as objectType, n as numberType, l as stringType, m as arrayType, p as enumType, u as unionType, q as getOAuthToken, r as isTokenExpired, t as refreshOAuthToken, O as OAUTH_CONFIGS, x as xpForLevelThreshold, v as formatElapsed, A as ACTIVITY_BASE_XP, w as activityTypeSchema, y as questDifficultySchema, z as booleanType, B as recordType, C as unknownType, a as getUserById, D as readTimer, E as getElapsedMinutes, F as clearTimer, G as writeTimer, H as readGrindConfig, I as signals, J as writeGrindConfig, K as users, L as quests, M as skills, N as gte, P as questLogs, g as getVaultContext, Q as getGrindConfig } from "./vault.server-Ndu49yTf.js";
1
+ import { f as createQuest, h as completeQuest, i as getQuestById, u as updateQuestStatus, j as updateCompanionMode, k as listCompanionInsights, m as deleteCompanionInsight, n as findQuestByPrefix, l as listQuestsByUser, o as updateQuest, p as listQuestLogs, a as listSkillsByUser, g as getCompanionByUserId, q as updateCompanionUserContext, r as updateCompanionInsight, s as findCompanionInsightByContent, t as createCompanionInsight, c as createServerRpc, v as getConversationById, w as createConversation, x as appendMessage, y as getConversationMessages, z as storedToModelMessages } from "./sessions-UCWtijHE.js";
2
+ import { b as and, e as eq, s as signals, d as desc, f as forgeRules, c as forgeRuleSchema, h as createForgeRuleInputSchema, i as forgeRuns, j as signalSchema, k as forgeRunSchema, l as sql, m as createForgeRunInputSchema, Z as ZodFirstPartyTypeKind, o as objectType, n as numberType, p as stringType, q as arrayType, r as enumType, u as unionType, t as getOAuthToken, v as isTokenExpired, w as refreshOAuthToken, O as OAUTH_CONFIGS, x as xpForLevelThreshold, y as formatElapsed, A as ACTIVITY_BASE_XP, z as activityTypeSchema, B as questDifficultySchema, C as booleanType, D as recordType, E as unknownType, a as getUserById, F as readTimer, G as getElapsedMinutes, H as clearTimer, I as writeTimer, J as readGrindConfig, K as writeGrindConfig, L as users, M as quests, N as skills, P as gte, Q as questLogs, g as getVaultContext, R as getGrindConfig } from "./vault.server-CscY5Z8e.js";
3
3
  import * as fs from "fs/promises";
4
4
  import * as path from "path";
5
5
  import os from "os";
6
6
  import { spawnSync } from "node:child_process";
7
+ import { homedir } from "node:os";
7
8
  import { c as createServerFn } from "../server.js";
8
9
  function rowToForgeRule(row) {
9
10
  return forgeRuleSchema.parse({
@@ -19,6 +20,18 @@ function rowToForgeRule(row) {
19
20
  updatedAt: row.updatedAt
20
21
  });
21
22
  }
23
+ function rowToSignal(row) {
24
+ return signalSchema.parse({
25
+ id: row.id,
26
+ userId: row.userId,
27
+ source: row.source,
28
+ type: row.type,
29
+ confidence: row.confidence,
30
+ payload: row.payload,
31
+ detectedAt: row.detectedAt,
32
+ ingestedAt: row.ingestedAt
33
+ });
34
+ }
22
35
  function rowToForgeRun(row) {
23
36
  return forgeRunSchema.parse({
24
37
  id: row.id,
@@ -98,6 +111,16 @@ async function deleteForgeRule(db, userId, ruleId) {
98
111
  const rows = await db.delete(forgeRules).where(and(eq(forgeRules.id, ruleId), eq(forgeRules.userId, userId))).returning({ id: forgeRules.id });
99
112
  return rows.length > 0;
100
113
  }
114
+ async function listSignals(db, userId, options = {}) {
115
+ const { limit = 20, source } = options;
116
+ const where = source ? and(eq(signals.userId, userId), eq(signals.source, source)) : eq(signals.userId, userId);
117
+ const rows = await db.query.signals.findMany({
118
+ where,
119
+ orderBy: [desc(signals.detectedAt)],
120
+ limit
121
+ });
122
+ return rows.map(rowToSignal);
123
+ }
101
124
  async function hasForgeRunByDedupe(db, ruleId, dedupeKey) {
102
125
  const rows = await db.select({ value: sql`count(1)` }).from(forgeRuns).where(and(eq(forgeRuns.ruleId, ruleId), eq(forgeRuns.dedupeKey, dedupeKey)));
103
126
  const count = rows[0]?.value ?? 0;
@@ -7164,8 +7187,8 @@ function requireGetVercelOidcToken() {
7164
7187
  }
7165
7188
  try {
7166
7189
  const [{ getTokenPayload, isExpired }, { refreshToken }] = await Promise.all([
7167
- await import("./token-util-1cB5CD6M.js").then((n) => n.t),
7168
- await import("./token-W0NPKas8.js").then((n) => n.t)
7190
+ await import("./token-util-Bw35afYM.js").then((n) => n.t),
7191
+ await import("./token-DGoahKjI.js").then((n) => n.t)
7169
7192
  ]);
7170
7193
  if (!token || isExpired(getTokenPayload(token))) {
7171
7194
  await refreshToken();
@@ -15441,7 +15464,7 @@ async function resolveModel(config2) {
15441
15464
  const modelId = config2.model ?? DEFAULT_MODELS[provider];
15442
15465
  switch (provider) {
15443
15466
  case "anthropic": {
15444
- const { createAnthropic } = await import("./index-C09LXa7Z.js");
15467
+ const { createAnthropic } = await import("./index-B2ULpkv2.js");
15445
15468
  if (config2.authType === "oauth") {
15446
15469
  const client2 = createAnthropic({
15447
15470
  authToken: "grind-oauth-managed",
@@ -15459,7 +15482,7 @@ async function resolveModel(config2) {
15459
15482
  return client(modelId);
15460
15483
  }
15461
15484
  case "openai": {
15462
- const { createOpenAI } = await import("./index-BDL7hA7T.js");
15485
+ const { createOpenAI } = await import("./index-BQUCDamI.js");
15463
15486
  if (config2.authType === "oauth") {
15464
15487
  const client2 = createOpenAI({
15465
15488
  apiKey: "openai-oauth-dummy",
@@ -15475,14 +15498,14 @@ async function resolveModel(config2) {
15475
15498
  return client(modelId);
15476
15499
  }
15477
15500
  case "google": {
15478
- const { createGoogleGenerativeAI } = await import("./index-D31yYLCV.js");
15501
+ const { createGoogleGenerativeAI } = await import("./index-BGBMycx-.js");
15479
15502
  const client = createGoogleGenerativeAI({
15480
15503
  ...config2.apiKey ? { apiKey: config2.apiKey } : {}
15481
15504
  });
15482
15505
  return client(modelId);
15483
15506
  }
15484
15507
  case "ollama": {
15485
- const { createOpenAI } = await import("./index-BDL7hA7T.js");
15508
+ const { createOpenAI } = await import("./index-BQUCDamI.js");
15486
15509
  const client = createOpenAI({
15487
15510
  baseURL: config2.baseUrl ?? "http://localhost:11434/v1",
15488
15511
  apiKey: "ollama"
@@ -15564,8 +15587,16 @@ TOOL USAGE:
15564
15587
  - When asked whether integrations/channels are connected or available (Telegram, WhatsApp, Discord, Google Calendar), call get_integrations_status first. Do not guess.
15565
15588
  - If the user asks to send or test a Telegram message, call send_telegram_message immediately. Never ask the user for their chat ID — it is resolved automatically.
15566
15589
  - 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.
15567
- - 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.
15568
- - Before updating or deleting a forge rule, call list_forge_rules to confirm the target. Use list_forge_runs to diagnose failures.
15590
+ - When the user asks to automate, schedule reminders, or set recurring workflows, use forge tools directly never tell the user to use the CLI manually.
15591
+ - Before updating, deleting, or running a specific rule, call list_forge_rules to confirm the target and read its xpImpact field.
15592
+ - xpImpact: false rules (notifications, reminders, monitors): act fully autonomously — no explanation needed beyond confirming what you did.
15593
+ - xpImpact: true rules (log-to-vault, update-skill): proceed autonomously and briefly mention in your reply that XP will be awarded automatically.
15594
+ - Deleting a rule is permanent — tell the user this before calling delete_forge_rule.
15595
+ - run-script rules execute shell scripts as automations — always show the full script in your reply when creating or updating one.
15596
+ - Use list_forge_runs to diagnose failures.
15597
+ - 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 — always look it up.
15598
+ - 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.
15599
+ - Never ask the user for a calendar ID — always resolve it yourself via list_calendars.
15569
15600
  - Keep responses concise. 1-3 sentences for simple actions. No walls of text.
15570
15601
 
15571
15602
  WEB & FILE ACCESS:
@@ -15905,6 +15936,15 @@ async function listCalendars(serviceConfig) {
15905
15936
  const data = await resp.json();
15906
15937
  return data.items ?? [];
15907
15938
  }
15939
+ async function createCalendar(serviceConfig, summary, timeZone) {
15940
+ const body = { summary };
15941
+ if (timeZone) body.timeZone = timeZone;
15942
+ const resp = await googleFetch(`${BASE$1}/calendars`, serviceConfig, {
15943
+ method: "POST",
15944
+ body: JSON.stringify(body)
15945
+ });
15946
+ return resp.json();
15947
+ }
15908
15948
  const BASE = "https://gmail.googleapis.com/gmail/v1/users/me";
15909
15949
  function extractHeader(headers, name16) {
15910
15950
  return headers.find((h) => h.name.toLowerCase() === name16.toLowerCase())?.value ?? "";
@@ -16075,6 +16115,8 @@ async function executeForgeAction(db, userId, plan) {
16075
16115
  return executeLogToVault(db, userId, plan);
16076
16116
  case "send-notification":
16077
16117
  return executeSendNotification(plan);
16118
+ case "run-script":
16119
+ return executeRunScript(plan);
16078
16120
  default:
16079
16121
  return {
16080
16122
  status: "skipped",
@@ -16270,6 +16312,53 @@ async function executeSendNotification(plan) {
16270
16312
  error: `Unsupported notification channel '${channel}'.`
16271
16313
  };
16272
16314
  }
16315
+ async function executeRunScript(plan) {
16316
+ const script = asString(plan.actionConfig.script);
16317
+ if (!script) {
16318
+ return {
16319
+ status: "failed",
16320
+ actionPayload: {},
16321
+ error: "run-script requires actionConfig.script."
16322
+ };
16323
+ }
16324
+ const timeoutMs = parsePositiveInt$1(plan.actionConfig.timeout) ?? 3e4;
16325
+ const rawWorkdir = asString(plan.actionConfig.workdir);
16326
+ const workdir = rawWorkdir ? rawWorkdir.replace(/^~/, homedir()) : void 0;
16327
+ const result = spawnSync("sh", ["-c", script], {
16328
+ encoding: "utf8",
16329
+ stdio: ["ignore", "pipe", "pipe"],
16330
+ timeout: timeoutMs,
16331
+ ...workdir ? { cwd: workdir } : {}
16332
+ });
16333
+ const errCode = result.error?.code;
16334
+ if (errCode === "ENOENT" && workdir) {
16335
+ return {
16336
+ status: "failed",
16337
+ actionPayload: { script, exitCode: null },
16338
+ error: `Working directory does not exist: ${workdir}`
16339
+ };
16340
+ }
16341
+ if (errCode === "ETIMEDOUT") {
16342
+ return {
16343
+ status: "failed",
16344
+ actionPayload: { script, exitCode: null },
16345
+ error: `Script timed out after ${timeoutMs}ms.`
16346
+ };
16347
+ }
16348
+ if (result.status !== 0) {
16349
+ const stderr = result.stderr?.trim().slice(0, 500) ?? "";
16350
+ return {
16351
+ status: "failed",
16352
+ actionPayload: { script, exitCode: result.status },
16353
+ error: `Script exited ${result.status ?? "null"}${stderr ? `: ${stderr}` : ""}`
16354
+ };
16355
+ }
16356
+ const stdout = result.stdout?.trim().slice(0, 2e3) ?? "";
16357
+ return {
16358
+ status: "success",
16359
+ actionPayload: { script, exitCode: 0, ...stdout ? { stdout } : {} }
16360
+ };
16361
+ }
16273
16362
  async function executeQueueQuest(db, userId, plan) {
16274
16363
  const eventPayload = asRecord$1(plan.actionConfig.eventPayload);
16275
16364
  const questId = asString(plan.actionConfig.questId) ?? asString(eventPayload?.questId) ?? asString(plan.actionConfig.targetQuestId);
@@ -16379,6 +16468,37 @@ function parseConfidence(value) {
16379
16468
  if (Number.isNaN(value) || value < 0 || value > 1) return null;
16380
16469
  return value;
16381
16470
  }
16471
+ const TRUST_LEVEL_NAMES = ["Watcher", "Advisor", "Scribe", "Agent", "Sovereign"];
16472
+ const TOOL_TRUST_REQUIREMENTS = {
16473
+ // Lv.2 Scribe: can act on behalf of the user
16474
+ complete_quest: 2,
16475
+ abandon_quest: 2,
16476
+ activate_quest: 2,
16477
+ start_timer: 2,
16478
+ stop_timer: 2,
16479
+ update_companion_mode: 2,
16480
+ // Lv.3 Agent: can create and modify structure
16481
+ create_quest: 3,
16482
+ update_quest: 3,
16483
+ // Lv.4 Sovereign: destructive or sensitive operations
16484
+ delete_insight: 4
16485
+ // Note: forge operations are not gated by trust level — the AI reasons
16486
+ // autonomously using the xpImpact field returned by list_forge_rules.
16487
+ };
16488
+ function requireTrust(ctx, toolName) {
16489
+ const required2 = TOOL_TRUST_REQUIREMENTS[toolName];
16490
+ if (required2 === void 0) return { denied: false };
16491
+ const current = ctx.trustLevel ?? 0;
16492
+ if (current < required2) {
16493
+ const requiredName = TRUST_LEVEL_NAMES[required2] ?? `Lv.${required2}`;
16494
+ const currentName = TRUST_LEVEL_NAMES[current] ?? `Lv.${current}`;
16495
+ return {
16496
+ denied: true,
16497
+ error: `Action requires trust level ${required2} (${requiredName}). Current level: ${current} (${currentName}). Grant trust with: grindxp companion trust ${required2}`
16498
+ };
16499
+ }
16500
+ return { denied: false };
16501
+ }
16382
16502
  const MAX_FETCH_SIZE = 50 * 1024;
16383
16503
  const MAX_READ_SIZE = 50 * 1024;
16384
16504
  const MAX_BASH_OUTPUT = 50 * 1024;
@@ -16435,6 +16555,38 @@ async function requirePermission(ctx, toolName, detail) {
16435
16555
  if (reply === "deny") return { denied: true, error: "Permission denied by user" };
16436
16556
  return { denied: false };
16437
16557
  }
16558
+ function classifyGoogleError(err) {
16559
+ if (err instanceof GoogleNotConnectedError || err instanceof GoogleTokenExpiredError) {
16560
+ return "Google account not connected or session expired. Run `grindxp integrations connect google`.";
16561
+ }
16562
+ if (err instanceof GoogleApiError) {
16563
+ switch (err.status) {
16564
+ case 401:
16565
+ return "Google account disconnected. Run `grindxp integrations connect google`.";
16566
+ case 403:
16567
+ return "No write access to this calendar. Check the calendar's sharing settings.";
16568
+ case 404:
16569
+ return "Calendar or event not found. Use list_calendars to verify available calendar IDs.";
16570
+ case 409:
16571
+ return "Conflict — this event may already exist on the calendar.";
16572
+ case 410:
16573
+ return "Sync token expired — a full re-sync will happen on the next poll.";
16574
+ }
16575
+ if (err.status >= 500) {
16576
+ return "Google Calendar is temporarily unavailable. Try again in a moment.";
16577
+ }
16578
+ if (err.status === 400) {
16579
+ try {
16580
+ const parsed = JSON.parse(err.body);
16581
+ const msg = parsed?.error?.message;
16582
+ if (msg) return `Invalid request: ${msg}`;
16583
+ } catch {
16584
+ }
16585
+ return "Invalid request — check the calendar ID and date format.";
16586
+ }
16587
+ }
16588
+ return err instanceof Error ? err.message : String(err);
16589
+ }
16438
16590
  async function extractText(html) {
16439
16591
  let text2 = "";
16440
16592
  let skip = false;
@@ -16593,16 +16745,6 @@ async function resolveTelegramChatId(ctx, token) {
16593
16745
  return { chatId: candidate, source: "recent-signal" };
16594
16746
  }
16595
16747
  }
16596
- const webhookActive = Boolean(
16597
- ctx.config?.gateway?.telegramWebhookSecret ?? freshConfig?.gateway?.telegramWebhookSecret
16598
- );
16599
- if (webhookActive) {
16600
- return {
16601
- chatId: null,
16602
- source: "none",
16603
- detail: "Send any message to your Telegram bot and I'll respond automatically. The chat ID will be captured on first contact."
16604
- };
16605
- }
16606
16748
  const updatesResponse = await fetch(
16607
16749
  `https://api.telegram.org/bot${token}/getUpdates?limit=50&timeout=0`,
16608
16750
  {
@@ -16668,10 +16810,17 @@ function persistTelegramDefaultChatId(ctx, chatId) {
16668
16810
  telegramDefaultChatId: chatId
16669
16811
  }
16670
16812
  };
16671
- writeGrindConfig(ctx.config);
16813
+ const onDisk = readGrindConfig();
16814
+ if (!onDisk?.gateway) return;
16815
+ writeGrindConfig({ ...onDisk, gateway: { ...onDisk.gateway, telegramDefaultChatId: chatId } });
16672
16816
  }
16673
16817
  const FORGE_TRIGGER_TYPES = ["cron", "event", "signal", "webhook", "manual"];
16674
- const FORGE_ACTION_TYPES = ["queue-quest", "log-to-vault", "send-notification"];
16818
+ const FORGE_ACTION_TYPES = [
16819
+ "queue-quest",
16820
+ "log-to-vault",
16821
+ "send-notification",
16822
+ "run-script"
16823
+ ];
16675
16824
  const FORGE_NOTIFICATION_CHANNELS = ["console", "telegram", "webhook", "whatsapp"];
16676
16825
  const FORGE_WEBHOOK_CHANNEL_TAGS = ["webhook", "telegram", "discord", "whatsapp"];
16677
16826
  const SIMPLE_CRON_FIELD_REGEX = /^[\d*/,\-]+$/;
@@ -16982,6 +17131,37 @@ async function normalizeForgeRuleDefinition(ctx, input) {
16982
17131
  }
16983
17132
  break;
16984
17133
  }
17134
+ case "run-script": {
17135
+ const script = asNonEmptyString(actionConfig.script);
17136
+ if (!script) {
17137
+ return {
17138
+ ok: false,
17139
+ error: "run-script requires actionConfig.script (shell command to execute)."
17140
+ };
17141
+ }
17142
+ actionConfig.script = script;
17143
+ if (actionConfig.timeout !== void 0) {
17144
+ const timeout = parsePositiveInt(actionConfig.timeout);
17145
+ if (!timeout) {
17146
+ return {
17147
+ ok: false,
17148
+ error: "run-script actionConfig.timeout must be a positive integer (milliseconds)."
17149
+ };
17150
+ }
17151
+ actionConfig.timeout = timeout;
17152
+ }
17153
+ if (actionConfig.workdir !== void 0) {
17154
+ const workdir = asNonEmptyString(actionConfig.workdir);
17155
+ if (!workdir) {
17156
+ return {
17157
+ ok: false,
17158
+ error: "run-script actionConfig.workdir must be a non-empty string when provided."
17159
+ };
17160
+ }
17161
+ actionConfig.workdir = workdir;
17162
+ }
17163
+ break;
17164
+ }
16985
17165
  }
16986
17166
  return {
16987
17167
  ok: true,
@@ -17049,7 +17229,7 @@ function createGrindTools(ctx) {
17049
17229
  const calendars = await listCalendars(googleConfig);
17050
17230
  return { ok: true, calendars, count: calendars.length };
17051
17231
  } catch (err) {
17052
- return { ok: false, error: err instanceof Error ? err.message : String(err) };
17232
+ return { ok: false, error: classifyGoogleError(err) };
17053
17233
  }
17054
17234
  }
17055
17235
  }),
@@ -17145,12 +17325,12 @@ function createGrindTools(ctx) {
17145
17325
  });
17146
17326
  return { ok: true, events: result.events, count: result.events.length };
17147
17327
  } catch (err) {
17148
- return { ok: false, error: err instanceof Error ? err.message : String(err) };
17328
+ return { ok: false, error: classifyGoogleError(err) };
17149
17329
  }
17150
17330
  }
17151
17331
  }),
17152
17332
  create_calendar_event: tool({
17153
- 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.",
17333
+ 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.",
17154
17334
  inputSchema: objectType({
17155
17335
  summary: stringType().min(1).max(500).describe("Event title"),
17156
17336
  startDateTime: stringType().describe("Start time (ISO 8601, e.g. 2026-02-21T09:00:00)"),
@@ -17159,7 +17339,10 @@ function createGrindTools(ctx) {
17159
17339
  location: stringType().optional().describe("Event location"),
17160
17340
  attendees: arrayType(stringType().email()).optional().describe("List of attendee email addresses"),
17161
17341
  allDay: booleanType().optional().describe("If true, treats startDateTime/endDateTime as dates (YYYY-MM-DD)"),
17162
- timeZone: stringType().optional().describe("IANA timezone name (e.g. America/New_York)")
17342
+ timeZone: stringType().optional().describe("IANA timezone name (e.g. America/New_York)"),
17343
+ calendarId: stringType().optional().describe(
17344
+ "Calendar ID to create the event in. Use list_calendars to resolve a calendar name to its id. Defaults to the primary calendar."
17345
+ )
17163
17346
  }),
17164
17347
  execute: async ({
17165
17348
  summary,
@@ -17169,7 +17352,8 @@ function createGrindTools(ctx) {
17169
17352
  location,
17170
17353
  attendees,
17171
17354
  allDay,
17172
- timeZone
17355
+ timeZone,
17356
+ calendarId
17173
17357
  }) => {
17174
17358
  const googleConfig = ctx.config?.services?.google;
17175
17359
  if (!googleConfig) {
@@ -17179,19 +17363,45 @@ function createGrindTools(ctx) {
17179
17363
  };
17180
17364
  }
17181
17365
  try {
17182
- const event = await createCalendarEvent(googleConfig, {
17183
- summary,
17184
- startDateTime,
17185
- endDateTime,
17186
- ...description ? { description } : {},
17187
- ...location ? { location } : {},
17188
- ...attendees ? { attendees } : {},
17189
- ...allDay ? { allDay } : {},
17190
- ...timeZone ? { timeZone } : {}
17191
- });
17366
+ const event = await createCalendarEvent(
17367
+ googleConfig,
17368
+ {
17369
+ summary,
17370
+ startDateTime,
17371
+ endDateTime,
17372
+ ...description ? { description } : {},
17373
+ ...location ? { location } : {},
17374
+ ...attendees ? { attendees } : {},
17375
+ ...allDay ? { allDay } : {},
17376
+ ...timeZone ? { timeZone } : {}
17377
+ },
17378
+ calendarId ?? "primary"
17379
+ );
17192
17380
  return { ok: true, event };
17193
17381
  } catch (err) {
17194
- return { ok: false, error: err instanceof Error ? err.message : String(err) };
17382
+ return { ok: false, error: classifyGoogleError(err) };
17383
+ }
17384
+ }
17385
+ }),
17386
+ create_calendar: tool({
17387
+ 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.",
17388
+ inputSchema: objectType({
17389
+ summary: stringType().min(1).max(255).describe("Calendar name (e.g. 'Work', 'God', 'Study')"),
17390
+ timeZone: stringType().optional().describe("IANA timezone name for the calendar (e.g. America/Sao_Paulo)")
17391
+ }),
17392
+ execute: async ({ summary, timeZone }) => {
17393
+ const googleConfig = ctx.config?.services?.google;
17394
+ if (!googleConfig) {
17395
+ return {
17396
+ ok: false,
17397
+ error: "Google account not connected. Run `grindxp integrations connect google`."
17398
+ };
17399
+ }
17400
+ try {
17401
+ const calendar = await createCalendar(googleConfig, summary, timeZone);
17402
+ return { ok: true, calendar };
17403
+ } catch (err) {
17404
+ return { ok: false, error: classifyGoogleError(err) };
17195
17405
  }
17196
17406
  }
17197
17407
  }),
@@ -17244,7 +17454,7 @@ function createGrindTools(ctx) {
17244
17454
  );
17245
17455
  return { ok: true, event };
17246
17456
  } catch (err) {
17247
- return { ok: false, error: err instanceof Error ? err.message : String(err) };
17457
+ return { ok: false, error: classifyGoogleError(err) };
17248
17458
  }
17249
17459
  }
17250
17460
  }),
@@ -17266,7 +17476,7 @@ function createGrindTools(ctx) {
17266
17476
  await deleteCalendarEvent(googleConfig, eventId, calendarId ?? "primary");
17267
17477
  return { ok: true, eventId };
17268
17478
  } catch (err) {
17269
- return { ok: false, error: err instanceof Error ? err.message : String(err) };
17479
+ return { ok: false, error: classifyGoogleError(err) };
17270
17480
  }
17271
17481
  }
17272
17482
  }),
@@ -17387,7 +17597,7 @@ function createGrindTools(ctx) {
17387
17597
  }
17388
17598
  }),
17389
17599
  list_forge_rules: tool({
17390
- description: "List forge automation rules. Use this before updating, deleting, or running a specific rule.",
17600
+ description: "List forge automation rules. Each rule includes xpImpact: true/false — use this to decide how to communicate the action to the user. Always call this before updating, deleting, or running a specific rule.",
17391
17601
  inputSchema: objectType({
17392
17602
  enabledOnly: booleanType().optional().describe("When true, return only enabled rules."),
17393
17603
  includeRecentRuns: booleanType().default(true).describe("When true, include recent execution history."),
@@ -17409,6 +17619,7 @@ function createGrindTools(ctx) {
17409
17619
  enabled: rule.enabled,
17410
17620
  triggerType: rule.triggerType,
17411
17621
  actionType: rule.actionType,
17622
+ xpImpact: ["log-to-vault", "update-skill"].includes(rule.actionType),
17412
17623
  triggerConfig: redactForgeValue(rule.triggerConfig),
17413
17624
  actionConfig: redactForgeValue(rule.actionConfig),
17414
17625
  updatedAt: rule.updatedAt
@@ -17483,14 +17694,14 @@ function createGrindTools(ctx) {
17483
17694
  }
17484
17695
  }),
17485
17696
  create_forge_rule: tool({
17486
- description: "Create a forge automation rule. This supports currently implemented actions only: queue-quest, log-to-vault, send-notification.",
17697
+ description: "Create a forge automation rule (queue-quest, log-to-vault, send-notification, run-script). send-notification and queue-quest have no XP impact — create autonomously. log-to-vault auto-awards XP — create it and mention that in your reply. run-script executes a shell script — always include the full script in your reply so the user can verify it.",
17487
17698
  inputSchema: objectType({
17488
17699
  name: stringType().min(2).max(128).describe("Human-readable rule name."),
17489
17700
  triggerType: enumType(FORGE_TRIGGER_TYPES).describe("Trigger type (cron, event, signal, webhook, manual)."),
17490
17701
  triggerConfig: recordType(stringType(), unknownType()).default({}).describe("Trigger configuration object."),
17491
17702
  actionType: enumType(FORGE_ACTION_TYPES).describe("Action type (queue-quest, log-to-vault, send-notification)."),
17492
17703
  actionConfig: recordType(stringType(), unknownType()).default({}).describe(
17493
- "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'."
17704
+ "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 ~)."
17494
17705
  ),
17495
17706
  enabled: booleanType().default(true).describe("Whether the rule starts enabled.")
17496
17707
  }),
@@ -17531,7 +17742,7 @@ function createGrindTools(ctx) {
17531
17742
  }
17532
17743
  }),
17533
17744
  update_forge_rule: tool({
17534
- description: "Update a forge rule by ID prefix or name. Use list_forge_rules first to confirm the target.",
17745
+ description: "Update a forge rule by ID prefix or name. Call list_forge_rules first to confirm the target. Act autonomously — no permission needed unless changing to a log-to-vault action, in which case mention the XP impact in your reply.",
17535
17746
  inputSchema: objectType({
17536
17747
  ruleSearch: stringType().min(1).describe("Rule ID prefix or rule name substring."),
17537
17748
  name: stringType().min(2).max(128).optional().describe("New rule name."),
@@ -17601,6 +17812,7 @@ function createGrindTools(ctx) {
17601
17812
  nextTriggerConfig = normalized.triggerConfig;
17602
17813
  nextActionConfig = normalized.actionConfig;
17603
17814
  }
17815
+ actionType ?? rule.actionType;
17604
17816
  const updated = await updateForgeRule(ctx.db, ctx.userId, rule.id, {
17605
17817
  ...name16 !== void 0 ? { name: name16 } : {},
17606
17818
  ...triggerType !== void 0 ? { triggerType } : {},
@@ -17630,7 +17842,7 @@ function createGrindTools(ctx) {
17630
17842
  }
17631
17843
  }),
17632
17844
  delete_forge_rule: tool({
17633
- description: "Delete a forge rule by ID prefix or name. Related run history is removed as part of rule deletion.",
17845
+ description: "Delete a forge rule by ID prefix or name. This is permanent warn the user before calling this. Run history is also removed.",
17634
17846
  inputSchema: objectType({
17635
17847
  ruleSearch: stringType().min(1).describe("Rule ID prefix or rule name substring.")
17636
17848
  }),
@@ -17639,6 +17851,12 @@ function createGrindTools(ctx) {
17639
17851
  if (!rule) {
17640
17852
  return { ok: false, error: `No forge rule matching "${ruleSearch}".` };
17641
17853
  }
17854
+ const perm = await requirePermission(
17855
+ ctx,
17856
+ "delete_forge_rule",
17857
+ `Permanently delete forge rule "${rule.name}"?`
17858
+ );
17859
+ if (perm.denied) return { ok: false, error: "Deletion cancelled." };
17642
17860
  const deleted = await deleteForgeRule(ctx.db, ctx.userId, rule.id);
17643
17861
  if (!deleted) {
17644
17862
  return { ok: false, error: "Failed to delete forge rule." };
@@ -17655,7 +17873,7 @@ function createGrindTools(ctx) {
17655
17873
  }
17656
17874
  }),
17657
17875
  run_forge_rule: tool({
17658
- description: "Run a forge rule immediately by ID prefix or name. Useful for testing automation behavior without waiting for its trigger.",
17876
+ 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.",
17659
17877
  inputSchema: objectType({
17660
17878
  ruleSearch: stringType().min(1).describe("Rule ID prefix or rule name substring."),
17661
17879
  eventPayload: recordType(stringType(), unknownType()).optional().describe("Optional payload passed as event context for this run-now execution."),
@@ -17763,6 +17981,8 @@ function createGrindTools(ctx) {
17763
17981
  baseXp: numberType().int().positive().default(10).describe("Base XP before multipliers. 10=small, 25=medium, 50=large, 100=epic")
17764
17982
  }),
17765
17983
  execute: async ({ title, description, type, difficulty, skillTags, baseXp }) => {
17984
+ const trust = requireTrust(ctx, "create_quest");
17985
+ if (trust.denied) return { error: trust.error };
17766
17986
  const active = await listQuestsByUser(ctx.db, ctx.userId, ["active"]);
17767
17987
  if (active.length >= 5) {
17768
17988
  return { error: "Max 5 active quests. Complete or abandon one first." };
@@ -17798,6 +18018,8 @@ function createGrindTools(ctx) {
17798
18018
  durationMinutes: numberType().int().positive().optional().describe("Duration in minutes if timer proof")
17799
18019
  }),
17800
18020
  execute: async ({ questSearch, proofType, durationMinutes }) => {
18021
+ const trust = requireTrust(ctx, "complete_quest");
18022
+ if (trust.denied) return { error: trust.error };
17801
18023
  const quest = await findQuestByPrefix(ctx.db, ctx.userId, questSearch);
17802
18024
  if (!quest) return { error: `No quest matching "${questSearch}"` };
17803
18025
  if (quest.status === "completed") return { error: "Quest already completed" };
@@ -17834,6 +18056,8 @@ function createGrindTools(ctx) {
17834
18056
  questSearch: stringType().describe("Quest ID prefix or title substring")
17835
18057
  }),
17836
18058
  execute: async ({ questSearch }) => {
18059
+ const trust = requireTrust(ctx, "abandon_quest");
18060
+ if (trust.denied) return { error: trust.error };
17837
18061
  const quest = await findQuestByPrefix(ctx.db, ctx.userId, questSearch);
17838
18062
  if (!quest) return { error: `No quest matching "${questSearch}"` };
17839
18063
  if (quest.status !== "active") return { error: `Quest is ${quest.status}, not active` };
@@ -17851,6 +18075,8 @@ function createGrindTools(ctx) {
17851
18075
  questSearch: stringType().describe("Quest ID prefix or title substring")
17852
18076
  }),
17853
18077
  execute: async ({ questSearch }) => {
18078
+ const trust = requireTrust(ctx, "start_timer");
18079
+ if (trust.denied) return { error: trust.error };
17854
18080
  const existing = readTimer(ctx.timerPath);
17855
18081
  if (existing) {
17856
18082
  const elapsed = formatElapsed(existing.startedAt);
@@ -17876,6 +18102,8 @@ function createGrindTools(ctx) {
17876
18102
  complete: booleanType().default(false).describe("Whether to also complete the quest with timer-duration proof")
17877
18103
  }),
17878
18104
  execute: async ({ complete }) => {
18105
+ const trust = requireTrust(ctx, "stop_timer");
18106
+ if (trust.denied) return { error: trust.error };
17879
18107
  const timer = readTimer(ctx.timerPath);
17880
18108
  if (!timer) return { error: "No timer running" };
17881
18109
  const elapsed = getElapsedMinutes(timer.startedAt);
@@ -18611,6 +18839,174 @@ ${normalized}` : normalized;
18611
18839
  return { pattern, matches, totalMatches: matches.length, truncated };
18612
18840
  }
18613
18841
  }),
18842
+ list_skills: tool({
18843
+ description: "List all skills with current XP, level, and category. Use this to see the full skill breakdown before making quest suggestions or updates.",
18844
+ inputSchema: objectType({}),
18845
+ execute: async () => {
18846
+ const allSkills = await listSkillsByUser(ctx.db, ctx.userId);
18847
+ return allSkills.map((s) => ({
18848
+ id: s.id.slice(0, 8),
18849
+ name: s.name,
18850
+ category: s.category,
18851
+ xp: s.xp,
18852
+ level: s.level
18853
+ }));
18854
+ }
18855
+ }),
18856
+ list_quest_logs: tool({
18857
+ 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.",
18858
+ inputSchema: objectType({
18859
+ limit: numberType().int().min(1).max(100).default(20).describe("Maximum number of logs to return"),
18860
+ sinceDaysAgo: numberType().int().min(1).max(90).optional().describe("Only return logs from this many days ago")
18861
+ }),
18862
+ execute: async ({ limit, sinceDaysAgo }) => {
18863
+ const since = sinceDaysAgo ? Date.now() - sinceDaysAgo * 24 * 60 * 60 * 1e3 : void 0;
18864
+ const logs = await listQuestLogs(ctx.db, ctx.userId, {
18865
+ limit,
18866
+ ...since ? { since } : {}
18867
+ });
18868
+ const questIds = [...new Set(logs.map((l) => l.questId))];
18869
+ const questTitles = {};
18870
+ for (const qid of questIds) {
18871
+ const q = await getQuestById(ctx.db, qid);
18872
+ if (q) questTitles[qid] = q.title;
18873
+ }
18874
+ return logs.map((l) => ({
18875
+ questTitle: questTitles[l.questId] ?? l.questId.slice(0, 8),
18876
+ completedAt: new Date(l.completedAt).toLocaleDateString(),
18877
+ xpEarned: l.xpEarned,
18878
+ durationMinutes: l.durationMinutes ?? null,
18879
+ proofType: l.proofType,
18880
+ streakDay: l.streakDay
18881
+ }));
18882
+ }
18883
+ }),
18884
+ list_signals: tool({
18885
+ description: "List recently detected signals (git commits, file changes, process observations, webhook events). Use this to understand what the user has been doing passively.",
18886
+ inputSchema: objectType({
18887
+ limit: numberType().int().min(1).max(50).default(20).describe("Maximum number of signals to return"),
18888
+ source: enumType(["git", "file", "process", "webhook"]).optional().describe("Filter by signal source")
18889
+ }),
18890
+ execute: async ({ limit, source }) => {
18891
+ const sigs = await listSignals(ctx.db, ctx.userId, {
18892
+ limit,
18893
+ ...source ? { source } : {}
18894
+ });
18895
+ return sigs.map((s) => ({
18896
+ source: s.source,
18897
+ type: s.type,
18898
+ confidence: s.confidence,
18899
+ detectedAt: new Date(s.detectedAt).toLocaleString(),
18900
+ payload: s.payload
18901
+ }));
18902
+ }
18903
+ }),
18904
+ update_quest: tool({
18905
+ description: "Update quest details: title, description, difficulty, type, baseXp, skillTags, or schedule. Does NOT change quest status — use abandon_quest or complete_quest for that.",
18906
+ inputSchema: objectType({
18907
+ questSearch: stringType().describe("Quest ID prefix or title substring"),
18908
+ title: stringType().min(1).max(256).optional().describe("New quest title"),
18909
+ description: stringType().max(2e3).nullable().optional().describe("New description"),
18910
+ type: enumType(["daily", "weekly", "epic", "bounty", "chain", "ritual"]).optional().describe("New quest type"),
18911
+ difficulty: enumType(["easy", "medium", "hard", "epic"]).optional().describe("New difficulty"),
18912
+ skillTags: arrayType(stringType()).optional().describe("Replace skill tags"),
18913
+ baseXp: numberType().int().positive().optional().describe("New base XP (before multipliers)"),
18914
+ scheduleCron: stringType().nullable().optional().describe("New cron schedule (null to remove)")
18915
+ }).refine(
18916
+ (v) => v.title !== void 0 || v.description !== void 0 || v.type !== void 0 || v.difficulty !== void 0 || v.skillTags !== void 0 || v.baseXp !== void 0 || v.scheduleCron !== void 0,
18917
+ { message: "Provide at least one field to update." }
18918
+ ),
18919
+ execute: async ({
18920
+ questSearch,
18921
+ title,
18922
+ description,
18923
+ type,
18924
+ difficulty,
18925
+ skillTags,
18926
+ baseXp,
18927
+ scheduleCron
18928
+ }) => {
18929
+ const trust = requireTrust(ctx, "update_quest");
18930
+ if (trust.denied) return { error: trust.error };
18931
+ const quest = await findQuestByPrefix(ctx.db, ctx.userId, questSearch);
18932
+ if (!quest) return { error: `No quest matching "${questSearch}"` };
18933
+ const updated = await updateQuest(ctx.db, quest.id, ctx.userId, {
18934
+ ...title !== void 0 ? { title } : {},
18935
+ ...description !== void 0 ? { description } : {},
18936
+ ...type !== void 0 ? { type } : {},
18937
+ ...difficulty !== void 0 ? { difficulty } : {},
18938
+ ...skillTags !== void 0 ? { skillTags } : {},
18939
+ ...baseXp !== void 0 ? { baseXp } : {},
18940
+ ...scheduleCron !== void 0 ? { scheduleCron } : {}
18941
+ });
18942
+ if (!updated) return { error: "Failed to update quest." };
18943
+ return {
18944
+ ok: true,
18945
+ id: updated.id.slice(0, 8),
18946
+ title: updated.title,
18947
+ type: updated.type,
18948
+ difficulty: updated.difficulty,
18949
+ skillTags: updated.skillTags,
18950
+ baseXp: updated.baseXp
18951
+ };
18952
+ }
18953
+ }),
18954
+ activate_quest: tool({
18955
+ 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.",
18956
+ inputSchema: objectType({
18957
+ questSearch: stringType().describe("Quest ID prefix or title substring")
18958
+ }),
18959
+ execute: async ({ questSearch }) => {
18960
+ const trust = requireTrust(ctx, "activate_quest");
18961
+ if (trust.denied) return { error: trust.error };
18962
+ const quest = await findQuestByPrefix(ctx.db, ctx.userId, questSearch);
18963
+ if (!quest) return { error: `No quest matching "${questSearch}"` };
18964
+ if (quest.status === "active") return { error: "Quest is already active." };
18965
+ if (quest.status === "completed")
18966
+ return { error: "Quest is completed — cannot reactivate." };
18967
+ if (quest.status === "abandoned")
18968
+ return { error: "Quest is abandoned — cannot reactivate." };
18969
+ const active = await listQuestsByUser(ctx.db, ctx.userId, ["active"]);
18970
+ if (active.length >= 5) {
18971
+ return { error: "Max 5 active quests. Complete or abandon one first." };
18972
+ }
18973
+ await updateQuestStatus(ctx.db, quest.id, ctx.userId, "active");
18974
+ return { ok: true, quest: quest.title, status: "active" };
18975
+ }
18976
+ }),
18977
+ delete_insight: tool({
18978
+ 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.",
18979
+ inputSchema: objectType({
18980
+ insightId: stringType().min(1).describe("Insight ID (full UUID or short ID prefix)")
18981
+ }),
18982
+ execute: async ({ insightId }) => {
18983
+ const trust = requireTrust(ctx, "delete_insight");
18984
+ if (trust.denied) return { error: trust.error };
18985
+ const insights = await listCompanionInsights(ctx.db, ctx.userId, 200);
18986
+ const match = insights.find((i) => i.id === insightId || i.id.startsWith(insightId));
18987
+ if (!match) return { error: `No insight matching "${insightId}"` };
18988
+ if (match.source === "user-stated") {
18989
+ return {
18990
+ error: "Cannot delete user-stated insights. These are facts you provided — they can only be updated, not removed."
18991
+ };
18992
+ }
18993
+ const deleted = await deleteCompanionInsight(ctx.db, match.id, ctx.userId);
18994
+ if (!deleted) return { error: "Failed to delete insight." };
18995
+ return { ok: true, deleted: true, content: match.content };
18996
+ }
18997
+ }),
18998
+ update_companion_mode: tool({
18999
+ description: "Change the companion operating mode. 'suggest' = propose only, 'assist' = act on explicit requests, 'auto' = act proactively.",
19000
+ inputSchema: objectType({
19001
+ mode: enumType(["off", "suggest", "assist", "auto"]).describe("New companion mode")
19002
+ }),
19003
+ execute: async ({ mode }) => {
19004
+ const trust = requireTrust(ctx, "update_companion_mode");
19005
+ if (trust.denied) return { error: trust.error };
19006
+ const updated = await updateCompanionMode(ctx.db, ctx.userId, mode);
19007
+ return { ok: true, mode: updated.mode };
19008
+ }
19009
+ }),
18614
19010
  bash: tool({
18615
19011
  description: "Execute a shell command. Returns stdout, stderr, and exit code. Both stdout and stderr are capped at 50KB.",
18616
19012
  inputSchema: objectType({
@@ -19009,7 +19405,8 @@ const streamMessage = createServerFn({
19009
19405
  db,
19010
19406
  userId,
19011
19407
  timerPath,
19012
- config: config2
19408
+ config: config2,
19409
+ trustLevel: companionRow?.trustLevel ?? 0
19013
19410
  };
19014
19411
  const promptCtxBase = {
19015
19412
  user,