@theplato/tiro-cli 0.1.0 → 0.2.1

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/dist/bin/tiro.js CHANGED
@@ -175,8 +175,8 @@ async function startLoopbackServer() {
175
175
  );
176
176
  if (pendingResolve) settle(pendingResolve, { code, state });
177
177
  });
178
- await new Promise((resolve2) => {
179
- server.listen(0, "127.0.0.1", () => resolve2());
178
+ await new Promise((resolve3) => {
179
+ server.listen(0, "127.0.0.1", () => resolve3());
180
180
  });
181
181
  const address = server.address();
182
182
  const port = address.port;
@@ -185,7 +185,7 @@ async function startLoopbackServer() {
185
185
  redirectUri,
186
186
  port,
187
187
  waitForCallback(timeoutMs) {
188
- return new Promise((resolve2, reject) => {
188
+ return new Promise((resolve3, reject) => {
189
189
  const timer = setTimeout(() => {
190
190
  pendingResolve = null;
191
191
  pendingReject = null;
@@ -193,7 +193,7 @@ async function startLoopbackServer() {
193
193
  }, timeoutMs);
194
194
  pendingResolve = (r) => {
195
195
  clearTimeout(timer);
196
- resolve2(r);
196
+ resolve3(r);
197
197
  };
198
198
  pendingReject = (e) => {
199
199
  clearTimeout(timer);
@@ -722,15 +722,47 @@ var NoteSchema = z2.object({
722
722
  recordingStartAt: z2.string().nullable().optional(),
723
723
  recordingEndAt: z2.string().nullable().optional()
724
724
  }).passthrough();
725
+ var TextObjectSchema = z2.object({
726
+ type: z2.string(),
727
+ content: z2.string()
728
+ });
729
+ var SpeakerInfoSchema = z2.object({
730
+ label: z2.string(),
731
+ personName: z2.string().nullable().optional()
732
+ });
733
+ var DiarizedSegmentSchema = z2.object({
734
+ content: z2.string(),
735
+ speaker: SpeakerInfoSchema
736
+ });
725
737
  var ParagraphSchema = z2.object({
726
- text: z2.string(),
727
- startMs: z2.number().nullable().optional(),
728
- endMs: z2.number().nullable().optional(),
729
- speaker: z2.object({
730
- name: z2.string().nullable().optional(),
731
- email: z2.string().nullable().optional()
732
- }).nullable().optional()
738
+ uuid: z2.string(),
739
+ transcribeLocale: z2.string().nullable().optional(),
740
+ transcript: TextObjectSchema.nullable().optional(),
741
+ diarizedSegments: z2.array(DiarizedSegmentSchema).nullable().optional(),
742
+ timeFrom: z2.string().nullable().optional(),
743
+ timeTo: z2.string().nullable().optional(),
744
+ locked: z2.boolean().optional()
733
745
  }).passthrough();
746
+ var McpSegmentSchema = z2.object({
747
+ content: z2.string(),
748
+ speaker: z2.object({
749
+ label: z2.string(),
750
+ name: z2.string().nullable()
751
+ }).nullable()
752
+ });
753
+ var McpParagraphSchema = z2.object({
754
+ timeFrom: z2.string().nullable(),
755
+ timeTo: z2.string().nullable(),
756
+ segments: z2.array(McpSegmentSchema)
757
+ });
758
+ var McpTranscriptSchema = z2.object({
759
+ noteGuid: z2.string(),
760
+ title: z2.string(),
761
+ participants: z2.array(z2.string()),
762
+ createdAt: z2.string(),
763
+ recordingDurationSeconds: z2.number(),
764
+ paragraphs: z2.array(McpParagraphSchema)
765
+ });
734
766
  var PageCursorResponseSchema = (item) => z2.object({
735
767
  content: z2.array(item),
736
768
  nextCursor: z2.string().nullable()
@@ -903,34 +935,113 @@ function createApiClient(opts = {}) {
903
935
  return new TiroApiClient(hostname, t.accessToken);
904
936
  }
905
937
 
938
+ // src/lib/util/parseDate.ts
939
+ var RELATIVE_RE = /^(\d+)([smhdw])$/i;
940
+ var UNIT_MS = {
941
+ s: 1e3,
942
+ m: 6e4,
943
+ h: 36e5,
944
+ d: 864e5,
945
+ w: 6048e5
946
+ };
947
+ function parseDate(input) {
948
+ const trimmed = input.trim();
949
+ const rel = trimmed.match(RELATIVE_RE);
950
+ if (rel) {
951
+ const num = parseInt(rel[1] ?? "", 10);
952
+ const unit = (rel[2] ?? "").toLowerCase();
953
+ const factor = UNIT_MS[unit];
954
+ if (!factor || !Number.isFinite(num)) {
955
+ throw invalidDate(input);
956
+ }
957
+ return new Date(Date.now() - num * factor).toISOString();
958
+ }
959
+ const d = new Date(trimmed);
960
+ if (Number.isNaN(d.getTime())) throw invalidDate(input);
961
+ return d.toISOString();
962
+ }
963
+ function invalidDate(input) {
964
+ return new TiroError(
965
+ {
966
+ code: "invalid_date",
967
+ message: `Invalid date: "${input}". Use ISO-8601 (e.g. 2026-04-01T10:00:00Z) or relative (e.g. 7d, 24h, 30m).`,
968
+ errorType: "bad_request"
969
+ },
970
+ ExitCode.Usage
971
+ );
972
+ }
973
+
974
+ // src/lib/util/noteFilter.ts
975
+ var PLACEHOLDER_TITLE = "Untitled";
976
+ var PLACEHOLDER_SOURCE_TYPES = /* @__PURE__ */ new Set(["onboarding"]);
977
+ function isVisibleNote(note) {
978
+ if (note.title === PLACEHOLDER_TITLE) return false;
979
+ if (note.sourceType !== null && note.sourceType !== void 0 && PLACEHOLDER_SOURCE_TYPES.has(note.sourceType)) {
980
+ return false;
981
+ }
982
+ return true;
983
+ }
984
+
906
985
  // src/commands/notes/list.ts
907
986
  var ListResponseSchema = PageCursorResponseSchema(NoteSchema);
987
+ var DEFAULT_PAGE_SIZE = 100;
988
+ var MAX_PAGE_SIZE = 1e3;
989
+ var HELP_AFTER = `
990
+ Examples:
991
+ tiro notes list --since 7d
992
+ tiro notes list --keyword "OKR" --since 30d --json
993
+ tiro notes list --folder <id> --limit 50
994
+
995
+ Keyword matching:
996
+ --keyword reorders results by OpenSearch relevance (case-insensitive,
997
+ full-text against note title and paragraph content). When --keyword is
998
+ set, nextCursor is always null. Without --keyword, results are ordered
999
+ by createdAt desc.
1000
+
1001
+ Note: placeholder notes (title='Untitled' or sourceType='onboarding') are
1002
+ filtered out by default. A page of N may return fewer than N visible notes \u2014
1003
+ keep paginating to fetch more, or pass --include-untitled to surface them.
1004
+ `;
908
1005
  function registerNotesList(parent) {
909
- parent.command("list").description("List recent notes").option("--folder <id>", "Filter by folder ID").option("--limit <n>", "Max results per page (default 50, max 500)").option("--cursor <token>", "Cursor for the next page").action(async (opts, cmd) => {
1006
+ parent.command("list").description("List notes (lightweight metadata).").option("--keyword <text>", 'Reorder by OpenSearch relevance for this keyword (e.g. "OKR")').option("--folder <id>", "Restrict to a folder and its descendants").option(
1007
+ "--since <date>",
1008
+ "Inclusive lower bound on createdAt (ISO-8601 or relative: 7d, 24h, 30m)"
1009
+ ).option("--until <date>", "Exclusive upper bound on createdAt").option(
1010
+ "--limit <n>",
1011
+ `Max results per page (default ${DEFAULT_PAGE_SIZE}, max ${MAX_PAGE_SIZE})`
1012
+ ).option("--cursor <token>", "Continue a previous page").option(
1013
+ "--include-untitled",
1014
+ "Include placeholder notes (title='Untitled' or sourceType='onboarding'). Default: hidden",
1015
+ false
1016
+ ).addHelpText("after", HELP_AFTER).action(async (opts, cmd) => {
910
1017
  const globalOpts = cmd.optsWithGlobals();
911
- const limit = clampLimit(opts.limit);
912
1018
  const client = createApiClient({
913
1019
  ...globalOpts.hostname !== void 0 && { hostnameOverride: globalOpts.hostname }
914
1020
  });
915
1021
  const params = {};
1022
+ if (opts.keyword) params["keyword"] = opts.keyword;
916
1023
  if (opts.folder) params["folderId"] = opts.folder;
917
- if (limit !== void 0) params["size"] = limit;
1024
+ if (opts.since) params["createdAtFrom"] = parseDate(opts.since);
1025
+ if (opts.until) params["createdAtTo"] = parseDate(opts.until);
1026
+ const size = clampLimit(opts.limit);
1027
+ if (size !== void 0) params["size"] = size;
918
1028
  if (opts.cursor) params["cursor"] = opts.cursor;
919
1029
  const res = await client.getJson("/v1/external/notes", ListResponseSchema, params);
1030
+ const visible = opts.includeUntitled === true ? res.content : res.content.filter(isVisibleNote);
920
1031
  const mode = resolveOutputMode(globalOpts);
921
1032
  if (mode === "json") {
922
- for (const note of res.content) printNdjson(note);
1033
+ for (const note of visible) printNdjson(note);
923
1034
  if (res.nextCursor) printNdjson({ _cursor: res.nextCursor });
924
1035
  } else {
925
- printPretty(res.content, res.nextCursor, globalOpts);
1036
+ printPretty(visible, res.nextCursor, globalOpts);
926
1037
  }
927
1038
  });
928
1039
  }
929
1040
  function clampLimit(raw) {
930
1041
  if (!raw) return void 0;
931
1042
  const n = parseInt(raw, 10);
932
- if (!Number.isFinite(n) || n <= 0) return 50;
933
- return Math.min(n, 500);
1043
+ if (!Number.isFinite(n) || n <= 0) return DEFAULT_PAGE_SIZE;
1044
+ return Math.min(n, MAX_PAGE_SIZE);
934
1045
  }
935
1046
  function printPretty(notes, nextCursor, opts) {
936
1047
  if (notes.length === 0) {
@@ -938,11 +1049,13 @@ function printPretty(notes, nextCursor, opts) {
938
1049
  `);
939
1050
  return;
940
1051
  }
1052
+ const titleWidth = computeTitleWidth();
941
1053
  for (const n of notes) {
942
1054
  const date = n.createdAt.slice(0, 10);
943
1055
  const dur = formatDuration(n.recordingDurationSeconds);
1056
+ const title = truncate(n.title, titleWidth);
944
1057
  process.stdout.write(
945
- `${color(date, "gray", opts)} ${color(n.guid, "dim", opts)} ${color(dur, "cyan", opts)} ${n.title}
1058
+ `${color(date, "gray", opts)} ${color(n.guid, "dim", opts)} ${color(dur, "cyan", opts)} ${title}
946
1059
  `
947
1060
  );
948
1061
  }
@@ -954,6 +1067,15 @@ function printPretty(notes, nextCursor, opts) {
954
1067
  );
955
1068
  }
956
1069
  }
1070
+ function computeTitleWidth() {
1071
+ const cols = process.stdout.columns;
1072
+ if (!cols || cols < 60) return 40;
1073
+ return Math.max(20, cols - 60);
1074
+ }
1075
+ function truncate(s, max) {
1076
+ if (s.length <= max) return s;
1077
+ return s.slice(0, Math.max(0, max - 1)) + "\u2026";
1078
+ }
957
1079
  function formatDuration(sec) {
958
1080
  if (!sec || sec <= 0) return "\u2014";
959
1081
  const m = Math.floor(sec / 60);
@@ -964,79 +1086,91 @@ function formatDuration(sec) {
964
1086
 
965
1087
  // src/commands/notes/search.ts
966
1088
  import "commander";
1089
+ var SearchResponseSchema = PageCursorResponseSchema(NoteSchema).passthrough();
1090
+ var DEFAULT_PAGE_SIZE2 = 100;
1091
+ var MAX_PAGE_SIZE2 = 1e3;
1092
+ var HELP_AFTER2 = `
1093
+ Examples:
1094
+ tiro notes search "Q3 Planning"
1095
+ tiro notes search "Acme Corp" --since 7d --json
1096
+ tiro notes search "release" --since 2026-04-01 --until 2026-05-01
967
1097
 
968
- // src/lib/util/parseDate.ts
969
- var RELATIVE_RE = /^(\d+)([smhdw])$/i;
970
- var UNIT_MS = {
971
- s: 1e3,
972
- m: 6e4,
973
- h: 36e5,
974
- d: 864e5,
975
- w: 6048e5
976
- };
977
- function parseDate(input) {
978
- const trimmed = input.trim();
979
- const rel = trimmed.match(RELATIVE_RE);
980
- if (rel) {
981
- const num = parseInt(rel[1] ?? "", 10);
982
- const unit = (rel[2] ?? "").toLowerCase();
983
- const factor = UNIT_MS[unit];
984
- if (!factor || !Number.isFinite(num)) {
985
- throw invalidDate(input);
986
- }
987
- return new Date(Date.now() - num * factor).toISOString();
988
- }
989
- const d = new Date(trimmed);
990
- if (Number.isNaN(d.getTime())) throw invalidDate(input);
991
- return d.toISOString();
992
- }
993
- function invalidDate(input) {
994
- return new TiroError(
995
- {
996
- code: "invalid_date",
997
- message: `Invalid date: "${input}". Use ISO-8601 (e.g. 2026-04-01T10:00:00Z) or relative (e.g. 7d, 24h, 30m).`,
998
- errorType: "bad_request"
999
- },
1000
- ExitCode.Usage
1001
- );
1002
- }
1098
+ Keyword matching:
1099
+ Full-text against note title + paragraph content via OpenSearch.
1100
+ Case-insensitive. Multi-word keywords are tokenized \u2014 "OKR planning"
1101
+ matches notes containing both terms. The deep search variant (this
1102
+ command) hydrates each result with its primary documents (one-pager,
1103
+ custom) so an MCP/LLM client can read content alongside metadata in
1104
+ one call.
1003
1105
 
1004
- // src/commands/notes/search.ts
1005
- var SearchResponseSchema = PageCursorResponseSchema(NoteSchema);
1106
+ Note: placeholder notes (title='Untitled' or sourceType='onboarding') are
1107
+ filtered out by default. Pass --include-untitled to surface them.
1108
+ `;
1006
1109
  function registerNotesSearch(parent) {
1007
- parent.command("search [query]").description("Search notes by speaker / date / folder / keyword").option("--speaker <name>", "Filter by speaker name").option("--since <date>", "Notes created after this date (ISO-8601 or 7d, 24h, 30m)").option("--until <date>", "Notes created before this date").option("--folder <id>", "Filter by folder ID").option("--limit <n>", "Max results per page (default 50, max 500)").option("--cursor <token>", "Cursor for the next page").action(async (query, opts, cmd) => {
1110
+ parent.command("search [keyword]").description("Deep keyword search \u2014 returns notes hydrated with their primary documents.").option(
1111
+ "--keyword <text>",
1112
+ 'Alternative to positional keyword (e.g. --keyword "Q3 Planning")'
1113
+ ).option("--folder <id>", "Restrict hits to a folder and its descendants").option(
1114
+ "--since <date>",
1115
+ "Inclusive lower bound on createdAt (ISO-8601 or relative: 7d, 24h, 30m)"
1116
+ ).option("--until <date>", "Exclusive upper bound on createdAt").option(
1117
+ "--limit <n>",
1118
+ `Max results per page (default ${DEFAULT_PAGE_SIZE2}, max ${MAX_PAGE_SIZE2})`
1119
+ ).option(
1120
+ "--cursor <token>",
1121
+ "Continue a previous page (reserved \u2014 backend currently always null)"
1122
+ ).option(
1123
+ "--include-untitled",
1124
+ "Include placeholder notes. Default: hidden",
1125
+ false
1126
+ ).addHelpText("after", HELP_AFTER2).action(async (positional, opts, cmd) => {
1008
1127
  const globalOpts = cmd.optsWithGlobals();
1128
+ const keyword = (positional ?? opts.keyword ?? "").trim();
1129
+ if (!keyword) {
1130
+ throw new TiroError(
1131
+ {
1132
+ code: "missing_keyword",
1133
+ message: "search requires a keyword (positional or --keyword).",
1134
+ errorType: "bad_request",
1135
+ suggestion: 'tiro notes search "OKR"'
1136
+ },
1137
+ ExitCode.Usage
1138
+ );
1139
+ }
1140
+ const filter = {};
1141
+ if (opts.folder) filter["folderId"] = opts.folder;
1142
+ if (opts.since) filter["createdAtFrom"] = parseDate(opts.since);
1143
+ if (opts.until) filter["createdAtTo"] = parseDate(opts.until);
1144
+ const pagination = {};
1145
+ const size = clampLimit2(opts.limit);
1146
+ if (size !== void 0) pagination["size"] = size;
1147
+ if (opts.cursor) pagination["cursor"] = opts.cursor;
1148
+ const body = { keyword };
1149
+ if (Object.keys(filter).length > 0) body["filter"] = filter;
1150
+ if (Object.keys(pagination).length > 0) body["pagination"] = pagination;
1009
1151
  const client = createApiClient({
1010
1152
  ...globalOpts.hostname !== void 0 && { hostnameOverride: globalOpts.hostname }
1011
1153
  });
1012
- const body = {};
1013
- if (query) body["query"] = query;
1014
- if (opts.speaker) body["speaker"] = opts.speaker;
1015
- if (opts.since) body["since"] = parseDate(opts.since);
1016
- if (opts.until) body["until"] = parseDate(opts.until);
1017
- if (opts.folder) body["folderId"] = opts.folder;
1018
- const limit = clampLimit2(opts.limit);
1019
- if (limit !== void 0) body["size"] = limit;
1020
- if (opts.cursor) body["cursor"] = opts.cursor;
1021
1154
  const res = await client.postJson(
1022
1155
  "/v1/external/notes/search",
1023
1156
  SearchResponseSchema,
1024
1157
  body
1025
1158
  );
1159
+ const visible = opts.includeUntitled === true ? res.content : res.content.filter(isVisibleNote);
1026
1160
  const mode = resolveOutputMode(globalOpts);
1027
1161
  if (mode === "json") {
1028
- for (const note of res.content) printNdjson(note);
1162
+ for (const note of visible) printNdjson(note);
1029
1163
  if (res.nextCursor) printNdjson({ _cursor: res.nextCursor });
1030
1164
  } else {
1031
- printPretty2(res.content, res.nextCursor, globalOpts);
1165
+ printPretty2(visible, res.nextCursor, globalOpts);
1032
1166
  }
1033
1167
  });
1034
1168
  }
1035
1169
  function clampLimit2(raw) {
1036
1170
  if (!raw) return void 0;
1037
1171
  const n = parseInt(raw, 10);
1038
- if (!Number.isFinite(n) || n <= 0) return 50;
1039
- return Math.min(n, 500);
1172
+ if (!Number.isFinite(n) || n <= 0) return DEFAULT_PAGE_SIZE2;
1173
+ return Math.min(n, MAX_PAGE_SIZE2);
1040
1174
  }
1041
1175
  function printPretty2(notes, nextCursor, opts) {
1042
1176
  if (notes.length === 0) {
@@ -1098,6 +1232,98 @@ async function fileExists(p) {
1098
1232
  }
1099
1233
  }
1100
1234
 
1235
+ // src/lib/output/transcript.ts
1236
+ function buildMcpTranscript(note, paragraphs) {
1237
+ return {
1238
+ noteGuid: note.guid,
1239
+ title: note.title,
1240
+ participants: note.participants?.map((p) => p.name || p.email || "").filter((s) => typeof s === "string" && s.length > 0) ?? [],
1241
+ createdAt: note.createdAt,
1242
+ recordingDurationSeconds: note.recordingDurationSeconds,
1243
+ paragraphs: paragraphsToMcp(paragraphs)
1244
+ };
1245
+ }
1246
+ function paragraphsToMcp(paragraphs) {
1247
+ return paragraphs.map((p) => ({
1248
+ timeFrom: p.timeFrom ?? null,
1249
+ timeTo: p.timeTo ?? null,
1250
+ segments: paragraphToSegments(p)
1251
+ })).filter((p) => p.segments.length > 0);
1252
+ }
1253
+ function paragraphToSegments(p) {
1254
+ const ds = p.diarizedSegments;
1255
+ if (ds && ds.length > 0) {
1256
+ return ds.map((s) => ({
1257
+ content: stripHtml(s.content),
1258
+ speaker: {
1259
+ label: s.speaker.label,
1260
+ name: s.speaker.personName ? stripHtml(s.speaker.personName) : null
1261
+ }
1262
+ })).filter((s) => s.content.length > 0);
1263
+ }
1264
+ const plain = stripHtml(p.transcript?.content ?? "");
1265
+ return plain ? [{ content: plain, speaker: null }] : [];
1266
+ }
1267
+ function renderTranscriptJson(t) {
1268
+ return `${JSON.stringify(t, null, 2)}
1269
+ `;
1270
+ }
1271
+ function renderTranscriptMarkdown(t) {
1272
+ const anchor = anchorTime(t);
1273
+ const lines = [];
1274
+ lines.push(`# ${t.title}`, "");
1275
+ if (t.participants.length > 0) {
1276
+ lines.push(`**Participants**: ${t.participants.join(", ")}`, "");
1277
+ }
1278
+ lines.push("## Transcript", "");
1279
+ for (const p of t.paragraphs) {
1280
+ const ts = elapsed(p.timeFrom, anchor);
1281
+ for (const s of p.segments) {
1282
+ const who = s.speaker?.name ?? s.speaker?.label ?? "Unknown";
1283
+ const tag = ts ? `${who}, ${ts}` : who;
1284
+ lines.push(`**[${tag}]** ${s.content}`);
1285
+ }
1286
+ lines.push("");
1287
+ }
1288
+ return `${lines.join("\n").trimEnd()}
1289
+ `;
1290
+ }
1291
+ function renderTranscriptText(t) {
1292
+ const lines = [];
1293
+ for (const p of t.paragraphs) {
1294
+ for (const s of p.segments) {
1295
+ const who = s.speaker?.name ?? s.speaker?.label ?? "Unknown";
1296
+ lines.push(`[${who}] ${s.content}`);
1297
+ }
1298
+ }
1299
+ return `${lines.join("\n")}
1300
+ `;
1301
+ }
1302
+ function anchorTime(t) {
1303
+ for (const p of t.paragraphs) {
1304
+ if (p.timeFrom) return p.timeFrom;
1305
+ }
1306
+ return null;
1307
+ }
1308
+ function elapsed(currentIso, anchorIso) {
1309
+ if (!currentIso || !anchorIso) return "";
1310
+ const cur = Date.parse(currentIso);
1311
+ const anc = Date.parse(anchorIso);
1312
+ if (!Number.isFinite(cur) || !Number.isFinite(anc)) return "";
1313
+ const seconds = Math.max(0, Math.floor((cur - anc) / 1e3));
1314
+ const h = Math.floor(seconds / 3600);
1315
+ const m = Math.floor(seconds % 3600 / 60);
1316
+ const s = seconds % 60;
1317
+ if (h > 0) return `${pad(h)}:${pad(m)}:${pad(s)}`;
1318
+ return `${pad(m)}:${pad(s)}`;
1319
+ }
1320
+ function pad(n) {
1321
+ return n.toString().padStart(2, "0");
1322
+ }
1323
+ function stripHtml(s) {
1324
+ return s.replace(/<[^>]*>/g, "").replace(/&nbsp;/g, " ").replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#39;/g, "'").trim();
1325
+ }
1326
+
1101
1327
  // src/lib/output/format.ts
1102
1328
  function formatNote(note, format, opts = {}) {
1103
1329
  switch (format) {
@@ -1134,20 +1360,19 @@ function formatMarkdown(note, opts) {
1134
1360
  parts.push("");
1135
1361
  }
1136
1362
  if (opts.includeTranscript && opts.paragraphs && opts.paragraphs.length > 0) {
1137
- parts.push("## Transcript", "");
1138
- for (const p of opts.paragraphs) {
1139
- const speaker = p.speaker?.name ?? "Unknown";
1140
- const ts = formatTimestamp(p.startMs ?? null);
1141
- parts.push(`**[${speaker}${ts ? `, ${ts}` : ""}]** ${p.text}`);
1142
- parts.push("");
1143
- }
1363
+ const mcp = buildMcpTranscript(note, opts.paragraphs);
1364
+ const transcriptBody = renderTranscriptMarkdown(mcp);
1365
+ const startsWithHeader = transcriptBody.startsWith(`# ${note.title}`);
1366
+ const trimmed = startsWithHeader ? transcriptBody.slice(transcriptBody.indexOf("\n") + 1) : transcriptBody;
1367
+ parts.push(trimmed.replace(/^\s*\n+/, ""));
1144
1368
  }
1145
- return parts.join("\n");
1369
+ return `${parts.join("\n").trimEnd()}
1370
+ `;
1146
1371
  }
1147
1372
  function formatJson(note, opts) {
1148
1373
  const out = { ...note };
1149
1374
  if (opts.includeTranscript && opts.paragraphs) {
1150
- out["transcript"] = { paragraphs: opts.paragraphs };
1375
+ out["transcript"] = { paragraphs: paragraphsToMcp(opts.paragraphs) };
1151
1376
  }
1152
1377
  return `${JSON.stringify(out, null, 2)}
1153
1378
  `;
@@ -1158,21 +1383,8 @@ function formatText(note, opts) {
1158
1383
  ${note.webUrl}
1159
1384
  `;
1160
1385
  }
1161
- return opts.paragraphs.map((p) => `[${p.speaker?.name ?? "Unknown"}] ${p.text}`).join("\n");
1162
- }
1163
- function formatTimestamp(ms) {
1164
- if (ms === null || ms === void 0 || !Number.isFinite(ms)) return "";
1165
- const totalSec = Math.floor(ms / 1e3);
1166
- const h = Math.floor(totalSec / 3600);
1167
- const m = Math.floor(totalSec % 3600 / 60);
1168
- const s = totalSec % 60;
1169
- if (h > 0) {
1170
- return `${pad(h)}:${pad(m)}:${pad(s)}`;
1171
- }
1172
- return `${pad(m)}:${pad(s)}`;
1173
- }
1174
- function pad(n) {
1175
- return n.toString().padStart(2, "0");
1386
+ const mcp = buildMcpTranscript(note, opts.paragraphs);
1387
+ return renderTranscriptText(mcp);
1176
1388
  }
1177
1389
  function escapeYaml(s) {
1178
1390
  if (/[:#\n"']/.test(s)) {
@@ -1182,20 +1394,35 @@ function escapeYaml(s) {
1182
1394
  }
1183
1395
 
1184
1396
  // src/commands/notes/get.ts
1397
+ var ALLOWED_INCLUDES = /* @__PURE__ */ new Set(["transcript"]);
1185
1398
  var ParagraphsListSchema = SimpleListResponseSchema(ParagraphSchema);
1186
1399
  var ParagraphsCursorSchema = PageCursorResponseSchema(ParagraphSchema);
1187
1400
  function registerNotesGet(parent) {
1188
- parent.command("get <guid>").description("Get a single note (stdout or file)").option("--output <path>", "Write to file (stdout becomes metadata only)").option("--format <md|json|txt>", "Output format (default: md for TTY, json for pipe)").option(
1401
+ parent.command("get <guid>").description("Get a single note. Outputs to stdout, or saves to a file with --output.").option("--output <path>", "Write to file (stdout becomes a single metadata line)").option(
1402
+ "--format <md|json|txt>",
1403
+ "Output format (default: md for TTY, json when piped)"
1404
+ ).option(
1189
1405
  "--include <items>",
1190
- "Comma-separated extras: transcript",
1406
+ "Comma-separated extras (v0.2 supports: transcript)",
1191
1407
  ""
1192
- ).option("--force", "Overwrite existing file").action(async (guid, opts, cmd) => {
1408
+ ).option("--force", "Overwrite existing file at --output path").addHelpText("after", `
1409
+ Examples:
1410
+ tiro notes get <guid> # markdown to stdout
1411
+ tiro notes get <guid> --include transcript # add speaker-attributed paragraphs
1412
+ tiro notes get <guid> --output ./meeting.md --include transcript
1413
+ tiro notes get <guid> --format json # JSON to stdout
1414
+
1415
+ Tip for agents: prefer --output <path>. The actual content goes to disk
1416
+ and stdout collapses to a single metadata line, keeping your context
1417
+ window light.
1418
+ `).action(async (guid, opts, cmd) => {
1193
1419
  const globalOpts = cmd.optsWithGlobals();
1420
+ const includes = parseIncludes(opts.include);
1421
+ validateIncludes(includes);
1422
+ const format = pickFormat(opts.format, opts.output);
1194
1423
  const client = createApiClient({
1195
1424
  ...globalOpts.hostname !== void 0 && { hostnameOverride: globalOpts.hostname }
1196
1425
  });
1197
- const includes = parseIncludes(opts.include);
1198
- const format = pickFormat(opts.format, opts.output);
1199
1426
  const note = await client.getJson(`/v1/external/notes/${guid}`, NoteSchema);
1200
1427
  let paragraphs;
1201
1428
  if (includes.has("transcript") || format === "txt") {
@@ -1231,7 +1458,7 @@ function registerNotesGet(parent) {
1231
1458
  ok: true,
1232
1459
  data: {
1233
1460
  ...note,
1234
- ...paragraphs && { transcript: { paragraphs } }
1461
+ ...paragraphs && { transcript: { paragraphs: paragraphsToMcp(paragraphs) } }
1235
1462
  }
1236
1463
  },
1237
1464
  globalOpts
@@ -1239,8 +1466,9 @@ function registerNotesGet(parent) {
1239
1466
  } else if (format === "json") {
1240
1467
  process.stdout.write(content);
1241
1468
  } else {
1242
- if (process.stdout.isTTY) {
1469
+ if (process.stdout.isTTY && format === "txt") {
1243
1470
  process.stdout.write(`${color(`# ${note.title}`, "bold", globalOpts)}
1471
+
1244
1472
  `);
1245
1473
  }
1246
1474
  process.stdout.write(content);
@@ -1248,13 +1476,13 @@ function registerNotesGet(parent) {
1248
1476
  });
1249
1477
  }
1250
1478
  async function fetchAllParagraphs(client, guid) {
1251
- const tryCursor = await client.getJson(
1479
+ const first = await client.getJson(
1252
1480
  `/v1/external/notes/${guid}/paragraphs`,
1253
1481
  ParagraphsCursorSchema.or(ParagraphsListSchema)
1254
1482
  );
1255
- const all = [...tryCursor.content];
1256
- if ("nextCursor" in tryCursor) {
1257
- let cursor = tryCursor.nextCursor;
1483
+ const all = [...first.content];
1484
+ if ("nextCursor" in first) {
1485
+ let cursor = first.nextCursor;
1258
1486
  while (cursor) {
1259
1487
  const next = await client.getJson(
1260
1488
  `/v1/external/notes/${guid}/paragraphs`,
@@ -1273,6 +1501,21 @@ function parseIncludes(raw) {
1273
1501
  raw.split(",").map((s) => s.trim().toLowerCase()).filter((s) => s.length > 0)
1274
1502
  );
1275
1503
  }
1504
+ function validateIncludes(includes) {
1505
+ for (const inc of includes) {
1506
+ if (!ALLOWED_INCLUDES.has(inc)) {
1507
+ throw new TiroError(
1508
+ {
1509
+ code: "invalid_include",
1510
+ message: `Invalid --include "${inc}". v0.2.0 supports: transcript.`,
1511
+ errorType: "bad_request",
1512
+ suggestion: "Use --include transcript"
1513
+ },
1514
+ ExitCode.Usage
1515
+ );
1516
+ }
1517
+ }
1518
+ }
1276
1519
  function pickFormat(format, output) {
1277
1520
  const allowed = ["md", "json", "txt"];
1278
1521
  if (format) {
@@ -1302,18 +1545,30 @@ import "commander";
1302
1545
  var ParagraphsListSchema2 = SimpleListResponseSchema(ParagraphSchema);
1303
1546
  var ParagraphsCursorSchema2 = PageCursorResponseSchema(ParagraphSchema);
1304
1547
  function registerNotesTranscript(parent) {
1305
- parent.command("transcript <guid>").description("Get raw transcript paragraphs of a note").option("--output <path>", "Write to file (stdout = metadata only)").option("--format <md|json|txt>", "Output format (default: txt for transcript)").option("--force", "Overwrite existing file").action(async (guid, opts, cmd) => {
1548
+ parent.command("transcript <guid>").description(
1549
+ "Get the full transcript of a note as speaker-attributed paragraphs.\nJSON output matches MCP get_note_transcript shape exactly."
1550
+ ).option("--output <path>", "Write to file (stdout = single metadata line)").option(
1551
+ "--format <md|json|txt>",
1552
+ "Output format (default: md if TTY, txt when piped; json mirrors MCP)"
1553
+ ).option("--force", "Overwrite existing file at --output path").addHelpText("after", `
1554
+ Examples:
1555
+ tiro notes transcript <guid> # md in TTY, txt in pipe
1556
+ tiro notes transcript <guid> --format md --output ./t.md
1557
+ tiro notes transcript <guid> --format json # MCP-shape JSON
1558
+ tiro notes transcript <guid> --format txt --output ./embed.txt
1559
+
1560
+ The --format json output is byte-for-byte identical to MCP's
1561
+ get_note_transcript so agents can swap surfaces without changing parsers.
1562
+ `).action(async (guid, opts, cmd) => {
1306
1563
  const globalOpts = cmd.optsWithGlobals();
1564
+ const format = pickFormat2(opts.format, opts.output, globalOpts);
1307
1565
  const client = createApiClient({
1308
1566
  ...globalOpts.hostname !== void 0 && { hostnameOverride: globalOpts.hostname }
1309
1567
  });
1310
- const format = pickFormat2(opts.format, opts.output);
1311
1568
  const note = await client.getJson(`/v1/external/notes/${guid}`, NoteSchema);
1312
1569
  const paragraphs = await fetchAllParagraphs2(client, guid);
1313
- const content = formatNote(note, format, {
1314
- includeTranscript: true,
1315
- paragraphs
1316
- });
1570
+ const mcp = buildMcpTranscript(note, paragraphs);
1571
+ const content = format === "json" ? renderTranscriptJson(mcp) : format === "md" ? renderTranscriptMarkdown(mcp) : renderTranscriptText(mcp);
1317
1572
  if (opts.output) {
1318
1573
  const result = await writeFileAtomic(opts.output, content, {
1319
1574
  ...opts.force === true && { force: true }
@@ -1326,7 +1581,8 @@ function registerNotesTranscript(parent) {
1326
1581
  size: result.size,
1327
1582
  format,
1328
1583
  guid: note.guid,
1329
- paragraphCount: paragraphs.length
1584
+ paragraphCount: mcp.paragraphs.length,
1585
+ segmentCount: mcp.paragraphs.reduce((sum, p) => sum + p.segments.length, 0)
1330
1586
  }
1331
1587
  },
1332
1588
  globalOpts
@@ -1335,10 +1591,7 @@ function registerNotesTranscript(parent) {
1335
1591
  }
1336
1592
  const mode = resolveOutputMode(globalOpts);
1337
1593
  if (mode === "json" && format !== "json") {
1338
- printOutput(
1339
- { ok: true, data: { guid: note.guid, paragraphs } },
1340
- globalOpts
1341
- );
1594
+ printOutput({ ok: true, data: mcp }, globalOpts);
1342
1595
  } else {
1343
1596
  process.stdout.write(content);
1344
1597
  }
@@ -1364,7 +1617,7 @@ async function fetchAllParagraphs2(client, guid) {
1364
1617
  }
1365
1618
  return all;
1366
1619
  }
1367
- function pickFormat2(format, output) {
1620
+ function pickFormat2(format, output, globalOpts) {
1368
1621
  const allowed = ["md", "json", "txt"];
1369
1622
  if (format) {
1370
1623
  const f = format.toLowerCase();
@@ -1383,9 +1636,12 @@ function pickFormat2(format, output) {
1383
1636
  if (output) {
1384
1637
  if (output.endsWith(".json")) return "json";
1385
1638
  if (output.endsWith(".md")) return "md";
1386
- return "txt";
1639
+ if (output.endsWith(".txt")) return "txt";
1640
+ return "md";
1387
1641
  }
1388
- return "txt";
1642
+ if (globalOpts.json) return "json";
1643
+ if (globalOpts.pretty) return "md";
1644
+ return process.stdout.isTTY ? "md" : "txt";
1389
1645
  }
1390
1646
 
1391
1647
  // src/commands/notes/index.ts
@@ -1397,21 +1653,74 @@ function registerNotes(program) {
1397
1653
  registerNotesTranscript(notes);
1398
1654
  }
1399
1655
 
1656
+ // src/lib/updateCheck.ts
1657
+ import updateNotifier from "update-notifier";
1658
+ import { readFile } from "fs/promises";
1659
+ import { fileURLToPath } from "url";
1660
+ import { dirname as dirname2, resolve as resolve2 } from "path";
1661
+ var HERE = dirname2(fileURLToPath(import.meta.url));
1662
+ var CANDIDATE_PATHS = [
1663
+ resolve2(HERE, "../../package.json"),
1664
+ resolve2(HERE, "../../../package.json")
1665
+ ];
1666
+ var ONE_DAY_MS = 24 * 60 * 60 * 1e3;
1667
+ async function startUpdateCheck() {
1668
+ if (process.env["NO_UPDATE_NOTIFIER"] === "1") return null;
1669
+ if (process.env["CI"]) return null;
1670
+ if (process.stdout.isTTY !== true) return null;
1671
+ const pkg = await loadPkg();
1672
+ if (!pkg) return null;
1673
+ try {
1674
+ const factory = updateNotifier;
1675
+ return factory({
1676
+ pkg,
1677
+ updateCheckInterval: ONE_DAY_MS,
1678
+ shouldNotifyInNpmScript: false
1679
+ });
1680
+ } catch {
1681
+ return null;
1682
+ }
1683
+ }
1684
+ function emitUpdateBanner(notifier) {
1685
+ if (!notifier) return;
1686
+ if (!notifier.update) return;
1687
+ notifier.notify({
1688
+ isGlobal: true,
1689
+ defer: false,
1690
+ message: "Update available {currentVersion} \u2192 {latestVersion}\nRun npm install -g {packageName} to update\n\nChangelog: https://www.npmjs.com/package/{packageName}?activeTab=versions"
1691
+ });
1692
+ }
1693
+ async function loadPkg() {
1694
+ for (const path of CANDIDATE_PATHS) {
1695
+ try {
1696
+ const raw = await readFile(path, "utf8");
1697
+ const parsed = JSON.parse(raw);
1698
+ if (typeof parsed.name === "string" && typeof parsed.version === "string" && parsed.name.length > 0 && parsed.version.length > 0) {
1699
+ return { name: parsed.name, version: parsed.version };
1700
+ }
1701
+ } catch {
1702
+ }
1703
+ }
1704
+ return null;
1705
+ }
1706
+
1400
1707
  // src/bin/tiro.ts
1401
1708
  var EXAMPLES = `
1402
1709
  EXAMPLES
1403
1710
  $ tiro auth login
1404
- $ tiro notes search --speaker "\uAE40\uCCA0\uC218" --since 7d --json
1711
+ $ tiro notes list --since 7d
1712
+ $ tiro notes search "Q3 Planning" --since 30d --json
1405
1713
  $ tiro notes get <guid> --output ./meeting.md --include transcript
1406
- $ tiro notes transcript <guid> --format txt
1714
+ $ tiro notes transcript <guid> --format md --output ./transcript.md
1407
1715
 
1408
1716
  ENVIRONMENT
1409
- TIRO_TOKEN Bearer token (overrides keychain)
1410
- TIRO_HOSTNAME API base URL (default: https://api.tiro.ooo)
1411
- NO_COLOR Disable colors
1717
+ TIRO_TOKEN Bearer token (overrides keychain \u2014 for CI / agents)
1718
+ TIRO_HOSTNAME API base URL (default: https://api.tiro.ooo)
1719
+ NO_COLOR Disable colors (https://no-color.org)
1720
+ NO_UPDATE_NOTIFIER Set to "1" to disable the update banner
1412
1721
 
1413
1722
  DOCS
1414
- https://api.tiro.ooo/cli
1723
+ https://api-docs.tiro.ooo/cli/overview
1415
1724
  `;
1416
1725
  function buildProgram() {
1417
1726
  const program = new Command10();
@@ -1423,8 +1732,10 @@ function buildProgram() {
1423
1732
  }
1424
1733
  async function main() {
1425
1734
  const program = buildProgram();
1735
+ const notifier = await startUpdateCheck();
1426
1736
  try {
1427
1737
  await program.parseAsync(process.argv);
1738
+ emitUpdateBanner(notifier);
1428
1739
  } catch (err) {
1429
1740
  handleError(err, program);
1430
1741
  }