@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 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, body, extraHeaders) {
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(), this.timeoutMs);
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
- ...hasBody ? { "Content-Type": "application/json" } : {},
517
+ ...payload && "json" in payload ? { "Content-Type": "application/json" } : {},
510
518
  ...extraHeaders ?? {}
511
519
  },
512
- ...hasBody ? { body: JSON.stringify(body) } : {}
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 ${this.timeoutMs}ms`
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, statSync, writeFileSync as writeFileSync3 } from "node:fs";
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({ streamId, status: p.status, after: p.cursor, limit: p.limit })}`);
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
- ` + ` --after iso only results after this ISO-8601 datetime (messages and memos only)
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.createdAt),
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 via its signed URL. 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).
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, urlResp] = await Promise.all([
1682
+ const [meta, response] = await Promise.all([
1626
1683
  getAttachment(ctx.client, id),
1627
- getAttachmentDownloadUrl(ctx.client, id)
1684
+ getAttachmentContent(ctx.client, id)
1628
1685
  ]);
1629
- const url = urlResp.data?.url;
1630
- if (!url)
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
- const response = await fetch(url);
1634
- if (!response.ok)
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, or download its bytes",
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, { streamRef: stream_id, status, cursor, limit })));
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), " + `before/after (ISO-8601 datetimes), limit (≤100, default 20).
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 (and live threads under an archived root) are omitted unless `include_archived` is true.",
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 provenance: 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.";
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 memos and get one by id",
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 roots
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, (s) => `${s.id ?? "?"} ${s.type ?? "?"} ${streamLabel2(s)}`, {
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 ?? "?"} ${streamLabel2(p.stream)}`);
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.archivedAt ? `archived ${stream.archivedAt}` : "active"}`;
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.1.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