@heyamiko/amiko-cli 0.18.0 β†’ 0.19.0

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.
package/README.md CHANGED
@@ -314,6 +314,10 @@ amiko chat send @sophie "made this 🎡" --audio <generated-url> --yes # attach
314
314
  amiko chat send @sophie --gif "happy dance" --yes # send the top Klipy GIF for a search (message optional)
315
315
  amiko chat gifs "happy dance" # browse GIFs first, then send a specific one:
316
316
  amiko chat send @sophie "we did it!" --gif <gifUrl> --yes # attach an exact GIF by its Klipy URL
317
+ amiko chat library --kind image # your Library (Create Studio creations) with refs
318
+ amiko chat explore --search sunset # public Explore media with refs
319
+ amiko chat send @sophie "for review" --attach drive:<docId> --attach library:<id> --yes # attach files you already have
320
+ amiko chat send @sophie --attach explore:post:<postId>:0 --yes # message optional with --attach
317
321
  amiko chat receipts <messageId> # who read your message + when (id from `chat read`)
318
322
  amiko chat pin <messageId> --yes # pin for everyone (id from `chat read`; groups: admins only)
319
323
  amiko chat unpin <messageId> --yes # remove a pin for everyone
@@ -324,7 +328,12 @@ amiko chat mark-read --all --yes # mark EVERY conversation as re
324
328
  - **`<target>`** for `read`/`send` is a **conversation id** (from `chat list` β€” use this for groups), a **user id**, an **`@handle`**, or a **name**. Names resolve against your **friends first**, then people search; if ambiguous, the CLI lists candidates instead of guessing. A cuid that isn't one of your conversations is retried as a user id, so pasting a user id "just works".
325
329
  - `send` to a person you have no DM with yet **creates the DM automatically** (find-or-create, after the confirmation). `read` never creates one β€” it errors if no DM exists yet.
326
330
  - **Unread mentions & replies**: rows whose unread messages **concern you directly** get an `@you` marker next to the unread count, and `chat list --mentions` filters to just those. The flag is server-computed over your unread messages and covers three cases: a direct **@mention** of you, a permitted **@all** in a group, and a **reply to one of your messages**. Composes with `--limit`/`--archived`/`--raw` (the raw payload is filtered too). Requires the amiko-web `has_unread_mention` deploy. On an older server the field is absent and reads as **false for every conversation**: plain `chat list` still shows unread counts, just without `@you`, and `chat list --mentions` returns no matches.
327
- - **Inline media**: `--image <pathOrUrl>` (jpg/png/webp/gif, local ≀10MB or an Amiko URL), `--audio <pathOrUrl>` (local ≀25MB or an Amiko URL β†’ sent as a voice note), and `--gif <queryOrUrl>` (search words send the **top Klipy result**; or pass an exact Klipy URL from `amiko chat gifs` β€” non-Klipy URLs are refused; to send another image, use `--image` with a local file or an Amiko URL). One media block per message (image *or* audio *or* gif). **General video files aren't supported in chat yet.** A local file is uploaded; an Amiko URL (e.g. a generated asset from `amiko create`) attaches directly with no re-upload. (A local *audio* file is sent as a voice note, with your text as a separate message.)
331
+ - **Inline media**: `--image <pathOrUrl>` (jpg/png/webp/gif, local ≀10MB or an Amiko URL), `--audio <pathOrUrl>` (local ≀25MB or an Amiko URL β†’ sent as a voice note), and `--gif <queryOrUrl>` (search words send the **top Klipy result**; or pass an exact Klipy URL from `amiko chat gifs` β€” non-Klipy URLs are refused; to send another image, use `--image` with a local file or an Amiko URL). One of those per message (image *or* audio *or* gif) β€” `--image` can also be combined with `--attach` below. **Local video files aren't supported in chat yet** (Library/Explore videos are, via `--attach`). A local file is uploaded; an Amiko URL (e.g. a generated asset from `amiko create`) attaches directly with no re-upload. (A local *audio* file is sent as a voice note, with your text as a separate message.)
332
+ - **Attach from Drive, Library or Explore**: `--attach <ref>` (repeatable, up to 6 per message including `--image`) sends files you already have on Amiko β€” the same sources as the web composer's "Add from Drive" and "Add from Library or Explore". Nothing is re-uploaded.
333
+ - `drive:<docId>` β€” an **image or document** (pdf, docx, …, ≀25MB) from your Drive; ids from `amiko drive list --json` / `drive search`. Audio/video Drive files are refused. Sent as a file attachment that keeps its Drive `doc_id`.
334
+ - `library:<id>` β€” one of your Create Studio creations (image, video or audio); refs from `amiko chat library` (`--kind`, `--search`, `--limit`, `--cursor`, `--raw`).
335
+ - `explore:post:<postId>:<n>` β€” media from a public post on Explore; refs from `amiko chat explore` (same flags, minus `--twin`).
336
+ - Drive and Library use your current twin; `--twin <id>` picks another. The message text is optional with `--attach` (recipients see an "Image"/"Video"/"Audio"/"File" label). Library/Explore refs are checked before the confirmation; Drive files are validated and signed only after you approve, so a declined send leaves nothing behind. `--attach` can't be combined with `--audio` or `--gif`.
328
337
  - **GIFs**: `amiko chat gifs [query]` lists Klipy GIFs (trending with no query; `--page`/`--limit`/`--raw`) β€” read-only and ungated, powered by KLIPY. The message text is optional with `--gif` (a captionless GIF sends as just the GIF); a caption renders under the GIF on every client.
329
338
  - **@all in groups**: `--all` prepends an @all mention, and a standalone `@all` word typed in the message converts too β€” either way **every member** is notified. Group **admins/owners** can always use it; everyone else only after an admin runs `amiko chat group mention-all <group> on`. Without permission, `--all` fails with the fix named, while a typed `@all` is delivered as plain text (with a note). Incoming mention markup renders as plain `@name`/`@all` in `chat read` and `chat list`.
330
339
  - `chat send` messages a real person, so it's **gated on `--yes`** in non-interactive shells. Delivery is real-time. `--raw` prints raw JSON on any subcommand.
@@ -998,7 +1007,7 @@ Your conversations (DMs + group chats) as the owner β€” list, read, send, manage
998
1007
  |---|---|
999
1008
  | `chat list` | List your conversations (DMs and group chats) |
1000
1009
  | `chat read` | Read recent messages in a conversation (conversation id, user id, @handle, or name) |
1001
- | `chat send` | Send a message as yourself (the owner) to a conversation or person (conversation id, user id, @handle, or name β€” DMs are opened automatically). The message is optional only with --gif. |
1010
+ | `chat send` | Send a message as yourself (the owner) to a conversation or person (conversation id, user id, @handle, or name β€” DMs are opened automatically). The message is optional only with --gif or --attach. |
1002
1011
  | `chat react` | React to a message as yourself (the owner). Get the message id from `amiko chat read`. One reaction per message β€” a new emoji replaces your previous one. |
1003
1012
  | `chat unreact` | Remove your (the owner's) reaction from a message. Get the message id from `amiko chat read`. |
1004
1013
  | `chat reactions` | Show the reactions on a message. Get the message id from `amiko chat read`. |
@@ -1008,6 +1017,8 @@ Your conversations (DMs + group chats) as the owner β€” list, read, send, manage
1008
1017
  | `chat pinned` | List a conversation's pinned messages, oldest first (conversation id, user id, @handle, or name) |
1009
1018
  | `chat mark-read` | Mark ALL conversations as read (requires --all). Clears every unread badge on the owner's devices; senders see read ticks. |
1010
1019
  | `chat gifs` | Search Klipy GIFs (trending when no query) β€” pass a result URL to `amiko chat send --gif` |
1020
+ | `chat library` | List your Library (Create Studio creations) β€” pass a ref to `amiko chat send --attach` |
1021
+ | `chat explore` | List recent public media from Explore β€” pass a ref to `amiko chat send --attach` |
1011
1022
  | `chat group` | Group chats β€” create, list, info, rename, add/remove members, @all permission, leave |
1012
1023
  | `chat lists` | Your chat lists (private folders of conversations) β€” run bare to see them; create, rename, add/remove conversations, delete |
1013
1024
  | `chat poll` | Chat polls β€” create, show, vote, retract your vote, close (as the owner) |
@@ -1034,13 +1045,15 @@ Read recent messages in a conversation (conversation id, user id, @handle, or na
1034
1045
 
1035
1046
  #### `amiko chat send <target> [message]`
1036
1047
 
1037
- Send a message as yourself (the owner) to a conversation or person (conversation id, user id, @handle, or name β€” DMs are opened automatically). The message is optional only with --gif.
1048
+ Send a message as yourself (the owner) to a conversation or person (conversation id, user id, @handle, or name β€” DMs are opened automatically). The message is optional only with --gif or --attach.
1038
1049
 
1039
1050
  | Flag | Default | Description |
1040
1051
  |---|---|---|
1041
1052
  | `--image <pathOrUrl>` | | Attach an image: local file (jpg/png/webp/gif, ≀10MB) or an Amiko URL (e.g. from `amiko create`) |
1042
1053
  | `--audio <pathOrUrl>` | | Attach audio as a voice note: local file (≀25MB) or an Amiko URL |
1043
1054
  | `--gif <queryOrUrl>` | | Attach a GIF: search words (sends the top Klipy result) or a Klipy URL from `amiko chat gifs` |
1055
+ | `--attach <ref>` | | Attach a file you already have on Amiko (repeatable, up to 6): drive:<docId> (image or document from `amiko drive list`), library:<id> (from `amiko chat library`), explore:post:<postId>:<n> (from `amiko chat explore`). Combines with --image. |
1056
+ | `--twin <id>` | | Twin whose Drive/Library --attach reads (default: your current twin) |
1044
1057
  | `--all` | | Mention @all β€” notifies every member of the group (admins/owners always; others only when enabled via `amiko chat group mention-all`) |
1045
1058
  | `--yes` | | Skip the confirmation (required in non-interactive shells) |
1046
1059
  | `--raw` | | Output raw JSON |
@@ -1125,6 +1138,31 @@ Search Klipy GIFs (trending when no query) β€” pass a result URL to `amiko chat
1125
1138
  | `--limit <n>` | `10` | Max results shown (1-24) |
1126
1139
  | `--raw` | | Output raw JSON |
1127
1140
 
1141
+ #### `amiko chat library`
1142
+
1143
+ List your Library (Create Studio creations) β€” pass a ref to `amiko chat send --attach`
1144
+
1145
+ | Flag | Default | Description |
1146
+ |---|---|---|
1147
+ | `--twin <id>` | | Twin whose Library to list (default: current twin) |
1148
+ | `--kind <kind>` | | Only image, video, or audio |
1149
+ | `--search <text>` | | Filter by prompt text |
1150
+ | `--limit <n>` | `20` | Items to fetch (1-50) |
1151
+ | `--cursor <cursor>` | | Continue from a previous page |
1152
+ | `--raw` | | Output raw JSON |
1153
+
1154
+ #### `amiko chat explore`
1155
+
1156
+ List recent public media from Explore β€” pass a ref to `amiko chat send --attach`
1157
+
1158
+ | Flag | Default | Description |
1159
+ |---|---|---|
1160
+ | `--kind <kind>` | | Only image, video, or audio |
1161
+ | `--search <text>` | | Filter by title |
1162
+ | `--limit <n>` | `20` | Posts to fetch (1-96) |
1163
+ | `--cursor <cursor>` | | Continue from a previous page |
1164
+ | `--raw` | | Output raw JSON |
1165
+
1128
1166
  #### `amiko chat group`
1129
1167
 
1130
1168
  Group chats β€” create, list, info, rename, add/remove members, @all permission, leave
package/dist/index.js CHANGED
@@ -28902,6 +28902,392 @@ function registerChatGroupCommand(group) {
28902
28902
  });
28903
28903
  }
28904
28904
 
28905
+ // src/lib/chat-attachments.ts
28906
+ var MAX_CHAT_ATTACHMENTS = 6;
28907
+ var LIBRARY_PAGE_SIZE = 50;
28908
+ var MAX_LIBRARY_PAGES = 10;
28909
+ var REF_HELP = "Use drive:<docId> (ids from `amiko drive list --json`), library:<id> (from `amiko chat library`), or explore:post:<postId>:<n> (from `amiko chat explore`).";
28910
+ var REF_RE = /^(drive|library|explore):(.+)$/i;
28911
+ var EXPLORE_ID_RE = /^(?:post:)?(.+):(\d+)$/i;
28912
+ function parseAttachRef(raw) {
28913
+ const value = raw.trim();
28914
+ const match = REF_RE.exec(value);
28915
+ const rest = match?.[2]?.trim() ?? "";
28916
+ if (!match || !rest) {
28917
+ throw new Error(`Unknown attachment "${raw}". ${REF_HELP}`);
28918
+ }
28919
+ const source = match[1].toLowerCase();
28920
+ if (source === "drive")
28921
+ return { source, raw: value, docId: rest };
28922
+ if (source === "library")
28923
+ return { source, raw: value, mediaId: rest };
28924
+ const explore = EXPLORE_ID_RE.exec(rest);
28925
+ if (!explore || !explore[1].trim()) {
28926
+ throw new Error(`"${raw}" is not an Explore item. Explore refs look like explore:post:<postId>:<n> β€” copy one from \`amiko chat explore\`.`);
28927
+ }
28928
+ return {
28929
+ source: "explore",
28930
+ raw: value,
28931
+ postId: explore[1].trim(),
28932
+ index: Number.parseInt(explore[2], 10)
28933
+ };
28934
+ }
28935
+ function refKey(ref) {
28936
+ switch (ref.source) {
28937
+ case "drive":
28938
+ return `drive:${ref.docId}`;
28939
+ case "library":
28940
+ return `library:${ref.mediaId}`;
28941
+ case "explore":
28942
+ return `explore:${ref.postId}:${ref.index}`;
28943
+ }
28944
+ }
28945
+ function parseAttachRefs(raws, extra = 0) {
28946
+ const refs = [];
28947
+ const seen = new Set;
28948
+ for (const raw of raws) {
28949
+ const ref = parseAttachRef(raw);
28950
+ const key = refKey(ref);
28951
+ if (seen.has(key))
28952
+ continue;
28953
+ seen.add(key);
28954
+ refs.push(ref);
28955
+ }
28956
+ if (refs.length + extra > MAX_CHAT_ATTACHMENTS) {
28957
+ throw new Error(`A message can carry at most ${MAX_CHAT_ATTACHMENTS} attachments (got ${refs.length + extra}).`);
28958
+ }
28959
+ return refs;
28960
+ }
28961
+ var VIDEO_EXT_RE = /\.(mp4|webm|mov|m4v)(\?|#|$)/i;
28962
+ var AUDIO_EXT_RE = /\.(mp3|wav|ogg|oga|m4a|aac|flac|opus|weba)(\?|#|$)/i;
28963
+ function mediaKindFromUrl(url2) {
28964
+ if (VIDEO_EXT_RE.test(url2))
28965
+ return "video";
28966
+ if (AUDIO_EXT_RE.test(url2))
28967
+ return "audio";
28968
+ return "image";
28969
+ }
28970
+ var AUDIO_SERVICES = new Set(["audio", "tts", "music", "sfx"]);
28971
+ function libraryItemKind(item) {
28972
+ const mime = item.mime_type?.toLowerCase() ?? "";
28973
+ if (item.service === "video" || mime.startsWith("video/"))
28974
+ return "video";
28975
+ if (AUDIO_SERVICES.has(item.service) || mime.startsWith("audio/")) {
28976
+ return "audio";
28977
+ }
28978
+ if (mime.startsWith("image/"))
28979
+ return "image";
28980
+ return mediaKindFromUrl(item.url);
28981
+ }
28982
+ function isHttpsUrl(url2) {
28983
+ if (!url2 || !URL.canParse(url2))
28984
+ return false;
28985
+ return new URL(url2).protocol === "https:";
28986
+ }
28987
+ var FALLBACK_NAME = {
28988
+ image: "Image",
28989
+ video: "Video",
28990
+ audio: "Audio",
28991
+ file: "File"
28992
+ };
28993
+ function attachmentMessageType(parts) {
28994
+ if (parts.images.length > 0)
28995
+ return "image";
28996
+ if (parts.videos.length > 0)
28997
+ return "video";
28998
+ if (parts.audios.length > 0)
28999
+ return "audio";
29000
+ return "file";
29001
+ }
29002
+ function attachmentFallbackContent(type) {
29003
+ return FALLBACK_NAME[type];
29004
+ }
29005
+ function fetchLibraryPage(auth, twinId, query) {
29006
+ return amikoWebFetch(auth, `/api/agents/${encodeURIComponent(twinId)}/media`, {
29007
+ query: {
29008
+ limit: query.limit,
29009
+ cursor: query.cursor,
29010
+ service: query.service
29011
+ },
29012
+ timeoutMs: 15000
29013
+ });
29014
+ }
29015
+ function fetchExplorePage(auth, query) {
29016
+ return amikoWebFetch(auth, "/api/design/explore", {
29017
+ query: { limit: query.limit, cursor: query.cursor },
29018
+ timeoutMs: 15000
29019
+ });
29020
+ }
29021
+ async function resolveLibraryRefs(auth, twinId, refs) {
29022
+ const wanted = new Set(refs.map((r) => r.mediaId));
29023
+ const found = new Map;
29024
+ let cursor;
29025
+ for (let page = 0;page < MAX_LIBRARY_PAGES && wanted.size > 0; page++) {
29026
+ const data = await fetchLibraryPage(auth, twinId, {
29027
+ limit: LIBRARY_PAGE_SIZE,
29028
+ cursor
29029
+ });
29030
+ for (const item of data.items ?? []) {
29031
+ if (wanted.delete(item.id))
29032
+ found.set(item.id, item);
29033
+ }
29034
+ if (!data.nextCursor)
29035
+ break;
29036
+ cursor = data.nextCursor;
29037
+ }
29038
+ const missing = refs.find((r) => !found.has(r.mediaId));
29039
+ if (missing) {
29040
+ throw new Error(`"${missing.raw}" wasn't found in twin ${twinId}'s Library (the ${MAX_LIBRARY_PAGES * LIBRARY_PAGE_SIZE} most recent creations are searched). Copy a ref from \`amiko chat library\`, or pass --twin for another twin's Library.`);
29041
+ }
29042
+ return found;
29043
+ }
29044
+ async function resolveExploreRef(auth, ref) {
29045
+ const notExplore = () => new Error(`"${ref.raw}" is not an Explore item (the post is missing, not public, or has no media #${ref.index}). Copy a ref from \`amiko chat explore\`.`);
29046
+ let data;
29047
+ try {
29048
+ data = await amikoWebFetch(auth, `/api/posts/${encodeURIComponent(ref.postId)}`, { timeoutMs: 15000 });
29049
+ } catch (e5) {
29050
+ if (e5.status === 404)
29051
+ throw notExplore();
29052
+ throw e5;
29053
+ }
29054
+ const post = data.post;
29055
+ if (!post || post.visibility !== "public" || post.media_status && post.media_status !== "ready") {
29056
+ throw notExplore();
29057
+ }
29058
+ const urls = (post.media_urls ?? []).filter(isHttpsUrl);
29059
+ const url2 = urls[ref.index];
29060
+ if (!url2)
29061
+ throw notExplore();
29062
+ const kind = mediaKindFromUrl(url2);
29063
+ return {
29064
+ ref,
29065
+ kind,
29066
+ record: {
29067
+ url: url2,
29068
+ name: post.title?.trim() || post.twin?.name?.trim() || FALLBACK_NAME[kind]
29069
+ }
29070
+ };
29071
+ }
29072
+ async function resolveAssetRefs(auth, refs, twinId) {
29073
+ const libraryRefs = refs.filter((r) => r.source === "library");
29074
+ const library = libraryRefs.length > 0 ? await resolveLibraryRefs(auth, twinId, libraryRefs) : new Map;
29075
+ const resolved = [];
29076
+ for (const ref of refs) {
29077
+ if (ref.source === "library") {
29078
+ const item = library.get(ref.mediaId);
29079
+ if (!isHttpsUrl(item.url)) {
29080
+ throw new Error(`"${ref.raw}" has no https URL, so it can't be sent.`);
29081
+ }
29082
+ const kind = libraryItemKind(item);
29083
+ resolved.push({
29084
+ ref,
29085
+ kind,
29086
+ record: {
29087
+ url: item.url,
29088
+ name: item.prompt?.trim() || FALLBACK_NAME[kind],
29089
+ ...item.mime_type ? { contentType: item.mime_type } : {}
29090
+ }
29091
+ });
29092
+ } else if (ref.source === "explore") {
29093
+ resolved.push(await resolveExploreRef(auth, ref));
29094
+ }
29095
+ }
29096
+ const seen = new Set;
29097
+ return resolved.filter((a) => {
29098
+ if (seen.has(a.record.url))
29099
+ return false;
29100
+ seen.add(a.record.url);
29101
+ return true;
29102
+ });
29103
+ }
29104
+ function driveError(e5, twinId) {
29105
+ const body = e5.body;
29106
+ let parsed = {};
29107
+ try {
29108
+ parsed = body ? JSON.parse(body) : {};
29109
+ } catch {}
29110
+ const which = parsed.name ? `"${parsed.name}"${parsed.docId ? ` (drive:${parsed.docId})` : ""}` : parsed.docId ? `drive:${parsed.docId}` : "A Drive file";
29111
+ switch (parsed.code) {
29112
+ case "NOT_FOUND":
29113
+ return new Error(`${which} wasn't found in twin ${twinId}'s Drive. Get ids from \`amiko drive list --json\` for that same twin (use --twin to pick another).`);
29114
+ case "UNSUPPORTED":
29115
+ return new Error(`${which} can't be attached to a chat β€” only images and documents (pdf, docx, …) can be sent from Drive.`);
29116
+ case "FILE_TOO_LARGE":
29117
+ return new Error(`${which} is over the 25MB chat attachment limit.`);
29118
+ case "INVALID_BODY":
29119
+ return new Error(parsed.error ?? "Invalid Drive attachment request.");
29120
+ default:
29121
+ return e5 instanceof Error ? e5 : new Error(String(e5));
29122
+ }
29123
+ }
29124
+ async function signDriveRefs(auth, refs, twinId) {
29125
+ const docIds = refs.flatMap((r) => r.source === "drive" ? [r.docId] : []);
29126
+ if (docIds.length === 0)
29127
+ return [];
29128
+ let data;
29129
+ try {
29130
+ data = await amikoWebFetch(auth, `/api/agents/${encodeURIComponent(twinId)}/docs/chat-attachments`, { method: "POST", body: { docIds }, timeoutMs: 30000 });
29131
+ } catch (e5) {
29132
+ throw driveError(e5, twinId);
29133
+ }
29134
+ const files = data.files ?? [];
29135
+ if (files.length !== docIds.length) {
29136
+ throw new Error("Drive returned an incomplete set of attachments.");
29137
+ }
29138
+ return files.map((f) => ({
29139
+ url: f.url,
29140
+ name: f.name,
29141
+ contentType: f.contentType,
29142
+ size: f.size,
29143
+ doc_id: f.docId
29144
+ }));
29145
+ }
29146
+ function buildAttachmentParts(assets, files, leadingImages = []) {
29147
+ const byKind = (kind) => assets.filter((a) => a.kind === kind).map((a) => a.record);
29148
+ return {
29149
+ images: [...leadingImages, ...byKind("image")],
29150
+ videos: byKind("video"),
29151
+ audios: byKind("audio"),
29152
+ files
29153
+ };
29154
+ }
29155
+ function attachmentMetadata(parts) {
29156
+ const out = {};
29157
+ if (parts.images.length)
29158
+ out.images = parts.images;
29159
+ if (parts.videos.length)
29160
+ out.videos = parts.videos;
29161
+ if (parts.audios.length)
29162
+ out.audios = parts.audios;
29163
+ if (parts.files.length)
29164
+ out.files = parts.files;
29165
+ return out;
29166
+ }
29167
+ function plural(n, word) {
29168
+ return `${n} ${word}${n === 1 ? "" : "s"}`;
29169
+ }
29170
+ function describeAttachments(refs, assets) {
29171
+ const counts = new Map;
29172
+ const bump = (label2) => counts.set(label2, (counts.get(label2) ?? 0) + 1);
29173
+ for (const ref of refs)
29174
+ if (ref.source === "drive")
29175
+ bump("drive file");
29176
+ for (const a of assets)
29177
+ bump(`${a.ref.source} ${a.kind}`);
29178
+ return [...counts].map(([label2, n]) => plural(n, label2)).join(", ");
29179
+ }
29180
+
29181
+ // src/commands/chat-assets.ts
29182
+ var DEFAULT_LIMIT = 20;
29183
+ var MAX_TITLE = 48;
29184
+ var KINDS = new Set(["image", "video", "audio"]);
29185
+ function clampLimit(raw, max) {
29186
+ const n = Number.parseInt(raw, 10);
29187
+ if (!Number.isFinite(n))
29188
+ return DEFAULT_LIMIT;
29189
+ return Math.min(max, Math.max(1, n));
29190
+ }
29191
+ function truncate(text, max) {
29192
+ const flat = text.replace(/\s+/g, " ").trim();
29193
+ return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
29194
+ }
29195
+ function shortDate(iso) {
29196
+ return iso ? iso.slice(0, 16).replace("T", " ") : "-";
29197
+ }
29198
+ function parseKind(raw) {
29199
+ if (!raw)
29200
+ return;
29201
+ const kind = raw.toLowerCase();
29202
+ if (!KINDS.has(kind)) {
29203
+ console.error(error(`--kind must be image, video, or audio (got "${raw}").`));
29204
+ process.exit(1);
29205
+ }
29206
+ return kind;
29207
+ }
29208
+ function matches(title, search) {
29209
+ const q = search.trim().toLowerCase();
29210
+ return !q || (title ?? "").toLowerCase().includes(q);
29211
+ }
29212
+ function registerChatAssetsCommands(chat) {
29213
+ chat.command("library").description("List your Library (Create Studio creations) β€” pass a ref to `amiko chat send --attach`").option("--twin <id>", "Twin whose Library to list (default: current twin)").option("--kind <kind>", "Only image, video, or audio").option("--search <text>", "Filter by prompt text").option("--limit <n>", "Items to fetch (1-50)", String(DEFAULT_LIMIT)).option("--cursor <cursor>", "Continue from a previous page").option("--raw", "Output raw JSON").action(async (opts) => {
29214
+ const auth = authOrExit();
29215
+ const kind = parseKind(opts.kind);
29216
+ const twinId = opts.twin || auth.twinId || "";
29217
+ let data;
29218
+ try {
29219
+ data = await fetchLibraryPage(auth, twinId, {
29220
+ limit: clampLimit(opts.limit, 50),
29221
+ cursor: opts.cursor,
29222
+ service: kind === "image" || kind === "video" ? kind : undefined
29223
+ });
29224
+ } catch (e5) {
29225
+ console.error(error(e5 instanceof Error ? e5.message : "Library lookup failed"));
29226
+ process.exit(1);
29227
+ }
29228
+ const items = (data.items ?? []).filter((it) => (!kind || libraryItemKind(it) === kind) && matches(it.prompt, opts.search ?? ""));
29229
+ renderOutput({ items, nextCursor: data.nextCursor ?? null }, (d) => {
29230
+ if (d.items.length === 0) {
29231
+ console.log(dim("No matching Library items."));
29232
+ } else {
29233
+ console.log(heading(`Library (${d.items.length})`));
29234
+ const rows = [
29235
+ [dim("REF"), dim("KIND"), dim("CREATED"), dim("PROMPT")],
29236
+ ...d.items.map((it) => [
29237
+ `library:${it.id}`,
29238
+ libraryItemKind(it),
29239
+ shortDate(it.created_at),
29240
+ truncate(it.prompt || "-", MAX_TITLE)
29241
+ ])
29242
+ ];
29243
+ console.log(table(rows));
29244
+ console.log(dim(`
29245
+ Attach: amiko chat send <target> "…" --attach <ref>`));
29246
+ }
29247
+ if (d.nextCursor) {
29248
+ console.log(dim(`More: --cursor ${JSON.stringify(d.nextCursor)}`));
29249
+ }
29250
+ }, { json: opts.raw });
29251
+ });
29252
+ chat.command("explore").description("List recent public media from Explore β€” pass a ref to `amiko chat send --attach`").option("--kind <kind>", "Only image, video, or audio").option("--search <text>", "Filter by title").option("--limit <n>", "Posts to fetch (1-96)", String(DEFAULT_LIMIT)).option("--cursor <cursor>", "Continue from a previous page").option("--raw", "Output raw JSON").action(async (opts) => {
29253
+ const auth = authOrExit();
29254
+ const kind = parseKind(opts.kind);
29255
+ let data;
29256
+ try {
29257
+ data = await fetchExplorePage(auth, {
29258
+ limit: clampLimit(opts.limit, 96),
29259
+ cursor: opts.cursor
29260
+ });
29261
+ } catch (e5) {
29262
+ console.error(error(e5 instanceof Error ? e5.message : "Explore lookup failed"));
29263
+ process.exit(1);
29264
+ }
29265
+ const items = (data.items ?? []).filter((it) => (!kind || it.kind.toLowerCase() === kind) && matches(it.title, opts.search ?? ""));
29266
+ renderOutput({ items, nextCursor: data.nextCursor ?? null }, (d) => {
29267
+ if (d.items.length === 0) {
29268
+ console.log(dim("No matching Explore items."));
29269
+ } else {
29270
+ console.log(heading(`Explore (${d.items.length})`));
29271
+ const rows = [
29272
+ [dim("REF"), dim("KIND"), dim("BY"), dim("TITLE")],
29273
+ ...d.items.map((it) => [
29274
+ `explore:${it.id}`,
29275
+ it.kind.toLowerCase(),
29276
+ it.author?.handle ? `@${it.author.handle}` : it.author?.name ?? "-",
29277
+ truncate(it.title || "-", MAX_TITLE)
29278
+ ])
29279
+ ];
29280
+ console.log(table(rows));
29281
+ console.log(dim(`
29282
+ Attach: amiko chat send <target> "…" --attach <ref>`));
29283
+ }
29284
+ if (d.nextCursor) {
29285
+ console.log(dim(`More: --cursor ${JSON.stringify(d.nextCursor)}`));
29286
+ }
29287
+ }, { json: opts.raw });
29288
+ });
29289
+ }
29290
+
28905
29291
  // src/lib/gifs.ts
28906
29292
  function parseUrl(url2) {
28907
29293
  try {
@@ -28951,19 +29337,19 @@ function gifToMetadata(gif) {
28951
29337
  }
28952
29338
 
28953
29339
  // src/commands/chat-gifs.ts
28954
- var DEFAULT_LIMIT = 10;
28955
- var MAX_TITLE = 40;
28956
- function clampLimit(raw) {
29340
+ var DEFAULT_LIMIT2 = 10;
29341
+ var MAX_TITLE2 = 40;
29342
+ function clampLimit2(raw) {
28957
29343
  const n = Number.parseInt(raw, 10);
28958
29344
  if (!Number.isFinite(n))
28959
- return DEFAULT_LIMIT;
29345
+ return DEFAULT_LIMIT2;
28960
29346
  return Math.min(24, Math.max(1, n));
28961
29347
  }
28962
- function truncate(text, max) {
29348
+ function truncate2(text, max) {
28963
29349
  return text.length > max ? `${text.slice(0, max - 1)}…` : text;
28964
29350
  }
28965
29351
  function registerChatGifsCommand(chat) {
28966
- chat.command("gifs [query]").description("Search Klipy GIFs (trending when no query) β€” pass a result URL to `amiko chat send --gif`").option("--page <n>", "Result page (24 per page)", "1").option("--limit <n>", "Max results shown (1-24)", String(DEFAULT_LIMIT)).option("--raw", "Output raw JSON").action(async (query, opts) => {
29352
+ chat.command("gifs [query]").description("Search Klipy GIFs (trending when no query) β€” pass a result URL to `amiko chat send --gif`").option("--page <n>", "Result page (24 per page)", "1").option("--limit <n>", "Max results shown (1-24)", String(DEFAULT_LIMIT2)).option("--raw", "Output raw JSON").action(async (query, opts) => {
28967
29353
  const auth = authOrExit();
28968
29354
  const q = query?.trim() ?? "";
28969
29355
  const page = Math.max(1, Number.parseInt(opts.page, 10) || 1);
@@ -28975,7 +29361,7 @@ function registerChatGifsCommand(chat) {
28975
29361
  process.exit(1);
28976
29362
  }
28977
29363
  renderOutput(data, (d) => {
28978
- const items = (d.items ?? []).slice(0, clampLimit(opts.limit));
29364
+ const items = (d.items ?? []).slice(0, clampLimit2(opts.limit));
28979
29365
  if (items.length === 0) {
28980
29366
  console.log(dim(q ? `No GIFs found for "${q}".` : "No trending GIFs."));
28981
29367
  console.log(dim("Powered by KLIPY"));
@@ -28985,7 +29371,7 @@ function registerChatGifsCommand(chat) {
28985
29371
  const rows = [
28986
29372
  [dim("TITLE"), dim("SIZE"), dim("URL")],
28987
29373
  ...items.map((gif) => [
28988
- truncate(gif.title || "GIF", MAX_TITLE),
29374
+ truncate2(gif.title || "GIF", MAX_TITLE2),
28989
29375
  gif.width && gif.height ? `${gif.width}x${gif.height}` : "-",
28990
29376
  gif.gifUrl
28991
29377
  ])
@@ -29027,15 +29413,15 @@ async function resolveList(auth, target) {
29027
29413
  }
29028
29414
  const lower = t.toLowerCase();
29029
29415
  const exact = all.filter((l) => l.name.toLowerCase() === lower);
29030
- const matches = exact.length ? exact : all.filter((l) => l.name.toLowerCase().includes(lower));
29031
- if (matches.length === 0) {
29416
+ const matches2 = exact.length ? exact : all.filter((l) => l.name.toLowerCase().includes(lower));
29417
+ if (matches2.length === 0) {
29032
29418
  throw new Error(`No chat list matching "${t}". Run \`amiko chat lists\` to see them.`);
29033
29419
  }
29034
- if (matches.length > 1) {
29035
- const names = matches.map((l) => `"${l.name}" (${l.id})`).join(", ");
29420
+ if (matches2.length > 1) {
29421
+ const names = matches2.map((l) => `"${l.name}" (${l.id})`).join(", ");
29036
29422
  throw new Error(`"${t}" is ambiguous β€” matches: ${names}. Use the list's id.`);
29037
29423
  }
29038
- return matches[0];
29424
+ return matches2[0];
29039
29425
  }
29040
29426
  async function resolveListOrExit(auth, target) {
29041
29427
  try {
@@ -29413,7 +29799,7 @@ function optionTexts(poll, ids) {
29413
29799
  const byId = new Map(poll.options.map((o3) => [o3.id, o3.text]));
29414
29800
  return ids.map((id) => byId.get(id) ?? id);
29415
29801
  }
29416
- function plural(n, word) {
29802
+ function plural2(n, word) {
29417
29803
  return `${n} ${word}${n === 1 ? "" : "s"}`;
29418
29804
  }
29419
29805
  function renderPoll(poll, opts = {}) {
@@ -29426,7 +29812,7 @@ function renderPoll(poll, opts = {}) {
29426
29812
  sortedOptions(poll).forEach((option, i) => {
29427
29813
  const marker = mine.has(option.id) ? "●" : "β—‹";
29428
29814
  const pct = poll.total_voters > 0 ? Math.round(option.vote_count / poll.total_voters * 100) : 0;
29429
- const result = state.resultsVisible ? dim(` ${plural(option.vote_count, "vote")} Β· ${pct}%`) : "";
29815
+ const result = state.resultsVisible ? dim(` ${plural2(option.vote_count, "vote")} Β· ${pct}%`) : "";
29430
29816
  const correct = option.is_correct ? " βœ“ correct" : "";
29431
29817
  lines.push(`${marker} ${i + 1}. ${option.text}${result}${correct}`);
29432
29818
  if (!opts.compact && opts.nameFor && state.resultsVisible && option.voter_ids?.length) {
@@ -29435,7 +29821,7 @@ function renderPoll(poll, opts = {}) {
29435
29821
  });
29436
29822
  if (poll.explanation)
29437
29823
  lines.push(dim(`Explanation: ${poll.explanation}`));
29438
- const footer = [plural(poll.total_voters, "voter")];
29824
+ const footer = [plural2(poll.total_voters, "voter")];
29439
29825
  if (!state.resultsVisible)
29440
29826
  footer.push("results hidden until you answer");
29441
29827
  lines.push(dim(footer.join(" Β· ")));
@@ -29927,15 +30313,28 @@ function registerChatCommand(chat) {
29927
30313
  }
29928
30314
  }
29929
30315
  });
29930
- chat.command("send <target> [message]").description("Send a message as yourself (the owner) to a conversation or person (conversation id, user id, @handle, or name β€” DMs are opened automatically). The message is optional only with --gif.").option("--image <pathOrUrl>", "Attach an image: local file (jpg/png/webp/gif, ≀10MB) or an Amiko URL (e.g. from `amiko create`)").option("--audio <pathOrUrl>", "Attach audio as a voice note: local file (≀25MB) or an Amiko URL").option("--gif <queryOrUrl>", "Attach a GIF: search words (sends the top Klipy result) or a Klipy URL from `amiko chat gifs`").option("--all", "Mention @all β€” notifies every member of the group (admins/owners always; others only when enabled via `amiko chat group mention-all`)").option("--yes", "Skip the confirmation (required in non-interactive shells)").option("--raw", "Output raw JSON").action(async (target, message, opts) => {
30316
+ chat.command("send <target> [message]").description("Send a message as yourself (the owner) to a conversation or person (conversation id, user id, @handle, or name β€” DMs are opened automatically). The message is optional only with --gif or --attach.").option("--image <pathOrUrl>", "Attach an image: local file (jpg/png/webp/gif, ≀10MB) or an Amiko URL (e.g. from `amiko create`)").option("--audio <pathOrUrl>", "Attach audio as a voice note: local file (≀25MB) or an Amiko URL").option("--gif <queryOrUrl>", "Attach a GIF: search words (sends the top Klipy result) or a Klipy URL from `amiko chat gifs`").option("--attach <ref>", "Attach a file you already have on Amiko (repeatable, up to 6): drive:<docId> (image or document from `amiko drive list`), library:<id> (from `amiko chat library`), explore:post:<postId>:<n> (from `amiko chat explore`). Combines with --image.", (value, previous) => [...previous, value], []).option("--twin <id>", "Twin whose Drive/Library --attach reads (default: your current twin)").option("--all", "Mention @all β€” notifies every member of the group (admins/owners always; others only when enabled via `amiko chat group mention-all`)").option("--yes", "Skip the confirmation (required in non-interactive shells)").option("--raw", "Output raw JSON").action(async (target, message, opts) => {
29931
30317
  const auth = authOrExit();
29932
30318
  const mediaFlagCount = [opts.image, opts.audio, opts.gif].filter(Boolean).length;
29933
30319
  if (mediaFlagCount > 1) {
29934
30320
  console.error(error("Attach only one of --image, --audio, or --gif (one media block per message)."));
29935
30321
  process.exit(1);
29936
30322
  }
29937
- if (message === undefined && !opts.gif) {
29938
- console.error(error("A message is required (it may only be omitted when sending a GIF with --gif)."));
30323
+ const attachRaw = opts.attach ?? [];
30324
+ if (attachRaw.length > 0 && (opts.audio || opts.gif)) {
30325
+ console.error(error("--attach can't be combined with --audio or --gif β€” send those as their own message (--attach does combine with --image)."));
30326
+ process.exit(1);
30327
+ }
30328
+ let attachRefs = [];
30329
+ try {
30330
+ attachRefs = parseAttachRefs(attachRaw, opts.image ? 1 : 0);
30331
+ } catch (e5) {
30332
+ console.error(error(e5 instanceof Error ? e5.message : String(e5)));
30333
+ process.exit(1);
30334
+ }
30335
+ const hasAttach = attachRefs.length > 0;
30336
+ if (message === undefined && !opts.gif && !hasAttach) {
30337
+ console.error(error("A message is required (it may only be omitted when sending a GIF with --gif or files with --attach)."));
29939
30338
  process.exit(1);
29940
30339
  }
29941
30340
  let resolved;
@@ -30005,14 +30404,36 @@ function registerChatCommand(chat) {
30005
30404
  process.exit(1);
30006
30405
  }
30007
30406
  }
30008
- const media = opts.image ? `image ${opts.image}` : opts.audio ? `audio ${opts.audio}` : gifAttachment ? `gif "${gifAttachment.name.slice(0, 40)}"` : "";
30407
+ const attachTwinId = opts.twin || auth.twinId || "";
30408
+ let attachAssets = [];
30409
+ if (hasAttach) {
30410
+ try {
30411
+ attachAssets = await resolveAssetRefs(auth, attachRefs, attachTwinId);
30412
+ } catch (e5) {
30413
+ console.error(error(e5 instanceof Error ? e5.message : "Could not resolve the attachments"));
30414
+ process.exit(1);
30415
+ }
30416
+ }
30417
+ const media = [
30418
+ opts.image ? `image ${opts.image}` : opts.audio ? `audio ${opts.audio}` : gifAttachment ? `gif "${gifAttachment.name.slice(0, 40)}"` : "",
30419
+ hasAttach ? describeAttachments(attachRefs, attachAssets) : ""
30420
+ ].filter(Boolean).join(", ");
30009
30421
  const shown = mentionMarkupToPlainText(outgoing);
30010
30422
  await requireApproval({
30011
30423
  free: "sends a message as you (the owner) β€” the recipients see it",
30012
30424
  summary: `Send to ${resolved.label}${media ? ` [+${media}]` : ""}${mentionAllActive ? " [@all β€” notifies every member]" : ""}: ${shown.slice(0, 80)}${shown.length > 80 ? "…" : ""}`,
30013
30425
  yes: opts.yes,
30014
- commandExample: `amiko chat send ${JSON.stringify(target)}${message !== undefined ? ` ${JSON.stringify(message)}` : ""}${opts.image ? ` --image ${JSON.stringify(opts.image)}` : ""}${opts.audio ? ` --audio ${JSON.stringify(opts.audio)}` : ""}${opts.gif ? ` --gif ${JSON.stringify(opts.gif)}` : ""}${opts.all ? " --all" : ""}`
30426
+ commandExample: `amiko chat send ${JSON.stringify(target)}${message !== undefined ? ` ${JSON.stringify(message)}` : ""}${opts.image ? ` --image ${JSON.stringify(opts.image)}` : ""}${opts.audio ? ` --audio ${JSON.stringify(opts.audio)}` : ""}${opts.gif ? ` --gif ${JSON.stringify(opts.gif)}` : ""}${attachRaw.map((r) => ` --attach ${JSON.stringify(r)}`).join("")}${opts.twin ? ` --twin ${JSON.stringify(opts.twin)}` : ""}${opts.all ? " --all" : ""}`
30015
30427
  });
30428
+ let driveFiles = [];
30429
+ if (hasAttach) {
30430
+ try {
30431
+ driveFiles = await signDriveRefs(auth, attachRefs, attachTwinId);
30432
+ } catch (e5) {
30433
+ console.error(error(e5 instanceof Error ? e5.message : "Could not attach the Drive files"));
30434
+ process.exit(1);
30435
+ }
30436
+ }
30016
30437
  let convId;
30017
30438
  try {
30018
30439
  convId = resolved.kind === "user" ? await findOrCreateDm(auth, resolved.userId) : resolved.id;
@@ -30038,6 +30459,20 @@ function registerChatCommand(chat) {
30038
30459
  image_caption: outgoing || null
30039
30460
  }
30040
30461
  });
30462
+ } else if (hasAttach) {
30463
+ const leading = opts.image ? [
30464
+ HTTPS_RE.test(opts.image) ? { url: opts.image, name: urlName(opts.image) } : await uploadChatImage(auth, convId, opts.image)
30465
+ ] : [];
30466
+ const parts = buildAttachmentParts(attachAssets, driveFiles, leading);
30467
+ const messageType = attachmentMessageType(parts);
30468
+ data = await post({
30469
+ content: outgoing || attachmentFallbackContent(messageType),
30470
+ message_type: messageType,
30471
+ metadata: {
30472
+ ...attachmentMetadata(parts),
30473
+ image_caption: outgoing || null
30474
+ }
30475
+ });
30041
30476
  } else if (opts.image) {
30042
30477
  const img = HTTPS_RE.test(opts.image) ? { url: opts.image, name: urlName(opts.image) } : await uploadChatImage(auth, convId, opts.image);
30043
30478
  data = await post({
@@ -30066,7 +30501,9 @@ function registerChatCommand(chat) {
30066
30501
  console.log(JSON.stringify(data, null, 2));
30067
30502
  return;
30068
30503
  }
30069
- const what = opts.image ? "Message + image sent" : opts.audio ? "Message + voice note sent" : gifAttachment ? outgoing ? "Message + GIF sent" : "GIF sent" : "Message sent";
30504
+ const attachCount = attachAssets.length + driveFiles.length + (opts.image ? 1 : 0);
30505
+ const attachLabel = `${attachCount} attachment${attachCount === 1 ? "" : "s"}`;
30506
+ const what = hasAttach ? outgoing ? `Message + ${attachLabel} sent` : `${attachLabel} sent` : opts.image ? "Message + image sent" : opts.audio ? "Message + voice note sent" : gifAttachment ? outgoing ? "Message + GIF sent" : "GIF sent" : "Message sent";
30070
30507
  console.log(success(what) + dim(` (conversation ${convId.slice(0, 8)}…)`));
30071
30508
  });
30072
30509
  chat.command("react <messageId> <emoji>").description("React to a message as yourself (the owner). Get the message id from `amiko chat read`. One reaction per message β€” a new emoji replaces your previous one.").option("--yes", "Skip the confirmation (required in non-interactive shells)").option("--raw", "Output raw JSON").action(async (messageId, emojiArg, opts) => {
@@ -30382,6 +30819,7 @@ function registerChatCommand(chat) {
30382
30819
  }
30383
30820
  });
30384
30821
  registerChatGifsCommand(chat);
30822
+ registerChatAssetsCommands(chat);
30385
30823
  const group = chat.command("group").description("Group chats β€” create, list, info, rename, add/remove members, @all permission, leave");
30386
30824
  registerChatGroupCommand(group);
30387
30825
  const lists = chat.command("lists").description("Your chat lists (private folders of conversations) β€” run bare to see them; create, rename, add/remove conversations, delete");
@@ -32052,15 +32490,15 @@ async function resolveFriend(auth, target) {
32052
32490
  }
32053
32491
  const lower = t.toLowerCase();
32054
32492
  const exact = all.filter((f) => f.friend.name?.toLowerCase() === lower || f.friend.handle?.toLowerCase() === lower || f.nickname?.toLowerCase() === lower);
32055
- const matches = exact.length ? exact : all.filter((f) => f.friend.name?.toLowerCase().includes(lower) || f.friend.handle?.toLowerCase().includes(lower) || f.nickname?.toLowerCase().includes(lower));
32056
- if (matches.length === 0) {
32493
+ const matches2 = exact.length ? exact : all.filter((f) => f.friend.name?.toLowerCase().includes(lower) || f.friend.handle?.toLowerCase().includes(lower) || f.nickname?.toLowerCase().includes(lower));
32494
+ if (matches2.length === 0) {
32057
32495
  throw new Error(`No friend matching "${t}". Nicknames only work for accepted friends β€” run \`amiko friends list\`.`);
32058
32496
  }
32059
- if (matches.length > 1) {
32060
- const names = matches.map((f) => `${friendLabel(f)} (${f.friend.id})`).join(", ");
32497
+ if (matches2.length > 1) {
32498
+ const names = matches2.map((f) => `${friendLabel(f)} (${f.friend.id})`).join(", ");
32061
32499
  throw new Error(`"${t}" is ambiguous β€” matches: ${names}. Use the friend's @handle or user id.`);
32062
32500
  }
32063
- return matches[0];
32501
+ return matches2[0];
32064
32502
  }
32065
32503
  async function resolveFriendOrExit(auth, target) {
32066
32504
  try {
@@ -32666,14 +33104,14 @@ The other user will be notified and must consent before the report is generated.
32666
33104
  });
32667
33105
  spinner?.stop();
32668
33106
  renderOutput(data, (d) => {
32669
- const matches = d.matches ?? [];
32670
- if (matches.length === 0) {
33107
+ const matches2 = d.matches ?? [];
33108
+ if (matches2.length === 0) {
32671
33109
  console.log(dim("No matches."));
32672
33110
  if (d.no_match_reason)
32673
33111
  console.log(dim(d.no_match_reason));
32674
33112
  return;
32675
33113
  }
32676
- console.log(heading(`Matches for "${opts.relationship}" (${matches.length})`));
33114
+ console.log(heading(`Matches for "${opts.relationship}" (${matches2.length})`));
32677
33115
  const rows = [
32678
33116
  [
32679
33117
  dim("USER"),
@@ -32682,7 +33120,7 @@ The other user will be notified and must consent before the report is generated.
32682
33120
  dim("STATUS"),
32683
33121
  dim("PAIRING")
32684
33122
  ],
32685
- ...matches.map((m) => [
33123
+ ...matches2.map((m) => [
32686
33124
  m.user_id,
32687
33125
  m.display_name ?? "-",
32688
33126
  m.score.toFixed(3),
@@ -32691,7 +33129,7 @@ The other user will be notified and must consent before the report is generated.
32691
33129
  ])
32692
33130
  ];
32693
33131
  console.log(table(rows));
32694
- for (const m of matches) {
33132
+ for (const m of matches2) {
32695
33133
  if (m.compatibility_highlights?.length) {
32696
33134
  console.log();
32697
33135
  console.log(label(m.display_name ?? m.user_id, m.compatibility_highlights.join("; ")));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heyamiko/amiko-cli",
3
- "version": "0.18.0",
3
+ "version": "0.19.0",
4
4
  "description": "Amiko CLI β€” swap tokens, manage credits, bridge cross-chain, and call marketplace agents",
5
5
  "type": "module",
6
6
  "bin": {
package/skills/SKILL.md CHANGED
@@ -266,7 +266,12 @@ MiniMax Hailuo (02/2.3) is silent β€” there is no CLI mux step to attach a separ
266
266
  - `amiko chat list` β€” all conversations, **DMs and group chats** (id, type, peer/title, last message, unread). Rows with unread messages that concern the owner directly show an **`@you` marker**, and `--mentions` filters to just those. `@you` / `--mentions` means the unread messages include a **direct @mention of the owner, a permitted @all in a group, or a reply to one of the owner's messages** β€” so "did anyone reply to me?" is also answered here, not just literal mentions. No `@you` rows under `--mentions` = nothing unread needs the owner's attention specifically. (On servers that predate the flag it reads as false for every conversation: unread counts still show, `@you` never does, and `--mentions` matches nothing β€” an empty result there is NOT proof nobody mentioned or replied.)
267
267
  - `amiko chat read <target>` β€” recent messages. `<target>` = a conversation id (from `list`), a **user id**, an `@handle`, or a name. Reading never creates a conversation β€” if there's no DM with that person yet, it says so.
268
268
  - `amiko chat send <target> "message"` β€” send **as the owner**. Delivered in real time. `--yes` required in non-interactive shells (it's an outward message to a real person). `<target>` = a conversation id, a **user id**, an `@handle`, or a name. A user id / @handle / unambiguous name opens (or reuses) the DM for you β€” but only **after** the owner approves the send (via the confirmation, or `--yes` in your shell); nothing is created if approval is refused. **The owner never needs to start the conversation first, and there is no platform authorization step for starting a DM.** For **group chats**, use the conversation id from `list`. **@all in groups:** add `--all` (or write a standalone `@all` word in the message) to notify **every member** β€” allowed for group admins/owners, or for everyone once an admin enables it (`amiko chat group mention-all <group> on`). The CLI pre-checks permission: `--all` without it fails and names the fix; a plain `@all` word still sends, as ordinary text, with a note. It pings the whole group β€” use it only when the owner clearly wants everyone notified.
269
- - **Inline media**: `--image <pathOrUrl>` (jpg/png/webp/gif, local ≀10MB or an Amiko URL β€” e.g. a generated image), `--audio <pathOrUrl>` (local ≀25MB or an Amiko URL β†’ sent as a voice note), and `--gif <queryOrUrl>` (see GIFs below). One media block per message (image XOR audio XOR gif). **General video files are not supported** on chat yet. To share a generated asset, pass its URL from `amiko create status` (no re-upload). A local audio file is sent as a voice note with the text as a separate message.
269
+ - **Inline media**: `--image <pathOrUrl>` (jpg/png/webp/gif, local ≀10MB or an Amiko URL β€” e.g. a generated image), `--audio <pathOrUrl>` (local ≀25MB or an Amiko URL β†’ sent as a voice note), and `--gif <queryOrUrl>` (see GIFs below). One of those per message (image XOR audio XOR gif); `--image` may be combined with `--attach`. **Local video files are not supported** on chat yet β€” a Library/Explore video goes through `--attach`. To share a generated asset, pass its URL from `amiko create status` (no re-upload) or its `library:<id>` ref. A local audio file is sent as a voice note with the text as a separate message.
270
+ - **Attach from Drive / Library / Explore** β€” `amiko chat send <target> "text" --attach <ref> [--attach <ref>…]`, up to **6 attachments per message** (a `--image` counts). Use this whenever the owner asks to send something that already lives on Amiko β€” "send Sophie the report from my drive", "share my latest creation", "send that sunset post from Explore". Refs:
271
+ - `drive:<docId>` β€” **images and documents only** (pdf, docx, …, ≀25MB); get the `id` from `amiko drive list --json` or `amiko drive search "<words>" --json`, matching by filename/title. Drive audio/video is refused (say so; offer the Library version if it's a creation).
272
+ - `library:<id>` β€” the owner's Create Studio creations; list with `amiko chat library` (`--kind image|video|audio`, `--search "<prompt words>"`, `--raw` for JSON). The owner's creations are *their* Library β€” don't use Explore for them.
273
+ - `explore:post:<postId>:<n>` β€” public media other people published; list with `amiko chat explore` (`--kind`, `--search "<title words>"`, `--cursor` to page). Copy the `REF` column as-is.
274
+ - Drive and Library read the current twin; `--twin <id>` targets another twin's. Text is optional with `--attach`. `--attach` can't be combined with `--audio`/`--gif`. Errors like "wasn't found", "can't be attached to a chat" or "over the 25MB chat attachment limit" are final for that ref β€” report them, don't retry blindly. `chat library`/`chat explore` are free, ungated reads; the send stays `--yes`-gated.
270
275
  - **GIFs** β€” `amiko chat send <target> --gif "<search words>"` sends the **top Klipy result** for those words (the message text is optional with `--gif`; when present it becomes the GIF's caption). To send a *specific* GIF, browse first with `amiko chat gifs "<search words>"` (trending when no query; `--page`/`--limit`/`--raw`; read-only and ungated) and pass the chosen result's URL to `--gif`. Only Klipy URLs are accepted there β€” any other image must go through `--image` as a local file or an Amiko URL (arbitrary external URLs aren't supported). Recipients on web/desktop/mobile see a looping GIF (with the KLIPY watermark), so this is the right tool when the owner asks to "send a GIF" β€” don't generate media with `amiko create` for that. The send itself stays `--yes`-gated like every `chat send`.
271
276
  - Plain names resolve against the owner's **friends first**, then people search; if ambiguous the CLI errors listing the candidates β€” ask the owner which one, don't blind-send. A plain name that exactly matches one of the owner's group titles targets that **group**. If a send fails, surface the CLI's error verbatim β€” don't invent permission explanations or ask the owner to open the chat from the app.
272
277
  - **Reactions** β€” `amiko chat react <messageId> <emoji>`, `amiko chat unreact <messageId>`, `amiko chat reactions <messageId>`. The owner **won't give you a message id** β€” they'll say something like *"react to the latest Ava message about the team with a heart"*. Get ids from `amiko chat read <target>`: it prints each message's `id` and any existing reactions β€” match the message by its **content**, then act on that id. `react` adds (or replaces) your reaction β€” **one per message**, a new emoji replaces the previous one, and re-reacting the same emoji is a no-op (use `unreact` to remove). Pass the **actual emoji character** (`❀️`, `πŸ‘`, `πŸ˜‚`). `react`/`unreact` are outward (other people see them), so `--yes` is required in non-interactive shells and the confirmation shows a preview of the target message; `reactions` (read) is ungated. These act **as the owner** (δΊΊε―ΉδΊΊ), just like `chat send` β€” not the twin's own `session_*`.