octwin-cli 0.1.11 → 0.1.13
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/CHANGELOG.md +38 -0
- package/dist/index.js +230 -28
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,44 @@ Format: [Keep a Changelog](https://keepachangelog.com/) — newest first, bucket
|
|
|
5
5
|
**Added · Changed · Deprecated · Removed · Fixed · Security**. The platform-wide view lives in the
|
|
6
6
|
repo root [`CHANGELOG.md`](../../CHANGELOG.md); this file is the CLI-only cut that ships with the package.
|
|
7
7
|
|
|
8
|
+
## [0.1.13] - 2026-07-22
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- **A headless media loop — produce + send.** Two paired additions close the coverage hole where any
|
|
12
|
+
media-collect flow (e.g. `activate-app`'s registration / ID uploads) stalled at the upload prompt
|
|
13
|
+
because the CLI could neither *make* nor *send* an image:
|
|
14
|
+
- `octwin media generate "<prompt>" [--out <file.png>] [--size 1024x1024] [--json]` — AI-generates an
|
|
15
|
+
image on the platform (needs a `media:generate`-scoped token), stores it as a public asset, and prints
|
|
16
|
+
its `MEDIA-` handle + serve URL. `--out` downloads the bytes to a file (WhatsApp renders only
|
|
17
|
+
`.png`/`.jpg`); `--json` emits `{ media_id, url, mime, width, height, bytes }`.
|
|
18
|
+
- `octwin chat --media <file|media-id>` — uploads a local file (or a media id from
|
|
19
|
+
`media generate --json`) as an image/document/audio inbound; any accompanying `"message"` rides as
|
|
20
|
+
its caption. The platform's media pipeline folds the upload into a running collect, so media flows
|
|
21
|
+
are now fully drivable headlessly.
|
|
22
|
+
|
|
23
|
+
### Changed
|
|
24
|
+
- **The KB-drift nudge now names what changed.** With the platform serving per-entry content hashes, the
|
|
25
|
+
post-command nudge appends a `(N changed · M added · K removed)` summary instead of a bare hash pair.
|
|
26
|
+
- **`octwin platform-kb` prints a changelog on pull.** Instead of only a doc/catalog count, the pull now
|
|
27
|
+
diffs the fresh index against your last pull and lists exactly which docs/catalogs were added (`+`),
|
|
28
|
+
changed (`~`), or removed (`-`) — so a replaced schema shape is visible, not silent.
|
|
29
|
+
|
|
30
|
+
## [0.1.12] - 2026-07-22
|
|
31
|
+
|
|
32
|
+
### Fixed
|
|
33
|
+
- **The KB-drift nudge now fires after `chat` too.** `chat` — the command a debugging session runs most —
|
|
34
|
+
was missing from the networked-command list, so a chat-heavy session never noticed the platform's
|
|
35
|
+
capability reference had moved. The nudge also now shows the concrete drift
|
|
36
|
+
(`old-hash → new-hash`) so you can see it's real, not a heuristic.
|
|
37
|
+
|
|
38
|
+
### Changed
|
|
39
|
+
- **Every networked command announces what it's doing before it does it.** `chat` prints
|
|
40
|
+
`→ Connecting to <tenant>/<project> as '<handle>' …` before opening the stream and
|
|
41
|
+
`… delivered — waiting for the reply (up to Ns)` after the send; `status` / `whoami` /
|
|
42
|
+
`platform-kb` / `records` / `logs` / `cases` each print a one-line `→ …` header naming the
|
|
43
|
+
action and target before the first network call — no more silent seconds followed by a result
|
|
44
|
+
(or a hang with no clue what was being attempted). JSON modes (`--json`) stay clean for piping.
|
|
45
|
+
|
|
8
46
|
## [0.1.11] - 2026-07-21
|
|
9
47
|
|
|
10
48
|
### Fixed
|
package/dist/index.js
CHANGED
|
@@ -17,7 +17,8 @@
|
|
|
17
17
|
* octwin records [entity] [id] # inspect the pack's XRM data (records:read token)
|
|
18
18
|
* octwin cases [caseId] [--queues] # inspect casework (support tickets) — list / one case + timeline
|
|
19
19
|
* octwin logs [conversationId] [--as h] [--json] # list conversations / show one's timeline
|
|
20
|
-
* octwin chat "msg" [--as h] [--tap <tap-id>] [--json] # drive a turn via the web channel
|
|
20
|
+
* octwin chat "msg" [--as h] [--tap <tap-id>] [--media <file|id>] [--json] # drive a turn via the web channel (+ send media)
|
|
21
|
+
* octwin media generate "<prompt>" [--out <file.png>] [--size 1024x1024] [--json] # AI-generate an image → MEDIA- handle (media:generate scope)
|
|
21
22
|
* octwin platform-kb [pull] [--dir .] # pull the platform capability reference for the authoring skill
|
|
22
23
|
* octwin test [--dir .] # = validate --remote (the full platform check)
|
|
23
24
|
*
|
|
@@ -35,7 +36,7 @@
|
|
|
35
36
|
* (pure-YAML enforcement + manifest/flow Zod) and installs it onto the project.
|
|
36
37
|
*/
|
|
37
38
|
import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, statSync, cpSync } from 'node:fs';
|
|
38
|
-
import { join, resolve, dirname } from 'node:path';
|
|
39
|
+
import { join, resolve, dirname, basename } from 'node:path';
|
|
39
40
|
import { homedir } from 'node:os';
|
|
40
41
|
import { fileURLToPath } from 'node:url';
|
|
41
42
|
import { parse as parseYaml } from 'yaml';
|
|
@@ -112,6 +113,50 @@ function errDetail(json) {
|
|
|
112
113
|
const msg = typeof json === 'string' ? json : (json.error ?? json.message ?? JSON.stringify(json));
|
|
113
114
|
return msg ? ` — ${msg}` : '';
|
|
114
115
|
}
|
|
116
|
+
// ── media helpers (produce/send loop) ───────────────────────────────────────
|
|
117
|
+
/** Extension → MIME for local files the `chat --media` sender uploads (mirrors
|
|
118
|
+
* the platform's accepted image/document/audio MIME sets). */
|
|
119
|
+
const MEDIA_MIME_BY_EXT = {
|
|
120
|
+
'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.gif': 'image/gif', '.webp': 'image/webp',
|
|
121
|
+
'.pdf': 'application/pdf', '.txt': 'text/plain',
|
|
122
|
+
'.doc': 'application/msword',
|
|
123
|
+
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
124
|
+
'.ogg': 'audio/ogg', '.mp3': 'audio/mpeg', '.m4a': 'audio/mp4', '.wav': 'audio/wav', '.webm': 'audio/webm', '.aac': 'audio/aac',
|
|
125
|
+
};
|
|
126
|
+
/** MIME → file extension (for naming a fetched-by-id media part). */
|
|
127
|
+
const EXT_BY_MEDIA_MIME = {
|
|
128
|
+
'image/jpeg': 'jpg', 'image/png': 'png', 'image/gif': 'gif', 'image/webp': 'webp', 'application/pdf': 'pdf',
|
|
129
|
+
};
|
|
130
|
+
/** The inbound `type` the platform expects for a MIME (image/document/audio). */
|
|
131
|
+
function mediaKindOf(mime) {
|
|
132
|
+
if (mime.startsWith('image/'))
|
|
133
|
+
return 'image';
|
|
134
|
+
if (mime.startsWith('audio/'))
|
|
135
|
+
return 'audio';
|
|
136
|
+
return 'document';
|
|
137
|
+
}
|
|
138
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
139
|
+
/** Resolve a `--media` argument to an uploadable part: a local file path, OR a
|
|
140
|
+
* media UUID (as returned by `octwin media generate --json`) fetched from the
|
|
141
|
+
* public serve route. The cosmetic `MEDIA-…` handle is NOT fetchable (the serve
|
|
142
|
+
* route is UUID-keyed) — use the file written by `--out`, or the `media_id`. */
|
|
143
|
+
async function resolveMediaPart(url, arg) {
|
|
144
|
+
if (existsSync(arg)) {
|
|
145
|
+
const buffer = readFileSync(arg);
|
|
146
|
+
const ext = arg.slice(arg.lastIndexOf('.')).toLowerCase();
|
|
147
|
+
const mime = MEDIA_MIME_BY_EXT[ext] ?? 'application/octet-stream';
|
|
148
|
+
return { blob: new Blob([buffer], { type: mime }), kind: mediaKindOf(mime), filename: basename(arg) };
|
|
149
|
+
}
|
|
150
|
+
if (UUID_RE.test(arg)) {
|
|
151
|
+
const res = await fetchOrDie(`${url}/api/media/${arg}`, undefined, 'fetch media');
|
|
152
|
+
if (!res.ok)
|
|
153
|
+
die(`--media '${arg}' not found (HTTP ${res.status}) — pass a local file path, or a media id from 'octwin media generate --json'`);
|
|
154
|
+
const mime = res.headers.get('content-type') ?? 'application/octet-stream';
|
|
155
|
+
const buffer = Buffer.from(await res.arrayBuffer());
|
|
156
|
+
return { blob: new Blob([buffer], { type: mime }), kind: mediaKindOf(mime), filename: `${arg}.${EXT_BY_MEDIA_MIME[mime] ?? 'bin'}` };
|
|
157
|
+
}
|
|
158
|
+
die(`--media '${arg}' is neither an existing file nor a media id (UUID). The cosmetic MEDIA-… handle isn't fetchable — send the file written by 'octwin media generate --out', or pass the media_id from '--json'.`);
|
|
159
|
+
}
|
|
115
160
|
// ── bundle collection ───────────────────────────────────────────────────────
|
|
116
161
|
const SKIP_DIRS = new Set(['.git', 'node_modules', '.pack-bundles', 'dist', '.mastra']);
|
|
117
162
|
/** Collect every text file under `packDir` into a `{ relPath: content }` map. */
|
|
@@ -243,18 +288,34 @@ async function notifyIfOutdated() {
|
|
|
243
288
|
}
|
|
244
289
|
catch { /* a version check must never break the CLI */ }
|
|
245
290
|
}
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
function readLocalKbHash(packDir) {
|
|
291
|
+
/** A previously-pulled KB's identity in `<packDir>/.octwin/platform-kb/index.json`
|
|
292
|
+
* (content hash + per-entry index), or null if nothing has been pulled yet. */
|
|
293
|
+
function readLocalKb(packDir) {
|
|
250
294
|
try {
|
|
251
295
|
const idx = JSON.parse(readFileSync(join(packDir, '.octwin', 'platform-kb', 'index.json'), 'utf8'));
|
|
252
|
-
return
|
|
296
|
+
return {
|
|
297
|
+
content_hash: typeof idx.content_hash === 'string' ? idx.content_hash : null,
|
|
298
|
+
index: Array.isArray(idx.index) ? idx.index : [],
|
|
299
|
+
};
|
|
253
300
|
}
|
|
254
301
|
catch {
|
|
255
302
|
return null;
|
|
256
303
|
}
|
|
257
304
|
}
|
|
305
|
+
/** Diff two KB indexes by key + per-entry `hash` → added / removed / changed keys. */
|
|
306
|
+
function diffKbIndex(prev, next) {
|
|
307
|
+
const prevMap = new Map(prev.map(e => [e.key, e.hash]));
|
|
308
|
+
const nextKeys = new Set(next.map(e => e.key));
|
|
309
|
+
const added = [], changed = [];
|
|
310
|
+
for (const e of next) {
|
|
311
|
+
if (!prevMap.has(e.key))
|
|
312
|
+
added.push(e.key);
|
|
313
|
+
else if (prevMap.get(e.key) !== e.hash)
|
|
314
|
+
changed.push(e.key);
|
|
315
|
+
}
|
|
316
|
+
const removed = prev.filter(e => !nextKeys.has(e.key)).map(e => e.key);
|
|
317
|
+
return { added, removed, changed };
|
|
318
|
+
}
|
|
258
319
|
/** Nudge (to stderr) when the platform's capability KB has changed since the last
|
|
259
320
|
* `octwin platform-kb pull`. The sibling of `notifyIfOutdated`, for the KB instead
|
|
260
321
|
* of the CLI: run only after commands that already hit the platform, so this adds
|
|
@@ -266,8 +327,8 @@ async function notifyIfKbStale(flags) {
|
|
|
266
327
|
return;
|
|
267
328
|
try {
|
|
268
329
|
const packDir = resolve(flags.dir ?? '.');
|
|
269
|
-
const
|
|
270
|
-
if (!
|
|
330
|
+
const local = readLocalKb(packDir);
|
|
331
|
+
if (!local?.content_hash)
|
|
271
332
|
return; // never pulled → the skill already says to pull
|
|
272
333
|
const t = resolveTargetOrNull(flags, packDir);
|
|
273
334
|
if (!t)
|
|
@@ -281,21 +342,37 @@ async function notifyIfKbStale(flags) {
|
|
|
281
342
|
if (!res.ok)
|
|
282
343
|
return;
|
|
283
344
|
const meta = await res.json();
|
|
284
|
-
if (meta.content_hash && meta.content_hash !==
|
|
285
|
-
|
|
286
|
-
|
|
345
|
+
if (meta.content_hash && meta.content_hash !== local.content_hash) {
|
|
346
|
+
// Per-entry summary (now that the index carries per-entry hashes) — the
|
|
347
|
+
// exact list of what changed is one `octwin platform-kb` away.
|
|
348
|
+
let summary = '';
|
|
349
|
+
if (Array.isArray(meta.index) && local.index.length) {
|
|
350
|
+
const { added, removed, changed } = diffKbIndex(local.index, meta.index);
|
|
351
|
+
const parts = [
|
|
352
|
+
changed.length && `${changed.length} changed`,
|
|
353
|
+
added.length && `${added.length} added`,
|
|
354
|
+
removed.length && `${removed.length} removed`,
|
|
355
|
+
].filter(Boolean);
|
|
356
|
+
if (parts.length)
|
|
357
|
+
summary = ` (${parts.join(' · ')})`;
|
|
358
|
+
}
|
|
359
|
+
console.error(`\n⬆ the platform capability reference changed since you last pulled it${summary}.`);
|
|
360
|
+
console.error(' Refresh it: octwin platform-kb');
|
|
287
361
|
}
|
|
288
362
|
}
|
|
289
363
|
catch { /* a KB check must never break the CLI */ }
|
|
290
364
|
}
|
|
291
|
-
/** Which commands already made
|
|
292
|
-
*
|
|
365
|
+
/** Which commands already made a platform call, so the trailing KB-drift poll
|
|
366
|
+
* rides on existing network work (never on offline `validate` / `init`;
|
|
367
|
+
* `platform-kb` refreshes the reference itself, so it needs no nudge). */
|
|
293
368
|
function commandTouchesPlatform(command, flags) {
|
|
294
369
|
switch (command) {
|
|
295
370
|
case 'validate': return flags.remote === true; // offline validate stays offline
|
|
296
371
|
case 'deploy':
|
|
297
372
|
case 'status':
|
|
298
373
|
case 'test':
|
|
374
|
+
case 'chat':
|
|
375
|
+
case 'media':
|
|
299
376
|
case 'records':
|
|
300
377
|
case 'cases':
|
|
301
378
|
case 'logs':
|
|
@@ -448,6 +525,7 @@ function resolveTargetOrNull(flags, packDir) {
|
|
|
448
525
|
async function cmdWhoami(flags) {
|
|
449
526
|
const packDir = resolve(flags.dir ?? '.');
|
|
450
527
|
const { url, tenant, token } = resolveTarget(flags, packDir);
|
|
528
|
+
console.log(`→ Checking the saved token against '${tenant}' @ ${url} …`);
|
|
451
529
|
const res = await fetchOrDie(`${url}/api/admin/tenants/${tenant}/packs`, { headers: { authorization: `Bearer ${token}` } }, 'token check');
|
|
452
530
|
if (res.ok) {
|
|
453
531
|
console.log(`✓ Token valid for tenant '${tenant}' at ${url} (${token.startsWith('oct_') ? 'deploy token' : 'session token'})`);
|
|
@@ -570,6 +648,7 @@ async function cmdStatus(flags) {
|
|
|
570
648
|
die('manifest.yaml must declare a string `id`');
|
|
571
649
|
const id = doc.id;
|
|
572
650
|
const localVersion = typeof doc?.version === 'string' ? doc.version : '?';
|
|
651
|
+
console.log(`→ Checking ${id}@${localVersion} on ${tenant}/${project} @ ${url} …`);
|
|
573
652
|
const res = await fetchOrDie(`${url}/api/admin/tenants/${tenant}/projects/${project}/packs/${id}/runtime`, {
|
|
574
653
|
headers: { authorization: `Bearer ${token}` },
|
|
575
654
|
}, 'status check');
|
|
@@ -609,6 +688,7 @@ async function cmdStatus(flags) {
|
|
|
609
688
|
async function cmdPlatformKb(flags) {
|
|
610
689
|
const packDir = resolve(flags.dir ?? '.');
|
|
611
690
|
const { url, tenant, token } = resolveTarget(flags, packDir);
|
|
691
|
+
console.log(`→ Pulling the platform capability reference from '${tenant}' @ ${url} …`);
|
|
612
692
|
const res = await fetchOrDie(`${url}/api/admin/tenants/${tenant}/octwin-platform-kb`, {
|
|
613
693
|
headers: { authorization: `Bearer ${token}` },
|
|
614
694
|
}, 'platform-kb pull');
|
|
@@ -626,6 +706,9 @@ async function cmdPlatformKb(flags) {
|
|
|
626
706
|
process.exit(1);
|
|
627
707
|
}
|
|
628
708
|
const bundle = JSON.parse(text);
|
|
709
|
+
// Snapshot the prior pull's index BEFORE overwriting it, so we can show the
|
|
710
|
+
// author EXACTLY what changed (not just a count).
|
|
711
|
+
const prior = readLocalKb(packDir);
|
|
629
712
|
// Write the reference into <packDir>/.octwin/platform-kb/ — markdown docs (the
|
|
630
713
|
// skill reads these first) + JSON catalogs (precise field schemas). Gitignored.
|
|
631
714
|
const outDir = join(packDir, '.octwin', 'platform-kb');
|
|
@@ -649,6 +732,23 @@ async function cmdPlatformKb(flags) {
|
|
|
649
732
|
writeFileSync(join(outDir, 'index.json'), JSON.stringify({ version: bundle.version, content_hash: bundle.content_hash, generated_at: bundle.generated_at, index: bundle.index }, null, 2) + '\n', 'utf8');
|
|
650
733
|
console.log(`✓ Pulled the Octwin platform KB → ${outDir}`);
|
|
651
734
|
console.log(` ${mdCount} markdown docs + ${jsonCount} JSON catalogs (reference version ${bundle.version ?? '?'})`);
|
|
735
|
+
// Changelog since the last pull — per-entry hashes tell us WHICH docs/catalogs
|
|
736
|
+
// moved (a schema shape being replaced shows as a `~ changed`), not just a count.
|
|
737
|
+
if (prior?.content_hash) {
|
|
738
|
+
if (prior.content_hash === bundle.content_hash) {
|
|
739
|
+
console.log(' (no content changes since your last pull)');
|
|
740
|
+
}
|
|
741
|
+
else {
|
|
742
|
+
const { added, removed, changed } = diffKbIndex(prior.index, bundle.index ?? []);
|
|
743
|
+
console.log(` Δ changes since your last pull (${prior.content_hash} → ${bundle.content_hash ?? '?'}):`);
|
|
744
|
+
for (const k of added)
|
|
745
|
+
console.log(` + ${k} (new)`);
|
|
746
|
+
for (const k of changed)
|
|
747
|
+
console.log(` ~ ${k} (changed)`);
|
|
748
|
+
for (const k of removed)
|
|
749
|
+
console.log(` - ${k} (removed)`);
|
|
750
|
+
}
|
|
751
|
+
}
|
|
652
752
|
console.log(' The octwin-pack authoring skill reads these as the source of truth for what the platform supports.');
|
|
653
753
|
}
|
|
654
754
|
// ── records / cases / logs / chat — headless inspect + test with the deploy token ────
|
|
@@ -674,6 +774,7 @@ async function cmdRecords(flags) {
|
|
|
674
774
|
const base = `${url}/api/admin/tenants/${tenant}/projects/${project}`;
|
|
675
775
|
const entity = flags._[0];
|
|
676
776
|
const recordId = flags._[1];
|
|
777
|
+
console.log(`→ Reading ${recordId ? `${entity} record ${recordId}` : entity ? `${entity} records` : 'the entity catalog'} from ${tenant}/${project} …`);
|
|
677
778
|
if (!entity) {
|
|
678
779
|
const { status, json } = await apiGet(`${base}/xrm/entities`, token);
|
|
679
780
|
if (status !== 200)
|
|
@@ -734,6 +835,8 @@ async function cmdLogs(flags) {
|
|
|
734
835
|
const convId = flags._[0];
|
|
735
836
|
const asJson = flags.json === true;
|
|
736
837
|
const asHandle = typeof flags.as === 'string' ? flags.as : undefined;
|
|
838
|
+
if (!asJson)
|
|
839
|
+
console.log(`→ Reading ${convId ? `conversation ${convId}` : 'recent conversations'} from ${tenant}/${project} …`);
|
|
737
840
|
if (!convId) {
|
|
738
841
|
const { status, json } = await apiGet(`${base}/conversations?limit=50`, token);
|
|
739
842
|
if (status !== 200)
|
|
@@ -923,11 +1026,14 @@ async function cmdChat(flags) {
|
|
|
923
1026
|
const from = flags.as ?? 'cli-tester';
|
|
924
1027
|
const asJson = flags.json === true;
|
|
925
1028
|
const tapId = typeof flags.tap === 'string' ? flags.tap : undefined;
|
|
1029
|
+
const mediaArg = typeof flags.media === 'string' ? flags.media : undefined;
|
|
926
1030
|
const message = flags._[0];
|
|
927
|
-
if (!message && !tapId)
|
|
928
|
-
die('usage: octwin chat "your message" [--as <handle>] [--tap <tap-id>] [--json]');
|
|
1031
|
+
if (!message && !tapId && !mediaArg)
|
|
1032
|
+
die('usage: octwin chat "your message" [--as <handle>] [--tap <tap-id>] [--media <file|id>] [--json]');
|
|
929
1033
|
// Fresh idempotency key per call (see the command doc above).
|
|
930
1034
|
const localId = `cli-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
1035
|
+
if (!asJson)
|
|
1036
|
+
console.log(`→ Connecting to ${tenant}/${project} as '${from}' …`);
|
|
931
1037
|
const evRes = await fetchOrDie(`${url}/api/web/events/${tenant}/${project}/${encodeURIComponent(from)}`, { headers: { accept: 'text/event-stream' } }, 'open chat stream');
|
|
932
1038
|
if (!evRes.ok || !evRes.body)
|
|
933
1039
|
die(`could not open chat stream (HTTP ${evRes.status})`);
|
|
@@ -947,20 +1053,41 @@ async function cmdChat(flags) {
|
|
|
947
1053
|
if (f.id != null && f.id > boundary)
|
|
948
1054
|
boundary = f.id;
|
|
949
1055
|
}
|
|
950
|
-
// Phase 2 — send the inbound (
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
1056
|
+
// Phase 2 — send the inbound (media upload, an interactive tap, or text).
|
|
1057
|
+
const inbound = `${url}/api/web/inbound/${tenant}/${project}`;
|
|
1058
|
+
let postRes;
|
|
1059
|
+
if (mediaArg && !tapId) {
|
|
1060
|
+
// Multipart upload → the platform's media pipeline sets $inbound_media, which
|
|
1061
|
+
// a running collect folds into $state.<field> (docs/19 §8a). Any accompanying
|
|
1062
|
+
// message rides as the media caption.
|
|
1063
|
+
const { blob, kind, filename } = await resolveMediaPart(url, mediaArg);
|
|
1064
|
+
console.log(`→ [${from}] (media:${kind}) ${filename}${message ? ` — "${message}"` : ''}`);
|
|
1065
|
+
const form = new FormData();
|
|
1066
|
+
form.append('type', kind);
|
|
1067
|
+
form.append('from', from);
|
|
1068
|
+
form.append('local_id', localId);
|
|
1069
|
+
if (message)
|
|
1070
|
+
form.append('caption', message);
|
|
1071
|
+
form.append('file', blob, filename);
|
|
1072
|
+
postRes = await fetchOrDie(inbound, { method: 'POST', body: form }, 'send media'); // fetch sets the multipart boundary
|
|
1073
|
+
}
|
|
1074
|
+
else {
|
|
1075
|
+
console.log(`→ [${from}] ${tapId ? `(tap) ${tapId}` : message}`);
|
|
1076
|
+
const body = tapId
|
|
1077
|
+
? { type: 'interactive', from, tap_id: tapId, ...(message ? { raw_title: message } : {}), local_id: localId }
|
|
1078
|
+
: { type: 'text', from, text: message, local_id: localId };
|
|
1079
|
+
postRes = await fetchOrDie(inbound, {
|
|
1080
|
+
method: 'POST',
|
|
1081
|
+
headers: { 'content-type': 'application/json' },
|
|
1082
|
+
body: JSON.stringify(body),
|
|
1083
|
+
}, 'send message');
|
|
1084
|
+
}
|
|
960
1085
|
if (!postRes.ok) {
|
|
961
1086
|
await cancel();
|
|
962
1087
|
die(`send rejected (HTTP ${postRes.status}): ${await postRes.text()}`);
|
|
963
1088
|
}
|
|
1089
|
+
if (!asJson)
|
|
1090
|
+
console.log(` … delivered — waiting for the reply (up to ${Math.round(REPLY_TIMEOUT_MS / 1000)}s)`);
|
|
964
1091
|
// Phase 3 — collect THIS turn's renders (id > boundary). A turn can send
|
|
965
1092
|
// several messages, so keep reading until a quiet gap after the last render.
|
|
966
1093
|
const deadline = Date.now() + REPLY_TIMEOUT_MS;
|
|
@@ -999,6 +1126,67 @@ async function cmdChat(flags) {
|
|
|
999
1126
|
}
|
|
1000
1127
|
console.log(`\n(same --as '${from}' continues this conversation — timeline: octwin logs --as ${from})`);
|
|
1001
1128
|
}
|
|
1129
|
+
/** `octwin media generate "<prompt>" [--out <file.png>] [--size 1024x1024] [--json]`
|
|
1130
|
+
* — AI-generate an image on the platform (needs a `media:generate`-scoped token),
|
|
1131
|
+
* store it as a public asset, and return its `MEDIA-` handle + serve URL. `--out`
|
|
1132
|
+
* downloads the bytes to a file (WhatsApp renders only `.png`/`.jpg`); the paired
|
|
1133
|
+
* `octwin chat --media <file|id>` feeds it into a running media-collect flow. */
|
|
1134
|
+
async function cmdMedia(flags) {
|
|
1135
|
+
const sub = flags._[0];
|
|
1136
|
+
if (sub !== 'generate')
|
|
1137
|
+
die('usage: octwin media generate "<prompt>" [--out <file.png>] [--size 1024x1024] [--json]');
|
|
1138
|
+
const packDir = resolve(flags.dir ?? '.');
|
|
1139
|
+
const { url, tenant, project, token } = resolveTarget(flags, packDir);
|
|
1140
|
+
const prompt = flags._[1];
|
|
1141
|
+
if (!prompt)
|
|
1142
|
+
die('usage: octwin media generate "<prompt>" [--out <file.png>] [--size 1024x1024] [--json]');
|
|
1143
|
+
const asJson = flags.json === true;
|
|
1144
|
+
const size = typeof flags.size === 'string' ? flags.size : undefined;
|
|
1145
|
+
const out = typeof flags.out === 'string' ? flags.out : undefined;
|
|
1146
|
+
if (!asJson)
|
|
1147
|
+
console.log(`→ Generating an image on '${tenant}/${project}' @ ${url} …`);
|
|
1148
|
+
const res = await fetchOrDie(`${url}/api/admin/tenants/${tenant}/projects/${project}/media/generate`, {
|
|
1149
|
+
method: 'POST',
|
|
1150
|
+
headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
|
|
1151
|
+
body: JSON.stringify({ prompt, ...(size ? { size } : {}) }),
|
|
1152
|
+
}, 'media generate');
|
|
1153
|
+
const text = await res.text();
|
|
1154
|
+
if (!res.ok) {
|
|
1155
|
+
let j;
|
|
1156
|
+
try {
|
|
1157
|
+
j = JSON.parse(text);
|
|
1158
|
+
}
|
|
1159
|
+
catch {
|
|
1160
|
+
j = text;
|
|
1161
|
+
}
|
|
1162
|
+
console.error(`✗ media generate failed (HTTP ${res.status})${errDetail(j)}`);
|
|
1163
|
+
printAuthHint(res.status, url);
|
|
1164
|
+
process.exit(1);
|
|
1165
|
+
}
|
|
1166
|
+
const r = JSON.parse(text);
|
|
1167
|
+
const absUrl = /^https?:/i.test(r.url) ? r.url : `${url}${r.url}`;
|
|
1168
|
+
// --out → download the bytes so the paired `chat --media <file>` can send them.
|
|
1169
|
+
if (out) {
|
|
1170
|
+
const dl = await fetchOrDie(absUrl, undefined, 'download generated image');
|
|
1171
|
+
if (!dl.ok)
|
|
1172
|
+
die(`could not download the generated image (HTTP ${dl.status})`);
|
|
1173
|
+
writeFileSync(out, Buffer.from(await dl.arrayBuffer()));
|
|
1174
|
+
const ext = out.slice(out.lastIndexOf('.')).toLowerCase();
|
|
1175
|
+
if (ext !== '.png' && ext !== '.jpg' && ext !== '.jpeg') {
|
|
1176
|
+
console.error(` ⚠ '${out}' isn't a .png/.jpg — WhatsApp only renders those extensions.`);
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
if (asJson) {
|
|
1180
|
+
console.log(JSON.stringify({ media_id: r.media_id, url: absUrl, mime: r.mime, width: r.width, height: r.height, bytes: r.bytes }));
|
|
1181
|
+
return;
|
|
1182
|
+
}
|
|
1183
|
+
console.log(`✓ Generated ${r.media_ref} (${r.width}×${r.height}, ${r.mime}, ${r.bytes} bytes)`);
|
|
1184
|
+
console.log(` id: ${r.media_id}`);
|
|
1185
|
+
console.log(` url: ${absUrl}`);
|
|
1186
|
+
if (out)
|
|
1187
|
+
console.log(` saved → ${out}`);
|
|
1188
|
+
console.log(` Send it into a chat: octwin chat "here you go" --media ${out ?? r.media_id} --as <handle>`);
|
|
1189
|
+
}
|
|
1002
1190
|
/** `octwin cases [caseId] [--queues]` — inspect casework (support tickets):
|
|
1003
1191
|
* the aggregate inbox, one case + its timeline, or the queue list. */
|
|
1004
1192
|
async function cmdCases(flags) {
|
|
@@ -1007,6 +1195,8 @@ async function cmdCases(flags) {
|
|
|
1007
1195
|
const base = `${url}/api/admin/tenants/${tenant}/projects/${project}`;
|
|
1008
1196
|
const caseId = flags._[0];
|
|
1009
1197
|
const asJson = flags.json === true;
|
|
1198
|
+
if (!asJson)
|
|
1199
|
+
console.log(`→ Reading ${flags.queues === true ? 'case queues' : caseId ? `case ${caseId}` : 'the case inbox'} from ${tenant}/${project} …`);
|
|
1010
1200
|
const caseFail = (what, status, json) => {
|
|
1011
1201
|
if (status === 403)
|
|
1012
1202
|
die(`forbidden — casework needs the 'cases' plan feature on this tenant, and a role whose grants reach the queue`);
|
|
@@ -1092,7 +1282,8 @@ function help() {
|
|
|
1092
1282
|
octwin records [entity] [id] # inspect the pack's XRM data (needs a records:read token)
|
|
1093
1283
|
octwin cases [caseId] [--queues] [--json] # inspect casework (support tickets) + timelines
|
|
1094
1284
|
octwin logs [conversationId] [--as <handle>] [--json] # list conversations / show one's event timeline
|
|
1095
|
-
octwin chat "message" [--as <handle>] [--tap <tap-id>] [--json] # drive a turn + print every render
|
|
1285
|
+
octwin chat "message" [--as <handle>] [--tap <tap-id>] [--media <file|id>] [--json] # drive a turn (+ send media) + print every render
|
|
1286
|
+
octwin media generate "<prompt>" [--out <file.png>] [--size 1024x1024] [--json] # AI-generate an image → MEDIA- handle (needs media:generate scope)
|
|
1096
1287
|
octwin platform-kb [pull] [--dir .] [--url <url>] [--tenant <slug>] [--token <t>]
|
|
1097
1288
|
octwin test [--dir .] # = validate --remote (the full platform check)
|
|
1098
1289
|
|
|
@@ -1131,11 +1322,19 @@ const COMMAND_HELP = {
|
|
|
1131
1322
|
No id = recent conversations (handle, status, last activity; --as filters).
|
|
1132
1323
|
With id = the full event timeline including what each turn rendered.
|
|
1133
1324
|
--json = raw events (verbatim payloads).`,
|
|
1134
|
-
chat: `octwin chat "message" [--as <handle>] [--tap <tap-id>] [--json]
|
|
1325
|
+
chat: `octwin chat "message" [--as <handle>] [--tap <tap-id>] [--media <file|id>] [--json]
|
|
1135
1326
|
Drive one turn through the dev web channel and print every render with its
|
|
1136
1327
|
tap ids. Same --as handle = same conversation (multi-turn works).
|
|
1137
1328
|
--tap presses a rendered button/list row instead of sending text.
|
|
1329
|
+
--media uploads a local file (or a media id from 'media generate --json') as
|
|
1330
|
+
an image/document/audio inbound — any "message" rides as its caption; feeds a
|
|
1331
|
+
running media-collect flow (e.g. activate-app).
|
|
1138
1332
|
--json dumps the raw SSE envelopes for the turn.`,
|
|
1333
|
+
media: `octwin media generate "<prompt>" [--out <file.png>] [--size 1024x1024] [--json]
|
|
1334
|
+
AI-generate an image (needs a media:generate-scoped token), store it as a
|
|
1335
|
+
public asset, and print its MEDIA- handle + serve URL. --out downloads the
|
|
1336
|
+
bytes (WhatsApp renders only .png/.jpg); --json emits { media_id, url, mime,
|
|
1337
|
+
width, height, bytes }. Pair with 'octwin chat --media' to drive media flows.`,
|
|
1139
1338
|
'platform-kb': `octwin platform-kb [pull] [--dir .] [--url <url>] [--tenant <slug>] [--token <t>]
|
|
1140
1339
|
Pull the platform capability reference (markdown + JSON catalogs) into
|
|
1141
1340
|
.octwin/platform-kb/ for the octwin-pack authoring skill.`,
|
|
@@ -1182,6 +1381,9 @@ async function main() {
|
|
|
1182
1381
|
case 'chat':
|
|
1183
1382
|
await cmdChat(flags);
|
|
1184
1383
|
break;
|
|
1384
|
+
case 'media':
|
|
1385
|
+
await cmdMedia(flags);
|
|
1386
|
+
break;
|
|
1185
1387
|
case 'platform-kb':
|
|
1186
1388
|
await cmdPlatformKb(flags);
|
|
1187
1389
|
break;
|
package/package.json
CHANGED