@gobi-ai/cli 2.0.35 → 2.0.39

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.
@@ -4,12 +4,12 @@
4
4
  "name": "gobi-ai"
5
5
  },
6
6
  "description": "Claude Code plugin for the Gobi collaborative knowledge platform CLI",
7
- "version": "2.0.35",
7
+ "version": "2.0.39",
8
8
  "plugins": [
9
9
  {
10
10
  "name": "gobi",
11
11
  "description": "Manage the Gobi collaborative knowledge platform from the command line. Publish vault profiles, create posts and replies, generate images and videos.",
12
- "version": "2.0.35",
12
+ "version": "2.0.39",
13
13
  "author": {
14
14
  "name": "gobi-ai"
15
15
  },
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "gobi",
3
3
  "description": "Manage the Gobi collaborative knowledge platform from the command line",
4
- "version": "2.0.35",
4
+ "version": "2.0.39",
5
5
  "author": {
6
6
  "name": "gobi-ai"
7
7
  },
package/README.md CHANGED
@@ -63,7 +63,7 @@ Each setup step unlocks a different family of commands — run only the ones the
63
63
  | Step | Unlocks |
64
64
  |------|---------|
65
65
  | `gobi auth login` | All authenticated commands |
66
- | `gobi vault init` | Every `gobi vault …` command (`publish`, `unpublish`, `sync`); also lets `artifact create --auto-attachments` resolve that vault automatically |
66
+ | `gobi vault init` | Every `gobi vault …` command (`publish`, `unpublish`, `sync`); also lets `<scope> artifact create --auto-attachments` resolve that vault automatically |
67
67
  | `gobi space warp` | Every `gobi space …` command without needing `--space-slug` |
68
68
 
69
69
  ---
@@ -218,34 +218,45 @@ Private posts and replies visible only to you. Same `Post` data model and subcom
218
218
  | `gobi personal edit-reply <replyId> [--content <c>] [--rich-text <json>]` | Edit a reply you authored |
219
219
  | `gobi personal delete-reply <replyId>` | Delete a reply you authored |
220
220
 
221
- ### Sense
221
+ ### Sense (activities & conversations)
222
222
 
223
- Activity and transcription data captured by Gobi Sense (or the mobile app).
223
+ Activity and conversation data captured by Gobi Sense (the wearable) and the mobile app, then ingested by the cloud pipeline. Read-only. See the `gobi-sense` skill for full workflows.
224
+
225
+ Like posts and artifacts, Sense data is **scoped to a space**: the subcommands live under `gobi personal …` (your personal space) and `gobi space …` (the active team space — `gobi space warp <slug>` or `gobi space --space-slug <slug> …`). `<scope>` below is `personal` or `space`.
226
+
227
+ - **activities** — what you were doing (category + details, start/end times). In a team space, every member's activities show up, attributed to their recorder.
228
+ - **conversations** — phone-mic Audio Logs plus Sense-detected conversations, each with a transcript and auto-generated summary. In a team space, every member's conversations show up, attributed to their recorder (transcript/audio stay owner-only). (This replaces the old `list-transcriptions` — transcriptions were unified into conversations.)
224
229
 
225
230
  | Command | Description |
226
231
  |---------|-------------|
227
- | `gobi sense list-activities --start-time <iso> --end-time <iso>` | List activity records in a time range |
228
- | `gobi sense list-transcriptions --start-time <iso> --end-time <iso>` | List transcription records in a time range |
232
+ | `gobi <scope> activities list [--limit N] [--before <cursor>] [--mine]` | List Sense activities in this scope (newest first) |
233
+ | `gobi <scope> activities get <activityId>` | Get one activity's details |
234
+ | `gobi <scope> activities transcript <activityId>` | Get an activity's transcript (owner-only) |
235
+ | `gobi <scope> conversations list [--limit N] [--before <cursor>] [--mine]` | List conversations captured in this scope (newest first) |
236
+ | `gobi <scope> conversations transcript <conversationId>` | Get a conversation's transcript and summary |
237
+ | `gobi <scope> conversations audio <conversationId>` | Get a signed URL for the recording (owner-only) |
229
238
 
230
- Times are ISO 8601 UTC (e.g. `2026-03-20T00:00:00Z`).
239
+ `gobi space …` lists are a complete, fully-paginated per-space history (every member's records); add `--mine` to restrict either `list` to records you recorded. `gobi personal conversations list` is filtered from the user-global conversations feed, so it shows your recent personal conversations rather than a fully paginated history (`gobi personal activities list` is fully paginated).
231
240
 
232
241
  ### Artifacts
233
242
 
234
243
  An *artifact* is a versioned, human-owned creation attached to posts. Kinds: `image | video | gif | markdown | meeting_summary`. Markdown kinds carry a body; media kinds carry an uploaded file. Revisions form a draft/published tree (at most one published per artifact). Markdown kinds store `metadata.vaultSlug` for `[[wikilink]]` resolution. See the `gobi-artifact` skill for full workflows.
235
244
 
245
+ Artifacts are **scoped to a space**: the subcommands live under `gobi personal artifact …` (your personal space) and `gobi space artifact …` (the active team space — `gobi space warp <slug>` or `gobi space --space-slug <slug> artifact …`). `<scope>` below is `personal` or `space`.
246
+
236
247
  | Command | Description |
237
248
  |---------|-------------|
238
- | `gobi artifact list [--kind <k>] [--limit N]` | List your artifacts (newest first) |
239
- | `gobi artifact get <artifactId>` | Get one artifact with its current revision |
240
- | `gobi artifact create --kind <k> [--file <path> \| --content <md>] [--title <t>] [--vault-slug <slug>] [--post-id <id>] [--auto-attachments] [--change-note <note>]` | Create an artifact. markdown/meeting_summary take a body via `--file`, `--content`, or stdin (`-`); image/gif/video upload `--file`. `--post-id` attaches it to a post (appends, doesn't clobber). `--auto-attachments` (markdown) uploads `[[wikilinks]]` to `--vault-slug`. |
241
- | `gobi artifact revise <artifactId> [--file <path> \| --content <md>] [--change-note <note>] [--from <revisionId>] [--auto-attachments]` | Add a draft revision. `--from` branches off a specific revision. `--auto-attachments` reuses the artifact's stored `metadata.vaultSlug`. |
242
- | `gobi artifact publish <artifactId> --revision <revisionId>` | Publish a revision (the artifact's single published revision) |
243
- | `gobi artifact revert <artifactId> --to <revisionId>` | Move the published pointer to an earlier revision |
244
- | `gobi artifact history <artifactId>` | List the full revision tree (owner only) |
245
- | `gobi artifact download <artifactId> [--revision <revisionId>] [--out <path>]` | Download a revision's content (markdown body to file/stdout; media bytes to file). Defaults to the current revision. |
246
- | `gobi artifact delete <artifactId>` | Delete an artifact and its revision tree |
247
-
248
- Attach an artifact to a post at creation time with `gobi artifact create --post-id <postId>` (it merges into the post's existing artifacts without clobbering them).
249
+ | `gobi <scope> artifact list [--kind <k>] [--limit N]` | List this scope's artifacts (newest first) |
250
+ | `gobi <scope> artifact get <artifactId>` | Get one artifact with its current revision |
251
+ | `gobi <scope> artifact create --kind <k> [--file <path> \| --content <md>] [--title <t>] [--vault-slug <slug>] [--post-id <id>] [--auto-attachments] [--change-note <note>]` | Create an artifact in this scope. markdown/meeting_summary take a body via `--file`, `--content`, or stdin (`-`); image/gif/video upload `--file`. `--post-id` attaches it to a post (appends, doesn't clobber). `--auto-attachments` (markdown) uploads `[[wikilinks]]` to `--vault-slug`. |
252
+ | `gobi <scope> artifact revise <artifactId> [--file <path> \| --content <md>] [--change-note <note>] [--from <revisionId>] [--auto-attachments]` | Add a draft revision. `--from` branches off a specific revision. `--auto-attachments` reuses the artifact's stored `metadata.vaultSlug`. |
253
+ | `gobi <scope> artifact publish <artifactId> --revision <revisionId>` | Publish a revision (the artifact's single published revision) |
254
+ | `gobi <scope> artifact revert <artifactId> --to <revisionId>` | Move the published pointer to an earlier revision |
255
+ | `gobi <scope> artifact history <artifactId>` | List the full revision tree (owner only) |
256
+ | `gobi <scope> artifact download <artifactId> [--revision <revisionId>] [--out <path>]` | Download a revision's content (markdown body to file/stdout; media bytes to file). Defaults to the current revision. |
257
+ | `gobi <scope> artifact delete <artifactId>` | Delete an artifact and its revision tree |
258
+
259
+ Attach an artifact to a post at creation time with `gobi <scope> artifact create --post-id <postId>` (it merges into the post's existing artifacts without clobbering them).
249
260
 
250
261
  ### Media generation
251
262
 
@@ -303,9 +314,9 @@ The CLI ships a `.claude-plugin/` manifest with skills that wrap the command gro
303
314
  | `gobi-core` | Auth, update, space list/warp |
304
315
  | `gobi-vault` | `gobi vault init/list/publish/unpublish/sync` |
305
316
  | `gobi-space` | `gobi space …`, `gobi global …`, and `gobi personal …` |
306
- | `gobi-artifact` | `gobi artifact …` |
317
+ | `gobi-artifact` | `gobi personal artifact …` and `gobi space artifact …` |
307
318
  | `gobi-media` | `gobi media …` |
308
- | `gobi-sense` | `gobi sense list-activities/list-transcriptions` |
319
+ | `gobi-sense` | `gobi personal activities/conversations …` and `gobi space activities/conversations …` |
309
320
  | `gobi-homepage` | Building custom HTML homepages with `window.gobi` |
310
321
 
311
322
  Each skill's `SKILL.md` is hand-written orientation; `references/` is regenerated from `--help` output by `npm run generate-skill-docs`.
@@ -135,11 +135,11 @@ function printArtifact(a) {
135
135
  console.log(` mediaUrl: ${rev.mediaUrl}`);
136
136
  }
137
137
  }
138
- export function registerArtifactCommand(program) {
139
- const artifact = program
140
- .command("artifact")
141
- .description("Versioned creations attached to posts. Kinds: image | video | gif | markdown | meeting_summary. " +
142
- "Always human-owned; revisions form a draft/published tree (at most one published per artifact).");
138
+ // Registers the full `artifact` subcommand tree under `parent` (a `gobi space`
139
+ // or `gobi personal` group). Moved here from a top-level `gobi artifact` group
140
+ // so artifacts are scoped to a space (team or personal), matching posts.
141
+ export function registerArtifactSubcommands(parent, scope, description) {
142
+ const artifact = parent.command("artifact").description(description);
143
143
  // ── Create ──
144
144
  artifact
145
145
  .command("create")
@@ -158,6 +158,10 @@ export function registerArtifactCommand(program) {
158
158
  throw new Error(`--kind must be one of: ${ALL_KINDS.join(", ")}`);
159
159
  }
160
160
  const body = { kind };
161
+ // Scope the new artifact to this group's space (team) or personal space.
162
+ const { spaceSlug } = scope.resolve();
163
+ if (spaceSlug)
164
+ body.spaceSlug = spaceSlug;
161
165
  if (opts.title != null)
162
166
  body.title = opts.title;
163
167
  if (opts.vaultSlug)
@@ -421,11 +425,15 @@ export function registerArtifactCommand(program) {
421
425
  // ── List ──
422
426
  artifact
423
427
  .command("list")
424
- .description("List your artifacts (newest first).")
428
+ .description("List this scope's artifacts (newest first).")
425
429
  .option("--kind <kind>", `Filter by kind: ${ALL_KINDS.join(" | ")}`)
426
430
  .option("--limit <n>", "Max items to return")
427
431
  .action(async (opts) => {
428
432
  const params = {};
433
+ // Scope the listing to this group's space (team) or personal space.
434
+ const { spaceSlug } = scope.resolve();
435
+ if (spaceSlug)
436
+ params.spaceSlug = spaceSlug;
429
437
  if (opts.kind) {
430
438
  if (!ALL_KINDS.includes(opts.kind)) {
431
439
  throw new Error(`--kind must be one of: ${ALL_KINDS.join(", ")}`);
@@ -198,7 +198,7 @@ export function registerGlobalCommand(program) {
198
198
  .option("--title <title>", "Title of the post")
199
199
  .option("--content <content>", "Post content (markdown supported, use \"-\" for stdin)")
200
200
  .option("--rich-text <richText>", "Rich-text JSON array (mutually exclusive with --content)")
201
- .option("--artifact <artifactId>", "Attach an existing artifact to the post (repeatable). Create artifacts with `gobi artifact create`.", (value, prev = []) => [...prev, value], [])
201
+ .option("--artifact <artifactId>", "Attach an existing artifact to the post (repeatable). Create artifacts with `gobi personal artifact create`.", (value, prev = []) => [...prev, value], [])
202
202
  .option("--attach <file>", "Local media or document file to attach. Repeatable. Mix rule: up to 4 photos + up to 4 document files (pdf/md/txt/csv) OR 1 GIF OR 1 video. Size ceilings: 10MB photos / 15MB GIFs / 512MB video / 250MB files.", (value, prev = []) => [...prev, value], [])
203
203
  .option("--repost-post-id <postId>", "Wrap an existing top-level post as the embedded card on this new post. Composes with --content / --rich-text / --attach (the wrapping author's text + media render above the embedded card). Reposts-of-reposts are collapsed to the transitive root server-side. The referenced post must exist, not be deleted, and not itself be a reply.")
204
204
  .action(async (opts) => {
@@ -264,7 +264,7 @@ export function registerGlobalCommand(program) {
264
264
  .option("--content <content>", "New content (markdown supported, use \"-\" for stdin)")
265
265
  .option("--rich-text <richText>", "Rich-text JSON array (mutually exclusive with --content)")
266
266
  .option("--attach <file>", "Replace the post's media attachments with the given files (existing attachments are removed). Repeatable. Mix rule: up to 4 photos + up to 4 document files (pdf/md/txt/csv) OR 1 GIF OR 1 video. Size ceilings: 10MB photos / 15MB GIFs / 512MB video / 250MB files. Omit to leave attachments unchanged.", (value, prev = []) => [...prev, value], [])
267
- .option("--artifact <artifactId>", "Replace the post's artifact attachments with the given artifact(s) (existing artifact attachments are removed). Repeatable. Omit to leave them unchanged. Create artifacts with `gobi artifact create`.", (value, prev = []) => [...prev, value], [])
267
+ .option("--artifact <artifactId>", "Replace the post's artifact attachments with the given artifact(s) (existing artifact attachments are removed). Repeatable. Omit to leave them unchanged. Create artifacts with `gobi personal artifact create`.", (value, prev = []) => [...prev, value], [])
268
268
  .action(async (postId, opts) => {
269
269
  const wantsAttachChange = !!(opts.attach && opts.attach.length > 0);
270
270
  const wantsArtifactChange = !!(opts.artifact && opts.artifact.length > 0);
@@ -1,6 +1,8 @@
1
1
  import { apiGet, apiPost, apiPatch, apiPut, apiDelete } from "../client.js";
2
2
  import { buildMentionMap, formatAttachmentLines, formatAttachmentSummary, formatPostLabel, formatReactionChips, formatReplyLine, isJsonMode, jsonOut, postBodyText, readStdin, unwrapResp, } from "./utils.js";
3
3
  import { uploadPostAttachments, assertPostAttachmentMix, } from "../attachments.js";
4
+ import { registerArtifactSubcommands } from "./artifact.js";
5
+ import { registerActivitiesSubcommands, registerConversationsSubcommands, } from "./sense.js";
4
6
  function readContent(value) {
5
7
  if (value === "-")
6
8
  return readStdin();
@@ -254,7 +256,7 @@ export function registerPersonalCommand(program) {
254
256
  .option("--title <title>", "Title of the post")
255
257
  .option("--content <content>", "Post content (markdown supported, use \"-\" for stdin)")
256
258
  .option("--rich-text <richText>", "Rich-text JSON array (mutually exclusive with --content)")
257
- .option("--artifact <artifactId>", "Attach an existing artifact to the post (repeatable). Create artifacts with `gobi artifact create`.", (value, prev = []) => [...prev, value], [])
259
+ .option("--artifact <artifactId>", "Attach an existing artifact to the post (repeatable). Create artifacts with `gobi personal artifact create`.", (value, prev = []) => [...prev, value], [])
258
260
  .option("--attach <file>", "Local media or document file to attach. Repeatable. Mix rule: up to 4 photos + up to 4 document files (pdf/md/txt/csv) OR 1 GIF OR 1 video. Size ceilings: 10MB photos / 15MB GIFs / 512MB video / 250MB files.", (value, prev = []) => [...prev, value], [])
259
261
  .option("--repost-post-id <postId>", "Wrap an existing top-level post as the embedded card on this new private post. The referenced post must be visible to you (your own personal-space post, a global-feed post, or a post in a space you're a member of). Reposting someone else's personal-space post returns 404.")
260
262
  .action(async (opts) => {
@@ -324,7 +326,7 @@ export function registerPersonalCommand(program) {
324
326
  .option("--content <content>", "New content (markdown supported, use \"-\" for stdin)")
325
327
  .option("--rich-text <richText>", "Rich-text JSON array (mutually exclusive with --content)")
326
328
  .option("--attach <file>", "Replace the post's media attachments with the given files (existing attachments are removed). Repeatable. Mix rule: up to 4 photos + up to 4 document files (pdf/md/txt/csv) OR 1 GIF OR 1 video. Size ceilings: 10MB photos / 15MB GIFs / 512MB video / 250MB files. Omit to leave attachments unchanged.", (value, prev = []) => [...prev, value], [])
327
- .option("--artifact <artifactId>", "Replace the post's artifact attachments with the given artifact(s) (existing artifact attachments are removed). Repeatable. Omit to leave them unchanged. Create artifacts with `gobi artifact create`.", (value, prev = []) => [...prev, value], [])
329
+ .option("--artifact <artifactId>", "Replace the post's artifact attachments with the given artifact(s) (existing artifact attachments are removed). Repeatable. Omit to leave them unchanged. Create artifacts with `gobi personal artifact create`.", (value, prev = []) => [...prev, value], [])
328
330
  .action(async (postId, opts) => {
329
331
  const wantsAttachChange = !!(opts.attach && opts.attach.length > 0);
330
332
  const wantsArtifactChange = !!(opts.artifact && opts.artifact.length > 0);
@@ -500,4 +502,31 @@ export function registerPersonalCommand(program) {
500
502
  console.log(`Removed ${emoji} reaction from ${postId}.` +
501
503
  (chips ? `\n Now: ${chips}` : ""));
502
504
  });
505
+ // ── Artifacts (scoped to your personal space) ──
506
+ registerArtifactSubcommands(personal, { resolve: () => ({}) }, "Versioned creations attached to posts, scoped to your personal space (visible " +
507
+ "only to you). Kinds: image | video | gif | markdown | meeting_summary. Always " +
508
+ "human-owned; revisions form a draft/published tree (one published per artifact).");
509
+ // ── Sense: activities + conversations (scoped to your personal space) ──
510
+ const senseScope = {
511
+ label: "personal",
512
+ listActivities: async (params) => {
513
+ const resp = (await apiGet("/app/activities", params));
514
+ return {
515
+ items: (resp.activities || []),
516
+ pagination: resp.pagination,
517
+ };
518
+ },
519
+ // `/app/conversations` is user-global (all scopes, newest ~50, no paging);
520
+ // filter to the personal scope (spaceId 0). Params are ignored — the endpoint
521
+ // takes none.
522
+ listConversations: async () => {
523
+ const resp = (await apiGet("/app/conversations"));
524
+ const all = (resp.conversations || []);
525
+ return { items: all.filter((c) => Number(c.spaceId ?? 0) === 0) };
526
+ },
527
+ };
528
+ registerActivitiesSubcommands(personal, senseScope, "Your personal Sense activities (what you were doing, from the wearable/app), " +
529
+ "browse-only. Recorded in your personal space (visible only to you).");
530
+ registerConversationsSubcommands(personal, senseScope, "Your personal Sense conversations (phone-mic Audio Logs + detected conversations), " +
531
+ "browse-only. Recorded in your personal space (visible only to you).");
503
532
  }
@@ -1,129 +1,180 @@
1
1
  import { apiGet } from "../client.js";
2
2
  import { isJsonMode, jsonOut } from "./utils.js";
3
- export function registerSenseCommand(program) {
4
- const sense = program
5
- .command("sense")
6
- .description("Sense commands (activities, transcriptions).");
7
- // ── Activities ──
8
- sense
9
- .command("list-activities")
10
- .description("List activity records within a time range.")
11
- .requiredOption("--start-time <iso>", "Start of time range (ISO 8601 UTC, e.g. 2026-03-20T00:00:00Z)")
12
- .requiredOption("--end-time <iso>", "End of time range (ISO 8601 UTC, e.g. 2026-03-20T23:59:59Z)")
3
+ // One-liner for a transcript turn. The backend resolves speaker identities at
4
+ // read time into `speakerLabel` (a managed-voice name, "Me", a session-stable
5
+ // "Speaker N", or "Unknown"); fall back to the raw diarization token only if
6
+ // it's somehow absent.
7
+ function formatTurnLine(t) {
8
+ const who = t.speakerLabel || t.speaker || "Unknown";
9
+ return `- ${who} (${t.timestamp}): ${t.text}`;
10
+ }
11
+ function printTranscriptTurns(turns) {
12
+ const cleaned = (turns || [])
13
+ .map((t) => ({ ...t, text: String(t.text ?? "").trim() }))
14
+ .filter((t) => t.text);
15
+ if (!cleaned.length) {
16
+ console.log("No transcript available.");
17
+ return;
18
+ }
19
+ console.log(`Transcript (${cleaned.length} turns):\n` + cleaned.map(formatTurnLine).join("\n"));
20
+ }
21
+ function formatActivityLine(a) {
22
+ const end = a.end_time ? ` → ${a.end_time}` : " → ongoing";
23
+ const details = a.details ? `: ${a.details}` : "";
24
+ // Space activities carry the recorder(s); personal ones don't.
25
+ const recorders = Array.isArray(a.recorders) && a.recorders.length
26
+ ? ` by ${a.recorders
27
+ .map((r) => r.name || `user ${r.id}`)
28
+ .join(", ")}`
29
+ : "";
30
+ return `- [${a.id}] ${a.category ?? "activity"}${details} (${a.start_time}${end})${recorders}`;
31
+ }
32
+ function formatConversationLine(c) {
33
+ const durMs = typeof c.durationMs === "number" ? c.durationMs : null;
34
+ const dur = durMs != null ? ` (${Math.max(1, Math.round(durMs / 60000))}m)` : "";
35
+ const cat = c.category ? ` — ${c.category}` : "";
36
+ const status = c.status ? ` [${c.status}]` : "";
37
+ // Space conversations carry the recorder (whose conversation it is); personal
38
+ // ones don't.
39
+ const rec = c.recorder && typeof c.recorder === "object"
40
+ ? ` by ${c.recorder.name ?? "someone"}`
41
+ : "";
42
+ return `- [${c.id}] ${c.source ?? "conversation"}${status}${cat} (${c.startTime}${dur})${rec}`;
43
+ }
44
+ function paginationFooter(pagination) {
45
+ return pagination?.hasMore ? `\n Next cursor: ${pagination.nextCursor}` : "";
46
+ }
47
+ // ── Activities ──
48
+ //
49
+ // Registers the `activities` subcommand tree under `parent` (a `gobi space` or
50
+ // `gobi personal` group). Replaces the old top-level `gobi sense list-activities`.
51
+ export function registerActivitiesSubcommands(parent, scope, description) {
52
+ const activities = parent.command("activities").description(description);
53
+ // ── List ──
54
+ activities
55
+ .command("list")
56
+ .description("List Sense activities in this scope (newest first).")
57
+ .option("--limit <n>", "Max items to return (default 30, max 100)")
58
+ .option("--before <cursor>", "Pagination cursor from a previous response (nextCursor)")
59
+ .option("--mine", "Only activities you recorded (space scope; no-op for personal, already yours)")
13
60
  .action(async (opts) => {
14
- const params = {
15
- startTime: opts.startTime,
16
- endTime: opts.endTime,
17
- };
18
- const resp = (await apiGet("/app/activities", params));
19
- const strip = ({ id, device_id, created_at, updated_at, ...rest }) => rest;
20
- const allActivities = (resp.activities || []).map((a) => strip(a));
21
- const lastSeenTime = resp.latestTimestamp;
22
- // Pull out the last activity with null end_time as "last_activity"
23
- let lastActivityIdx = -1;
24
- for (let i = allActivities.length - 1; i >= 0; i--) {
25
- if (allActivities[i].end_time == null) {
26
- lastActivityIdx = i;
27
- break;
28
- }
29
- }
30
- const last_activity = lastActivityIdx !== -1 ? allActivities[lastActivityIdx] : undefined;
31
- const activities = lastActivityIdx !== -1
32
- ? allActivities.filter((_, i) => i !== lastActivityIdx)
33
- : allActivities;
34
- if (isJsonMode(sense)) {
35
- jsonOut({ activities, last_activity, lastSeenTime });
61
+ const { items, pagination } = await scope.listActivities({
62
+ limit: opts.limit ? parseInt(opts.limit, 10) : undefined,
63
+ before: opts.before,
64
+ mine: opts.mine,
65
+ });
66
+ if (isJsonMode(activities)) {
67
+ jsonOut({ activities: items, pagination: pagination ?? {} });
36
68
  return;
37
69
  }
38
- if (!activities.length && !last_activity) {
70
+ if (!items.length) {
39
71
  console.log("No activities found.");
40
- if (lastSeenTime)
41
- console.log(`Latest data available: ${lastSeenTime}`);
42
72
  return;
43
73
  }
44
- if (last_activity) {
45
- console.log(`Last activity: [${last_activity.device_id}] ${last_activity.category}: ${last_activity.details} (${last_activity.start_time} → ongoing)`);
74
+ console.log(`Activities (${items.length} items, newest first):\n` +
75
+ items.map(formatActivityLine).join("\n") +
76
+ paginationFooter(pagination));
77
+ });
78
+ // ── Get (by id; scope-independent) ──
79
+ activities
80
+ .command("get <activityId>")
81
+ .description("Get one activity's details (visible to you if you recorded it or are a member of its space).")
82
+ .action(async (activityId) => {
83
+ const a = (await apiGet(`/app/activity/${activityId}`));
84
+ if (isJsonMode(activities)) {
85
+ jsonOut(a);
86
+ return;
46
87
  }
47
- const lines = activities.map((a) => {
48
- const endStr = a.end_time ? ` ${a.end_time}` : "";
49
- return `- [${a.device_id}] ${a.category}: ${a.details} (${a.start_time}${endStr})`;
50
- });
51
- if (lines.length)
52
- console.log(`Activities (${activities.length} items):\n` + lines.join("\n"));
53
- if (lastSeenTime)
54
- console.log(`Latest data available: ${lastSeenTime}`);
88
+ console.log(`Activity ${a.id}\n` +
89
+ ` category: ${a.category ?? "(none)"}\n` +
90
+ (a.details ? ` details: ${a.details}\n` : "") +
91
+ ` start: ${a.start_time}\n` +
92
+ ` end: ${a.end_time ?? "ongoing"}`);
93
+ });
94
+ // ── Transcript (by id; owner-only) ──
95
+ activities
96
+ .command("transcript <activityId>")
97
+ .description("Get an activity's transcript (owner-only; 403 for other space members).")
98
+ .action(async (activityId) => {
99
+ const resp = (await apiGet(`/app/activity/${activityId}/transcript`));
100
+ const turns = (resp.turns || []);
101
+ if (isJsonMode(activities)) {
102
+ jsonOut({ turns });
103
+ return;
104
+ }
105
+ printTranscriptTurns(turns);
55
106
  });
56
- // ── Transcriptions ──
57
- sense
58
- .command("list-transcriptions")
59
- .description("List transcription records within a time range.")
60
- .requiredOption("--start-time <iso>", "Start of time range (ISO 8601 UTC, e.g. 2026-03-20T00:00:00Z)")
61
- .requiredOption("--end-time <iso>", "End of time range (ISO 8601 UTC, e.g. 2026-03-20T23:59:59Z)")
107
+ }
108
+ // ── Conversations ──
109
+ //
110
+ // Registers the `conversations` subcommand tree under `parent`. The `space`
111
+ // group lists via `/spaces/:slug/conversations` (every member's, keyset-paged);
112
+ // the `personal` group lists via the user-global `/app/conversations` filtered
113
+ // to the personal scope. Replaces the old top-level `gobi sense list-transcriptions`.
114
+ export function registerConversationsSubcommands(parent, scope, description) {
115
+ const conversations = parent.command("conversations").description(description);
116
+ // ── List ──
117
+ conversations
118
+ .command("list")
119
+ .description("List conversations captured in this scope (newest first).")
120
+ .option("--limit <n>", "Max items to return (default 30, max 100). Ignored for personal.")
121
+ .option("--before <cursor>", "Pagination cursor from a previous response (nextCursor). Space scope only.")
122
+ .option("--mine", "Only conversations you recorded (space scope; no-op for personal, already yours)")
62
123
  .action(async (opts) => {
63
- const params = {
64
- startTime: opts.startTime,
65
- endTime: opts.endTime,
66
- };
67
- const resp = (await apiGet("/app/transcriptions", params));
68
- const transcriptions = (resp.transcriptions || []);
69
- const lastSeenTime = resp.latestTimestamp;
70
- const allTurns = [];
71
- for (const t of transcriptions) {
72
- const rawTurns = (t.turns || []);
73
- for (const turn of rawTurns) {
74
- const text = String(turn.text ?? "").trim();
75
- if (!text)
76
- continue;
77
- allTurns.push({
78
- speaker: String(turn.speaker ?? ""),
79
- timestamp: String(turn.timestamp ?? ""),
80
- text,
81
- });
82
- }
124
+ const { items, pagination } = await scope.listConversations({
125
+ limit: opts.limit ? parseInt(opts.limit, 10) : undefined,
126
+ before: opts.before,
127
+ mine: opts.mine,
128
+ });
129
+ if (isJsonMode(conversations)) {
130
+ jsonOut({ conversations: items, pagination: pagination ?? {} });
131
+ return;
132
+ }
133
+ if (!items.length) {
134
+ console.log("No conversations found.");
135
+ return;
83
136
  }
84
- // Filter to requested time range and sort by time.
85
- const startMs = new Date(opts.startTime).getTime();
86
- const endMs = new Date(opts.endTime).getTime();
87
- const filtered = allTurns
88
- .filter((t) => {
89
- const ts = new Date(t.timestamp).getTime();
90
- return ts >= startMs && ts <= endMs;
91
- })
92
- .sort((a, b) => a.timestamp.localeCompare(b.timestamp));
93
- // Friendly speaker labels: named voices → their name, the user → "Me",
94
- // anonymous voices (bare index, Person:/uv:) → a stable "Speaker N"
95
- // numbered by first appearance across the sorted transcript.
96
- const anonOrdinals = new Map();
97
- let nextOrdinal = 1;
98
- const labelFor = (token) => {
99
- const named = token.match(/^(.+):(\d+)$/);
100
- if (named) {
101
- const name = named[1].trim();
102
- if (name === "Me")
103
- return "Me";
104
- if (name && name !== "Person" && name !== "uv")
105
- return name;
106
- }
107
- if (!anonOrdinals.has(token))
108
- anonOrdinals.set(token, nextOrdinal++);
109
- return `Speaker ${anonOrdinals.get(token)}`;
110
- };
111
- if (isJsonMode(sense)) {
112
- jsonOut({
113
- transcriptions: filtered.map((t) => ({ ...t, speaker: labelFor(t.speaker) })),
114
- lastSeenTime,
115
- });
137
+ console.log(`Conversations (${items.length} items, newest first):\n` +
138
+ items.map(formatConversationLine).join("\n") +
139
+ paginationFooter(pagination));
140
+ });
141
+ // ── Transcript (by id) ──
142
+ conversations
143
+ .command("transcript <conversationId>")
144
+ .description("Get a conversation's transcript and summary (owner-only).")
145
+ .action(async (conversationId) => {
146
+ const resp = (await apiGet(`/app/conversations/${conversationId}/transcript`));
147
+ const turns = (resp.turns || []);
148
+ const summary = resp.summary || null;
149
+ if (isJsonMode(conversations)) {
150
+ jsonOut(resp);
151
+ return;
152
+ }
153
+ if (summary && (summary.category || summary.details)) {
154
+ console.log(`Summary: ${summary.category ?? "(uncategorized)"}` +
155
+ (summary.details ? `\n ${summary.details}` : ""));
156
+ console.log("");
157
+ }
158
+ if (resp.status && resp.status !== "ready") {
159
+ console.log(`Status: ${resp.status}`);
160
+ }
161
+ printTranscriptTurns(turns);
162
+ });
163
+ // ── Audio (by id; signed URL, owner-only) ──
164
+ conversations
165
+ .command("audio <conversationId>")
166
+ .description("Get a signed URL for a conversation's combined recording (owner-only; null for analyzer conversations).")
167
+ .action(async (conversationId) => {
168
+ const resp = (await apiGet(`/app/conversations/${conversationId}/audio`));
169
+ const url = resp.url ?? null;
170
+ if (isJsonMode(conversations)) {
171
+ jsonOut({ url });
116
172
  return;
117
173
  }
118
- if (!filtered.length) {
119
- console.log("No transcriptions found.");
120
- if (lastSeenTime)
121
- console.log(`Latest data available: ${lastSeenTime}`);
174
+ if (!url) {
175
+ console.log("No audio available for this conversation.");
122
176
  return;
123
177
  }
124
- const lines = filtered.map((t) => `- ${labelFor(t.speaker)} (${t.timestamp}): ${t.text}`);
125
- console.log(`Transcriptions (${filtered.length} turns):\n` + lines.join("\n"));
126
- if (lastSeenTime)
127
- console.log(`Latest data available: ${lastSeenTime}`);
178
+ console.log(url);
128
179
  });
129
180
  }
@@ -3,6 +3,8 @@ import { apiGet, apiPost, apiPatch, apiPut, apiDelete } from "../client.js";
3
3
  import { requireSpace, selectSpace, setSpaceRequirement, writeSpaceSetting, } from "./init.js";
4
4
  import { buildMentionMap, formatAttachmentLines, formatAttachmentSummary, formatPostLabel, formatReactionChips, formatReplyLine, isJsonMode, jsonOut, postBodyText, readStdin, resolveSpaceSlug, unwrapResp, } from "./utils.js";
5
5
  import { uploadPostAttachments, assertPostAttachmentMix, } from "../attachments.js";
6
+ import { registerArtifactSubcommands } from "./artifact.js";
7
+ import { registerActivitiesSubcommands, registerConversationsSubcommands, } from "./sense.js";
6
8
  function readContent(value) {
7
9
  if (value === "-")
8
10
  return readStdin();
@@ -389,7 +391,7 @@ export function registerSpaceCommand(program) {
389
391
  .option("--title <title>", "Title of the post")
390
392
  .option("--content <content>", "Post content (markdown supported, use \"-\" for stdin)")
391
393
  .option("--rich-text <richText>", "Rich-text JSON array (mutually exclusive with --content)")
392
- .option("--artifact <artifactId>", "Attach an existing artifact to the post (repeatable). Create artifacts with `gobi artifact create`.", (value, prev = []) => [...prev, value], [])
394
+ .option("--artifact <artifactId>", "Attach an existing artifact to the post (repeatable). Create artifacts with `gobi space artifact create`.", (value, prev = []) => [...prev, value], [])
393
395
  .option("--space-slug <spaceSlug>", "Space slug (overrides .gobi/settings.yaml)")
394
396
  .option("--attach <file>", "Local media or document file to attach. Repeatable. Mix rule: up to 4 photos + up to 4 document files (pdf/md/txt/csv) OR 1 GIF OR 1 video. Size ceilings: 10MB photos / 15MB GIFs / 512MB video / 250MB files.", (value, prev = []) => [...prev, value], [])
395
397
  .option("--repost-post-id <postId>", "Wrap an existing top-level post as the embedded card on this new post. Composes with --content / --rich-text / --attach (the wrapping author's text + media render above the embedded card). Reposts-of-reposts are collapsed to the transitive root server-side. The referenced post must exist, not be deleted, and not itself be a reply.")
@@ -462,7 +464,7 @@ export function registerSpaceCommand(program) {
462
464
  .option("--rich-text <richText>", "Rich-text JSON array (mutually exclusive with --content)")
463
465
  .option("--space-slug <spaceSlug>", "Space slug (overrides .gobi/settings.yaml)")
464
466
  .option("--attach <file>", "Replace the post's media attachments with the given files (existing attachments are removed). Repeatable. Mix rule: up to 4 photos + up to 4 document files (pdf/md/txt/csv) OR 1 GIF OR 1 video. Size ceilings: 10MB photos / 15MB GIFs / 512MB video / 250MB files. Omit to leave attachments unchanged.", (value, prev = []) => [...prev, value], [])
465
- .option("--artifact <artifactId>", "Replace the post's artifact attachments with the given artifact(s) (existing artifact attachments are removed). Repeatable. Omit to leave them unchanged. Create artifacts with `gobi artifact create`.", (value, prev = []) => [...prev, value], [])
467
+ .option("--artifact <artifactId>", "Replace the post's artifact attachments with the given artifact(s) (existing artifact attachments are removed). Repeatable. Omit to leave them unchanged. Create artifacts with `gobi space artifact create`.", (value, prev = []) => [...prev, value], [])
466
468
  .action(async (postId, opts) => {
467
469
  const wantsAttachChange = !!(opts.attach && opts.attach.length > 0);
468
470
  const wantsArtifactChange = !!(opts.artifact && opts.artifact.length > 0);
@@ -724,4 +726,42 @@ export function registerSpaceCommand(program) {
724
726
  }
725
727
  console.log(`Channel members (${items.length}):\n` + lines.join("\n"));
726
728
  });
729
+ // ── Artifacts (scoped to this space) ──
730
+ registerArtifactSubcommands(space, { resolve: () => ({ spaceSlug: resolveSpaceSlug(space) }) }, "Versioned creations attached to posts, scoped to this space (visible to its " +
731
+ "members). Kinds: image | video | gif | markdown | meeting_summary. Always " +
732
+ "human-owned; revisions form a draft/published tree (one published per artifact).");
733
+ // ── Sense: activities + conversations (scoped to this space) ──
734
+ //
735
+ // The conversations list endpoint is user-global (returns the caller's recent
736
+ // conversations across all scopes, each tagged with spaceId), so the scope
737
+ // resolves this space's numeric id — via GET /spaces/:slug — to filter it.
738
+ const senseScope = {
739
+ label: "space",
740
+ listActivities: async (params) => {
741
+ const q = { limit: params.limit, before: params.before };
742
+ if (params.mine)
743
+ q.mine = true;
744
+ const resp = (await apiGet(`/spaces/${resolveSpaceSlug(space)}/activities`, q));
745
+ return {
746
+ items: (resp.activities || []),
747
+ pagination: resp.pagination,
748
+ };
749
+ },
750
+ listConversations: async (params) => {
751
+ const q = { limit: params.limit, before: params.before };
752
+ if (params.mine)
753
+ q.mine = true;
754
+ const resp = (await apiGet(`/spaces/${resolveSpaceSlug(space)}/conversations`, q));
755
+ return {
756
+ items: (resp.conversations || []),
757
+ pagination: resp.pagination,
758
+ };
759
+ },
760
+ };
761
+ registerActivitiesSubcommands(space, senseScope, "This space's Sense activities — every member's, attributed to each recorder " +
762
+ "(browse-only). Use `gobi space --space-slug <slug> activities …` or set the " +
763
+ "active space with `gobi space warp`.");
764
+ registerConversationsSubcommands(space, senseScope, "This space's Sense conversations — every member's, attributed to each recorder " +
765
+ "(browse-only; transcript/audio stay owner-only). Use `gobi space --space-slug " +
766
+ "<slug> conversations …` or set the active space with `gobi space warp`.");
727
767
  }