flyspace-cli 0.1.14 → 0.1.16

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.
@@ -163,9 +163,9 @@ async function refreshAutomationRegistry() {
163
163
  return;
164
164
  }
165
165
  const next = data ?? [];
166
- const snapshot = JSON.stringify(next, (key, value) => key === "updated_at" ? void 0 : value);
167
- if (snapshot === lastSnapshot) return;
168
- lastSnapshot = snapshot;
166
+ const snapshot2 = JSON.stringify(next, (key, value) => key === "updated_at" ? void 0 : value);
167
+ if (snapshot2 === lastSnapshot) return;
168
+ lastSnapshot = snapshot2;
169
169
  rows = next;
170
170
  for (const cb of changeListeners) {
171
171
  try {
@@ -263,7 +263,7 @@ function stopAutomationRegistryPolling() {
263
263
  }
264
264
 
265
265
  // src/channels.ts
266
- import { existsSync as existsSync2, mkdirSync, statSync } from "fs";
266
+ import { existsSync as existsSync2, mkdirSync, realpathSync, statSync } from "fs";
267
267
  import { homedir } from "os";
268
268
  import { isAbsolute, resolve as resolve2, sep } from "path";
269
269
  function isLogicalCwd(p) {
@@ -272,11 +272,22 @@ function isLogicalCwd(p) {
272
272
  function logicalRoot() {
273
273
  return process.env["FLYSPACE_ROOT"] || resolve2(homedir(), "Flyspace");
274
274
  }
275
+ var workspaceRoots = /* @__PURE__ */ new Map();
276
+ async function refreshWorkspaceRoots() {
277
+ const { data, error } = await getSupabaseClient().from("workspaces").select("slug, root_path");
278
+ if (error) return;
279
+ workspaceRoots = new Map(
280
+ (data ?? []).filter((w) => w.root_path).map((w) => [w.slug, expandPath(w.root_path)])
281
+ );
282
+ }
275
283
  function resolveLogicalCwd(p) {
276
- return resolve2(logicalRoot(), p);
284
+ const [slug, name] = p.split("/");
285
+ const root = workspaceRoots.get(slug);
286
+ return root ? resolve2(root, name) : resolve2(logicalRoot(), p);
277
287
  }
278
288
  function ensureLogicalCwdDir(cwd) {
279
- if (!cwd.startsWith(logicalRoot() + sep) || existsSync2(cwd)) return;
289
+ const roots = [logicalRoot(), ...workspaceRoots.values()];
290
+ if (!roots.some((r) => cwd.startsWith(r + sep)) || existsSync2(cwd)) return;
280
291
  mkdirSync(cwd, { recursive: true });
281
292
  }
282
293
  function expandPath(p) {
@@ -358,18 +369,21 @@ function indexRegistry(list) {
358
369
  );
359
370
  }
360
371
  async function refreshChannelRegistry() {
372
+ const rootsBefore = JSON.stringify([...workspaceRoots]);
373
+ await refreshWorkspaceRoots();
374
+ if (JSON.stringify([...workspaceRoots]) !== rootsBefore) lastSnapshot2 = void 0;
361
375
  const { data, error } = await getSupabaseClient().from("channels").select("*").order("position", { ascending: true });
362
376
  if (error) {
363
377
  console.error("[channels] refreshChannelRegistry failed:", error.message);
364
378
  return;
365
379
  }
366
380
  const rows2 = data ?? [];
367
- const snapshot = JSON.stringify(
381
+ const snapshot2 = JSON.stringify(
368
382
  rows2,
369
383
  (key, value) => key === "updated_at" || key === "created_at" ? void 0 : value
370
384
  );
371
- if (snapshot === lastSnapshot2) return;
372
- lastSnapshot2 = snapshot;
385
+ if (snapshot2 === lastSnapshot2) return;
386
+ lastSnapshot2 = snapshot2;
373
387
  indexRegistry(rows2.map(rowToChannel));
374
388
  for (const cb of changeListeners2) {
375
389
  try {
@@ -522,7 +536,44 @@ function toRow(input) {
522
536
  if (input.archivedAt !== void 0) row["archived_at"] = input.archivedAt;
523
537
  return row;
524
538
  }
539
+ var ChannelCwdError = class extends Error {
540
+ constructor(message) {
541
+ super(message);
542
+ this.name = "ChannelCwdError";
543
+ }
544
+ };
545
+ function realpathOrSelf(p) {
546
+ try {
547
+ return realpathSync(p);
548
+ } catch {
549
+ return p;
550
+ }
551
+ }
552
+ async function assertCwdInWorkspace(profile, cwd) {
553
+ if (isLogicalCwd(cwd)) {
554
+ if (cwd.split("/")[0] !== profile) {
555
+ throw new ChannelCwdError(`Folder "${cwd}" belongs to another workspace.`);
556
+ }
557
+ return cwd;
558
+ }
559
+ const { data, error } = await getSupabaseClient().from("workspaces").select("slug, root_path");
560
+ if (error) throw new Error(error.message);
561
+ const workspaces = data ?? [];
562
+ const ownRoot = workspaces.find((w) => w.slug === profile)?.root_path;
563
+ const home = ownRoot?.match(/^(\/Users\/[^/]+|\/home\/[^/]+|\/root)(?=\/|$)/)?.[1];
564
+ const absolute = home && (cwd === "~" || cwd.startsWith("~/")) ? resolve2(home, cwd.slice(2)) : expandPath(cwd);
565
+ const path = realpathOrSelf(absolute);
566
+ const roots = workspaces.filter((w) => w.root_path).map((w) => ({ slug: w.slug, root: realpathOrSelf(expandPath(w.root_path)) }));
567
+ const owner = roots.filter(({ root }) => path === root || path.startsWith(root + sep)).sort((a, b) => b.root.length - a.root.length)[0];
568
+ if (owner ? owner.slug !== profile : roots.some((r) => r.slug === profile)) {
569
+ throw new ChannelCwdError(
570
+ owner ? `Folder "${cwd}" belongs to the "${owner.slug}" workspace.` : `Folder "${cwd}" is outside this workspace's folder.`
571
+ );
572
+ }
573
+ return absolute;
574
+ }
525
575
  async function createChannel(input) {
576
+ input = { ...input, cwd: await assertCwdInWorkspace(getProfile(), input.cwd) };
526
577
  const { data, error } = await getSupabaseClient().from("channels").insert({ ...toRow(input), profile: getProfile() }).select().single();
527
578
  if (error) throw new Error(error.message);
528
579
  const channel = rowToChannel(data);
@@ -547,6 +598,9 @@ async function updateChannelById(id, patch) {
547
598
  if (existing?.isDefault && (patch.visibility === "private" || patch.archivedAt || patch.isDefault === false || patch.name !== void 0 && patch.name.trim() !== existing.name)) {
548
599
  throw new DefaultChannelError();
549
600
  }
601
+ if (patch.cwd !== void 0) {
602
+ patch = { ...patch, cwd: await assertCwdInWorkspace(existing?.profile ?? getProfile(), patch.cwd) };
603
+ }
550
604
  const { data, error } = await getSupabaseClient().from("channels").update({ ...toRow(patch), updated_at: (/* @__PURE__ */ new Date()).toISOString() }).eq("id", id).select().single();
551
605
  if (error) throw new Error(error.message);
552
606
  await refreshChannelRegistry();
@@ -1543,9 +1597,9 @@ function isAuthError(text) {
1543
1597
  }
1544
1598
  function authErrorMessage(raw) {
1545
1599
  return `The agent can't sign in to Claude right now (the Host's login expired and could not be refreshed).
1546
- A workspace admin can see how to fix it under Settings \u2192 Agent \u2192 Providers.
1600
+ To fix it, a workspace admin can open Settings \u2192 Agent \u2192 Providers and click Sign in next to Claude Code.
1547
1601
 
1548
- Original error: ${raw}`;
1602
+ > Original error: ${raw}`;
1549
1603
  }
1550
1604
  function isTransientError(text, apiErrorStatus) {
1551
1605
  if (apiErrorStatus == null) {
@@ -2086,6 +2140,400 @@ Fix the channel's working directory in its settings (or recreate the directory),
2086
2140
  }
2087
2141
  }
2088
2142
 
2143
+ // src/feature-flags.ts
2144
+ var featureFlags = {
2145
+ "ai-cost": {
2146
+ title: "AI cost",
2147
+ description: "Shows admins what the agent has spent: a Cost section in a channel's AI overview and a per-thread cost in the thread menu. Estimated at API list rates, so on a Claude subscription it will not match your bill.",
2148
+ stage: "beta"
2149
+ },
2150
+ "read-aloud": {
2151
+ title: "Read aloud",
2152
+ description: "Listen to messages, threads and docs as audio. Still rough in places, and each playback uses text-to-speech.",
2153
+ stage: "beta"
2154
+ },
2155
+ excalidraw: {
2156
+ title: "Excalidraw",
2157
+ description: "Create and edit whiteboard drawings (.excalidraw files) in the Docs pane. Existing drawings stay viewable with this off.",
2158
+ stage: "beta"
2159
+ },
2160
+ sandbox: {
2161
+ title: "Sandbox runtime",
2162
+ description: "Run a channel agent in an isolated sandbox without Docker. Channels already on Sandbox keep working with this off.",
2163
+ stage: "beta"
2164
+ },
2165
+ dictation: {
2166
+ title: "Dictation",
2167
+ description: "A mic button in the message composer that turns speech into text as you talk. Each phrase uses speech-to-text.",
2168
+ stage: "beta"
2169
+ },
2170
+ "agent-memory": {
2171
+ title: "Agent memory",
2172
+ description: "Channel memory the agent reads every turn, saved by the agent and picked up from idle threads, with a Memory tab and an undo chip. While on, Claude's built-in auto memory is switched off so the two can be compared; turning this off keeps every memory.",
2173
+ stage: "beta"
2174
+ }
2175
+ };
2176
+ var featureFlagIds = Object.keys(featureFlags);
2177
+ function isFeatureFlagId(id) {
2178
+ return Object.hasOwn(featureFlags, id);
2179
+ }
2180
+ function isFeatureVisible(stage, devHost) {
2181
+ return stage === "beta" || stage === "dev" && devHost;
2182
+ }
2183
+ function resolveFeatureFlags(enabledIds, devHost) {
2184
+ const enabled = new Set(enabledIds);
2185
+ const out = {};
2186
+ for (const id of featureFlagIds) {
2187
+ const stage = featureFlags[id].stage;
2188
+ out[id] = stage === "ga" || isFeatureVisible(stage, devHost) && enabled.has(id);
2189
+ }
2190
+ return out;
2191
+ }
2192
+
2193
+ // src/store/feature-flag-store.ts
2194
+ async function listEnabledFeatureFlags(workspaceId) {
2195
+ const { data, error } = await getSupabaseClient().from("feature_flags").select("flag_id").eq("workspace_id", workspaceId);
2196
+ if (error) throw new Error(`listEnabledFeatureFlags(${workspaceId}): ${error.message}`);
2197
+ return (data ?? []).map((r) => r.flag_id);
2198
+ }
2199
+ async function listEnabledFeatureFlagsBySlug(slug) {
2200
+ const { data, error } = await getSupabaseClient().from("workspaces").select("id").eq("slug", slug).maybeSingle();
2201
+ if (error) throw new Error(`listEnabledFeatureFlagsBySlug(${slug}): ${error.message}`);
2202
+ return data ? listEnabledFeatureFlags(data.id) : [];
2203
+ }
2204
+ async function enableFeatureFlag(workspaceId, flagId, userId) {
2205
+ const { error } = await getSupabaseClient().from("feature_flags").upsert(
2206
+ { workspace_id: workspaceId, flag_id: flagId, enabled_by: userId },
2207
+ { onConflict: "workspace_id,flag_id" }
2208
+ );
2209
+ if (error) throw new Error(`enableFeatureFlag(${workspaceId}, ${flagId}): ${error.message}`);
2210
+ }
2211
+ async function disableFeatureFlag(workspaceId, flagId) {
2212
+ const { error } = await getSupabaseClient().from("feature_flags").delete().eq("workspace_id", workspaceId).eq("flag_id", flagId);
2213
+ if (error) throw new Error(`disableFeatureFlag(${workspaceId}, ${flagId}): ${error.message}`);
2214
+ }
2215
+
2216
+ // src/store/memory-store.ts
2217
+ var MEMORY_TYPES = ["preference", "decision", "fact", "reference"];
2218
+ var channelTarget = (channelId) => ({ scope: "channel", channelId });
2219
+ var NAME_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
2220
+ var DESCRIPTION_MAX = 200;
2221
+ var BODY_MAX = 16e3;
2222
+ var MEMORY_INDEX_MAX_CHARS = 8e3;
2223
+ function isMemoryType(value) {
2224
+ return MEMORY_TYPES.includes(value);
2225
+ }
2226
+ function validateMemoryInput(input) {
2227
+ if (!NAME_RE.test(input.name))
2228
+ throw new Error("name must be a kebab-case slug, e.g. deploy-flow");
2229
+ if (!isMemoryType(input.type)) throw new Error(`type must be one of ${MEMORY_TYPES.join(", ")}`);
2230
+ const description = input.description.trim();
2231
+ if (!description || description.includes("\n") || description.length > DESCRIPTION_MAX) {
2232
+ throw new Error(`description must be one line of at most ${DESCRIPTION_MAX} characters`);
2233
+ }
2234
+ const body = input.body.trim();
2235
+ if (!body || body.length > BODY_MAX) {
2236
+ throw new Error(`body must be 1 to ${BODY_MAX} characters`);
2237
+ }
2238
+ }
2239
+ function rowToMemory(row) {
2240
+ return {
2241
+ id: row["id"],
2242
+ scope: row["scope"] ?? "channel",
2243
+ workspaceId: row["workspace_id"],
2244
+ channelId: row["channel_id"] ?? null,
2245
+ userId: row["user_id"] ?? null,
2246
+ name: row["name"],
2247
+ description: row["description"],
2248
+ type: row["type"],
2249
+ body: row["body"],
2250
+ sourceThreadId: row["source_thread_id"] ?? null,
2251
+ createdBy: row["created_by"],
2252
+ createdAt: row["created_at"],
2253
+ updatedAt: row["updated_at"],
2254
+ lastUsedAt: row["last_used_at"] ?? null
2255
+ };
2256
+ }
2257
+ function targetRow(target) {
2258
+ switch (target.scope) {
2259
+ case "channel":
2260
+ return { scope: "channel", channel_id: target.channelId };
2261
+ case "workspace":
2262
+ return { scope: "workspace", workspace_id: target.workspaceId };
2263
+ case "user":
2264
+ return { scope: "user", workspace_id: target.workspaceId, user_id: target.userId };
2265
+ }
2266
+ }
2267
+ function inTarget(query, target) {
2268
+ let q = query;
2269
+ for (const [column, value] of Object.entries(targetRow(target))) q = q.eq(column, value);
2270
+ return q;
2271
+ }
2272
+ var describeTarget = (t) => t.scope === "channel" ? t.channelId : t.scope === "workspace" ? `workspace ${t.workspaceId}` : `user ${t.userId}`;
2273
+ async function listMemories(target) {
2274
+ const { data, error } = await inTarget(getSupabaseClient().from("memories").select("*"), target).order("updated_at", { ascending: false });
2275
+ if (error) throw new Error(`listMemories(${describeTarget(target)}): ${error.message}`);
2276
+ return (data ?? []).map(rowToMemory);
2277
+ }
2278
+ async function getMemory(target, name) {
2279
+ const { data, error } = await inTarget(getSupabaseClient().from("memories").select("*"), target).eq("name", name).maybeSingle();
2280
+ if (error) throw new Error(`getMemory(${describeTarget(target)}, ${name}): ${error.message}`);
2281
+ return data ? rowToMemory(data) : null;
2282
+ }
2283
+ async function saveMemory(target, input, actor) {
2284
+ validateMemoryInput(input);
2285
+ const supabase = getSupabaseClient();
2286
+ const existing = await getMemory(target, input.name);
2287
+ const fields = {
2288
+ description: input.description.trim(),
2289
+ type: input.type,
2290
+ body: input.body.trim(),
2291
+ source_thread_id: input.sourceThreadId ?? existing?.sourceThreadId ?? null,
2292
+ created_by: input.createdBy
2293
+ };
2294
+ if (existing && sameContent(existing, fields)) {
2295
+ return { memory: existing, created: false, changed: false };
2296
+ }
2297
+ const { data, error } = existing ? await supabase.from("memories").update({ ...fields, updated_at: (/* @__PURE__ */ new Date()).toISOString() }).eq("id", existing.id).select("*").single() : await supabase.from("memories").insert({ ...fields, ...targetRow(target), name: input.name }).select("*").single();
2298
+ if (error)
2299
+ throw new Error(`saveMemory(${describeTarget(target)}, ${input.name}): ${error.message}`);
2300
+ const memory = rowToMemory(data);
2301
+ await logMemoryEvent({
2302
+ memory,
2303
+ action: existing ? "update" : "create",
2304
+ before: existing ? snapshot(existing) : null,
2305
+ after: snapshot(memory),
2306
+ actor,
2307
+ threadId: input.sourceThreadId ?? null
2308
+ });
2309
+ return { memory, created: !existing, changed: true };
2310
+ }
2311
+ async function updateMemory(target, memoryId, patch, actor) {
2312
+ const existing = (await listMemories(target)).find((m) => m.id === memoryId);
2313
+ if (!existing) return null;
2314
+ const { memory } = await saveMemory(
2315
+ target,
2316
+ {
2317
+ name: existing.name,
2318
+ description: patch.description ?? existing.description,
2319
+ type: patch.type ?? existing.type,
2320
+ body: patch.body ?? existing.body,
2321
+ createdBy: existing.createdBy
2322
+ },
2323
+ actor
2324
+ );
2325
+ return memory;
2326
+ }
2327
+ async function searchMemories(target, query) {
2328
+ const words = query.replaceAll(/[,()*%\\]/g, " ").split(/\s+/).filter(Boolean);
2329
+ if (!words.length) return [];
2330
+ let request = inTarget(getSupabaseClient().from("memories").select("*"), target);
2331
+ for (const word of words) {
2332
+ request = request.or(`name.ilike.*${word}*,description.ilike.*${word}*,body.ilike.*${word}*`);
2333
+ }
2334
+ const { data, error } = await request.order("updated_at", { ascending: false }).limit(20);
2335
+ if (error) throw new Error(`searchMemories(${describeTarget(target)}): ${error.message}`);
2336
+ return (data ?? []).map(rowToMemory);
2337
+ }
2338
+ var USED_GRANULARITY_MS = 24 * 60 * 6e4;
2339
+ async function markMemoryUsed(memory) {
2340
+ const cutoff = new Date(Date.now() - USED_GRANULARITY_MS).toISOString();
2341
+ if (memory.lastUsedAt && memory.lastUsedAt > cutoff) return;
2342
+ const { error } = await getSupabaseClient().from("memories").update({ last_used_at: (/* @__PURE__ */ new Date()).toISOString() }).eq("id", memory.id).or(`last_used_at.is.null,last_used_at.lt.${cutoff}`);
2343
+ if (error) console.error(`[memory] markMemoryUsed(${memory.id}) failed:`, error.message);
2344
+ }
2345
+ async function deleteMemory(target, idOrName, actor, threadId) {
2346
+ const { data, error } = await inTarget(getSupabaseClient().from("memories").delete(), target).eq(isUuid(idOrName) ? "id" : "name", idOrName).select("*");
2347
+ if (error)
2348
+ throw new Error(`deleteMemory(${describeTarget(target)}, ${idOrName}): ${error.message}`);
2349
+ const deleted = (data ?? []).map(rowToMemory);
2350
+ for (const memory of deleted) {
2351
+ await logMemoryEvent({
2352
+ memory,
2353
+ action: "delete",
2354
+ before: snapshot(memory),
2355
+ after: null,
2356
+ actor,
2357
+ threadId: threadId ?? null
2358
+ });
2359
+ }
2360
+ return deleted.length > 0;
2361
+ }
2362
+ async function promoteChannelMemory(channelId, memoryId) {
2363
+ const supabase = getSupabaseClient();
2364
+ const existing = (await listChannelMemories(channelId)).find((m) => m.id === memoryId);
2365
+ if (!existing) return { ok: false, reason: "not-found" };
2366
+ if (await getMemory({ scope: "workspace", workspaceId: existing.workspaceId }, existing.name)) {
2367
+ return { ok: false, reason: "name-taken" };
2368
+ }
2369
+ const { data, error } = await supabase.from("memories").update({ scope: "workspace", channel_id: null, updated_at: (/* @__PURE__ */ new Date()).toISOString() }).eq("id", memoryId).eq("scope", "channel").select("*").maybeSingle();
2370
+ if (error) throw new Error(`promoteChannelMemory(${memoryId}): ${error.message}`);
2371
+ if (!data) return { ok: false, reason: "not-found" };
2372
+ const memory = rowToMemory(data);
2373
+ await logMemoryEvent({ memory, action: "create", before: null, after: snapshot(memory), actor: "user", threadId: null });
2374
+ return { ok: true, memory };
2375
+ }
2376
+ var listChannelMemories = (channelId) => listMemories(channelTarget(channelId));
2377
+ var getChannelMemory = (channelId, name) => getMemory(channelTarget(channelId), name);
2378
+ var saveChannelMemory = ({ channelId, ...fields }, actor) => saveMemory(channelTarget(channelId), fields, actor);
2379
+ var updateChannelMemory = (channelId, memoryId, patch, actor) => updateMemory(channelTarget(channelId), memoryId, patch, actor);
2380
+ var deleteChannelMemory = (channelId, idOrName, actor, threadId) => deleteMemory(channelTarget(channelId), idOrName, actor, threadId);
2381
+ function unchangedSince(current, after) {
2382
+ return current && after ? sameContent(current, after) : !current && !after;
2383
+ }
2384
+ function snapshot(m) {
2385
+ return {
2386
+ description: m.description,
2387
+ type: m.type,
2388
+ body: m.body,
2389
+ sourceThreadId: m.sourceThreadId,
2390
+ createdBy: m.createdBy
2391
+ };
2392
+ }
2393
+ function sameContent(m, other) {
2394
+ return m.description === other.description && m.type === other.type && m.body === other.body;
2395
+ }
2396
+ async function logMemoryEvent(e) {
2397
+ const { error } = await getSupabaseClient().from("memory_events").insert({
2398
+ channel_id: e.memory.channelId,
2399
+ // A channel event's workspace comes from its channel (0100 trigger).
2400
+ ...e.memory.channelId ? {} : { workspace_id: e.memory.workspaceId },
2401
+ scope: e.memory.scope,
2402
+ user_id: e.memory.userId,
2403
+ memory_id: e.action === "delete" ? null : e.memory.id,
2404
+ thread_id: e.threadId,
2405
+ name: e.memory.name,
2406
+ action: e.action,
2407
+ before: e.before,
2408
+ after: e.after,
2409
+ actor: e.actor
2410
+ });
2411
+ if (error) console.error(`[memory] event log failed for ${e.memory.name}:`, error.message);
2412
+ }
2413
+ function rowToEvent(row) {
2414
+ return {
2415
+ id: row["id"],
2416
+ channelId: row["channel_id"],
2417
+ memoryId: row["memory_id"] ?? null,
2418
+ threadId: row["thread_id"] ?? null,
2419
+ name: row["name"],
2420
+ action: row["action"],
2421
+ before: row["before"] ?? null,
2422
+ after: row["after"] ?? null,
2423
+ actor: row["actor"],
2424
+ createdAt: row["created_at"],
2425
+ undoneAt: row["undone_at"] ?? null
2426
+ };
2427
+ }
2428
+ async function listMemoryEvents(channelId, filter = {}) {
2429
+ const supabase = getSupabaseClient();
2430
+ let query = supabase.from("memory_events").select("*").eq("channel_id", channelId);
2431
+ if (filter.threadId) query = query.eq("thread_id", filter.threadId);
2432
+ if (filter.actor) query = query.eq("actor", filter.actor);
2433
+ const { data, error } = await query.order("created_at", { ascending: false }).limit(filter.limit ?? 50);
2434
+ if (error) throw new Error(`listMemoryEvents(${channelId}): ${error.message}`);
2435
+ const events = (data ?? []).map(rowToEvent);
2436
+ if (!events.length) return [];
2437
+ const names = [...new Set(events.map((e) => e.name))];
2438
+ const live = await supabase.from("memories").select("*").eq("scope", "channel").eq("channel_id", channelId).in("name", names);
2439
+ if (live.error) throw new Error(`listMemoryEvents(${channelId}): ${live.error.message}`);
2440
+ const byName = new Map(
2441
+ (live.data ?? []).map((r) => [
2442
+ r["name"],
2443
+ rowToMemory(r)
2444
+ ])
2445
+ );
2446
+ return events.map((e) => {
2447
+ const current = byName.get(e.name) ?? null;
2448
+ const unchanged = unchangedSince(current, e.after);
2449
+ return {
2450
+ ...e,
2451
+ currentState: unchanged ? "current" : current ? "changed" : "deleted",
2452
+ undoable: !e.undoneAt && unchanged
2453
+ };
2454
+ });
2455
+ }
2456
+ var listThreadMemoryEvents = (channelId, threadId) => listMemoryEvents(channelId, { threadId, actor: "agent" });
2457
+ async function undoMemoryEvent(channelId, eventId) {
2458
+ const supabase = getSupabaseClient();
2459
+ const { data, error } = await supabase.from("memory_events").select("*").eq("id", eventId).eq("channel_id", channelId).maybeSingle();
2460
+ if (error) throw new Error(`undoMemoryEvent(${eventId}): ${error.message}`);
2461
+ if (!data) return "not-found";
2462
+ const event = rowToEvent(data);
2463
+ if (event.undoneAt) return "already-undone";
2464
+ const current = await getChannelMemory(channelId, event.name);
2465
+ if (!unchangedSince(current, event.after)) return "conflict";
2466
+ const claimed = await supabase.from("memory_events").update({ undone_at: (/* @__PURE__ */ new Date()).toISOString() }).eq("id", eventId).is("undone_at", null).select("id");
2467
+ if (claimed.error) throw new Error(`undoMemoryEvent(${eventId}): ${claimed.error.message}`);
2468
+ if (!(claimed.data ?? []).length) return "already-undone";
2469
+ const before = event.before;
2470
+ const restore = before ? {
2471
+ description: before.description,
2472
+ type: before.type,
2473
+ body: before.body,
2474
+ source_thread_id: before.sourceThreadId,
2475
+ created_by: before.createdBy
2476
+ } : null;
2477
+ const { error: writeError } = !restore ? await supabase.from("memories").delete().eq("id", current.id) : current ? await supabase.from("memories").update({ ...restore, updated_at: (/* @__PURE__ */ new Date()).toISOString() }).eq("id", current.id) : await supabase.from("memories").insert({ ...restore, scope: "channel", channel_id: channelId, name: event.name });
2478
+ if (writeError) {
2479
+ await supabase.from("memory_events").update({ undone_at: null }).eq("id", eventId);
2480
+ throw new Error(`undoMemoryEvent(${eventId}): ${writeError.message}`);
2481
+ }
2482
+ return "undone";
2483
+ }
2484
+ function isUuid(value) {
2485
+ return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value);
2486
+ }
2487
+ function formatMemoryPrompt(channelId, memories, extras = {}) {
2488
+ let used = 0;
2489
+ const indexOf = (list, searchHint) => {
2490
+ const lines = [];
2491
+ let omitted = 0;
2492
+ for (const m of list) {
2493
+ const line = `- \`${m.name}\` (${m.type}): ${m.description}`;
2494
+ if (used + line.length > MEMORY_INDEX_MAX_CHARS) {
2495
+ omitted++;
2496
+ continue;
2497
+ }
2498
+ lines.push(line);
2499
+ used += line.length + 1;
2500
+ }
2501
+ if (!lines.length && !omitted) return "(none yet)";
2502
+ return lines.join("\n") + (omitted ? `
2503
+ - (${omitted} older memories not listed; find them with \`${searchHint}\`)` : "");
2504
+ };
2505
+ const user = extras.user;
2506
+ const userFlags2 = user ? `--scope user --user ${user.userId}` : "";
2507
+ const userIndex = user ? indexOf(user.memories, `memory search --channel ${channelId} ${userFlags2}`) : "";
2508
+ const channelIndex = indexOf(memories, "memory search");
2509
+ const workspaceIndex = extras.workspace?.length ? indexOf(extras.workspace, `memory search --channel ${channelId} --scope workspace`) : "";
2510
+ return [
2511
+ `## Channel memory`,
2512
+ ``,
2513
+ `This channel has a persistent memory shared by every thread in it and by every agent runner. Use it instead of any built-in memory directory of your own CLI.`,
2514
+ ``,
2515
+ `- Read a memory: \`flyspace memory get --channel ${channelId} <name>\``,
2516
+ `- Search: \`flyspace memory search --channel ${channelId} <words>\``,
2517
+ `- Save or overwrite: \`flyspace memory save --channel ${channelId} --name <kebab-slug> --type <${MEMORY_TYPES.join("|")}> --description "<one line>" --body "<text>" [--thread <thread-id>]\``,
2518
+ `- Delete one that turned out wrong: \`flyspace memory delete --channel ${channelId} <name> [--thread <thread-id>]\``,
2519
+ ``,
2520
+ `Save when someone asks you to remember something, corrects you, or a decision is made that later threads will need. Save what is not derivable from the code, git history or docs: decisions and why, preferences, corrections, where external things live. Do not save one-off details of this conversation. Check the index first and update an existing memory rather than adding a near-duplicate. Write memories in English. Pass \`--thread\` with the thread id from the \`[context: ...]\` header, so the thread shows what changed and it can be undone there.`,
2521
+ ...user ? [
2522
+ ``,
2523
+ `A personal preference of @${user.handle} (language, tone, reply length, time zone, how they like to work) belongs in their personal memory instead, which only their own turns see: add \`${userFlags2}\` to the same commands. Never save someone else's preferences there, and never put project knowledge there.`
2524
+ ] : [],
2525
+ ...workspaceIndex ? [
2526
+ ``,
2527
+ `Workspace memories below apply to every channel. They are read-only to you: read one with \`--scope workspace\` on \`get\`; a Workspace Admin manages them.`
2528
+ ] : [],
2529
+ ``,
2530
+ ...user ? [`Personal memory of @${user.handle}:`, userIndex, ``] : [],
2531
+ `Index:`,
2532
+ channelIndex,
2533
+ ...workspaceIndex ? [``, `Workspace memory:`, workspaceIndex] : []
2534
+ ].join("\n");
2535
+ }
2536
+
2089
2537
  // src/store/workspace-store.ts
2090
2538
  function rowToMembership(row) {
2091
2539
  return {
@@ -2097,7 +2545,8 @@ function rowToMembership(row) {
2097
2545
  role: row["role"],
2098
2546
  status: row["status"],
2099
2547
  invitedAt: row["invited_at"],
2100
- acceptedAt: row["accepted_at"] ?? null
2548
+ acceptedAt: row["accepted_at"] ?? null,
2549
+ memoryAutoPaused: row["memory_auto_paused"] === true
2101
2550
  };
2102
2551
  }
2103
2552
  var WORKSPACE_CACHE_TTL_MS = 6e4;
@@ -2163,6 +2612,7 @@ async function deleteWorkspace(workspace) {
2163
2612
  }
2164
2613
  async function setWorkspaceStripeSubscription(workspaceId, fields) {
2165
2614
  const { error } = await getSupabaseClient().from("workspaces").update({
2615
+ plan: fields.plan,
2166
2616
  stripe_customer_id: fields.stripeCustomerId,
2167
2617
  stripe_subscription_id: fields.stripeSubscriptionId,
2168
2618
  plan_status: fields.planStatus
@@ -2198,11 +2648,12 @@ async function getWorkspaceByStripeCustomerId(stripeCustomerId) {
2198
2648
  return data;
2199
2649
  }
2200
2650
  async function getWorkspaceBilling(workspaceId) {
2201
- const { data, error } = await getSupabaseClient().from("workspaces").select("plan_status, stripe_customer_id, stripe_subscription_id").eq("id", workspaceId).maybeSingle();
2651
+ const { data, error } = await getSupabaseClient().from("workspaces").select("plan, plan_status, stripe_customer_id, stripe_subscription_id").eq("id", workspaceId).maybeSingle();
2202
2652
  if (error) throw new Error(`getWorkspaceBilling(${workspaceId}): ${error.message}`);
2203
2653
  const row = data ?? {};
2204
2654
  return {
2205
2655
  planStatus: row["plan_status"] ?? null,
2656
+ plan: row["plan"] ?? null,
2206
2657
  stripeCustomerId: row["stripe_customer_id"] ?? null,
2207
2658
  stripeSubscriptionId: row["stripe_subscription_id"] ?? null
2208
2659
  };
@@ -2251,6 +2702,10 @@ async function listMembershipsByEmail(email) {
2251
2702
  if (error) throw new Error(`listMembershipsByEmail: ${error.message}`);
2252
2703
  return (data ?? []).map(rowToMembership);
2253
2704
  }
2705
+ async function setMemoryAutoPaused(workspaceId, userId, paused) {
2706
+ const { error } = await getSupabaseClient().from("memberships").update({ memory_auto_paused: paused }).eq("workspace_id", workspaceId).eq("user_id", userId);
2707
+ if (error) throw new Error(`setMemoryAutoPaused(${workspaceId}): ${error.message}`);
2708
+ }
2254
2709
  async function listMemberships(workspaceId) {
2255
2710
  const { data, error } = await getSupabaseClient().from("memberships").select("*").eq("workspace_id", workspaceId).order("invited_at", { ascending: true });
2256
2711
  if (error) throw new Error(`listMemberships(${workspaceId}): ${error.message}`);
@@ -2374,6 +2829,37 @@ async function setAgentPaused(workspaceId, paused) {
2374
2829
  if (error) throw new Error(`setAgentPaused(${workspaceId}): ${error.message}`);
2375
2830
  }
2376
2831
 
2832
+ // src/store/memory-context.ts
2833
+ async function loadAgentMemory(profile, channelId, opts = {}) {
2834
+ const enabled = await listEnabledFeatureFlagsBySlug(profile).then((ids) => resolveFeatureFlags(ids, false)["agent-memory"]).catch((err) => {
2835
+ console.error("[memory] feature flag lookup failed:", err.message);
2836
+ return false;
2837
+ });
2838
+ if (!enabled || opts.readable === false || !channelId || !isUuid(channelId)) return { enabled };
2839
+ const memoryPrompt = await buildPrompt(profile, channelId, opts).catch((err) => {
2840
+ console.error("[memory] memory lookup failed:", err.message);
2841
+ return void 0;
2842
+ });
2843
+ return memoryPrompt ? { enabled, memoryPrompt } : { enabled };
2844
+ }
2845
+ async function buildPrompt(profile, channelId, opts) {
2846
+ const workspace = await getWorkspaceBySlug(profile);
2847
+ const handle = opts.senderHandle?.toLowerCase();
2848
+ const sender = workspace && handle ? (await listMemberships(workspace.id)).find(
2849
+ (m) => m.userId && m.handle.toLowerCase() === handle
2850
+ ) : void 0;
2851
+ const [channel, shared, own] = await Promise.all([
2852
+ listMemories({ scope: "channel", channelId }),
2853
+ workspace ? listMemories({ scope: "workspace", workspaceId: workspace.id }) : [],
2854
+ workspace && sender ? listMemories({ scope: "user", workspaceId: workspace.id, userId: sender.userId }) : []
2855
+ ]);
2856
+ const extras = {
2857
+ workspace: shared,
2858
+ ...sender ? { user: { userId: sender.userId, handle: sender.handle, memories: own } } : {}
2859
+ };
2860
+ return formatMemoryPrompt(channelId, channel, extras);
2861
+ }
2862
+
2377
2863
  // src/dashboard/settings.ts
2378
2864
  import { readFileSync as readFileSync4, writeFileSync as writeFileSync3, existsSync as existsSync9, mkdirSync as mkdirSync6, rmSync as rmSync2 } from "fs";
2379
2865
  import { dirname, resolve as resolve5 } from "path";
@@ -3739,17 +4225,21 @@ async function runAutomation(opts) {
3739
4225
 
3740
4226
  `;
3741
4227
  try {
4228
+ const memory = await loadAgentMemory(getProfile(), channel && findChannel(channel)?.id);
3742
4229
  const useCodex = (explicitModel ? runnerForModel(explicitModel) === "codex" : runnerForModel(loadDashboardSettings().agent) === "codex") && !requiresClaudeAutomationRunner(prompt);
3743
4230
  const response = useCodex ? await askCodex(automationPrefix + prompt, cwd, {
3744
4231
  ...explicitModel && runnerForModel(explicitModel) === "codex" ? { model: explicitModel } : codexModel?.trim() ? { model: codexModel.trim() } : {},
3745
4232
  // Same reason as the interactive paths: no --append-system-prompt on
3746
4233
  // Codex, so the persona and channel instructions go in the prompt.
3747
- systemPrompt: buildSystemPrompt(
3748
- botName,
3749
- void 0,
3750
- resolveChannelPrompt(channel),
3751
- resolveSelectedOutputStyle(claudeConfigDir)?.body
3752
- ),
4234
+ systemPrompt: [
4235
+ buildSystemPrompt(
4236
+ botName,
4237
+ void 0,
4238
+ resolveChannelPrompt(channel),
4239
+ resolveSelectedOutputStyle(claudeConfigDir)?.body
4240
+ ),
4241
+ memory.memoryPrompt
4242
+ ].filter(Boolean).join("\n\n"),
3753
4243
  // Same attribution as the Claude branch below — no userId, the
3754
4244
  // schedule's name is the label.
3755
4245
  usageContext: { source: "automation", channelId: channel, label: name }
@@ -3763,6 +4253,8 @@ async function runAutomation(opts) {
3763
4253
  model,
3764
4254
  maxBudgetUsd,
3765
4255
  channelPrompt: resolveChannelPrompt(channel),
4256
+ ...memory.memoryPrompt ? { memoryPrompt: memory.memoryPrompt } : {},
4257
+ ...memory.enabled ? { disableAutoMemory: true } : {},
3766
4258
  // No userId: nobody triggered this. The schedule's name is the
3767
4259
  // label, which is what makes "which automation is burning tokens"
3768
4260
  // answerable without joining anything.
@@ -4085,6 +4577,11 @@ import { CronExpressionParser } from "cron-parser";
4085
4577
  var Scheduler = class {
4086
4578
  jobs = /* @__PURE__ */ new Map();
4087
4579
  caughtUp = /* @__PURE__ */ new Set();
4580
+ // A run is only recorded when it finishes, so while one is in flight the
4581
+ // store still shows the previous tick. Without this guard, a registry
4582
+ // reload during a long run re-registers the schedule and catchup starts a
4583
+ // second, overlapping copy (sah-budget-fetch ran twice a day this way).
4584
+ running = /* @__PURE__ */ new Set();
4088
4585
  store;
4089
4586
  constructor(store) {
4090
4587
  this.store = store;
@@ -4136,6 +4633,11 @@ var Scheduler = class {
4136
4633
  wrappedRun();
4137
4634
  }
4138
4635
  async runAndRecord(name, runFn) {
4636
+ if (this.running.has(name)) {
4637
+ console.log(`[scheduler] ${name} is still running, skipping overlapping run`);
4638
+ return;
4639
+ }
4640
+ this.running.add(name);
4139
4641
  const startedAt = /* @__PURE__ */ new Date();
4140
4642
  const elapsed = () => Date.now() - startedAt.getTime();
4141
4643
  try {
@@ -4144,6 +4646,8 @@ var Scheduler = class {
4144
4646
  } catch (err) {
4145
4647
  console.error(`[scheduler] Error in ${name}:`, err);
4146
4648
  this.store?.recordRun(name, "error", startedAt, elapsed());
4649
+ } finally {
4650
+ this.running.delete(name);
4147
4651
  }
4148
4652
  }
4149
4653
  unregister(name) {
@@ -8376,6 +8880,7 @@ export {
8376
8880
  warnOnBrokenChannelCwds,
8377
8881
  resolveCwd,
8378
8882
  resolveChannelPrompt,
8883
+ ChannelCwdError,
8379
8884
  createChannel,
8380
8885
  DefaultChannelError,
8381
8886
  updateChannelById,
@@ -8403,11 +8908,40 @@ export {
8403
8908
  modelLabel,
8404
8909
  usageLimitedUntil,
8405
8910
  usageLimitedModels,
8911
+ clearClaudeAuthFailed,
8406
8912
  claudeAuthFailed,
8407
8913
  buildSystemPrompt,
8408
8914
  docsAccessPromptNote,
8409
8915
  pinnedClaudeConfigDir,
8410
8916
  askClaude,
8917
+ featureFlags,
8918
+ featureFlagIds,
8919
+ isFeatureFlagId,
8920
+ isFeatureVisible,
8921
+ resolveFeatureFlags,
8922
+ listEnabledFeatureFlags,
8923
+ enableFeatureFlag,
8924
+ disableFeatureFlag,
8925
+ MEMORY_TYPES,
8926
+ isMemoryType,
8927
+ validateMemoryInput,
8928
+ listMemories,
8929
+ getMemory,
8930
+ saveMemory,
8931
+ updateMemory,
8932
+ searchMemories,
8933
+ markMemoryUsed,
8934
+ deleteMemory,
8935
+ promoteChannelMemory,
8936
+ listChannelMemories,
8937
+ getChannelMemory,
8938
+ saveChannelMemory,
8939
+ updateChannelMemory,
8940
+ deleteChannelMemory,
8941
+ listMemoryEvents,
8942
+ listThreadMemoryEvents,
8943
+ undoMemoryEvent,
8944
+ isUuid,
8411
8945
  getWorkspaceBySlug,
8412
8946
  getWorkspaceById,
8413
8947
  listWorkspaces,
@@ -8427,6 +8961,7 @@ export {
8427
8961
  getMembershipByEmail,
8428
8962
  getMembershipByUserId,
8429
8963
  listMembershipsByEmail,
8964
+ setMemoryAutoPaused,
8430
8965
  listMemberships,
8431
8966
  createMembership,
8432
8967
  HANDLE_PATTERN,
@@ -8443,6 +8978,7 @@ export {
8443
8978
  setAllowGuestAccess,
8444
8979
  getAgentPaused,
8445
8980
  setAgentPaused,
8981
+ loadAgentMemory,
8446
8982
  DEFAULT_TITLE_RULES,
8447
8983
  extractCategories,
8448
8984
  buildAutoTitle,