viveworker 0.5.2 → 0.5.5

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.
@@ -18,7 +18,7 @@ import { generatePairingCredentials, shouldRotatePairing, upsertEnvText } from "
18
18
  import { renderMarkdownHtml } from "./lib/markdown-render.mjs";
19
19
  import { buildAgentCard, handleA2ARequest, resolveA2ATaskDecision, completeA2ATask, failA2ATask } from "./a2a-handler.mjs";
20
20
  import { registerWithRelay, startRelayPolling, stopRelayPolling, postRelayResult, getRelayStatus, updatePublicTasksFlag } from "./a2a-relay-client.mjs";
21
- import { createMoltbookClient, readScoutState, writeScoutState, rollScoutDayIfNeeded, markPostSeen, recordComposeAttempt, writeDraft, readDraft, deleteDraft, listPendingDrafts, solveVerificationPuzzle, solvePuzzleWithLLM } from "./moltbook-api.mjs";
21
+ import { createMoltbookClient, readScoutState, writeScoutState, rollScoutDayIfNeeded, markPostSeen, recordComposeAttempt, writeDraft, readDraft, deleteDraft, listPendingDrafts, solveVerificationPuzzle, solvePuzzleWithLLM, listInboxItems } from "./moltbook-api.mjs";
22
22
 
23
23
  const __filename = fileURLToPath(import.meta.url);
24
24
  const __dirname = path.dirname(__filename);
@@ -29,7 +29,7 @@ const sessionCookieName = "viveworker_session";
29
29
  const deviceCookieName = "viveworker_device";
30
30
  const historyKinds = new Set(["completion", "assistant_final", "plan_ready", "approval", "plan", "choice", "info", "moltbook_reply", "moltbook_draft", "thread_share", "a2a_task", "a2a_task_result"]);
31
31
  const timelineMessageKinds = new Set(["user_message", "assistant_commentary", "assistant_final"]);
32
- const timelineKinds = new Set([...timelineMessageKinds, "approval", "plan", "choice", "plan_ready", "file_event", "moltbook_reply", "moltbook_draft", "thread_share", "a2a_task", "a2a_task_result"]);
32
+ const timelineKinds = new Set([...timelineMessageKinds, "ambient_suggestions", "approval", "plan", "choice", "plan_ready", "file_event", "moltbook_reply", "moltbook_draft", "thread_share", "a2a_task", "a2a_task_result"]);
33
33
  const SQLITE_COMPLETION_BATCH_SIZE = 200;
34
34
  const DEFAULT_DEVICE_TRUST_TTL_MS = 30 * 24 * 60 * 60 * 1000;
35
35
  const MAX_PAIRED_DEVICES = 200;
@@ -95,6 +95,11 @@ const runtime = {
95
95
  pairingAttemptsByRemoteAddress: new Map(),
96
96
  ipcClient: null,
97
97
  stopping: false,
98
+ // In-memory cache for the `/api/share/status` endpoint. A 30s TTL avoids
99
+ // hammering the share worker on every settings-page render. Purely runtime —
100
+ // deliberately NOT persisted via `state` since the cache is worthless after
101
+ // a restart and would just bloat the state file.
102
+ a2aShareStatusCache: null,
98
103
  };
99
104
  const state = await loadState(config.stateFile);
100
105
  runtime.threadRegistry = await loadThreadRegistry(config.threadRegistryFile);
@@ -156,8 +161,10 @@ runtime.recentHistoryItems = initialHistoryItems;
156
161
  runtime.recentTimelineEntries = initialTimelineEntries;
157
162
  state.recentHistoryItems = initialHistoryItems;
158
163
  state.recentTimelineEntries = initialTimelineEntries;
164
+ const backfilledAmbientSuggestionsStateChanged = backfillAmbientSuggestionsState({ config, runtime, state });
159
165
  const migratedRecentCodeEventsStateChanged = migrateRecentCodeEventsState({ config, runtime, state });
160
166
  const restoredTimelineImagePathsStateChanged = await backfillPersistedTimelineImagePaths({ config, runtime, state });
167
+ const backfilledMoltbookInboxChanged = await backfillMoltbookInboxHistory({ config, runtime, state });
161
168
  runtime.historyFileState.offset = Number(state.historyFileOffset) || 0;
162
169
  runtime.historyFileState.sourceFile = cleanText(state.historyFileSourceFile ?? "");
163
170
 
@@ -269,6 +276,9 @@ function buildSessionLocalePayload(config, state, deviceId) {
269
276
  moltbookEnabled: Boolean(config.moltbookApiKey),
270
277
  a2aEnabled: Boolean(config.a2aApiKey),
271
278
  a2aRelayEnabled: Boolean(config.a2aRelayUrl && config.a2aRelayUserId),
279
+ // Share piggy-backs on A2A credentials — it's "enabled" as soon as both
280
+ // halves (user id + API key) are provisioned.
281
+ a2aShareEnabled: Boolean(config.a2aRelayUserId && config.a2aApiKey),
272
282
  a2aExecutors: runtime.a2aAvailableExecutors || { codex: false, claude: false },
273
283
  a2aExecutorPreference: state.a2aExecutorPreference || "ask",
274
284
  };
@@ -282,6 +292,8 @@ function kindTitle(locale, kind) {
282
292
  return t(locale, "server.title.assistantCommentary");
283
293
  case "assistant_final":
284
294
  return t(locale, "server.title.assistantFinal");
295
+ case "ambient_suggestions":
296
+ return t(locale, "server.title.ambientSuggestions");
285
297
  case "approval":
286
298
  return t(locale, "server.title.approval");
287
299
  case "plan":
@@ -310,6 +322,117 @@ function kindTitle(locale, kind) {
310
322
  }
311
323
  }
312
324
 
325
+ function normalizeAmbientSuggestion(raw) {
326
+ if (!isPlainObject(raw)) {
327
+ return null;
328
+ }
329
+
330
+ const title = cleanText(raw.title ?? "");
331
+ const prompt = normalizeLongText(raw.prompt ?? "");
332
+ if (!title || !prompt) {
333
+ return null;
334
+ }
335
+
336
+ const threadAction = isPlainObject(raw.threadAction)
337
+ ? {
338
+ ...(cleanText(raw.threadAction.type ?? "") ? { type: cleanText(raw.threadAction.type) } : {}),
339
+ }
340
+ : null;
341
+ const appIds = Array.isArray(raw.appIds)
342
+ ? raw.appIds.map((value) => cleanText(value ?? "")).filter(Boolean)
343
+ : [];
344
+
345
+ return {
346
+ ...(cleanText(raw.id ?? "") ? { id: cleanText(raw.id) } : {}),
347
+ title,
348
+ prompt,
349
+ ...(cleanText(raw.description ?? "") ? { description: cleanText(raw.description) } : {}),
350
+ ...(threadAction && threadAction.type ? { threadAction } : {}),
351
+ ...(appIds.length > 0 ? { appIds } : {}),
352
+ };
353
+ }
354
+
355
+ function normalizeAmbientSuggestions(raw) {
356
+ if (!Array.isArray(raw)) {
357
+ return [];
358
+ }
359
+ return raw.map((entry) => normalizeAmbientSuggestion(entry)).filter(Boolean);
360
+ }
361
+
362
+ function parseAmbientSuggestionsEnvelope(value) {
363
+ const normalized = cleanText(value ?? "");
364
+ if (!normalized) {
365
+ return null;
366
+ }
367
+
368
+ let payload;
369
+ try {
370
+ payload = JSON.parse(normalized);
371
+ } catch {
372
+ return null;
373
+ }
374
+
375
+ if (!isPlainObject(payload)) {
376
+ return null;
377
+ }
378
+
379
+ const hasSuggestionsField = Array.isArray(payload.suggestions);
380
+ const hasExcludeField = Array.isArray(payload.exclude);
381
+ if (!hasSuggestionsField && !hasExcludeField) {
382
+ return null;
383
+ }
384
+
385
+ const suggestions = hasSuggestionsField ? normalizeAmbientSuggestions(payload.suggestions) : [];
386
+ if (suggestions.length > 0) {
387
+ return { type: "suggestions", suggestions };
388
+ }
389
+
390
+ return {
391
+ type: "metadata",
392
+ hasSuggestionsField,
393
+ hasExcludeField,
394
+ };
395
+ }
396
+
397
+ function parseAmbientSuggestionsPayload(value) {
398
+ const envelope = parseAmbientSuggestionsEnvelope(value);
399
+ return envelope?.type === "suggestions" ? envelope.suggestions : null;
400
+ }
401
+
402
+ function isAmbientSuggestionsMetadataPayload(value) {
403
+ return parseAmbientSuggestionsEnvelope(value) !== null;
404
+ }
405
+
406
+ function ambientSuggestionsSummary(locale, suggestions) {
407
+ const count = Math.max(0, Array.isArray(suggestions) ? suggestions.length : 0);
408
+ if (count <= 0) {
409
+ return "";
410
+ }
411
+ const firstTitle = cleanText(suggestions[0]?.title || "");
412
+ return t(locale, "summary.ambientSuggestions", {
413
+ count,
414
+ firstTitle,
415
+ more: Math.max(0, count - 1),
416
+ });
417
+ }
418
+
419
+ function ambientSuggestionsMessageText(locale, suggestions) {
420
+ if (!Array.isArray(suggestions) || suggestions.length === 0) {
421
+ return "";
422
+ }
423
+ return [
424
+ kindTitle(locale, "ambient_suggestions"),
425
+ "",
426
+ ...suggestions.flatMap((suggestion, index) => {
427
+ const description = cleanText(suggestion.description || "");
428
+ return [
429
+ `${index + 1}. ${cleanText(suggestion.title || "")}`,
430
+ ...(description ? [description] : []),
431
+ ];
432
+ }),
433
+ ].join("\n");
434
+ }
435
+
313
436
  function looksLikeGeneratedThreadTitle(value) {
314
437
  const normalized = cleanText(value || "");
315
438
  if (!normalized.includes("|")) {
@@ -2241,6 +2364,8 @@ function timelineKindSortPriority(kind) {
2241
2364
  case "plan_ready":
2242
2365
  case "choice":
2243
2366
  return 60;
2367
+ case "ambient_suggestions":
2368
+ return 52;
2244
2369
  case "file_event":
2245
2370
  return 45;
2246
2371
  case "assistant_final":
@@ -2272,6 +2397,10 @@ function normalizeTimelineEntry(raw) {
2272
2397
  const diffText = normalizeTimelineDiffText(raw.diffText ?? "");
2273
2398
  const diffSource = normalizeTimelineDiffSource(raw.diffSource ?? "");
2274
2399
  const diffCounts = diffLineCounts(diffText);
2400
+ const suggestions = normalizeAmbientSuggestions(raw.suggestions ?? []);
2401
+ if (kind === "ambient_suggestions" && suggestions.length === 0) {
2402
+ return null;
2403
+ }
2275
2404
  const summary =
2276
2405
  normalizeNotificationText(raw.summary ?? "") ||
2277
2406
  formatNotificationBody(messageText, 180) ||
@@ -2342,6 +2471,7 @@ function normalizeTimelineEntry(raw) {
2342
2471
  // A2A task-specific fields
2343
2472
  ...(raw.instruction != null ? { instruction: cleanText(raw.instruction) } : {}),
2344
2473
  ...(raw.taskStatus != null ? { taskStatus: cleanText(raw.taskStatus) } : {}),
2474
+ ...(suggestions.length > 0 ? { suggestions } : {}),
2345
2475
  };
2346
2476
  }
2347
2477
 
@@ -2516,6 +2646,9 @@ function historyItemFromEvent(event) {
2516
2646
 
2517
2647
  const kind = event.kind === "task_complete" ? "completion" : "plan_ready";
2518
2648
  const messageText = normalizeLongText(event.detailText || event.message || "");
2649
+ if (kind === "completion" && isAmbientSuggestionsMetadataPayload(messageText)) {
2650
+ return null;
2651
+ }
2519
2652
  return normalizeHistoryItem({
2520
2653
  stableId: event.id,
2521
2654
  kind,
@@ -2549,6 +2682,94 @@ function recordHistoryItem({ config, runtime, state, item }) {
2549
2682
  return changed;
2550
2683
  }
2551
2684
 
2685
+ function backfillAmbientSuggestionsState({ config, runtime, state }) {
2686
+ const nextTimelineEntries = normalizeTimelineEntries(
2687
+ (runtime.recentTimelineEntries ?? []).map((entry) => {
2688
+ const entryKind = cleanText(entry?.kind || "");
2689
+ if (entryKind === "ambient_suggestions") {
2690
+ const suggestions = normalizeAmbientSuggestions(entry?.suggestions ?? []);
2691
+ if (suggestions.length === 0) {
2692
+ return null;
2693
+ }
2694
+ return {
2695
+ ...entry,
2696
+ title: kindTitle(DEFAULT_LOCALE, "ambient_suggestions"),
2697
+ summary: ambientSuggestionsSummary(DEFAULT_LOCALE, suggestions),
2698
+ messageText: ambientSuggestionsMessageText(DEFAULT_LOCALE, suggestions),
2699
+ suggestions,
2700
+ };
2701
+ }
2702
+ if (entryKind !== "assistant_final") {
2703
+ return entry;
2704
+ }
2705
+ const envelope = parseAmbientSuggestionsEnvelope(entry?.messageText ?? "");
2706
+ if (!envelope) {
2707
+ return entry;
2708
+ }
2709
+ if (envelope.type !== "suggestions") {
2710
+ return null;
2711
+ }
2712
+ const suggestions = envelope.suggestions;
2713
+ return {
2714
+ ...entry,
2715
+ kind: "ambient_suggestions",
2716
+ title: kindTitle(DEFAULT_LOCALE, "ambient_suggestions"),
2717
+ summary: ambientSuggestionsSummary(DEFAULT_LOCALE, suggestions),
2718
+ messageText: ambientSuggestionsMessageText(DEFAULT_LOCALE, suggestions),
2719
+ suggestions,
2720
+ };
2721
+ }),
2722
+ config.maxTimelineEntries
2723
+ );
2724
+
2725
+ const nextHistoryItems = normalizeHistoryItems(
2726
+ (runtime.recentHistoryItems ?? []).filter((item) => {
2727
+ const kind = cleanText(item?.kind || "");
2728
+ if (kind !== "assistant_final" && kind !== "completion") {
2729
+ return true;
2730
+ }
2731
+ return !isAmbientSuggestionsMetadataPayload(item?.messageText ?? "");
2732
+ }),
2733
+ config.maxHistoryItems
2734
+ );
2735
+
2736
+ const timelineChanged =
2737
+ JSON.stringify(nextTimelineEntries) !== JSON.stringify(runtime.recentTimelineEntries ?? []);
2738
+ const historyChanged =
2739
+ JSON.stringify(nextHistoryItems) !== JSON.stringify(runtime.recentHistoryItems ?? []);
2740
+
2741
+ if (timelineChanged) {
2742
+ runtime.recentTimelineEntries = nextTimelineEntries;
2743
+ state.recentTimelineEntries = nextTimelineEntries;
2744
+ }
2745
+ if (historyChanged) {
2746
+ runtime.recentHistoryItems = nextHistoryItems;
2747
+ state.recentHistoryItems = nextHistoryItems;
2748
+ }
2749
+
2750
+ return timelineChanged || historyChanged;
2751
+ }
2752
+
2753
+ // Flip a previously-recorded history item's readOnly flag without disturbing
2754
+ // its ordering or other fields. Used by moltbook_reply / moltbook_draft flows
2755
+ // to transition pending → resolved so the entry starts matching the completed
2756
+ // tab's readOnly filter. Returns true if a mutation happened (caller should
2757
+ // saveState in that case).
2758
+ function flipHistoryItemReadOnly({ runtime, state, stableId, readOnly = true }) {
2759
+ const normalizedStableId = cleanText(stableId || "");
2760
+ if (!normalizedStableId) return false;
2761
+ const idx = runtime.recentHistoryItems.findIndex((entry) => entry.stableId === normalizedStableId);
2762
+ if (idx === -1) return false;
2763
+ const current = runtime.recentHistoryItems[idx];
2764
+ if (current.readOnly === readOnly) return false;
2765
+ const next = { ...current, readOnly };
2766
+ const nextItems = runtime.recentHistoryItems.slice();
2767
+ nextItems[idx] = next;
2768
+ runtime.recentHistoryItems = nextItems;
2769
+ state.recentHistoryItems = nextItems;
2770
+ return true;
2771
+ }
2772
+
2552
2773
  function recordActionHistoryItem({
2553
2774
  config,
2554
2775
  runtime,
@@ -3760,6 +3981,66 @@ async function backfillPersistedTimelineImagePaths({ config, runtime, state }) {
3760
3981
  return true;
3761
3982
  }
3762
3983
 
3984
+ // Walk ~/.viveworker/moltbook-inbox/ on startup and synthesize readOnly
3985
+ // history entries for items the CLI / reconcile marked as replied or skipped.
3986
+ // Without this, an inbox item that was handled before the history hooks
3987
+ // existed — or that was resolved purely via the CLI path, which doesn't touch
3988
+ // the bridge's in-memory maps — never makes it into `recentHistoryItems` and
3989
+ // so never appears in the completed tab. We match the same stableId / token
3990
+ // scheme as the live push (sourceId = `comment:<commentId>`) so a future
3991
+ // watcher push for the same commentId replaces this entry cleanly rather than
3992
+ // creating a duplicate.
3993
+ async function backfillMoltbookInboxHistory({ config, runtime, state }) {
3994
+ let changed = false;
3995
+ try {
3996
+ const inboxItems = await listInboxItems();
3997
+ for (const inbox of inboxItems) {
3998
+ const status = String(inbox?.status || "").toLowerCase();
3999
+ if (status !== "replied" && status !== "skipped") continue;
4000
+ const commentId = String(inbox?.commentId || "");
4001
+ if (!commentId) continue;
4002
+ const sourceId = `comment:${commentId}`;
4003
+ const stableId = `moltbook_reply:${sourceId}`;
4004
+ if (runtime.recentHistoryItems.some((entry) => entry.stableId === stableId)) continue;
4005
+ const token = historyToken(`moltbook:${sourceId}`);
4006
+ const createdAtMs = Number(Date.parse(inbox.updatedAt || inbox.createdAt || "")) || Date.now();
4007
+ const postTitle = cleanText(inbox.postTitle || "");
4008
+ const contextText = cleanText(inbox.contextText || "");
4009
+ const replyText = cleanText(inbox.replyText || "");
4010
+ const messageText = status === "replied" ? (replyText || contextText) : contextText;
4011
+ const summary = (status === "replied" ? (replyText || contextText) : contextText).slice(0, 160);
4012
+ const titlePrefix = status === "replied" ? "replied" : "skipped";
4013
+ const title = postTitle ? `${titlePrefix} → ${postTitle}` : (status === "replied" ? "Moltbook reply" : "Moltbook skipped");
4014
+ const recorded = recordHistoryItem({
4015
+ config,
4016
+ runtime,
4017
+ state,
4018
+ item: {
4019
+ stableId,
4020
+ token,
4021
+ kind: "moltbook_reply",
4022
+ threadId: "moltbook",
4023
+ threadLabel: postTitle || "Moltbook",
4024
+ title,
4025
+ summary,
4026
+ messageText,
4027
+ createdAtMs,
4028
+ readOnly: true,
4029
+ provider: "moltbook",
4030
+ },
4031
+ });
4032
+ if (recorded) changed = true;
4033
+ }
4034
+ } catch (error) {
4035
+ console.error(`[moltbook-inbox-backfill] ${error.message}`);
4036
+ return false;
4037
+ }
4038
+ if (changed) {
4039
+ console.log(`[moltbook-inbox-backfill] Synthesized history entries for resolved inbox items`);
4040
+ }
4041
+ return changed;
4042
+ }
4043
+
3763
4044
  async function backfillRecentTimelineEntryDiffs({ config, runtime, state }) {
3764
4045
  const nextEntries = runtime.recentTimelineEntries.map((entry) => ({ ...entry }));
3765
4046
  let changed = false;
@@ -4051,6 +4332,20 @@ function buildSqliteTimelineEntry({ row, config, runtime }) {
4051
4332
  if (!messageText) {
4052
4333
  return null;
4053
4334
  }
4335
+ const ambientSuggestionsEnvelope = kind === "assistant_final" ? parseAmbientSuggestionsEnvelope(messageText) : null;
4336
+ if (ambientSuggestionsEnvelope?.type === "metadata") {
4337
+ return null;
4338
+ }
4339
+ const ambientSuggestions = ambientSuggestionsEnvelope?.type === "suggestions"
4340
+ ? ambientSuggestionsEnvelope.suggestions
4341
+ : null;
4342
+ const entryKind = ambientSuggestions ? "ambient_suggestions" : kind;
4343
+ const entryMessageText = ambientSuggestions
4344
+ ? ambientSuggestionsMessageText(config.defaultLocale, ambientSuggestions)
4345
+ : messageText;
4346
+ const entrySummary = ambientSuggestions
4347
+ ? ambientSuggestionsSummary(config.defaultLocale, ambientSuggestions)
4348
+ : formatNotificationBody(messageText, 180) || messageText;
4054
4349
 
4055
4350
  const createdAtMs = Math.max(0, Number(row.ts) || 0) * 1000 || Date.now();
4056
4351
  const threadLabel = getNativeThreadLabel({
@@ -4058,17 +4353,18 @@ function buildSqliteTimelineEntry({ row, config, runtime }) {
4058
4353
  conversationId: threadId,
4059
4354
  cwd: "",
4060
4355
  });
4061
- const stableId = messageTimelineStableId(kind, threadId, item.id || row.id, messageText, createdAtMs);
4356
+ const stableId = messageTimelineStableId(entryKind, threadId, item.id || row.id, entryMessageText, createdAtMs);
4062
4357
 
4063
4358
  return normalizeTimelineEntry({
4064
4359
  stableId,
4065
4360
  token: historyToken(stableId),
4066
- kind,
4361
+ kind: entryKind,
4067
4362
  threadId,
4068
4363
  threadLabel,
4069
- title: threadLabel || kindTitle(config.defaultLocale, kind),
4070
- summary: formatNotificationBody(messageText, 180) || messageText,
4071
- messageText,
4364
+ title: ambientSuggestions ? kindTitle(config.defaultLocale, entryKind) : threadLabel || kindTitle(config.defaultLocale, entryKind),
4365
+ summary: entrySummary,
4366
+ messageText: entryMessageText,
4367
+ ...(ambientSuggestions ? { suggestions: ambientSuggestions } : {}),
4072
4368
  createdAtMs,
4073
4369
  readOnly: true,
4074
4370
  });
@@ -4520,7 +4816,11 @@ async function processScannedEvent({ config, runtime, state, event }) {
4520
4816
  item: historyItemFromEvent(event),
4521
4817
  }) || dirty;
4522
4818
 
4523
- if (event.kind === "task_complete") {
4819
+ const isAmbientSuggestionsCompletion =
4820
+ event.kind === "task_complete" &&
4821
+ isAmbientSuggestionsMetadataPayload(normalizeLongText(event.detailText || event.message || ""));
4822
+
4823
+ if (event.kind === "task_complete" && !isAmbientSuggestionsCompletion) {
4524
4824
  dirty =
4525
4825
  (await deliverWebPushItem({
4526
4826
  config,
@@ -4537,7 +4837,7 @@ async function processScannedEvent({ config, runtime, state, event }) {
4537
4837
  })) || dirty;
4538
4838
  }
4539
4839
 
4540
- if (config.enableNtfy) {
4840
+ if (config.enableNtfy && !isAmbientSuggestionsCompletion) {
4541
4841
  try {
4542
4842
  await publishNtfy(config, event);
4543
4843
  } catch (error) {
@@ -9262,12 +9562,26 @@ function buildCompletedInboxItems(runtime, state, config, locale) {
9262
9562
  const items = normalizeHistoryItems(state.recentHistoryItems ?? runtime.recentHistoryItems, config.maxHistoryItems);
9263
9563
  runtime.recentHistoryItems = items;
9264
9564
  const completedKinds = new Set(["assistant_final", "approval", "moltbook_reply", "moltbook_draft", "thread_share", "a2a_task_result"]);
9265
- return items
9565
+
9566
+ // Infrequent kinds (A2A, thread_share) get evicted from the fixed-size
9567
+ // history FIFO by the high volume of approval/assistant_final entries.
9568
+ // Fall back to recentTimelineEntries so completed A2A tasks are never lost
9569
+ // while they're still within the timeline window.
9570
+ const infrequentKinds = new Set(["a2a_task_result", "thread_share"]);
9571
+ const historyStableIds = new Set(items.map((i) => i.stableId).filter(Boolean));
9572
+ const timelineEntries = (runtime.recentTimelineEntries ?? [])
9573
+ .filter((e) => infrequentKinds.has(cleanText(e?.kind || "")) && !historyStableIds.has(e.stableId));
9574
+ const merged = [...items, ...timelineEntries];
9575
+
9576
+ return merged
9266
9577
  .filter((item) => {
9267
9578
  const k = cleanText(item?.kind || "");
9268
9579
  if (!completedKinds.has(k)) return false;
9269
- // Only resolved approvals (readOnly = true)
9270
- if (k === "approval" && !item.readOnly) return false;
9580
+ // Only resolved approvals (readOnly = true). Moltbook reply/draft items
9581
+ // are gated the same way so pending entries don't double-show as
9582
+ // completed while they're also surfaced via moltbookItemsByToken /
9583
+ // moltbookDraftsByToken on the pending list.
9584
+ if ((k === "approval" || k === "moltbook_reply" || k === "moltbook_draft") && !item.readOnly) return false;
9271
9585
  return true;
9272
9586
  })
9273
9587
  .slice()
@@ -9539,22 +9853,44 @@ function buildOperationalTimelineEntries(runtime, state, config, locale) {
9539
9853
  }
9540
9854
 
9541
9855
  for (const historyItem of normalizeHistoryItems(state.recentHistoryItems ?? runtime.recentHistoryItems, config.maxHistoryItems)) {
9542
- if (!timelineKinds.has(historyItem.kind)) {
9856
+ let historyKind = historyItem.kind;
9857
+ let historySummary = historyItem.summary;
9858
+ let historyMessageText = historyItem.messageText;
9859
+ let historyTitle = historyItem.threadLabel ? formatTitle(kindTitle(locale, historyItem.kind), historyItem.threadLabel) : historyItem.title;
9860
+ let historySuggestions = [];
9861
+
9862
+ if (historyItem.kind === "assistant_final" || historyItem.kind === "completion") {
9863
+ const ambientSuggestionsEnvelope = parseAmbientSuggestionsEnvelope(historyItem.messageText ?? "");
9864
+ if (ambientSuggestionsEnvelope?.type === "metadata") {
9865
+ continue;
9866
+ }
9867
+ if (ambientSuggestionsEnvelope?.type === "suggestions") {
9868
+ const ambientSuggestions = ambientSuggestionsEnvelope.suggestions;
9869
+ historyKind = "ambient_suggestions";
9870
+ historyTitle = kindTitle(locale, historyKind);
9871
+ historySummary = ambientSuggestionsSummary(locale, ambientSuggestions);
9872
+ historyMessageText = ambientSuggestionsMessageText(locale, ambientSuggestions);
9873
+ historySuggestions = ambientSuggestions;
9874
+ }
9875
+ }
9876
+
9877
+ if (!timelineKinds.has(historyKind)) {
9543
9878
  continue;
9544
9879
  }
9545
9880
  items.push(
9546
9881
  normalizeTimelineEntry({
9547
9882
  stableId: historyItem.stableId,
9548
9883
  token: historyItem.token,
9549
- kind: historyItem.kind,
9884
+ kind: historyKind,
9550
9885
  threadId: cleanText(extractConversationIdFromStableId(historyItem.stableId) || ""),
9551
9886
  threadLabel: historyItem.threadLabel,
9552
- title: historyItem.threadLabel ? formatTitle(kindTitle(locale, historyItem.kind), historyItem.threadLabel) : historyItem.title,
9553
- summary: historyItem.summary,
9554
- messageText: historyItem.messageText,
9887
+ title: historyTitle,
9888
+ summary: historySummary,
9889
+ messageText: historyMessageText,
9555
9890
  outcome: historyItem.outcome,
9556
9891
  createdAtMs: historyItem.createdAtMs,
9557
9892
  provider: normalizeProvider(historyItem.provider),
9893
+ ...(historySuggestions.length > 0 && historyKind === "ambient_suggestions" ? { suggestions: historySuggestions } : {}),
9558
9894
  })
9559
9895
  );
9560
9896
  }
@@ -9978,6 +10314,25 @@ function buildTimelineMessageDetail(entry, locale, runtime = null) {
9978
10314
  };
9979
10315
  }
9980
10316
 
10317
+ function buildAmbientSuggestionsDetail(entry, locale) {
10318
+ const suggestions = normalizeAmbientSuggestions(entry?.suggestions ?? []);
10319
+ return {
10320
+ kind: "ambient_suggestions",
10321
+ token: cleanText(entry?.token || ""),
10322
+ threadId: cleanText(entry?.threadId || ""),
10323
+ title: cleanText(entry?.title || "") || kindTitle(locale, "ambient_suggestions"),
10324
+ threadLabel: cleanText(entry?.threadLabel || ""),
10325
+ createdAtMs: Number(entry?.createdAtMs) || 0,
10326
+ messageHtml: renderMessageHtml(
10327
+ t(locale, "detail.ambientSuggestions.copy"),
10328
+ `<p>${escapeHtml(t(locale, "detail.detailUnavailable"))}</p>`
10329
+ ),
10330
+ suggestions,
10331
+ readOnly: true,
10332
+ actions: [],
10333
+ };
10334
+ }
10335
+
9981
10336
  function buildTimelineFileEventDetail(entry, locale) {
9982
10337
  const fileEventType = normalizeTimelineFileEventType(entry?.fileEventType ?? "");
9983
10338
  return {
@@ -10699,6 +11054,33 @@ async function buildApiItemDetail({ config, runtime, state, kind, token, locale
10699
11054
  const entry = timelineEntryByToken(runtime, token, kind);
10700
11055
  return entry ? buildTimelineFileEventDetail(entry, locale) : null;
10701
11056
  }
11057
+ if (kind === "ambient_suggestions") {
11058
+ const entry = timelineEntryByToken(runtime, token, kind);
11059
+ if (entry) {
11060
+ return buildAmbientSuggestionsDetail(entry, locale);
11061
+ }
11062
+ const historicalSource = (runtime.recentHistoryItems ?? []).find((item) => {
11063
+ if (cleanText(item?.token || "") !== cleanText(token || "")) {
11064
+ return false;
11065
+ }
11066
+ const itemKind = cleanText(item?.kind || "");
11067
+ if (itemKind !== "assistant_final" && itemKind !== "completion") {
11068
+ return false;
11069
+ }
11070
+ return parseAmbientSuggestionsPayload(item?.messageText ?? "") !== null;
11071
+ });
11072
+ if (!historicalSource) {
11073
+ return null;
11074
+ }
11075
+ return buildAmbientSuggestionsDetail({
11076
+ token: historicalSource.token,
11077
+ threadId: historyItemThreadId(historicalSource),
11078
+ threadLabel: historicalSource.threadLabel,
11079
+ title: kindTitle(locale, "ambient_suggestions"),
11080
+ createdAtMs: historicalSource.createdAtMs,
11081
+ suggestions: parseAmbientSuggestionsPayload(historicalSource.messageText ?? "") ?? [],
11082
+ }, locale);
11083
+ }
10702
11084
  if (timelineMessageKinds.has(kind)) {
10703
11085
  const entry = timelineEntryByToken(runtime, token, kind);
10704
11086
  if (!entry) return null;
@@ -11429,7 +11811,7 @@ function createNativeApprovalServer({ config, runtime, state }) {
11429
11811
  return writeJson(res, 200, { enabled: false });
11430
11812
  }
11431
11813
  try {
11432
- const { readScoutState, rollScoutDayIfNeeded } = await import("./moltbook-api.mjs");
11814
+ const { readScoutState, rollScoutDayIfNeeded, createMoltbookClient } = await import("./moltbook-api.mjs");
11433
11815
  const scoutState = rollScoutDayIfNeeded(await readScoutState());
11434
11816
  const batch = scoutState.batch;
11435
11817
  const batchInfo = batch && batch.candidates?.length ? {
@@ -11438,8 +11820,39 @@ function createNativeApprovalServer({ config, runtime, state }) {
11438
11820
  topScore: Math.max(...batch.candidates.map((c) => c.score || 0)),
11439
11821
  remainingSeconds: Math.max(0, Math.round(((batch.startedAt || 0) + (batch.windowMs || 1800000) - Date.now()) / 1000)),
11440
11822
  } : null;
11823
+
11824
+ // Agent profile — lazy-fetched, cached 1h. Stale cache survives
11825
+ // fetch failures so the UI never goes blank mid-outage.
11826
+ let account = null;
11827
+ try {
11828
+ const PROFILE_TTL_MS = 60 * 60 * 1000;
11829
+ const cached = runtime.moltbookAgentProfile;
11830
+ const nowMs = Date.now();
11831
+ if (!cached || nowMs - (cached.fetchedAtMs || 0) > PROFILE_TTL_MS) {
11832
+ const mb = createMoltbookClient(config.moltbookApiKey);
11833
+ const meRes = await mb("/agents/me");
11834
+ const name = cleanText(meRes?.agent?.name || meRes?.agent?.display_name || "");
11835
+ if (name) {
11836
+ runtime.moltbookAgentProfile = {
11837
+ name,
11838
+ profileUrl: `https://www.moltbook.com/u/${encodeURIComponent(name)}`,
11839
+ fetchedAtMs: nowMs,
11840
+ };
11841
+ }
11842
+ }
11843
+ } catch {
11844
+ // Fall through — if fetch fails, use whatever's cached (possibly null).
11845
+ }
11846
+ if (runtime.moltbookAgentProfile) {
11847
+ account = {
11848
+ name: runtime.moltbookAgentProfile.name,
11849
+ profileUrl: runtime.moltbookAgentProfile.profileUrl,
11850
+ };
11851
+ }
11852
+
11441
11853
  return writeJson(res, 200, {
11442
11854
  enabled: true,
11855
+ account,
11443
11856
  day: scoutState.day,
11444
11857
  sentToday: scoutState.sentToday,
11445
11858
  maxDaily: 5,
@@ -11478,6 +11891,82 @@ function createNativeApprovalServer({ config, runtime, state }) {
11478
11891
  });
11479
11892
  }
11480
11893
 
11894
+ // A2A Share status (HTML hosting) for the settings UI. Reuses A2A
11895
+ // credentials; enabled iff both user id + API key are provisioned.
11896
+ // Results from the upstream `/api/list` call are cached for 30s so a
11897
+ // settings-page render doesn't hammer the worker on every refresh tick.
11898
+ if (url.pathname === "/api/share/status" && req.method === "GET") {
11899
+ const session = requireApiSession(req, res, config, state);
11900
+ if (!session) return;
11901
+ const enabled = Boolean(config.a2aRelayUserId && config.a2aApiKey);
11902
+ if (!enabled) {
11903
+ return writeJson(res, 200, { enabled: false });
11904
+ }
11905
+ const shareHost = (() => {
11906
+ try { return new URL(config.a2aShareUrl).host; } catch { return config.a2aShareUrl || ""; }
11907
+ })();
11908
+ // Mirror the constants baked into share-worker/worker.js. If those
11909
+ // change, bump here too — there's no shared module between the two.
11910
+ const limits = {
11911
+ maxFileBytes: 5 * 1024 * 1024,
11912
+ maxTotalBytes: 5 * 1024 * 1024,
11913
+ maxFiles: 10,
11914
+ defaultExpiresDays: 30,
11915
+ maxExpiresDays: 30,
11916
+ uploadRatePerHour: 10,
11917
+ };
11918
+ const cacheKey = `${config.a2aRelayUserId}|${config.a2aShareUrl}`;
11919
+ const nowMs = Date.now();
11920
+ const cached = runtime.a2aShareStatusCache;
11921
+ if (cached && cached.key === cacheKey && nowMs - cached.fetchedAtMs < 30_000) {
11922
+ return writeJson(res, 200, {
11923
+ enabled: true,
11924
+ userId: config.a2aRelayUserId,
11925
+ shareUrl: config.a2aShareUrl,
11926
+ shareHost,
11927
+ limits,
11928
+ ...cached.payload,
11929
+ fetchedAtMs: cached.fetchedAtMs,
11930
+ });
11931
+ }
11932
+ let upstream = { items: [], quota: null, error: null };
11933
+ const controller = new AbortController();
11934
+ const timer = setTimeout(() => controller.abort(), 10_000);
11935
+ try {
11936
+ const resUpstream = await fetch(`${config.a2aShareUrl}/api/list`, {
11937
+ method: "GET",
11938
+ headers: {
11939
+ "x-a2a-user": config.a2aRelayUserId,
11940
+ "x-a2a-key": config.a2aApiKey,
11941
+ },
11942
+ signal: controller.signal,
11943
+ });
11944
+ if (resUpstream.ok) {
11945
+ const body = await resUpstream.json().catch(() => ({}));
11946
+ upstream.items = Array.isArray(body.items) ? body.items : [];
11947
+ upstream.quota = body.quota || null;
11948
+ } else {
11949
+ upstream.error = `HTTP ${resUpstream.status}`;
11950
+ }
11951
+ } catch (error) {
11952
+ upstream.error = error?.name === "AbortError"
11953
+ ? "upstream timeout"
11954
+ : (error?.message || String(error));
11955
+ } finally {
11956
+ clearTimeout(timer);
11957
+ }
11958
+ runtime.a2aShareStatusCache = { key: cacheKey, fetchedAtMs: nowMs, payload: upstream };
11959
+ return writeJson(res, 200, {
11960
+ enabled: true,
11961
+ userId: config.a2aRelayUserId,
11962
+ shareUrl: config.a2aShareUrl,
11963
+ shareHost,
11964
+ limits,
11965
+ ...upstream,
11966
+ fetchedAtMs: nowMs,
11967
+ });
11968
+ }
11969
+
11481
11970
  // Toggle acceptPublicTasks on the A2A relay.
11482
11971
  if (url.pathname === "/api/a2a/public-tasks" && req.method === "POST") {
11483
11972
  const session = requireApiSession(req, res, config, state);
@@ -12247,6 +12736,14 @@ function createNativeApprovalServer({ config, runtime, state }) {
12247
12736
  if (eventType === "resolve") {
12248
12737
  const token = historyToken(`moltbook:${sourceId}`);
12249
12738
  runtime.moltbookItemsByToken.delete(token);
12739
+ // Flip the history item to readOnly so it moves from pending → completed.
12740
+ try {
12741
+ if (flipHistoryItemReadOnly({ runtime, state, stableId: `moltbook_reply:${sourceId}` })) {
12742
+ await saveState(config.stateFile, state);
12743
+ }
12744
+ } catch (error) {
12745
+ console.error(`[moltbook-reply-resolve] ${error.message}`);
12746
+ }
12250
12747
  return writeJson(res, 200, { ok: true });
12251
12748
  }
12252
12749
  const token = historyToken(`moltbook:${sourceId}`);
@@ -12268,24 +12765,21 @@ function createNativeApprovalServer({ config, runtime, state }) {
12268
12765
  };
12269
12766
  runtime.moltbookItemsByToken.set(token, item);
12270
12767
  try {
12271
- recordTimelineEntry({
12272
- config,
12273
- runtime,
12274
- state,
12275
- entry: {
12276
- stableId: `moltbook_reply:${sourceId}`,
12277
- token,
12278
- kind: "moltbook_reply",
12279
- threadId: item.threadId,
12280
- threadLabel: item.threadLabel,
12281
- title: item.title,
12282
- summary: item.summary,
12283
- messageText: item.contextText,
12284
- createdAtMs: item.createdAtMs,
12285
- readOnly: false,
12286
- provider: "moltbook",
12287
- },
12288
- });
12768
+ const historyEntry = {
12769
+ stableId: `moltbook_reply:${sourceId}`,
12770
+ token,
12771
+ kind: "moltbook_reply",
12772
+ threadId: item.threadId,
12773
+ threadLabel: item.threadLabel,
12774
+ title: item.title,
12775
+ summary: item.summary,
12776
+ messageText: item.contextText,
12777
+ createdAtMs: item.createdAtMs,
12778
+ readOnly: false,
12779
+ provider: "moltbook",
12780
+ };
12781
+ recordTimelineEntry({ config, runtime, state, entry: historyEntry });
12782
+ recordHistoryItem({ config, runtime, state, item: historyEntry });
12289
12783
  await saveState(config.stateFile, state);
12290
12784
  } catch (error) {
12291
12785
  console.error(`[moltbook-timeline-save] ${error.message}`);
@@ -12351,6 +12845,13 @@ function createNativeApprovalServer({ config, runtime, state }) {
12351
12845
  }
12352
12846
  item.resolved = true;
12353
12847
  runtime.moltbookItemsByToken.delete(token);
12848
+ try {
12849
+ if (flipHistoryItemReadOnly({ runtime, state, stableId: `moltbook_reply:${item.sourceId}` })) {
12850
+ await saveState(config.stateFile, state);
12851
+ }
12852
+ } catch (error) {
12853
+ console.error(`[moltbook-reply-phone] ${error.message}`);
12854
+ }
12354
12855
  return writeJson(res, 200, { ok: true, action });
12355
12856
  }
12356
12857
 
@@ -12419,36 +12920,33 @@ function createNativeApprovalServer({ config, runtime, state }) {
12419
12920
  runtime.moltbookDraftsByToken.set(token, draft);
12420
12921
  await writeDraft(draft).catch((e) => console.error(`[moltbook-draft-persist] ${e.message}`));
12421
12922
  try {
12422
- recordTimelineEntry({
12423
- config,
12424
- runtime,
12425
- state,
12426
- entry: {
12427
- stableId: `moltbook_draft:${sourceId}`,
12428
- token,
12429
- kind: "moltbook_draft",
12430
- threadId: "moltbook",
12431
- threadLabel: postTitle || "Moltbook",
12432
- title: draftType === "original_post"
12433
- ? (postTitle || "Moltbook new post")
12434
- : (postTitle ? `draft → ${postTitle}` : "Moltbook draft"),
12435
- summary: contextSummary || String(draftText).slice(0, 160),
12436
- messageText: contextSummary || draftText,
12437
- draftText,
12438
- draftType,
12439
- submoltName,
12440
- intent,
12441
- slot,
12442
- postId,
12443
- postUrl,
12444
- postTitle,
12445
- postAuthor,
12446
- postBody,
12447
- createdAtMs: draft.createdAtMs,
12448
- readOnly: false,
12449
- provider: "moltbook",
12450
- },
12451
- });
12923
+ const draftEntry = {
12924
+ stableId: `moltbook_draft:${sourceId}`,
12925
+ token,
12926
+ kind: "moltbook_draft",
12927
+ threadId: "moltbook",
12928
+ threadLabel: postTitle || "Moltbook",
12929
+ title: draftType === "original_post"
12930
+ ? (postTitle || "Moltbook new post")
12931
+ : (postTitle ? `draft → ${postTitle}` : "Moltbook draft"),
12932
+ summary: contextSummary || String(draftText).slice(0, 160),
12933
+ messageText: contextSummary || draftText,
12934
+ draftText,
12935
+ draftType,
12936
+ submoltName,
12937
+ intent,
12938
+ slot,
12939
+ postId,
12940
+ postUrl,
12941
+ postTitle,
12942
+ postAuthor,
12943
+ postBody,
12944
+ createdAtMs: draft.createdAtMs,
12945
+ readOnly: false,
12946
+ provider: "moltbook",
12947
+ };
12948
+ recordTimelineEntry({ config, runtime, state, entry: draftEntry });
12949
+ recordHistoryItem({ config, runtime, state, item: draftEntry });
12452
12950
  await saveState(config.stateFile, state);
12453
12951
  } catch (error) {
12454
12952
  console.error(`[moltbook-draft-timeline-save] ${error.message}`);
@@ -12549,6 +13047,15 @@ function createNativeApprovalServer({ config, runtime, state }) {
12549
13047
  // Persist decision to disk.
12550
13048
  await writeDraft(draft).catch((e) => console.error(`[moltbook-draft-persist-decision] ${e.message}`));
12551
13049
 
13050
+ // Flip the history entry to readOnly so it moves from pending → completed.
13051
+ try {
13052
+ if (flipHistoryItemReadOnly({ runtime, state, stableId: `moltbook_draft:${draft.sourceId}` })) {
13053
+ await saveState(config.stateFile, state);
13054
+ }
13055
+ } catch (error) {
13056
+ console.error(`[moltbook-draft-decision-flip] ${error.message}`);
13057
+ }
13058
+
12552
13059
  if (action === "approve") {
12553
13060
  // Execute posting asynchronously — fire-and-forget from the HTTP handler.
12554
13061
  (async () => {
@@ -14564,12 +15071,11 @@ async function executeMoltbookDraftPost(draft, config, runtime, state) {
14564
15071
  const scoutState = rollScoutDayIfNeeded(await readScoutState());
14565
15072
  scoutState.sentToday += 1;
14566
15073
  markPostSeen(scoutState, draft.postId, "published");
14567
- // Note: replies are tracked via sentToday + markPostSeen("published").
14568
- // They intentionally do NOT flow through recordComposeAttempt because
14569
- // `composedToday` / `recentComposeTitles` back the "本日の新規投稿数"
14570
- // settings row, which is labelled for ORIGINAL posts only. Counting
14571
- // replies there produced a misleading "6 / 3" over-quota display even
14572
- // when the user only published one actual new post for the day.
15074
+ // Append the reply to `recentComposeTitles` so it shows up in the
15075
+ // "最近の投稿" list with its reply badge. `recordComposeAttempt` knows
15076
+ // not to bump `composedToday` when type === "reply", so the
15077
+ // "本日の新規投稿数" counter only reflects original posts.
15078
+ recordComposeAttempt(scoutState, draft.postTitle || draft.postId, draft.postId, "reply");
14573
15079
  await writeScoutState(scoutState);
14574
15080
  }
14575
15081
 
@@ -14643,6 +15149,11 @@ function buildConfig(cli) {
14643
15149
  a2aRelayUserId: cleanText(process.env.A2A_RELAY_USER_ID || ""),
14644
15150
  a2aRelaySecret: cleanText(process.env.A2A_RELAY_SECRET || ""),
14645
15151
  a2aRelayRegisterSecret: cleanText(process.env.A2A_RELAY_REGISTER_SECRET || ""),
15152
+ // share.viveworker.com — HTML hosting backed by the same A2A credentials.
15153
+ // Override for staging / self-hosted deployments via VIVEWORKER_SHARE_URL.
15154
+ a2aShareUrl: stripTrailingSlash(
15155
+ process.env.VIVEWORKER_SHARE_URL || "https://share.viveworker.com"
15156
+ ),
14646
15157
  webUiEnabled: boolEnv("WEB_UI_ENABLED", true),
14647
15158
  authRequired: boolEnv("AUTH_REQUIRED", true),
14648
15159
  webPushEnabled: boolEnv("WEB_PUSH_ENABLED", false),
@@ -15992,8 +16503,10 @@ async function main() {
15992
16503
  migratedPairedDevicesStateChanged ||
15993
16504
  restoredPendingPlanStateChanged ||
15994
16505
  restoredTimelineImagePathsStateChanged ||
16506
+ backfilledAmbientSuggestionsStateChanged ||
15995
16507
  migratedRecentCodeEventsStateChanged ||
15996
16508
  restoredPendingUserInputStateChanged ||
16509
+ backfilledMoltbookInboxChanged ||
15997
16510
  refreshResolvedThreadLabels({ config, runtime, state })
15998
16511
  ) {
15999
16512
  await saveState(config.stateFile, state);