@threahq/cli 0.1.0 → 0.2.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 +7 -1
- package/cli.js +200 -48
- package/package.json +1 -1
- package/skills/threa-cli/SKILL.md +8 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @threahq/cli
|
|
2
2
|
|
|
3
|
-
`threa` is a command-line client for one Threa workspace. It wraps Threa's public REST API so any local agent or script gets workspace access with one API key: read and search streams, users, messages, conversations, memos, and attachments; send, edit, and delete messages; manage labels; and run the delegation lifecycle end to end.
|
|
3
|
+
`threa` is a command-line client for one Threa workspace. It wraps Threa's public REST API so any local agent or script gets workspace access with one API key: read and search streams, users, messages, conversations, memos, and attachments; send, edit, and delete messages; upload attachments; manage labels; and run the delegation lifecycle end to end.
|
|
4
4
|
|
|
5
5
|
It is one package with two heads. The command-line interface is the primary head. The same core is also served over the Model Context Protocol with `threa mcp serve`, so an MCP client such as Claude Code can call the same operations as tools. Both heads bind to one workspace and one API key from config, so no command and no tool takes a workspace id. There are no WebSockets; every call goes to `<baseUrl>/api/v1/workspaces/{workspaceId}/…`.
|
|
6
6
|
|
|
@@ -64,11 +64,13 @@ threa whoami # authenticated principal, a
|
|
|
64
64
|
threa streams list --type channel --query eng # list accessible streams (page with --after)
|
|
65
65
|
threa streams read #eng --members --limit 20 # a stream plus a page of its messages
|
|
66
66
|
threa streams archive stream_abc # archive a stream; unarchive reopens it
|
|
67
|
+
threa streams list --archived # include archived streams, marked [archived]
|
|
67
68
|
threa users list --query alice # workspace users
|
|
68
69
|
threa search "deploy plan" --what messages # search messages (--semantic, --exact, --type)
|
|
69
70
|
threa search "" --what memos --knowledge-type decision # browse workspace memory (empty query allowed)
|
|
70
71
|
threa search invoice --what attachments # search attachments by name or extracted text
|
|
71
72
|
threa conversations list --stream #eng --status active
|
|
73
|
+
threa conversations list --stream stream_abc --archived # include conversations in archived streams
|
|
72
74
|
threa conversations read conv_123 --limit 50
|
|
73
75
|
threa messages find-by-metadata github.pr=org/repo#42 --stream #eng
|
|
74
76
|
threa memos list --stream #eng # browse recent memos, newest first
|
|
@@ -76,6 +78,7 @@ threa memos get memo_123
|
|
|
76
78
|
threa attachments list --stream #eng # browse recent attachments, newest first
|
|
77
79
|
threa attachments get att_123 --url # --url returns a short-lived signed download URL
|
|
78
80
|
threa attachments download att_123 ./ # download bytes; dir dest names it after the file
|
|
81
|
+
threa attachments upload ./report.csv # upload a file; reference it as [report.csv](attachment:<id>)
|
|
79
82
|
threa skill print # print the threa-cli agent skill to stdout
|
|
80
83
|
|
|
81
84
|
# writes
|
|
@@ -112,6 +115,8 @@ threa mcp serve
|
|
|
112
115
|
|
|
113
116
|
Any stream argument (`streams read`, `messages send`, `labels add`, `labels remove`, `search --stream`, `conversations list --stream`, `messages find-by-metadata --stream`) accepts a `stream_…` id or a `#channel-slug`. An `@user-slug` is not resolvable as a stream (a DM hides its counterpart on the wire); pass the DM's `stream_…` id. A ref that matches nothing or is ambiguous fails before any API call with code `UNRESOLVED_REF`.
|
|
114
117
|
|
|
118
|
+
An archived stream stays readable to any key that could read it before. `streams read`, `conversations read`, and `search` work on it and on any thread under it; a send, edit, or delete there fails with code `STREAM_READ_ONLY`. By default `streams list` leaves out archived streams and their threads, and `conversations list` leaves out the conversations in those streams. With `--archived`, both commands include those rows, which print `[archived]` and carry `archived: true` in JSON. A `#channel-slug` does not resolve an archived channel (the ref fails with `UNRESOLVED_REF`), so pass the `stream_…` id that `streams list --archived` shows.
|
|
119
|
+
|
|
115
120
|
`messages send` and `messages edit` take content as an argument; `messages send` reads stdin when the content argument is `-`. `delegations finish --result -` also reads the result markdown from stdin.
|
|
116
121
|
|
|
117
122
|
Once a key is unlocked, `streams read` and `messages send` work on an end-to-end-encrypted stream (a scratchpad or one of its threads) the same way they do on a plaintext one. A read opens each body locally, so what prints is the message rather than the opaque placeholder the server stores; a body sealed to a key generation you were never wrapped to keeps a null `content` and says why, costing you that row and not the page. A send checks the stream first and seals the body under the stream key before anything leaves the machine — the plaintext is never posted, not even once to be rejected. Both take `--key-store` and `--key-dir`. With no key unlocked here, each fails and names `threa e2e unlock` instead of falling back to plaintext. A sealed stream carries no `--metadata` and no conversation: both would travel in the clear, so they are refused rather than dropped.
|
|
@@ -186,6 +191,7 @@ The scopes on the key decide which areas work. A key without a scope does not ge
|
|
|
186
191
|
| `messages:write` | `messages send`, `messages edit`, `messages delete` |
|
|
187
192
|
| `memos:read` | `search --what memos`, `memos list`, `memos get` |
|
|
188
193
|
| `attachments:read` | `search --what attachments`, `attachments list`, `attachments get`, `attachments download` |
|
|
194
|
+
| `attachments:write` | `attachments upload` |
|
|
189
195
|
| `labels:read` | `labels list` |
|
|
190
196
|
| `labels:write` | `labels add`, `labels remove` |
|
|
191
197
|
| `delegations:read` | `delegations list`, `delegations get` |
|
package/cli.js
CHANGED
|
@@ -441,6 +441,7 @@ function assertSafeBaseUrl(baseUrl) {
|
|
|
441
441
|
|
|
442
442
|
// src/api-client.ts
|
|
443
443
|
var FETCH_TIMEOUT_MS = 30000;
|
|
444
|
+
var UPLOAD_MIN_BYTES_PER_MS = 256;
|
|
444
445
|
var RETRY_DELAYS_MS = [2000, 4000, 8000];
|
|
445
446
|
function deriveHint(status) {
|
|
446
447
|
if (status === 404) {
|
|
@@ -482,23 +483,30 @@ class ThreaApiClient {
|
|
|
482
483
|
return this.request("GET", path);
|
|
483
484
|
}
|
|
484
485
|
post(path, body, headers) {
|
|
485
|
-
return this.request("POST", path, body, headers);
|
|
486
|
+
return this.request("POST", path, body === undefined ? undefined : { json: body }, headers);
|
|
487
|
+
}
|
|
488
|
+
postForm(path, form, bodyBytes) {
|
|
489
|
+
return this.request("POST", path, { form }, undefined, {
|
|
490
|
+
timeoutMs: this.timeoutMs + Math.ceil(bodyBytes / UPLOAD_MIN_BYTES_PER_MS)
|
|
491
|
+
});
|
|
486
492
|
}
|
|
487
493
|
patch(path, body) {
|
|
488
|
-
return this.request("PATCH", path, body);
|
|
494
|
+
return this.request("PATCH", path, body === undefined ? undefined : { json: body });
|
|
489
495
|
}
|
|
490
496
|
delete(path) {
|
|
491
497
|
return this.request("DELETE", path);
|
|
492
498
|
}
|
|
499
|
+
getRaw(path) {
|
|
500
|
+
return this.request("GET", path, undefined, undefined, { raw: true });
|
|
501
|
+
}
|
|
493
502
|
workspacePath(path) {
|
|
494
503
|
return `${this.baseUrl}/api/v1/workspaces/${this.workspaceId}${path}`;
|
|
495
504
|
}
|
|
496
|
-
async request(method, path,
|
|
505
|
+
async request(method, path, payload, extraHeaders, { raw = false, timeoutMs = this.timeoutMs } = {}) {
|
|
497
506
|
const url = this.workspacePath(path);
|
|
498
|
-
const hasBody = body !== undefined;
|
|
499
507
|
for (let attempt = 0;; attempt++) {
|
|
500
508
|
const controller = new AbortController;
|
|
501
|
-
const timeout = setTimeout(() => controller.abort(),
|
|
509
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
502
510
|
let retryAfter429 = false;
|
|
503
511
|
try {
|
|
504
512
|
const response = await fetch(url, {
|
|
@@ -506,10 +514,10 @@ class ThreaApiClient {
|
|
|
506
514
|
signal: controller.signal,
|
|
507
515
|
headers: {
|
|
508
516
|
Authorization: `Bearer ${this.apiKey}`,
|
|
509
|
-
...
|
|
517
|
+
...payload && "json" in payload ? { "Content-Type": "application/json" } : {},
|
|
510
518
|
...extraHeaders ?? {}
|
|
511
519
|
},
|
|
512
|
-
...
|
|
520
|
+
...payload ? { body: "json" in payload ? JSON.stringify(payload.json) : payload.form } : {}
|
|
513
521
|
});
|
|
514
522
|
if (response.status === 429 && attempt < RETRY_DELAYS_MS.length) {
|
|
515
523
|
retryAfter429 = true;
|
|
@@ -517,6 +525,8 @@ class ThreaApiClient {
|
|
|
517
525
|
if (!response.ok) {
|
|
518
526
|
throw await this.toError(response);
|
|
519
527
|
}
|
|
528
|
+
if (raw)
|
|
529
|
+
return response;
|
|
520
530
|
if (response.status === 204)
|
|
521
531
|
return;
|
|
522
532
|
return await response.json();
|
|
@@ -526,7 +536,7 @@ class ThreaApiClient {
|
|
|
526
536
|
throw new ThreaApiError({
|
|
527
537
|
status: 0,
|
|
528
538
|
code: "TIMEOUT",
|
|
529
|
-
message: `Threa API ${method} ${path} timed out after ${
|
|
539
|
+
message: `Threa API ${method} ${path} timed out after ${timeoutMs}ms`
|
|
530
540
|
});
|
|
531
541
|
}
|
|
532
542
|
throw error;
|
|
@@ -936,7 +946,10 @@ class TokenStore {
|
|
|
936
946
|
}
|
|
937
947
|
|
|
938
948
|
// src/commands/attachments.ts
|
|
939
|
-
import { existsSync as existsSync3,
|
|
949
|
+
import { createWriteStream, existsSync as existsSync3, rmSync as rmSync2, statSync } from "node:fs";
|
|
950
|
+
import { readFile } from "node:fs/promises";
|
|
951
|
+
import { once } from "node:events";
|
|
952
|
+
import { finished } from "node:stream/promises";
|
|
940
953
|
import { basename, extname, join as join5 } from "node:path";
|
|
941
954
|
|
|
942
955
|
// src/enrich.ts
|
|
@@ -1150,7 +1163,13 @@ async function findMessagesByMetadata(client, resolver, p) {
|
|
|
1150
1163
|
}
|
|
1151
1164
|
async function listConversations(client, resolver, p) {
|
|
1152
1165
|
const streamId = p.streamRef ? await resolver.resolveStream(p.streamRef) : undefined;
|
|
1153
|
-
const response = await client.get(`/conversations${buildQuery({
|
|
1166
|
+
const response = await client.get(`/conversations${buildQuery({
|
|
1167
|
+
streamId,
|
|
1168
|
+
status: p.status,
|
|
1169
|
+
after: p.cursor,
|
|
1170
|
+
limit: p.limit,
|
|
1171
|
+
includeArchived: p.includeArchived ? "true" : undefined
|
|
1172
|
+
})}`);
|
|
1154
1173
|
const withParticipants = await Promise.all(response.data.map((c) => enrichConversation(c, resolver)));
|
|
1155
1174
|
return { ...response, data: await enrichStreamContext(withParticipants, resolver) };
|
|
1156
1175
|
}
|
|
@@ -1173,12 +1192,23 @@ async function readConversation(client, resolver, p) {
|
|
|
1173
1192
|
function getMemo(client, memoId) {
|
|
1174
1193
|
return client.get(`/memos/${encodeURIComponent(memoId)}`);
|
|
1175
1194
|
}
|
|
1195
|
+
function recallMemos(client, query) {
|
|
1196
|
+
return client.post("/memos/recall", { query });
|
|
1197
|
+
}
|
|
1176
1198
|
function getAttachment(client, attachmentId) {
|
|
1177
1199
|
return client.get(`/attachments/${encodeURIComponent(attachmentId)}`);
|
|
1178
1200
|
}
|
|
1179
1201
|
function getAttachmentDownloadUrl(client, attachmentId) {
|
|
1180
1202
|
return client.get(`/attachments/${encodeURIComponent(attachmentId)}/url`);
|
|
1181
1203
|
}
|
|
1204
|
+
function getAttachmentContent(client, attachmentId) {
|
|
1205
|
+
return client.getRaw(`/attachments/${encodeURIComponent(attachmentId)}/content`);
|
|
1206
|
+
}
|
|
1207
|
+
function uploadAttachment(client, file, filename) {
|
|
1208
|
+
const form = new FormData;
|
|
1209
|
+
form.append("file", file, filename);
|
|
1210
|
+
return client.postForm("/attachments", form, file.size);
|
|
1211
|
+
}
|
|
1182
1212
|
var SEARCH_WHATS = ["messages", "memos", "attachments"];
|
|
1183
1213
|
var ALLOWED_FILTERS = {
|
|
1184
1214
|
messages: ["query", "semantic", "exact", "stream_ids", "type", "before", "after", "limit"],
|
|
@@ -1416,8 +1446,10 @@ var searchCommand = {
|
|
|
1416
1446
|
` + ` --what w messages | memos | attachments (required)
|
|
1417
1447
|
` + ` --stream ref limit to a source stream (stream_ id or #slug); repeatable
|
|
1418
1448
|
` + ` --limit n max results (messages/attachments <= 50, memos <= 100; default 20)
|
|
1419
|
-
` + ` --before iso only results before this ISO-8601 datetime (messages and memos only
|
|
1420
|
-
` + `
|
|
1449
|
+
` + ` --before iso only results before this ISO-8601 datetime (messages and memos only; memos by when
|
|
1450
|
+
` + ` their source messages were posted)
|
|
1451
|
+
` + ` --after iso only results after this ISO-8601 datetime (messages and memos only; memos by when
|
|
1452
|
+
` + ` their source messages were posted)
|
|
1421
1453
|
` + "messages: --semantic --exact --type " + STREAM_TYPES.join("|") + ` (repeatable)
|
|
1422
1454
|
` + "memos: --exact --memo-type " + MEMO_TYPES.join("|") + " (repeatable) --knowledge-type " + KNOWLEDGE_TYPES.join("|") + " (repeatable) --tag t (repeatable) --scope " + MEMO_SCOPES.join("|") + `
|
|
1423
1455
|
` + "attachments: --content-type " + EXTRACTION_CONTENT_TYPES.join("|") + ` (repeatable)
|
|
@@ -1525,7 +1557,7 @@ function renderMemoResult(r) {
|
|
|
1525
1557
|
const header = joinFields([
|
|
1526
1558
|
String(memo.id ?? "?"),
|
|
1527
1559
|
typeof memo.knowledgeType === "string" ? memo.knowledgeType : undefined,
|
|
1528
|
-
fmtTimestamp(memo.
|
|
1560
|
+
fmtTimestamp(memo.latestSourceAt),
|
|
1529
1561
|
scopeLabel || undefined
|
|
1530
1562
|
]);
|
|
1531
1563
|
const title = typeof memo.title === "string" ? memo.title : "";
|
|
@@ -1606,13 +1638,38 @@ function resolveDownloadTarget(destination, filename) {
|
|
|
1606
1638
|
}
|
|
1607
1639
|
return candidate;
|
|
1608
1640
|
}
|
|
1641
|
+
var DOWNLOAD_IDLE_MS = 30000;
|
|
1642
|
+
async function saveDownload(body, target, idleMs = DOWNLOAD_IDLE_MS) {
|
|
1643
|
+
const reader = body.getReader();
|
|
1644
|
+
const out = createWriteStream(target);
|
|
1645
|
+
try {
|
|
1646
|
+
for (;; ) {
|
|
1647
|
+
let timer;
|
|
1648
|
+
const stalled = new Promise((_, reject) => {
|
|
1649
|
+
timer = setTimeout(() => reject(new Error(`download stalled: no bytes for ${idleMs}ms`)), idleMs);
|
|
1650
|
+
});
|
|
1651
|
+
const chunk = await Promise.race([reader.read(), stalled]).finally(() => clearTimeout(timer));
|
|
1652
|
+
if (chunk.done)
|
|
1653
|
+
break;
|
|
1654
|
+
if (!out.write(chunk.value))
|
|
1655
|
+
await once(out, "drain");
|
|
1656
|
+
}
|
|
1657
|
+
out.end();
|
|
1658
|
+
await finished(out);
|
|
1659
|
+
} catch (error) {
|
|
1660
|
+
reader.cancel().catch(() => {});
|
|
1661
|
+
out.destroy();
|
|
1662
|
+
rmSync2(target, { force: true });
|
|
1663
|
+
throw error;
|
|
1664
|
+
}
|
|
1665
|
+
}
|
|
1609
1666
|
var downloadVerb = {
|
|
1610
1667
|
name: "download",
|
|
1611
1668
|
summary: "Download an attachment's raw bytes to a local file",
|
|
1612
1669
|
usage: "threa attachments download <id> [destination]",
|
|
1613
1670
|
help: `threa attachments download <id> [destination] [flags]
|
|
1614
1671
|
|
|
1615
|
-
` + "Download the attachment's raw bytes
|
|
1672
|
+
` + "Download the attachment's raw bytes. destination defaults to the current directory. " + "A directory destination names the file after the attachment and picks `name (1).ext` on conflict; an " + `explicit file path is written as given (overwriting).
|
|
1616
1673
|
|
|
1617
1674
|
` + `Flags:
|
|
1618
1675
|
` + ` --json force JSON output
|
|
@@ -1622,37 +1679,78 @@ var downloadVerb = {
|
|
|
1622
1679
|
const id = positionals[0];
|
|
1623
1680
|
if (!id)
|
|
1624
1681
|
throw new UsageError("attachments download requires a <id> (an att_ id)");
|
|
1625
|
-
const [meta,
|
|
1682
|
+
const [meta, response] = await Promise.all([
|
|
1626
1683
|
getAttachment(ctx.client, id),
|
|
1627
|
-
|
|
1684
|
+
getAttachmentContent(ctx.client, id)
|
|
1628
1685
|
]);
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
throw new Error(`no download URL returned for ${id}`);
|
|
1686
|
+
if (!response.body)
|
|
1687
|
+
throw new Error(`download failed: empty body for ${id}`);
|
|
1632
1688
|
const target = resolveDownloadTarget(positionals[1] ?? ".", meta.data?.filename ?? id);
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
throw new Error(`download failed: HTTP ${response.status}`);
|
|
1636
|
-
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
1637
|
-
writeFileSync3(target, bytes);
|
|
1638
|
-
return { downloaded: true, id, path: target, sizeBytes: bytes.byteLength };
|
|
1689
|
+
await saveDownload(response.body, target);
|
|
1690
|
+
return { downloaded: true, id, path: target, sizeBytes: statSync(target).size };
|
|
1639
1691
|
},
|
|
1640
1692
|
render: (payload) => {
|
|
1641
1693
|
const p = payload;
|
|
1642
1694
|
return `downloaded ${p.id ?? "?"} → ${p.path ?? "?"} (${p.sizeBytes ?? 0} bytes)`;
|
|
1643
1695
|
}
|
|
1644
1696
|
};
|
|
1697
|
+
var MIME_BY_EXTENSION = {
|
|
1698
|
+
".csv": "text/csv",
|
|
1699
|
+
".tsv": "text/tab-separated-values",
|
|
1700
|
+
".txt": "text/plain",
|
|
1701
|
+
".md": "text/markdown",
|
|
1702
|
+
".html": "text/html",
|
|
1703
|
+
".json": "application/json",
|
|
1704
|
+
".pdf": "application/pdf",
|
|
1705
|
+
".zip": "application/zip",
|
|
1706
|
+
".png": "image/png",
|
|
1707
|
+
".jpg": "image/jpeg",
|
|
1708
|
+
".jpeg": "image/jpeg",
|
|
1709
|
+
".gif": "image/gif",
|
|
1710
|
+
".webp": "image/webp",
|
|
1711
|
+
".svg": "image/svg+xml"
|
|
1712
|
+
};
|
|
1713
|
+
var uploadVerb = {
|
|
1714
|
+
name: "upload",
|
|
1715
|
+
summary: "Upload a local file as an attachment",
|
|
1716
|
+
usage: "threa attachments upload <path> [--name filename] [--type mime]",
|
|
1717
|
+
help: `threa attachments upload <path> [flags]
|
|
1718
|
+
|
|
1719
|
+
` + "Upload a file and print its attachment id. Reference it in message markdown as `[name](attachment:<id>)` " + `to attach it. The content type is guessed from the extension, else application/octet-stream.
|
|
1720
|
+
|
|
1721
|
+
` + `Flags:
|
|
1722
|
+
` + ` --name filename filename to store (default: the file's basename)
|
|
1723
|
+
` + ` --type mime content type to store, overriding the guess
|
|
1724
|
+
` + ` --json force JSON output
|
|
1725
|
+
` + " --help show this help",
|
|
1726
|
+
options: {
|
|
1727
|
+
name: { type: "string" },
|
|
1728
|
+
type: { type: "string" }
|
|
1729
|
+
},
|
|
1730
|
+
run: async (ctx, positionals, values) => {
|
|
1731
|
+
const path = positionals[0];
|
|
1732
|
+
if (!path)
|
|
1733
|
+
throw new UsageError("attachments upload requires a <path>");
|
|
1734
|
+
const filename = stringFlag(values, "name") ?? basename(path);
|
|
1735
|
+
const type = stringFlag(values, "type") ?? MIME_BY_EXTENSION[extname(filename).toLowerCase()] ?? "application/octet-stream";
|
|
1736
|
+
return uploadAttachment(ctx.client, new Blob([await readFile(path)], { type }), filename);
|
|
1737
|
+
},
|
|
1738
|
+
render: (payload) => {
|
|
1739
|
+
const a = payload.data ?? {};
|
|
1740
|
+
return `uploaded ${a.filename ?? "?"} → ${a.id ?? "?"} (${a.sizeBytes ?? 0} bytes)`;
|
|
1741
|
+
}
|
|
1742
|
+
};
|
|
1645
1743
|
var attachmentsNoun = {
|
|
1646
1744
|
name: "attachments",
|
|
1647
|
-
summary: "List attachments, get one's text or URL,
|
|
1648
|
-
verbs: [listVerb, getVerb, downloadVerb]
|
|
1745
|
+
summary: "List attachments, get one's text or URL, download its bytes, or upload a file",
|
|
1746
|
+
verbs: [listVerb, getVerb, downloadVerb, uploadVerb]
|
|
1649
1747
|
};
|
|
1650
1748
|
|
|
1651
1749
|
// src/commands/conversations.ts
|
|
1652
1750
|
var listVerb2 = {
|
|
1653
1751
|
name: "list",
|
|
1654
1752
|
summary: "List conversations, optionally scoped to a stream",
|
|
1655
|
-
usage: "threa conversations list [--stream ref] [--status s] [--cursor c] [--limit n]",
|
|
1753
|
+
usage: "threa conversations list [--stream ref] [--status s] [--cursor c] [--limit n] [--archived]",
|
|
1656
1754
|
help: `threa conversations list [flags]
|
|
1657
1755
|
|
|
1658
1756
|
` + `List conversations (grouped runs of messages under a stream's effective root), newest activity first.
|
|
@@ -1662,19 +1760,24 @@ var listVerb2 = {
|
|
|
1662
1760
|
` + " --status s " + CONVERSATION_STATUSES.join(" | ") + `
|
|
1663
1761
|
` + ` --cursor c pagination cursor from a previous response
|
|
1664
1762
|
` + ` --limit n max results, <= 100 (default 50)
|
|
1763
|
+
` + ` --archived include conversations in archived streams and in threads under archived ancestors.
|
|
1764
|
+
` + ` An archived channel is not resolvable by #slug: pass --stream its stream_ id
|
|
1765
|
+
` + ` (threa streams list --archived shows it)
|
|
1665
1766
|
` + ` --json force JSON output
|
|
1666
1767
|
` + " --help show this help",
|
|
1667
1768
|
options: {
|
|
1668
1769
|
stream: { type: "string" },
|
|
1669
1770
|
status: { type: "string" },
|
|
1670
1771
|
cursor: { type: "string" },
|
|
1671
|
-
limit: { type: "string" }
|
|
1772
|
+
limit: { type: "string" },
|
|
1773
|
+
archived: { type: "boolean" }
|
|
1672
1774
|
},
|
|
1673
1775
|
run: (ctx, _positionals, values) => listConversations(ctx.client, ctx.resolver, {
|
|
1674
1776
|
streamRef: stringFlag(values, "stream"),
|
|
1675
1777
|
status: enumFlag(values, "status", CONVERSATION_STATUSES),
|
|
1676
1778
|
cursor: stringFlag(values, "cursor"),
|
|
1677
|
-
limit: intFlag(values, "limit")
|
|
1779
|
+
limit: intFlag(values, "limit"),
|
|
1780
|
+
includeArchived: boolFlag(values, "archived")
|
|
1678
1781
|
}),
|
|
1679
1782
|
render: (payload) => renderList(payload, renderConversationRow, {
|
|
1680
1783
|
empty: "(no conversations)",
|
|
@@ -1687,7 +1790,8 @@ function renderConversationRow(c) {
|
|
|
1687
1790
|
c.status ?? "?",
|
|
1688
1791
|
c.messageCount !== undefined ? `${c.messageCount} msgs` : undefined,
|
|
1689
1792
|
fmtTimestamp(c.lastActivityAt) || undefined,
|
|
1690
|
-
streamLabel(c) || undefined
|
|
1793
|
+
streamLabel(c) || undefined,
|
|
1794
|
+
c.archived ? "[archived]" : undefined
|
|
1691
1795
|
].filter(Boolean).join(" ");
|
|
1692
1796
|
const topic = snippet(c.topicSummary ?? c.summary ?? "");
|
|
1693
1797
|
return topic ? `${header}
|
|
@@ -1725,7 +1829,7 @@ var readVerb = {
|
|
|
1725
1829
|
const lines = [];
|
|
1726
1830
|
const c = p.conversation;
|
|
1727
1831
|
if (c) {
|
|
1728
|
-
lines.push([c.id ?? "?", c.status ?? "?", streamLabel(c) || undefined].filter(Boolean).join(" "), [
|
|
1832
|
+
lines.push([c.id ?? "?", c.status ?? "?", streamLabel(c) || undefined, c.archived ? "[archived]" : undefined].filter(Boolean).join(" "), [
|
|
1729
1833
|
fmtTimestamp(c.createdAt) && `created ${fmtTimestamp(c.createdAt)}`,
|
|
1730
1834
|
fmtTimestamp(c.lastActivityAt) && `last activity ${fmtTimestamp(c.lastActivityAt)}`,
|
|
1731
1835
|
c.messageCount !== undefined && `${c.messageCount} messages`
|
|
@@ -2978,14 +3082,21 @@ import { z as z2 } from "zod";
|
|
|
2978
3082
|
function registerConversationTools(server, client, resolver) {
|
|
2979
3083
|
server.registerTool("list_conversations", {
|
|
2980
3084
|
title: "List conversations",
|
|
2981
|
-
description: "List conversations (grouped runs of messages under a stream's effective root), newest activity first. " + "Filter with `stream_id` (accepts a stream_ id or `#channel-slug`; scopes to that stream's root and its " + "threads) and `status` (active, stalled, resolved). Each conversation mirrors its participantIds with a " + "participants array carrying name/slug. Page by passing the previous response's `cursor` value back as " + "`cursor`; `hasMore` tells you when to stop. limit ≤ 100 (default 50).",
|
|
3085
|
+
description: "List conversations (grouped runs of messages under a stream's effective root), newest activity first. " + "Filter with `stream_id` (accepts a stream_ id or `#channel-slug`; scopes to that stream's root and its " + "threads) and `status` (active, stalled, resolved). Conversations in an archived stream, or in a thread " + "under an archived ancestor, are omitted unless `include_archived` is true; those rows then carry " + "`archived: true`. An archived channel is not resolvable by `#channel-slug`: pass its stream_ id, which " + "`list_streams` with `include_archived` returns. Each conversation mirrors its participantIds with a " + "participants array carrying name/slug. Page by passing the previous response's `cursor` value back as " + "`cursor`; `hasMore` tells you when to stop. limit ≤ 100 (default 50).",
|
|
2982
3086
|
inputSchema: {
|
|
2983
3087
|
stream_id: z2.string().optional(),
|
|
2984
3088
|
status: z2.enum(CONVERSATION_STATUSES).optional(),
|
|
2985
3089
|
cursor: z2.string().optional(),
|
|
2986
|
-
limit: z2.number().int().min(1).max(100).optional()
|
|
3090
|
+
limit: z2.number().int().min(1).max(100).optional(),
|
|
3091
|
+
include_archived: z2.boolean().optional()
|
|
2987
3092
|
}
|
|
2988
|
-
}, async ({ stream_id, status, cursor, limit }) => runTool(() => listConversations(client, resolver, {
|
|
3093
|
+
}, async ({ stream_id, status, cursor, limit, include_archived }) => runTool(() => listConversations(client, resolver, {
|
|
3094
|
+
streamRef: stream_id,
|
|
3095
|
+
status,
|
|
3096
|
+
cursor,
|
|
3097
|
+
limit,
|
|
3098
|
+
includeArchived: include_archived
|
|
3099
|
+
})));
|
|
2989
3100
|
server.registerTool("read_conversation", {
|
|
2990
3101
|
title: "Read a conversation with its messages",
|
|
2991
3102
|
description: "Fetch a conversation and a page of its member messages in one call (concurrent requests). Returns " + "{ conversation, messages: { data, hasMore, cursor } }. The conversation mirrors its participantIds with " + "a participants array (name/slug) and message rows carry author { id, type, name, slug? }. Messages are " + "chronological and may span the " + "conversation's root stream and its threads; each carries its own `streamId`. Page the messages by " + "passing the previous response's `cursor` value back as `cursor`; `hasMore` tells you when to stop. " + "limit ≤ 100 (default 50) applies to messages. If the conversation cannot be read the whole call " + "errors — no partial data is returned.",
|
|
@@ -3119,6 +3230,13 @@ function registerMemoTools(server, client) {
|
|
|
3119
3230
|
memo_id: z5.string()
|
|
3120
3231
|
}
|
|
3121
3232
|
}, async ({ memo_id }) => runTool(() => getMemo(client, memo_id)));
|
|
3233
|
+
server.registerTool("recall_memos", {
|
|
3234
|
+
title: "Recall memos for a message",
|
|
3235
|
+
description: "Pass the message you are about to answer and get back the few memos that actually help with it, " + "scored for relevance (at most 5). An empty `data` with outcome 'nothing_relevant' or 'no_candidates' " + "means memory holds nothing useful for it; 'unscored', 'timeout' or 'failed' mean recall could not " + "judge, so fall back to the `search` tool (what: 'memos'). Each call runs a model, so recall once per " + "message rather than per idea; use `search` to explore.",
|
|
3236
|
+
inputSchema: {
|
|
3237
|
+
message: z5.string().min(1)
|
|
3238
|
+
}
|
|
3239
|
+
}, async ({ message }) => runTool(() => recallMemos(client, message)));
|
|
3122
3240
|
}
|
|
3123
3241
|
|
|
3124
3242
|
// src/tools/messages.ts
|
|
@@ -3177,7 +3295,7 @@ function registerSearchTools(server, client, resolver) {
|
|
|
3177
3295
|
title: "Search the workspace",
|
|
3178
3296
|
description: "One search across the workspace, routed by `what`. Only the filters listed for the chosen `what` are " + `accepted; passing any other filter is an error that names the offending arg.
|
|
3179
3297
|
` + "- what: 'messages' — full-text search over messages in accessible streams. Filters: query (required), " + "`semantic: true` for meaning-based vector retrieval when you know the idea but not the words, " + "`exact: true` to match the query as a literal phrase, stream_ids, type (stream types), before/after " + `(ISO-8601 datetimes), limit (≤50, default 20).
|
|
3180
|
-
` + "- what: 'memos' — search GAM (the knowledge extracted from this workspace's conversations: decisions, " + "learnings, procedures, context, references). Search this BEFORE asking a human. query is meaning-based " + "and OPTIONAL — leave it empty to browse the most recent memos. Filters: query, `exact: true` (or wrap " + "the query in double quotes) to match a literal phrase, stream_ids, memo_type (message vs. conversation " + "source), knowledge_type, tags, scope (user = private to this key's user, stream, workspace), " +
|
|
3298
|
+
` + "- what: 'memos' — search GAM (the knowledge extracted from this workspace's conversations: decisions, " + "learnings, procedures, context, references). Search this BEFORE asking a human. query is meaning-based " + "and OPTIONAL — leave it empty to browse the most recent memos. Filters: query, `exact: true` (or wrap " + "the query in double quotes) to match a literal phrase, stream_ids, memo_type (message vs. conversation " + "source), knowledge_type, tags, scope (user = private to this key's user, stream, workspace), " + "before/after (ISO-8601 datetimes, matched against when the memo's source messages were posted, not " + `when it was captured), limit (≤100, default 20).
|
|
3181
3299
|
` + "- what: 'attachments' — search accessible attachments by filename or extracted content. query is " + "OPTIONAL — leave it empty to browse the most recent attachments. Filters: query, stream_ids, " + `content_types (chart, table, diagram, screenshot, photo, document, other), limit (≤50, default 20).
|
|
3182
3300
|
` + "stream_ids maps to the API's source-stream scope; each entry accepts a stream_ id or `#channel-slug`. " + "For what='messages', result rows carry author { id, type, name, slug? }; message, attachment, and " + "conversation rows carry stream { id, name?, type? } and, for threads, rootStream. " + "Results are the search envelope { data: [...] }. For " + "exact metadata lookup use find_messages_by_metadata; for a memo's source provenance use get_memo.",
|
|
3183
3301
|
inputSchema: {
|
|
@@ -3204,7 +3322,7 @@ import { z as z8 } from "zod";
|
|
|
3204
3322
|
function registerStreamTools(server, client, resolver, sealed) {
|
|
3205
3323
|
server.registerTool("list_streams", {
|
|
3206
3324
|
title: "List streams",
|
|
3207
|
-
description: "List streams this key can access, newest-ish first. Filter with `type` (one or more of scratchpad, " + "channel, dm, thread, system) and `query` (text match on name). Page by passing the previous " + "response's `cursor` value back as `after`; `hasMore` tells you when to stop. limit ≤ 200 (default 50). " + "Archived streams
|
|
3325
|
+
description: "List streams this key can access, newest-ish first. Filter with `type` (one or more of scratchpad, " + "channel, dm, thread, system) and `query` (text match on name). Page by passing the previous " + "response's `cursor` value back as `after`; `hasMore` tells you when to stop. limit ≤ 200 (default 50). " + "Archived streams and live threads under an archived ancestor are omitted unless `include_archived` is true; " + "both then carry `archived: true`.",
|
|
3208
3326
|
inputSchema: {
|
|
3209
3327
|
type: z8.array(z8.enum(STREAM_TYPES)).optional(),
|
|
3210
3328
|
query: z8.string().optional(),
|
|
@@ -3248,7 +3366,7 @@ function registerUserTools(server, client) {
|
|
|
3248
3366
|
}
|
|
3249
3367
|
|
|
3250
3368
|
// src/server.ts
|
|
3251
|
-
var INSTRUCTIONS = "Threa MCP server. Wraps the Threa public REST API for one workspace, bound once from this server's " + "config — no tool takes a workspaceId. Authentication is a single API key. Tool results are JSON text in " + "the API's envelope: single resources under `data`, lists as { data, hasMore, cursor? } (feed `cursor` back " + "as the paging arg to continue); " + "failures come back as isError results carrying { code, message, hint? }. A 404 can mean the resource " + "does not exist or that the key lacks the required scope. Identifier args accept a raw id or a " + "sigil-prefixed slug: a stream arg takes a stream_ id or `#channel-slug` (your DM with a user by `@user-slug` " + "is not resolvable — the public API hides a DM's counterpart, so pass the DM's stream_ id), and user args " + "take a usr_/bot_ id or `@user-slug` (bots and personas are not queryable by slug). An unresolved ref fails " + "before any call with code UNRESOLVED_REF. Payloads are self-descriptive: message rows carry " + "author { id, type, name, slug? }, conversation participantIds are mirrored by a participants array with " + "name/slug, and stream members carry name/slug — so you rarely need a follow-up lookup. Start with `whoami` " + "to confirm the key and " + "identity. Read tools: list_streams and read_stream (one call returns a stream plus a page of its messages " + "by numeric sequence, and its members when include_members is set), list_users, list_conversations and " + "read_conversation (a conversation plus a page of its messages), and find_messages_by_metadata (exact " + "reference lookup). Search: one `search` tool with what = messages | memos | attachments — memos is GAM " + "(the knowledge extracted from this workspace's conversations), search it before asking a human; each " + "`what` accepts only its own filters. Write tools: send_message (markdown; optional conversation resume via " + "conversation_id or a new one via start_conversation; auto client_message_id for idempotent retries), " + "update_message / delete_message (only messages this key sent), and list_labels / apply_label / remove_label " + "(labels are private to the key actor and found-or-created by name). Memory
|
|
3369
|
+
var INSTRUCTIONS = "Threa MCP server. Wraps the Threa public REST API for one workspace, bound once from this server's " + "config — no tool takes a workspaceId. Authentication is a single API key. Tool results are JSON text in " + "the API's envelope: single resources under `data`, lists as { data, hasMore, cursor? } (feed `cursor` back " + "as the paging arg to continue); " + "failures come back as isError results carrying { code, message, hint? }. A 404 can mean the resource " + "does not exist or that the key lacks the required scope. Identifier args accept a raw id or a " + "sigil-prefixed slug: a stream arg takes a stream_ id or `#channel-slug` (your DM with a user by `@user-slug` " + "is not resolvable — the public API hides a DM's counterpart, so pass the DM's stream_ id), and user args " + "take a usr_/bot_ id or `@user-slug` (bots and personas are not queryable by slug). An unresolved ref fails " + "before any call with code UNRESOLVED_REF. Payloads are self-descriptive: message rows carry " + "author { id, type, name, slug? }, conversation participantIds are mirrored by a participants array with " + "name/slug, and stream members carry name/slug — so you rarely need a follow-up lookup. Start with `whoami` " + "to confirm the key and " + "identity. Read tools: list_streams and read_stream (one call returns a stream plus a page of its messages " + "by numeric sequence, and its members when include_members is set), list_users, list_conversations and " + "read_conversation (a conversation plus a page of its messages), and find_messages_by_metadata (exact " + "reference lookup). Search: one `search` tool with what = messages | memos | attachments — memos is GAM " + "(the knowledge extracted from this workspace's conversations), search it before asking a human; each " + "`what` accepts only its own filters. Write tools: send_message (markdown; optional conversation resume via " + "conversation_id or a new one via start_conversation; auto client_message_id for idempotent retries), " + "update_message / delete_message (only messages this key sent), and list_labels / apply_label / remove_label " + "(labels are private to the key actor and found-or-created by name). Memory: recall_memos returns the few " + "memos relevant to a message you are about to answer (empty when nothing is), get_memo traces a " + "memo to its source messages. Attachments: get_attachment (metadata plus extracted text), " + "get_attachment_download_url (short-lived signed URL for the raw bytes). Delegations: close the loop on a " + "delegated task with list_delegations (the open queue; since filters availability changes) → get_delegation (inspect brief/context without claim secrets) → " + "claim_delegation (returns a claim token shown once and persisted to ~/.threa/state.json; 15-min TTL) → optional " + "update_delegation while working (status_note reports progress and renews the claim, no note is a pure heartbeat) → " + "finish_delegation (outcome complete or fail) or release_delegation for controlled stop. Historical expired tasks are not listed but can be inspected and claimed directly by id. " + "A completed result is posted in the thread anchored on the delegation card and enters the normal message pipeline, so workspace memory can capture it. Lifecycle tools reuse the " + "stored token; pass claim_token to override or to recover on another machine. request_delegation_access " + "is bot-key only. End-to-end-encrypted streams: read_stream and send_message open and seal bodies with the " + "key this machine holds, so they read and write like any other stream; when no key is held here they say " + "so rather than returning the placeholder the server stores.";
|
|
3252
3370
|
function createThreaMcpServer(config, deps = {}) {
|
|
3253
3371
|
const server = new McpServer({ name: "threa", version: "0.1.0" }, { instructions: INSTRUCTIONS });
|
|
3254
3372
|
const client = new ThreaApiClient({
|
|
@@ -3502,16 +3620,52 @@ var getVerb3 = {
|
|
|
3502
3620
|
return getMemo(ctx.client, id);
|
|
3503
3621
|
}
|
|
3504
3622
|
};
|
|
3623
|
+
var recallVerb = {
|
|
3624
|
+
name: "recall",
|
|
3625
|
+
summary: "Recall the memos relevant to a message",
|
|
3626
|
+
usage: "threa memos recall <message>",
|
|
3627
|
+
help: `threa memos recall <message>
|
|
3628
|
+
|
|
3629
|
+
` + "Pass the message you are about to answer; get back the few memos that help with it (at most 5), " + "scored for relevance by a model. Nothing comes back when memory holds nothing relevant. To explore " + "memory by idea or filter, use `threa search --what memos`.\n\n" + `Flags:
|
|
3630
|
+
` + ` --json force JSON output
|
|
3631
|
+
` + " --help show this help",
|
|
3632
|
+
options: {},
|
|
3633
|
+
run: (ctx, positionals) => {
|
|
3634
|
+
const message = positionals.join(" ").trim();
|
|
3635
|
+
if (!message)
|
|
3636
|
+
throw new UsageError("memos recall requires a <message>");
|
|
3637
|
+
return recallMemos(ctx.client, message);
|
|
3638
|
+
},
|
|
3639
|
+
render: (payload) => {
|
|
3640
|
+
const { data = [], outcome } = payload;
|
|
3641
|
+
if (data.length > 0)
|
|
3642
|
+
return data.map((memo) => renderSearchResult({ memo })).join(`
|
|
3643
|
+
`);
|
|
3644
|
+
if (outcome === "nothing_relevant" || outcome === "no_candidates")
|
|
3645
|
+
return "(nothing relevant)";
|
|
3646
|
+
return `(recall ${outcome ?? "failed"}; try \`threa search --what memos\`)`;
|
|
3647
|
+
}
|
|
3648
|
+
};
|
|
3505
3649
|
var memosNoun = {
|
|
3506
3650
|
name: "memos",
|
|
3507
|
-
summary: "List
|
|
3508
|
-
verbs: [listVerb5, getVerb3]
|
|
3651
|
+
summary: "List, recall, and get memos",
|
|
3652
|
+
verbs: [listVerb5, recallVerb, getVerb3]
|
|
3509
3653
|
};
|
|
3510
3654
|
|
|
3511
3655
|
// src/commands/streams.ts
|
|
3512
3656
|
function streamLabel2(s) {
|
|
3513
3657
|
return s.displayName ?? s.name ?? (s.slug ? `#${s.slug}` : s.id ?? "?");
|
|
3514
3658
|
}
|
|
3659
|
+
function renderStreamRow(s) {
|
|
3660
|
+
return [s.id ?? "?", s.type ?? "?", streamLabel2(s), s.archived ? "[archived]" : undefined].filter(Boolean).join(" ");
|
|
3661
|
+
}
|
|
3662
|
+
function archiveState(s) {
|
|
3663
|
+
if (s.archivedAt)
|
|
3664
|
+
return `archived ${s.archivedAt}`;
|
|
3665
|
+
if (s.archived)
|
|
3666
|
+
return "sealed by an archived ancestor";
|
|
3667
|
+
return "active";
|
|
3668
|
+
}
|
|
3515
3669
|
var listVerb6 = {
|
|
3516
3670
|
name: "list",
|
|
3517
3671
|
summary: "List streams this key can access",
|
|
@@ -3525,7 +3679,7 @@ var listVerb6 = {
|
|
|
3525
3679
|
` + ` --query q text match on stream name
|
|
3526
3680
|
` + ` --after c pagination cursor from a previous response
|
|
3527
3681
|
` + ` --limit n max results, <= 200 (default 50)
|
|
3528
|
-
` + ` --archived include archived streams and threads under archived
|
|
3682
|
+
` + ` --archived include archived streams and threads under archived ancestors
|
|
3529
3683
|
` + ` --json force JSON output
|
|
3530
3684
|
` + " --help show this help",
|
|
3531
3685
|
options: {
|
|
@@ -3542,10 +3696,7 @@ var listVerb6 = {
|
|
|
3542
3696
|
limit: intFlag(values, "limit"),
|
|
3543
3697
|
includeArchived: boolFlag(values, "archived")
|
|
3544
3698
|
}),
|
|
3545
|
-
render: (payload) => renderList(payload,
|
|
3546
|
-
empty: "(no streams)",
|
|
3547
|
-
cursorFlag: "after"
|
|
3548
|
-
})
|
|
3699
|
+
render: (payload) => renderList(payload, renderStreamRow, { empty: "(no streams)", cursorFlag: "after" })
|
|
3549
3700
|
};
|
|
3550
3701
|
var readVerb2 = {
|
|
3551
3702
|
name: "read",
|
|
@@ -3588,8 +3739,9 @@ var readVerb2 = {
|
|
|
3588
3739
|
render: (payload) => {
|
|
3589
3740
|
const p = payload;
|
|
3590
3741
|
const lines = [];
|
|
3591
|
-
if (p.stream)
|
|
3592
|
-
lines.push(`stream: ${p.stream.id ?? "?"}
|
|
3742
|
+
if (p.stream) {
|
|
3743
|
+
lines.push([`stream: ${p.stream.id ?? "?"}`, streamLabel2(p.stream), p.stream.archived ? "[archived]" : undefined].filter(Boolean).join(" "));
|
|
3744
|
+
}
|
|
3593
3745
|
const msgs = p.messages?.data ?? [];
|
|
3594
3746
|
lines.push(`messages: ${msgs.length}${p.messages?.hasMore ? " (more)" : ""}`);
|
|
3595
3747
|
for (const m of msgs) {
|
|
@@ -3629,7 +3781,7 @@ function archiveVerb(archived) {
|
|
|
3629
3781
|
},
|
|
3630
3782
|
render: (payload) => {
|
|
3631
3783
|
const stream = payload.data;
|
|
3632
|
-
return `${stream.id ?? "?"} ${streamLabel2(stream)} ${stream
|
|
3784
|
+
return `${stream.id ?? "?"} ${streamLabel2(stream)} ${archiveState(stream)}`;
|
|
3633
3785
|
}
|
|
3634
3786
|
};
|
|
3635
3787
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@threahq/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Command-line client and MCP server for Threa: read and search streams, send messages, manage attachments, and serve the Threa tools to any MCP-capable agent.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -41,6 +41,12 @@ You rarely need to look up an id first. Any stream argument takes a `stream_…`
|
|
|
41
41
|
|
|
42
42
|
Two things you cannot do this way, by design of the API: an `@user-slug` will not stand in for your DM with that user (DM streams hide their counterpart on the wire), and bots and personas are not queryable by slug. For a DM, find its `stream_…` id with `threa streams list --type dm` and `threa streams read <id> --members`; for a bot or persona, pass its id.
|
|
43
43
|
|
|
44
|
+
## Archived streams stay readable
|
|
45
|
+
|
|
46
|
+
If your key could read a stream before it was archived, it still can. `threa streams read`, `threa conversations read`, and `threa search` (tools `read_stream`, `read_conversation`, `search`) work on an archived stream and on any thread under it. A send, edit, or delete there fails with code `STREAM_READ_ONLY`.
|
|
47
|
+
|
|
48
|
+
By default `threa streams list` leaves out archived streams and their threads, and `threa conversations list` leaves out the conversations in those streams. Pass `--archived` to either command (tools `list_streams` and `list_conversations` with `include_archived: true`) to include those rows, which carry `archived: true` in JSON and print `[archived]` in text output. Refer to an archived channel by its `stream_…` id, which `threa streams list --archived` shows; its `#channel-slug` fails with `UNRESOLVED_REF`.
|
|
49
|
+
|
|
44
50
|
## Payloads name their authors
|
|
45
51
|
|
|
46
52
|
Read results already carry author identity, so you seldom need a second call to name who said what. Message rows carry `author: { id, type, name, slug? }` (a bot or persona author has no slug and its name comes from `authorDisplayName`). Conversations carry a `participants` array parallel to `participantIds` with each participant's name and slug. Stream members carry name and slug. Message search results, attachment rows, and conversations also carry `stream: { id, name?, type? }` — and `rootStream` when the stream is a thread — so you can name the scope without a `streams read`. Use these fields directly instead of listing users or streams per id.
|
|
@@ -52,6 +58,7 @@ Read results already carry author identity, so you seldom need a second call to
|
|
|
52
58
|
- The query is semantic by default and optional. Pass the idea you are after even if you do not know the exact wording; leave it empty (`--what memos` with no query) to browse the most recent memos.
|
|
53
59
|
- Pass `--exact` to match a literal phrase.
|
|
54
60
|
- Narrow with `--stream`, `--knowledge-type`, `--memo-type`, `--tag`, `--scope`, and `--before`/`--after`.
|
|
61
|
+
- When you are answering a specific message, `threa memos recall "<message>"` (tool `recall_memos`) returns only the memos a model judged relevant to it, at most 5, or `(nothing relevant)`. It runs a model per call, so recall once per message and use search to explore.
|
|
55
62
|
- Follow a hit with `threa memos get <id>` (tool `get_memo`) to see the source messages it was extracted from, so you can cite or verify the origin.
|
|
56
63
|
|
|
57
64
|
## Conversation create and resume
|
|
@@ -88,7 +95,7 @@ One `search` command covers all three kinds; pick with `--what`, and pass only t
|
|
|
88
95
|
|
|
89
96
|
- **`--what messages`**, default full-text: you know the words that appear in the message. Add `--semantic` when you know the idea but not the wording, or `--exact` to match the query as a literal phrase. Query required.
|
|
90
97
|
- **`--what memos`**: search workspace memory (see the memory section). Query optional and semantic; an empty query browses recent memos.
|
|
91
|
-
- **`--what attachments`**: search files by filename or extracted content, then `threa attachments get <id>` (tool `get_attachment`) for the full extracted text, or `threa attachments get <id> --url` (tool `get_attachment_download_url`) only when you need the raw bytes' URL. Query optional; omit it to browse the most recent attachments. `threa attachments download <id> [dest]` fetches the bytes to disk — a directory dest names the file after the attachment (`name (1).ext` on conflict), a file dest is written as given.
|
|
98
|
+
- **`--what attachments`**: search files by filename or extracted content, then `threa attachments get <id>` (tool `get_attachment`) for the full extracted text, or `threa attachments get <id> --url` (tool `get_attachment_download_url`) only when you need the raw bytes' URL. Query optional; omit it to browse the most recent attachments. `threa attachments download <id> [dest]` fetches the bytes to disk — a directory dest names the file after the attachment (`name (1).ext` on conflict), a file dest is written as given. `threa attachments upload <path> [--name f] [--type mime]` uploads a file and returns its id; link it in a message as `[name](attachment:<id>)`.
|
|
92
99
|
- **Recent-first browsing**: `threa memos list` and `threa attachments list` (both take repeatable `--stream` and `--limit`) are shorthand for a query-less search — use them for "what's new here" instead of inventing a query.
|
|
93
100
|
- **`messages find-by-metadata`**: you want messages by a reference you stamped at send time, not by text. It is exact key/value AND-containment, the right tool for dedup and external-id lookup.
|
|
94
101
|
|