@howler/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.
Files changed (3) hide show
  1. package/README.md +38 -10
  2. package/dist/bin.js +249 -125
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @howler/cli
2
2
 
3
- Terminal UI client for [Howler](https://howler.pages.dev) encrypted messaging.
3
+ Terminal UI client for [Howler](https://howler.pages.dev) encrypted messaging. Same account, same E2E encryption, same conversations — from your terminal.
4
4
 
5
5
  ## Install
6
6
 
@@ -8,33 +8,61 @@ Terminal UI client for [Howler](https://howler.pages.dev) encrypted messaging.
8
8
  npm install -g @howler/cli
9
9
  ```
10
10
 
11
+ Or run without installing:
12
+
13
+ ```bash
14
+ npx @howler/cli
15
+ ```
16
+
17
+ ## Getting started
18
+
19
+ 1. Run `howler` in your terminal
20
+ 2. On first launch, your browser opens to log in with your Howler account
21
+ 3. Once authenticated, the TUI connects and loads your inbox
22
+ 4. Use `Tab` to switch between the inbox panel (left) and conversation panel (right)
23
+ 5. Press `Enter` on a chat to open it, type a message, and press `Enter` to send
24
+
25
+ If you don't have an account yet, sign up at https://howler.pages.dev.
26
+
11
27
  ## Usage
12
28
 
13
29
  ```bash
14
30
  howler # Launch TUI (opens browser for login on first run)
15
- howler --logout # Sign out
31
+ howler --logout # Sign out and clear stored credentials
16
32
  ```
17
33
 
18
- ### Keyboard shortcuts
34
+ ## Keyboard shortcuts
19
35
 
20
36
  | Key | Action |
21
37
  |-----|--------|
22
- | `Tab` / `Shift+Tab` | Switch between panels |
38
+ | `Tab` / `Shift+Tab` | Switch between inbox and conversation panels |
23
39
  | `↑` / `↓` | Navigate list or scroll messages |
24
40
  | `Enter` | Select chat / send message |
25
- | `Escape` | Return to chat list |
26
- | `1` `2` `3` `4` | Filter: All, 1:1, Groups, AI |
41
+ | `Escape` | Return to chat list or branch list |
42
+ | `1` `2` `3` `4` | Filter inbox: All, 1:1, Groups, AI |
27
43
  | `l` | Load older messages |
44
+ | `/` | Open slash command autocomplete |
45
+
46
+ ## Slash commands
28
47
 
29
- ### Slash commands
48
+ Type `/` in the message input to see autocomplete suggestions.
30
49
 
31
50
  | Command | Description |
32
51
  |---------|-------------|
33
- | `/vault <query>` | Search your Obsidian vault |
34
52
  | `/agent <goal>` | Create an agent task (bot chats only) |
35
- | `/bot <name> <message>` | Send a message to a named bot |
53
+ | `/addmember @username` | Add a member to the current group |
54
+ | `/change-branch` | Switch branch in repo bot chats |
55
+ | `/current-branch` | Show the active branch |
56
+ | `/exit` | Quit the TUI |
57
+
58
+ ## Features
59
+
60
+ - **E2E encrypted** — Signal Protocol, same keys as the web app
61
+ - **Real-time** — messages appear instantly via Supabase subscriptions
62
+ - **Multi-device** — works alongside the web app on the same account
63
+ - **Bot/agent support** — interact with AI agents, view branch-based conversations
36
64
 
37
65
  ## Requirements
38
66
 
39
67
  - Node.js 18+
40
- - A Howler account (sign up at howler.pages.dev)
68
+ - A Howler account (sign up at https://howler.pages.dev)
package/dist/bin.js CHANGED
@@ -201,6 +201,8 @@ var init_store = __esm({
201
201
  init_db();
202
202
  SignalProtocolStore = class _SignalProtocolStore {
203
203
  static instance = null;
204
+ /** Callback invoked when a remote identity key changes. */
205
+ onIdentityKeyChanged = null;
204
206
  static getInstance() {
205
207
  if (!_SignalProtocolStore.instance) {
206
208
  _SignalProtocolStore.instance = new _SignalProtocolStore();
@@ -230,7 +232,11 @@ var init_store = __esm({
230
232
  const existingB64 = arrayBufferToBase64(existing.identityKey);
231
233
  const newB64 = arrayBufferToBase64(publicKey);
232
234
  if (existingB64 !== newB64) {
235
+ const previousKey = existing.identityKey;
233
236
  await db2.put("trustedIdentities", { identityKey: publicKey }, encodedAddress);
237
+ if (this.onIdentityKeyChanged) {
238
+ this.onIdentityKeyChanged({ address: encodedAddress, previousKey, newKey: publicKey });
239
+ }
234
240
  return true;
235
241
  }
236
242
  return false;
@@ -703,6 +709,7 @@ async function verifyDeviceExists(userId) {
703
709
  var LEGACY_DEVICE_ID_KEY, LEGACY_DEVICE_NAME_KEY, LEGACY_DEVICE_CREATED_AT_KEY, cachedDeviceId, cachedDeviceName, cachedDeviceCreatedAt, cacheInitialized;
704
710
  var init_device = __esm({
705
711
  "../../packages/core/src/lib/device/index.ts"() {
712
+ "use strict";
706
713
  init_define_import_meta_env();
707
714
  init_supabase();
708
715
  init_db();
@@ -784,7 +791,7 @@ async function fetchAllDeviceKeyBundles(userId) {
784
791
  const bundle = await fetchPublicKeyBundle(userId, 1);
785
792
  return bundle ? [bundle] : [];
786
793
  }
787
- console.log(`[Signal] Found ${keyBundles.length} key bundle(s) for user ${userId.slice(0, 8)}`);
794
+ if (define_import_meta_env_default.DEV) console.log(`[Signal] Found ${keyBundles.length} key bundle(s) for user ${userId.slice(0, 8)}`);
788
795
  const bundles = await Promise.all(
789
796
  keyBundles.map(
790
797
  (d) => fetchPublicKeyBundle(userId, d.device_id)
@@ -893,13 +900,13 @@ async function decryptFromSender(ciphertext, senderUserId, deviceId = 1) {
893
900
  const address = new SignalProtocolAddress(senderUserId, deviceId);
894
901
  const addressKey = `${senderUserId}.${deviceId}`;
895
902
  const existingSession = await signalStore.loadSession(addressKey);
896
- console.log(`[Signal] Decrypting from ${senderUserId.slice(0, 8)}.${deviceId}, type: ${ciphertext.type}, hasSession: ${!!existingSession}`);
903
+ if (define_import_meta_env_default.DEV) console.log(`[Signal] Decrypting from ${senderUserId.slice(0, 8)}.${deviceId}, type: ${ciphertext.type}, hasSession: ${!!existingSession}`);
897
904
  const sessionCipher = new SessionCipher(signalStore, address);
898
905
  const binaryBody = base64ToBinaryString(ciphertext.body);
899
906
  let plaintext;
900
907
  try {
901
908
  if (ciphertext.type === 3) {
902
- console.log("[Signal] Decrypting PreKeyWhisperMessage (type 3) - will establish session");
909
+ if (define_import_meta_env_default.DEV) console.log("[Signal] Decrypting PreKeyWhisperMessage (type 3) - will establish session");
903
910
  plaintext = await sessionCipher.decryptPreKeyWhisperMessage(binaryBody, "binary");
904
911
  } else {
905
912
  if (!existingSession) {
@@ -938,9 +945,9 @@ async function hasSessionWith(userId, deviceId = 1) {
938
945
  return !!session;
939
946
  }
940
947
  async function resetSessionWith(userId) {
941
- console.log("[Signal] Resetting encryption session with:", userId);
948
+ if (define_import_meta_env_default.DEV) console.log("[Signal] Resetting encryption session with:", userId);
942
949
  await signalStore.removeAllSessions(userId);
943
- console.log("[Signal] Session reset complete");
950
+ if (define_import_meta_env_default.DEV) console.log("[Signal] Session reset complete");
944
951
  }
945
952
  var init_crypto = __esm({
946
953
  "../../packages/core/src/lib/signal/crypto.ts"() {
@@ -1032,6 +1039,17 @@ async function generateAESKey() {
1032
1039
  async function exportKey(key) {
1033
1040
  return crypto.subtle.exportKey("raw", key);
1034
1041
  }
1042
+ async function sealKey(key) {
1043
+ const raw = await crypto.subtle.exportKey("raw", key);
1044
+ return crypto.subtle.importKey(
1045
+ "raw",
1046
+ raw,
1047
+ { name: "AES-GCM", length: 256 },
1048
+ false,
1049
+ // non-extractable
1050
+ ["encrypt", "decrypt"]
1051
+ );
1052
+ }
1035
1053
  async function importKey(keyData) {
1036
1054
  return crypto.subtle.importKey(
1037
1055
  "raw",
@@ -1085,18 +1103,19 @@ async function encryptAudioForRecipient(audioData, recipientUserId) {
1085
1103
  }
1086
1104
  async function encryptAudioForAllDevices(audioData, recipientUserId, senderUserId, transcript, providedAesKey) {
1087
1105
  const aesKey = providedAesKey || await generateAESKey();
1088
- const { ciphertext, iv } = await encryptWithAES(audioData, aesKey);
1106
+ const keyBytes = await exportKey(aesKey);
1107
+ const sealedKey = await sealKey(aesKey);
1108
+ const { ciphertext, iv } = await encryptWithAES(audioData, sealedKey);
1089
1109
  let encryptedTranscript;
1090
1110
  let transcriptIv;
1091
1111
  if (transcript && transcript.length > 0) {
1092
1112
  const transcriptJson = JSON.stringify(transcript);
1093
1113
  const transcriptBytes = new TextEncoder().encode(transcriptJson);
1094
- const { ciphertext: transcriptCiphertext, iv: tIv } = await encryptWithAES(transcriptBytes.buffer, aesKey);
1114
+ const { ciphertext: transcriptCiphertext, iv: tIv } = await encryptWithAES(transcriptBytes.buffer, sealedKey);
1095
1115
  encryptedTranscript = transcriptCiphertext;
1096
1116
  transcriptIv = arrayBufferToBase643(tIv.buffer);
1097
- console.log(`[E2E] Encrypted transcript (${transcript.length} segments)`);
1117
+ if (define_import_meta_env_default.DEV) console.log(`[E2E] Encrypted transcript (${transcript.length} segments)`);
1098
1118
  }
1099
- const keyBytes = await exportKey(aesKey);
1100
1119
  const [recipientBundles, senderBundles] = await Promise.all([
1101
1120
  fetchAllDeviceKeyBundles(recipientUserId),
1102
1121
  fetchAllDeviceKeyBundles(senderUserId)
@@ -1113,20 +1132,19 @@ async function encryptAudioForAllDevices(audioData, recipientUserId, senderUserI
1113
1132
  if (allDevices.length === 0) {
1114
1133
  throw new Error(`No devices found for users`);
1115
1134
  }
1116
- console.log(`[E2E] Encrypting for ${allDevices.length} device(s): ${recipientBundles.length} recipient + ${senderUserId !== recipientUserId ? senderBundles.length : 0} sender`);
1117
- const encryptedKeys = await Promise.all(
1118
- allDevices.map(async ({ userId, deviceId }) => {
1119
- const encrypted = await encryptForRecipient(keyBytes, userId, deviceId);
1120
- return {
1121
- userId,
1122
- deviceId,
1123
- type: encrypted.type,
1124
- body: encrypted.body,
1125
- registrationId: encrypted.registrationId
1126
- };
1127
- })
1128
- );
1129
- console.log(`[E2E] Encrypted for ${encryptedKeys.length} device(s)`);
1135
+ if (define_import_meta_env_default.DEV) console.log(`[E2E] Encrypting for ${allDevices.length} device(s): ${recipientBundles.length} recipient + ${senderUserId !== recipientUserId ? senderBundles.length : 0} sender`);
1136
+ const encryptedKeys = [];
1137
+ for (const { userId, deviceId } of allDevices) {
1138
+ const encrypted = await encryptForRecipient(keyBytes, userId, deviceId);
1139
+ encryptedKeys.push({
1140
+ userId,
1141
+ deviceId,
1142
+ type: encrypted.type,
1143
+ body: encrypted.body,
1144
+ registrationId: encrypted.registrationId
1145
+ });
1146
+ }
1147
+ if (define_import_meta_env_default.DEV) console.log(`[E2E] Encrypted for ${encryptedKeys.length} device(s)`);
1130
1148
  const senderDeviceId = getLocalDeviceId();
1131
1149
  if (!senderDeviceId) {
1132
1150
  throw new Error("Sender device ID not found. Please refresh the page.");
@@ -1150,7 +1168,7 @@ async function encryptAudioForAllDevices(audioData, recipientUserId, senderUserI
1150
1168
  };
1151
1169
  }
1152
1170
  async function decryptAudioFromSender(encryptedAudio, ivBase64, encryptedKey, senderUserId, encryptedKeys, myUserId, myDeviceId, encryptedTranscript, transcriptIv, encryptedAttachments, senderDeviceId) {
1153
- console.log(`[E2E] decryptAudioFromSender called:`, {
1171
+ if (define_import_meta_env_default.DEV) console.log(`[E2E] decryptAudioFromSender called:`, {
1154
1172
  senderUserId,
1155
1173
  myUserId,
1156
1174
  myDeviceId,
@@ -1164,23 +1182,23 @@ async function decryptAudioFromSender(encryptedAudio, ivBase64, encryptedKey, se
1164
1182
  let sessionDeviceId = 1;
1165
1183
  if (senderDeviceId) {
1166
1184
  sessionDeviceId = senderDeviceId;
1167
- console.log(`[E2E] Using senderDeviceId from metadata: ${senderDeviceId}`);
1185
+ if (define_import_meta_env_default.DEV) console.log(`[E2E] Using senderDeviceId from metadata: ${senderDeviceId}`);
1168
1186
  }
1169
1187
  if (encryptedKeys && encryptedKeys.length > 0 && myUserId && myDeviceId) {
1170
1188
  const availableDevices = encryptedKeys.filter((k) => k.userId === myUserId).map((k) => k.deviceId);
1171
- console.log(`[E2E] Message encrypted for user ${myUserId} devices: [${availableDevices.join(", ")}], current device: ${myDeviceId}`);
1189
+ if (define_import_meta_env_default.DEV) console.log(`[E2E] Message encrypted for user ${myUserId} devices: [${availableDevices.join(", ")}], current device: ${myDeviceId}`);
1172
1190
  const myKey = encryptedKeys.find((k) => k.userId === myUserId && k.deviceId === myDeviceId);
1173
1191
  if (!senderDeviceId) {
1174
1192
  const senderKey = encryptedKeys.find((k) => k.userId === senderUserId);
1175
1193
  if (senderKey) {
1176
1194
  sessionDeviceId = senderKey.deviceId;
1177
- console.log(`[E2E] Fallback: using first sender device found: ${sessionDeviceId} (may be incorrect for multi-device senders)`);
1195
+ if (define_import_meta_env_default.DEV) console.log(`[E2E] Fallback: using first sender device found: ${sessionDeviceId} (may be incorrect for multi-device senders)`);
1178
1196
  }
1179
1197
  }
1180
1198
  if (myKey) {
1181
1199
  keyToDecrypt = { type: myKey.type, body: myKey.body };
1182
1200
  sessionUserId = myUserId === senderUserId ? myUserId : senderUserId;
1183
- console.log(`[E2E] Found key for user ${myUserId} device ${myDeviceId}, using session ${sessionUserId}.${sessionDeviceId}`);
1201
+ if (define_import_meta_env_default.DEV) console.log(`[E2E] Found key for user ${myUserId} device ${myDeviceId}, using session ${sessionUserId}.${sessionDeviceId}`);
1184
1202
  } else {
1185
1203
  console.error(`[E2E] No key for device ${myDeviceId}. Message was encrypted for devices: [${availableDevices.join(", ")}]`);
1186
1204
  throw new Error(`This message was sent before this device was registered. Available devices: [${availableDevices.join(", ")}], your device: ${myDeviceId}`);
@@ -1198,18 +1216,18 @@ async function decryptAudioFromSender(encryptedAudio, ivBase64, encryptedKey, se
1198
1216
  const transcriptBytes = await decryptWithAES(transcriptCiphertext, aesKey, tIv);
1199
1217
  const transcriptJson = new TextDecoder().decode(transcriptBytes);
1200
1218
  transcript = JSON.parse(transcriptJson);
1201
- console.log(`[E2E] Decrypted transcript (${transcript.length} segments)`);
1219
+ if (define_import_meta_env_default.DEV) console.log(`[E2E] Decrypted transcript (${transcript.length} segments)`);
1202
1220
  } catch (err) {
1203
1221
  console.error("[E2E] Failed to decrypt transcript:", err);
1204
1222
  }
1205
1223
  }
1206
1224
  let attachments;
1207
1225
  if (encryptedAttachments && encryptedAttachments.length > 0) {
1208
- console.log(`[E2E] Decrypting ${encryptedAttachments.length} attachment(s)...`);
1226
+ if (define_import_meta_env_default.DEV) console.log(`[E2E] Decrypting ${encryptedAttachments.length} attachment(s)...`);
1209
1227
  attachments = [];
1210
1228
  for (const encAtt of encryptedAttachments) {
1211
1229
  if (!encAtt.encrypted || !encAtt.iv) {
1212
- console.log(`[E2E] Skipping unencrypted attachment: ${encAtt.name}`);
1230
+ if (define_import_meta_env_default.DEV) console.log(`[E2E] Skipping unencrypted attachment: ${encAtt.name}`);
1213
1231
  continue;
1214
1232
  }
1215
1233
  try {
@@ -1228,13 +1246,13 @@ async function decryptAudioFromSender(encryptedAudio, ivBase64, encryptedKey, se
1228
1246
  mimeType: encAtt.mimeType,
1229
1247
  size: encAtt.size
1230
1248
  });
1231
- console.log(`[E2E] Decrypted attachment: ${encAtt.name}`);
1249
+ if (define_import_meta_env_default.DEV) console.log(`[E2E] Decrypted attachment: ${encAtt.name}`);
1232
1250
  } catch (err) {
1233
1251
  console.error(`[E2E] Failed to decrypt attachment ${encAtt.name}:`, err);
1234
1252
  }
1235
1253
  }
1236
1254
  if (attachments.length > 0) {
1237
- console.log(`[E2E] Successfully decrypted ${attachments.length} attachment(s)`);
1255
+ if (define_import_meta_env_default.DEV) console.log(`[E2E] Successfully decrypted ${attachments.length} attachment(s)`);
1238
1256
  }
1239
1257
  }
1240
1258
  return { audio: audioData, transcript, attachments };
@@ -1494,6 +1512,7 @@ async function isAdmin(groupId, userId) {
1494
1512
  }
1495
1513
  var init_members = __esm({
1496
1514
  "../../packages/core/src/lib/groups/members.ts"() {
1515
+ "use strict";
1497
1516
  init_define_import_meta_env();
1498
1517
  init_supabase();
1499
1518
  init_senderKeyStore();
@@ -1549,25 +1568,26 @@ __export(groupEncryption_exports, {
1549
1568
  encryptAudioForGroup: () => encryptAudioForGroup
1550
1569
  });
1551
1570
  async function encryptAudioForGroup(groupId, audioData, _senderUserId, transcript, providedAesKey) {
1552
- console.log("[Group E2E] Starting encryption for group:", groupId);
1571
+ if (define_import_meta_env_default.DEV) console.log("[Group E2E] Starting encryption for group:", groupId);
1553
1572
  const members = await getGroupMembers(groupId);
1554
1573
  const memberUserIds = members.map((m) => m.user_id);
1555
- console.log(`[Group E2E] Encrypting for ${members.length} group members`);
1574
+ if (define_import_meta_env_default.DEV) console.log(`[Group E2E] Encrypting for ${members.length} group members`);
1556
1575
  const aesKey = providedAesKey || await generateAESKey();
1557
- const { ciphertext, iv } = await encryptWithAES(audioData, aesKey);
1558
- console.log(`[Group E2E] Audio encrypted (${ciphertext.byteLength} bytes)`);
1576
+ const keyBytes = await exportKey(aesKey);
1577
+ const sealedKey = await sealKey(aesKey);
1578
+ const { ciphertext, iv } = await encryptWithAES(audioData, sealedKey);
1579
+ if (define_import_meta_env_default.DEV) console.log(`[Group E2E] Audio encrypted (${ciphertext.byteLength} bytes)`);
1559
1580
  let encryptedTranscript;
1560
1581
  let transcriptIv;
1561
1582
  if (transcript && transcript.length > 0) {
1562
1583
  const transcriptJson = JSON.stringify(transcript);
1563
1584
  const transcriptBytes = new TextEncoder().encode(transcriptJson);
1564
- const { ciphertext: transcriptCiphertext, iv: tIv } = await encryptWithAES(transcriptBytes.buffer, aesKey);
1585
+ const { ciphertext: transcriptCiphertext, iv: tIv } = await encryptWithAES(transcriptBytes.buffer, sealedKey);
1565
1586
  encryptedTranscript = transcriptCiphertext;
1566
1587
  transcriptIv = arrayBufferToBase643(tIv.buffer);
1567
- console.log(`[Group E2E] Encrypted transcript (${transcript.length} segments)`);
1588
+ if (define_import_meta_env_default.DEV) console.log(`[Group E2E] Encrypted transcript (${transcript.length} segments)`);
1568
1589
  }
1569
- const keyBytes = await exportKey(aesKey);
1570
- console.log("[Group E2E] Fetching device bundles for all members...");
1590
+ if (define_import_meta_env_default.DEV) console.log("[Group E2E] Fetching device bundles for all members...");
1571
1591
  const allBundles = await Promise.all(
1572
1592
  memberUserIds.map((userId) => fetchAllDeviceKeyBundles(userId))
1573
1593
  );
@@ -1582,22 +1602,21 @@ async function encryptAudioForGroup(groupId, audioData, _senderUserId, transcrip
1582
1602
  if (allDevices.length === 0) {
1583
1603
  throw new Error(`No devices found for group members`);
1584
1604
  }
1585
- console.log(`[Group E2E] Encrypting AES key for ${allDevices.length} device(s) across ${members.length} members`);
1605
+ if (define_import_meta_env_default.DEV) console.log(`[Group E2E] Encrypting AES key for ${allDevices.length} device(s) across ${members.length} members`);
1586
1606
  const startTime = performance.now();
1587
- const encryptedKeys = await Promise.all(
1588
- allDevices.map(async ({ userId, deviceId }) => {
1589
- const encrypted = await encryptForRecipient(keyBytes, userId, deviceId);
1590
- return {
1591
- userId,
1592
- deviceId,
1593
- type: encrypted.type,
1594
- body: encrypted.body,
1595
- registrationId: encrypted.registrationId
1596
- };
1597
- })
1598
- );
1607
+ const encryptedKeys = [];
1608
+ for (const { userId, deviceId } of allDevices) {
1609
+ const encrypted = await encryptForRecipient(keyBytes, userId, deviceId);
1610
+ encryptedKeys.push({
1611
+ userId,
1612
+ deviceId,
1613
+ type: encrypted.type,
1614
+ body: encrypted.body,
1615
+ registrationId: encrypted.registrationId
1616
+ });
1617
+ }
1599
1618
  const encryptionTime = performance.now() - startTime;
1600
- console.log(`[Group E2E] Encrypted for ${encryptedKeys.length} device(s) in ${encryptionTime.toFixed(0)}ms`);
1619
+ if (define_import_meta_env_default.DEV) console.log(`[Group E2E] Encrypted for ${encryptedKeys.length} device(s) in ${encryptionTime.toFixed(0)}ms`);
1601
1620
  const senderDeviceId = getLocalDeviceId();
1602
1621
  if (!senderDeviceId) {
1603
1622
  throw new Error("Sender device ID not found. Please refresh the page.");
@@ -2028,7 +2047,9 @@ __export(cache_exports, {
2028
2047
  getCachedAttachments: () => getCachedAttachments,
2029
2048
  getCachedAudio: () => getCachedAudio,
2030
2049
  getCachedTranscript: () => getCachedTranscript,
2031
- revokeAttachmentBlobUrls: () => revokeAttachmentBlobUrls
2050
+ getTranscriptCacheProvider: () => getTranscriptCacheProvider,
2051
+ revokeAttachmentBlobUrls: () => revokeAttachmentBlobUrls,
2052
+ setTranscriptCacheProvider: () => setTranscriptCacheProvider
2032
2053
  });
2033
2054
  import { openDB as openDB2 } from "idb";
2034
2055
  async function getAudioCacheDB() {
@@ -2079,7 +2100,7 @@ async function cacheAudio(howlerId, audioBlob, transcript, attachments) {
2079
2100
  console.error("[AudioCache] Error caching audio:", err);
2080
2101
  }
2081
2102
  }
2082
- async function cacheTranscript(howlerId, transcript) {
2103
+ async function cacheTranscriptInIndexedDB(howlerId, transcript) {
2083
2104
  try {
2084
2105
  const db2 = await getAudioCacheDB();
2085
2106
  await db2.put("decryptedAudio", {
@@ -2095,7 +2116,7 @@ async function cacheTranscript(howlerId, transcript) {
2095
2116
  console.error("[AudioCache] Error caching transcript:", err);
2096
2117
  }
2097
2118
  }
2098
- async function getCachedTranscript(howlerId) {
2119
+ async function getCachedTranscriptFromIndexedDB(howlerId) {
2099
2120
  try {
2100
2121
  const db2 = await getAudioCacheDB();
2101
2122
  const cached = await db2.get("decryptedAudio", howlerId);
@@ -2188,15 +2209,30 @@ async function getCacheStats() {
2188
2209
  return { count: 0, estimatedSize: 0 };
2189
2210
  }
2190
2211
  }
2191
- var DB_NAME2, DB_VERSION2, dbInstance2, attachmentBlobUrlCache;
2212
+ async function cacheTranscript(howlerId, transcript) {
2213
+ await transcriptCacheProvider.cacheTranscript(howlerId, transcript);
2214
+ }
2215
+ async function getCachedTranscript(howlerId) {
2216
+ return transcriptCacheProvider.getCachedTranscript(howlerId);
2217
+ }
2218
+ function setTranscriptCacheProvider(provider) {
2219
+ transcriptCacheProvider = provider;
2220
+ }
2221
+ function getTranscriptCacheProvider() {
2222
+ return transcriptCacheProvider;
2223
+ }
2224
+ var DB_NAME2, DB_VERSION2, dbInstance2, attachmentBlobUrlCache, transcriptCacheProvider;
2192
2225
  var init_cache = __esm({
2193
2226
  "../../packages/core/src/lib/audio/cache.ts"() {
2194
- "use strict";
2195
2227
  init_define_import_meta_env();
2196
2228
  DB_NAME2 = "howler-audio-cache";
2197
2229
  DB_VERSION2 = 3;
2198
2230
  dbInstance2 = null;
2199
2231
  attachmentBlobUrlCache = /* @__PURE__ */ new Map();
2232
+ transcriptCacheProvider = {
2233
+ getCachedTranscript: getCachedTranscriptFromIndexedDB,
2234
+ cacheTranscript: cacheTranscriptInIndexedDB
2235
+ };
2200
2236
  }
2201
2237
  });
2202
2238
 
@@ -2223,8 +2259,10 @@ __export(messages_exports, {
2223
2259
  sendEncryptedTextMessage: () => sendEncryptedTextMessage,
2224
2260
  sendGroupHowler: () => sendGroupHowler,
2225
2261
  sendGroupMessage: () => sendGroupMessage,
2262
+ sendGroupPlainTextMessage: () => sendGroupPlainTextMessage,
2226
2263
  sendGroupTextMessage: () => sendGroupTextMessage,
2227
2264
  sendHowler: () => sendHowler,
2265
+ sendPlainTextMessage: () => sendPlainTextMessage,
2228
2266
  updateHowlerTranscript: () => updateHowlerTranscript
2229
2267
  });
2230
2268
  function parseEncryptionMetadata(json) {
@@ -2285,6 +2323,36 @@ async function sendHowler(params) {
2285
2323
  });
2286
2324
  return data;
2287
2325
  }
2326
+ async function sendPlainTextMessage(params) {
2327
+ return sendHowler({
2328
+ recipientId: params.recipientId,
2329
+ recordingUrl: "text://plain",
2330
+ transcript: params.transcript,
2331
+ parentHowlerId: params.parentHowlerId
2332
+ });
2333
+ }
2334
+ async function sendGroupPlainTextMessage(params) {
2335
+ const { data: { user } } = await db.auth.getUser();
2336
+ if (!user) throw new Error("Not authenticated");
2337
+ const { data, error } = await db.from("howlers").insert({
2338
+ sender_id: user.id,
2339
+ group_id: params.groupId,
2340
+ recipient_id: null,
2341
+ recording_url: "text://plain",
2342
+ duration_seconds: 0,
2343
+ parent_howler_id: params.parentHowlerId,
2344
+ transcript: JSON.stringify(params.transcript)
2345
+ }).select().single();
2346
+ if (error) throw error;
2347
+ const { data: senderData } = await db.from("users").select("first_name").eq("id", user.id).single();
2348
+ const { data: groupData } = await db.from("groups").select("name").eq("id", params.groupId).single();
2349
+ sendGroupPushNotification({
2350
+ groupId: params.groupId,
2351
+ senderName: senderData?.first_name || void 0,
2352
+ groupName: groupData?.name || void 0
2353
+ });
2354
+ return data;
2355
+ }
2288
2356
  async function updateHowlerTranscript(howlerId, transcript) {
2289
2357
  const { error } = await db.from("howlers").update({ transcript: JSON.stringify(transcript) }).eq("id", howlerId);
2290
2358
  if (error) {
@@ -2751,12 +2819,13 @@ async function isEncryptionAvailable() {
2751
2819
  return hasLocalKeys();
2752
2820
  }
2753
2821
  async function getDecryptedTranscript(howlerId) {
2754
- const { getCachedTranscript: getCachedTranscript3 } = await Promise.resolve().then(() => (init_cache(), cache_exports));
2755
- return getCachedTranscript3(howlerId);
2822
+ const { getTranscriptCacheProvider: getTranscriptCacheProvider2 } = await Promise.resolve().then(() => (init_cache(), cache_exports));
2823
+ return getTranscriptCacheProvider2().getCachedTranscript(howlerId);
2756
2824
  }
2757
2825
  async function decryptTextOnlyTranscript(howlerId) {
2758
- const { getCachedTranscript: getCachedTranscript3, cacheTranscript: cacheTranscript3 } = await Promise.resolve().then(() => (init_cache(), cache_exports));
2759
- const cached = await getCachedTranscript3(howlerId);
2826
+ const { getTranscriptCacheProvider: getTranscriptCacheProvider2 } = await Promise.resolve().then(() => (init_cache(), cache_exports));
2827
+ const provider = getTranscriptCacheProvider2();
2828
+ const cached = await provider.getCachedTranscript(howlerId);
2760
2829
  if (cached) return cached;
2761
2830
  const { data: howler, error } = await db.from("howlers").select("id, sender_id, transcript, encryption_metadata").eq("id", howlerId).single();
2762
2831
  if (error || !howler) return null;
@@ -2800,7 +2869,7 @@ async function decryptTextOnlyTranscript(howlerId) {
2800
2869
  const plaintext = await decryptWithAES2(ciphertext, aesKey, iv);
2801
2870
  const json = new TextDecoder().decode(plaintext);
2802
2871
  const transcript = JSON.parse(json);
2803
- await cacheTranscript3(howlerId, transcript);
2872
+ await provider.cacheTranscript(howlerId, transcript);
2804
2873
  console.log("[E2E] Decrypted text-only transcript:", howlerId);
2805
2874
  return transcript;
2806
2875
  } catch (err) {
@@ -28613,6 +28682,7 @@ init_messages();
28613
28682
 
28614
28683
  // src/client.ts
28615
28684
  init_signal();
28685
+ init_cache();
28616
28686
 
28617
28687
  // src/signal/index.ts
28618
28688
  init_define_import_meta_env();
@@ -28681,6 +28751,8 @@ async function removeFile(path) {
28681
28751
  }
28682
28752
  var FileSignalStore = class _FileSignalStore {
28683
28753
  static instance = null;
28754
+ /** Callback invoked when a remote identity key changes. */
28755
+ onIdentityKeyChanged = null;
28684
28756
  static getInstance() {
28685
28757
  if (!_FileSignalStore.instance) {
28686
28758
  _FileSignalStore.instance = new _FileSignalStore();
@@ -28709,7 +28781,11 @@ var FileSignalStore = class _FileSignalStore {
28709
28781
  const newB64 = arrayBufferToBase644(publicKey);
28710
28782
  if (existing) {
28711
28783
  if (existing.identityKey !== newB64) {
28784
+ const previousKey = base64ToArrayBuffer3(existing.identityKey);
28712
28785
  await writeJSON(filePath, { identityKey: newB64 });
28786
+ if (this.onIdentityKeyChanged) {
28787
+ this.onIdentityKeyChanged({ address: encodedAddress, previousKey, newKey: publicKey });
28788
+ }
28713
28789
  return true;
28714
28790
  }
28715
28791
  return false;
@@ -28915,10 +28991,44 @@ var FileSenderKeyStore = class _FileSenderKeyStore {
28915
28991
  };
28916
28992
  var fileSenderKeyStore = FileSenderKeyStore.getInstance();
28917
28993
 
28994
+ // src/hooks/transcriptCache.ts
28995
+ init_define_import_meta_env();
28996
+ import { join as join4 } from "node:path";
28997
+ import { homedir as homedir4 } from "node:os";
28998
+ import { readFile as readFile4, writeFile as writeFile4, mkdir as mkdir4 } from "node:fs/promises";
28999
+ var CACHE_DIR = join4(homedir4(), ".howler", "transcript-cache");
29000
+ var dirEnsured = false;
29001
+ async function ensureDir4() {
29002
+ if (dirEnsured) return;
29003
+ await mkdir4(CACHE_DIR, { recursive: true, mode: 448 });
29004
+ dirEnsured = true;
29005
+ }
29006
+ async function getCachedTranscript2(howlerId) {
29007
+ try {
29008
+ await ensureDir4();
29009
+ const data = await readFile4(join4(CACHE_DIR, `${howlerId}.json`), "utf-8");
29010
+ return JSON.parse(data);
29011
+ } catch {
29012
+ return null;
29013
+ }
29014
+ }
29015
+ async function cacheTranscript2(howlerId, transcript) {
29016
+ try {
29017
+ await ensureDir4();
29018
+ await writeFile4(
29019
+ join4(CACHE_DIR, `${howlerId}.json`),
29020
+ JSON.stringify(transcript),
29021
+ { mode: 384 }
29022
+ );
29023
+ } catch {
29024
+ }
29025
+ }
29026
+
28918
29027
  // src/client.ts
28919
29028
  function initializeClient() {
28920
29029
  initializeSupabase(getSupabase());
28921
29030
  setSignalStore(fileSignalStore);
29031
+ setTranscriptCacheProvider({ getCachedTranscript: getCachedTranscript2, cacheTranscript: cacheTranscript2 });
28922
29032
  }
28923
29033
 
28924
29034
  // src/components/ChatList.tsx
@@ -29493,39 +29603,6 @@ function useRealtimeMessages(conversation, currentUserId, onNewMessage, onUpdate
29493
29603
  }, [conversation?.id, conversation?.type, currentUserId, retryTick]);
29494
29604
  }
29495
29605
 
29496
- // src/hooks/transcriptCache.ts
29497
- init_define_import_meta_env();
29498
- import { join as join4 } from "node:path";
29499
- import { homedir as homedir4 } from "node:os";
29500
- import { readFile as readFile4, writeFile as writeFile4, mkdir as mkdir4 } from "node:fs/promises";
29501
- var CACHE_DIR = join4(homedir4(), ".howler", "transcript-cache");
29502
- var dirEnsured = false;
29503
- async function ensureDir4() {
29504
- if (dirEnsured) return;
29505
- await mkdir4(CACHE_DIR, { recursive: true, mode: 448 });
29506
- dirEnsured = true;
29507
- }
29508
- async function getCachedTranscript2(howlerId) {
29509
- try {
29510
- await ensureDir4();
29511
- const data = await readFile4(join4(CACHE_DIR, `${howlerId}.json`), "utf-8");
29512
- return JSON.parse(data);
29513
- } catch {
29514
- return null;
29515
- }
29516
- }
29517
- async function cacheTranscript2(howlerId, transcript) {
29518
- try {
29519
- await ensureDir4();
29520
- await writeFile4(
29521
- join4(CACHE_DIR, `${howlerId}.json`),
29522
- JSON.stringify(transcript),
29523
- { mode: 384 }
29524
- );
29525
- } catch {
29526
- }
29527
- }
29528
-
29529
29606
  // src/hooks/useMessages.ts
29530
29607
  async function getGroupMessages(groupId, limit = 50, beforeTimestamp) {
29531
29608
  const { data, error } = await supabase.rpc("get_group_messages", {
@@ -29561,16 +29638,8 @@ async function getGroupMessages(groupId, limit = 50, beforeTimestamp) {
29561
29638
  async function tryDecrypt(msg) {
29562
29639
  if (!msg.isEncrypted || msg.recording_url === "text://plain") return msg;
29563
29640
  try {
29564
- const cached = await getCachedTranscript2(msg.id);
29565
- if (cached && cached.length > 0) {
29566
- return {
29567
- ...msg,
29568
- transcript: JSON.stringify(cached)
29569
- };
29570
- }
29571
29641
  const transcript = await decryptTextOnlyTranscript(msg.id);
29572
29642
  if (transcript && transcript.length > 0) {
29573
- await cacheTranscript2(msg.id, transcript);
29574
29643
  return {
29575
29644
  ...msg,
29576
29645
  transcript: JSON.stringify(transcript)
@@ -29581,12 +29650,26 @@ async function tryDecrypt(msg) {
29581
29650
  }
29582
29651
  return msg;
29583
29652
  }
29653
+ function isCacheableTranscriptJson(transcript) {
29654
+ if (!transcript) return false;
29655
+ try {
29656
+ const parsed = JSON.parse(transcript);
29657
+ return Array.isArray(parsed) || typeof parsed === "string" || !!parsed;
29658
+ } catch {
29659
+ return false;
29660
+ }
29661
+ }
29584
29662
  function useMessages(conversation, currentUserId) {
29585
29663
  const [messages, setMessages] = useState3([]);
29586
29664
  const [loading, setLoading] = useState3(!!conversation);
29587
29665
  const [error, setError] = useState3(null);
29588
29666
  const [hasMore, setHasMore] = useState3(false);
29589
29667
  const decryptedIds = useRef3(/* @__PURE__ */ new Set());
29668
+ const transcriptCache = useRef3(/* @__PURE__ */ new Map());
29669
+ const rememberResolvedTranscript = useCallback2((msg) => {
29670
+ if (!isCacheableTranscriptJson(msg.transcript)) return;
29671
+ transcriptCache.current.set(msg.id, msg.transcript);
29672
+ }, []);
29590
29673
  const fetchMessages = useCallback2(
29591
29674
  (beforeTimestamp) => {
29592
29675
  if (!conversation) return;
@@ -29612,27 +29695,35 @@ function useMessages(conversation, currentUserId) {
29612
29695
  conversation.name,
29613
29696
  "hasMore=" + result.hasMore
29614
29697
  );
29698
+ const withCached = result.messages.map((m) => {
29699
+ const cached = transcriptCache.current.get(m.id);
29700
+ return cached ? { ...m, transcript: cached } : m;
29701
+ });
29615
29702
  if (beforeTimestamp) {
29616
29703
  setMessages((prev) => {
29617
29704
  const ids = new Set(prev.map((m) => m.id));
29618
- const newMsgs = result.messages.filter(
29705
+ const newMsgs = withCached.filter(
29619
29706
  (m) => !ids.has(m.id)
29620
29707
  );
29621
29708
  return [...newMsgs, ...prev];
29622
29709
  });
29623
29710
  } else {
29624
- setMessages(result.messages);
29711
+ setMessages(withCached);
29625
29712
  }
29626
29713
  setHasMore(result.hasMore);
29627
29714
  setLoading(false);
29628
29715
  const encrypted = result.messages.filter(
29629
29716
  (m) => m.isEncrypted && !decryptedIds.current.has(m.id)
29630
29717
  );
29631
- if (encrypted.length > 0) {
29632
- Promise.all(encrypted.map(tryDecrypt)).then((decrypted) => {
29718
+ const needsDecrypt = encrypted.filter(
29719
+ (m) => !transcriptCache.current.has(m.id)
29720
+ );
29721
+ if (needsDecrypt.length > 0) {
29722
+ Promise.all(needsDecrypt.map(tryDecrypt)).then((decrypted) => {
29633
29723
  const decryptedMap = /* @__PURE__ */ new Map();
29634
29724
  for (const msg of decrypted) {
29635
29725
  decryptedIds.current.add(msg.id);
29726
+ rememberResolvedTranscript(msg);
29636
29727
  decryptedMap.set(msg.id, msg);
29637
29728
  }
29638
29729
  setMessages(
@@ -29663,6 +29754,7 @@ function useMessages(conversation, currentUserId) {
29663
29754
  if (msg.isEncrypted && !decryptedIds.current.has(msg.id)) {
29664
29755
  decryptedIds.current.add(msg.id);
29665
29756
  tryDecrypt(msg).then((processed) => {
29757
+ rememberResolvedTranscript(processed);
29666
29758
  setMessages((prev) => {
29667
29759
  if (prev.some((m) => m.id === processed.id)) return prev;
29668
29760
  return [...prev, processed];
@@ -29674,7 +29766,7 @@ function useMessages(conversation, currentUserId) {
29674
29766
  if (prev.some((m) => m.id === msg.id)) return prev;
29675
29767
  return [...prev, msg];
29676
29768
  });
29677
- }, []);
29769
+ }, [rememberResolvedTranscript]);
29678
29770
  const handleUpdateMessage = useCallback2(
29679
29771
  (id, updates) => {
29680
29772
  setMessages(
@@ -29686,7 +29778,9 @@ function useMessages(conversation, currentUserId) {
29686
29778
  if (!msg || !msg.isEncrypted || msg.recording_url === "text://plain") return prev;
29687
29779
  decryptedIds.current.delete(id);
29688
29780
  decryptedIds.current.add(id);
29781
+ transcriptCache.current.delete(id);
29689
29782
  tryDecrypt(msg).then((processed) => {
29783
+ rememberResolvedTranscript(processed);
29690
29784
  setMessages(
29691
29785
  (p) => p.map((m) => m.id === id ? processed : m)
29692
29786
  );
@@ -29695,7 +29789,7 @@ function useMessages(conversation, currentUserId) {
29695
29789
  });
29696
29790
  }
29697
29791
  },
29698
- []
29792
+ [rememberResolvedTranscript]
29699
29793
  );
29700
29794
  useRealtimeMessages(
29701
29795
  conversation,
@@ -29714,11 +29808,15 @@ function useMessages(conversation, currentUserId) {
29714
29808
  fetchMessages();
29715
29809
  }, [fetchMessages]);
29716
29810
  const addMessage = useCallback2((msg) => {
29811
+ if (msg.isEncrypted && msg.recording_url !== "text://plain" && isCacheableTranscriptJson(msg.transcript)) {
29812
+ decryptedIds.current.add(msg.id);
29813
+ rememberResolvedTranscript(msg);
29814
+ }
29717
29815
  setMessages((prev) => {
29718
29816
  if (prev.some((m) => m.id === msg.id)) return prev;
29719
29817
  return [...prev, msg];
29720
29818
  });
29721
- }, []);
29819
+ }, [rememberResolvedTranscript]);
29722
29820
  return { messages, loading, error, hasMore, loadMore, refresh, addMessage };
29723
29821
  }
29724
29822
 
@@ -29726,7 +29824,7 @@ function useMessages(conversation, currentUserId) {
29726
29824
  init_define_import_meta_env();
29727
29825
  init_messages();
29728
29826
  import { useState as useState4, useCallback as useCallback3 } from "react";
29729
- function useSendMessage(conversation, currentUserId) {
29827
+ function useSendMessage(conversation, currentUserId, encrypted = true) {
29730
29828
  const [sending, setSending] = useState4(false);
29731
29829
  const [error, setError] = useState4(null);
29732
29830
  const send = useCallback3(
@@ -29739,14 +29837,27 @@ function useSendMessage(conversation, currentUserId) {
29739
29837
  ];
29740
29838
  const isGroup = conversation.type === "group";
29741
29839
  try {
29742
- const data = isGroup ? await sendGroupTextMessage({
29743
- groupId: conversation.id,
29744
- transcript,
29745
- parentHowlerId: options?.parentHowlerId
29746
- }) : await sendEncryptedTextMessage({
29747
- recipientId: conversation.id,
29748
- transcript
29749
- });
29840
+ let data;
29841
+ if (encrypted) {
29842
+ data = isGroup ? await sendGroupTextMessage({
29843
+ groupId: conversation.id,
29844
+ transcript,
29845
+ parentHowlerId: options?.parentHowlerId
29846
+ }) : await sendEncryptedTextMessage({
29847
+ recipientId: conversation.id,
29848
+ transcript
29849
+ });
29850
+ } else {
29851
+ data = isGroup ? await sendGroupPlainTextMessage({
29852
+ groupId: conversation.id,
29853
+ transcript,
29854
+ parentHowlerId: options?.parentHowlerId
29855
+ }) : await sendPlainTextMessage({
29856
+ recipientId: conversation.id,
29857
+ transcript,
29858
+ parentHowlerId: options?.parentHowlerId
29859
+ });
29860
+ }
29750
29861
  const msg = {
29751
29862
  ...data,
29752
29863
  transcript: JSON.stringify(transcript),
@@ -29757,7 +29868,7 @@ function useSendMessage(conversation, currentUserId) {
29757
29868
  last_name: null,
29758
29869
  avatar_url: null
29759
29870
  },
29760
- isEncrypted: true
29871
+ isEncrypted: encrypted
29761
29872
  };
29762
29873
  setSending(false);
29763
29874
  return msg;
@@ -29768,7 +29879,7 @@ function useSendMessage(conversation, currentUserId) {
29768
29879
  return null;
29769
29880
  }
29770
29881
  },
29771
- [conversation, currentUserId]
29882
+ [conversation, currentUserId, encrypted]
29772
29883
  );
29773
29884
  return { send, sending, error };
29774
29885
  }
@@ -30455,11 +30566,12 @@ var ConversationPanel = React2.forwardRef(
30455
30566
  panelWidth: panelWidthProp
30456
30567
  }, ref) {
30457
30568
  const { messages, loading, error, hasMore, loadMore, addMessage } = useMessages(conversation, currentUserId);
30569
+ const [encryptionEnabled, setEncryptionEnabled] = React2.useState(true);
30458
30570
  const {
30459
30571
  send,
30460
30572
  sending,
30461
30573
  error: sendError
30462
- } = useSendMessage(conversation, currentUserId);
30574
+ } = useSendMessage(conversation, currentUserId, encryptionEnabled);
30463
30575
  const { branches, loading: branchesLoading, selectedBranch, selectBranch, filteredMessages, threadMap } = useBranches(conversation, messages);
30464
30576
  const {
30465
30577
  execute: executeCommand,
@@ -30558,6 +30670,17 @@ var ConversationPanel = React2.forwardRef(
30558
30670
  }
30559
30671
  return;
30560
30672
  }
30673
+ if (text.trim().toLowerCase() === "/encrypt") {
30674
+ setEncryptionEnabled((prev) => {
30675
+ const next = !prev;
30676
+ setStatusMsg({
30677
+ text: next ? "Encryption ON" : "Encryption OFF (diagnostic mode)",
30678
+ color: next ? "green" : "yellow"
30679
+ });
30680
+ return next;
30681
+ });
30682
+ return;
30683
+ }
30561
30684
  if (isCommand(text)) {
30562
30685
  await executeCommand(text);
30563
30686
  return;
@@ -30786,6 +30909,7 @@ var SLASH_COMMANDS = [
30786
30909
  { name: "/bot", args: "<name> <msg>", description: "Send to a bot" },
30787
30910
  { name: "/change-branch", args: "[name]", description: "Switch branch" },
30788
30911
  { name: "/current-branch", args: "", description: "Show current branch" },
30912
+ { name: "/encrypt", args: "", description: "Toggle encryption on/off" },
30789
30913
  { name: "/exit", args: "", description: "Quit Howler TUI" },
30790
30914
  { name: "/vault", args: "<query>", description: "Search your vault" }
30791
30915
  ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@howler/cli",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "type": "module",
5
5
  "description": "Terminal UI client for Howler encrypted messaging",
6
6
  "bin": {