@howler/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.
Files changed (3) hide show
  1. package/README.md +39 -8
  2. package/dist/bin.js +250 -124
  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,64 @@ 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
52
  | `/vault <query>` | Search your Obsidian vault |
34
53
  | `/agent <goal>` | Create an agent task (bot chats only) |
35
54
  | `/bot <name> <message>` | Send a message to a named bot |
55
+ | `/addmember @username` | Add a member to the current group |
56
+ | `/change-branch` | Switch branch in repo bot chats |
57
+ | `/current-branch` | Show the active branch |
58
+ | `/exit` | Quit the TUI |
59
+
60
+ ## Features
61
+
62
+ - **E2E encrypted** — Signal Protocol, same keys as the web app
63
+ - **Real-time** — messages appear instantly via Supabase subscriptions
64
+ - **Multi-device** — works alongside the web app on the same account
65
+ - **Bot/agent support** — interact with AI agents, view branch-based conversations
66
+ - **Vault search** — query your Obsidian vault from the terminal
36
67
 
37
68
  ## Requirements
38
69
 
39
70
  - Node.js 18+
40
- - A Howler account (sign up at howler.pages.dev)
71
+ - 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 };
@@ -1312,6 +1330,7 @@ __export(signal_exports, {
1312
1330
  });
1313
1331
  var init_signal = __esm({
1314
1332
  "../../packages/core/src/lib/signal/index.ts"() {
1333
+ "use strict";
1315
1334
  init_define_import_meta_env();
1316
1335
  init_db();
1317
1336
  init_store();
@@ -1494,6 +1513,7 @@ async function isAdmin(groupId, userId) {
1494
1513
  }
1495
1514
  var init_members = __esm({
1496
1515
  "../../packages/core/src/lib/groups/members.ts"() {
1516
+ "use strict";
1497
1517
  init_define_import_meta_env();
1498
1518
  init_supabase();
1499
1519
  init_senderKeyStore();
@@ -1549,25 +1569,26 @@ __export(groupEncryption_exports, {
1549
1569
  encryptAudioForGroup: () => encryptAudioForGroup
1550
1570
  });
1551
1571
  async function encryptAudioForGroup(groupId, audioData, _senderUserId, transcript, providedAesKey) {
1552
- console.log("[Group E2E] Starting encryption for group:", groupId);
1572
+ if (define_import_meta_env_default.DEV) console.log("[Group E2E] Starting encryption for group:", groupId);
1553
1573
  const members = await getGroupMembers(groupId);
1554
1574
  const memberUserIds = members.map((m) => m.user_id);
1555
- console.log(`[Group E2E] Encrypting for ${members.length} group members`);
1575
+ if (define_import_meta_env_default.DEV) console.log(`[Group E2E] Encrypting for ${members.length} group members`);
1556
1576
  const aesKey = providedAesKey || await generateAESKey();
1557
- const { ciphertext, iv } = await encryptWithAES(audioData, aesKey);
1558
- console.log(`[Group E2E] Audio encrypted (${ciphertext.byteLength} bytes)`);
1577
+ const keyBytes = await exportKey(aesKey);
1578
+ const sealedKey = await sealKey(aesKey);
1579
+ const { ciphertext, iv } = await encryptWithAES(audioData, sealedKey);
1580
+ if (define_import_meta_env_default.DEV) console.log(`[Group E2E] Audio encrypted (${ciphertext.byteLength} bytes)`);
1559
1581
  let encryptedTranscript;
1560
1582
  let transcriptIv;
1561
1583
  if (transcript && transcript.length > 0) {
1562
1584
  const transcriptJson = JSON.stringify(transcript);
1563
1585
  const transcriptBytes = new TextEncoder().encode(transcriptJson);
1564
- const { ciphertext: transcriptCiphertext, iv: tIv } = await encryptWithAES(transcriptBytes.buffer, aesKey);
1586
+ const { ciphertext: transcriptCiphertext, iv: tIv } = await encryptWithAES(transcriptBytes.buffer, sealedKey);
1565
1587
  encryptedTranscript = transcriptCiphertext;
1566
1588
  transcriptIv = arrayBufferToBase643(tIv.buffer);
1567
- console.log(`[Group E2E] Encrypted transcript (${transcript.length} segments)`);
1589
+ if (define_import_meta_env_default.DEV) console.log(`[Group E2E] Encrypted transcript (${transcript.length} segments)`);
1568
1590
  }
1569
- const keyBytes = await exportKey(aesKey);
1570
- console.log("[Group E2E] Fetching device bundles for all members...");
1591
+ if (define_import_meta_env_default.DEV) console.log("[Group E2E] Fetching device bundles for all members...");
1571
1592
  const allBundles = await Promise.all(
1572
1593
  memberUserIds.map((userId) => fetchAllDeviceKeyBundles(userId))
1573
1594
  );
@@ -1582,22 +1603,21 @@ async function encryptAudioForGroup(groupId, audioData, _senderUserId, transcrip
1582
1603
  if (allDevices.length === 0) {
1583
1604
  throw new Error(`No devices found for group members`);
1584
1605
  }
1585
- console.log(`[Group E2E] Encrypting AES key for ${allDevices.length} device(s) across ${members.length} members`);
1606
+ if (define_import_meta_env_default.DEV) console.log(`[Group E2E] Encrypting AES key for ${allDevices.length} device(s) across ${members.length} members`);
1586
1607
  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
- );
1608
+ const encryptedKeys = [];
1609
+ for (const { userId, deviceId } of allDevices) {
1610
+ const encrypted = await encryptForRecipient(keyBytes, userId, deviceId);
1611
+ encryptedKeys.push({
1612
+ userId,
1613
+ deviceId,
1614
+ type: encrypted.type,
1615
+ body: encrypted.body,
1616
+ registrationId: encrypted.registrationId
1617
+ });
1618
+ }
1599
1619
  const encryptionTime = performance.now() - startTime;
1600
- console.log(`[Group E2E] Encrypted for ${encryptedKeys.length} device(s) in ${encryptionTime.toFixed(0)}ms`);
1620
+ if (define_import_meta_env_default.DEV) console.log(`[Group E2E] Encrypted for ${encryptedKeys.length} device(s) in ${encryptionTime.toFixed(0)}ms`);
1601
1621
  const senderDeviceId = getLocalDeviceId();
1602
1622
  if (!senderDeviceId) {
1603
1623
  throw new Error("Sender device ID not found. Please refresh the page.");
@@ -2028,7 +2048,9 @@ __export(cache_exports, {
2028
2048
  getCachedAttachments: () => getCachedAttachments,
2029
2049
  getCachedAudio: () => getCachedAudio,
2030
2050
  getCachedTranscript: () => getCachedTranscript,
2031
- revokeAttachmentBlobUrls: () => revokeAttachmentBlobUrls
2051
+ getTranscriptCacheProvider: () => getTranscriptCacheProvider,
2052
+ revokeAttachmentBlobUrls: () => revokeAttachmentBlobUrls,
2053
+ setTranscriptCacheProvider: () => setTranscriptCacheProvider
2032
2054
  });
2033
2055
  import { openDB as openDB2 } from "idb";
2034
2056
  async function getAudioCacheDB() {
@@ -2079,7 +2101,7 @@ async function cacheAudio(howlerId, audioBlob, transcript, attachments) {
2079
2101
  console.error("[AudioCache] Error caching audio:", err);
2080
2102
  }
2081
2103
  }
2082
- async function cacheTranscript(howlerId, transcript) {
2104
+ async function cacheTranscriptInIndexedDB(howlerId, transcript) {
2083
2105
  try {
2084
2106
  const db2 = await getAudioCacheDB();
2085
2107
  await db2.put("decryptedAudio", {
@@ -2095,7 +2117,7 @@ async function cacheTranscript(howlerId, transcript) {
2095
2117
  console.error("[AudioCache] Error caching transcript:", err);
2096
2118
  }
2097
2119
  }
2098
- async function getCachedTranscript(howlerId) {
2120
+ async function getCachedTranscriptFromIndexedDB(howlerId) {
2099
2121
  try {
2100
2122
  const db2 = await getAudioCacheDB();
2101
2123
  const cached = await db2.get("decryptedAudio", howlerId);
@@ -2188,7 +2210,19 @@ async function getCacheStats() {
2188
2210
  return { count: 0, estimatedSize: 0 };
2189
2211
  }
2190
2212
  }
2191
- var DB_NAME2, DB_VERSION2, dbInstance2, attachmentBlobUrlCache;
2213
+ async function cacheTranscript(howlerId, transcript) {
2214
+ await transcriptCacheProvider.cacheTranscript(howlerId, transcript);
2215
+ }
2216
+ async function getCachedTranscript(howlerId) {
2217
+ return transcriptCacheProvider.getCachedTranscript(howlerId);
2218
+ }
2219
+ function setTranscriptCacheProvider(provider) {
2220
+ transcriptCacheProvider = provider;
2221
+ }
2222
+ function getTranscriptCacheProvider() {
2223
+ return transcriptCacheProvider;
2224
+ }
2225
+ var DB_NAME2, DB_VERSION2, dbInstance2, attachmentBlobUrlCache, transcriptCacheProvider;
2192
2226
  var init_cache = __esm({
2193
2227
  "../../packages/core/src/lib/audio/cache.ts"() {
2194
2228
  "use strict";
@@ -2197,6 +2231,10 @@ var init_cache = __esm({
2197
2231
  DB_VERSION2 = 3;
2198
2232
  dbInstance2 = null;
2199
2233
  attachmentBlobUrlCache = /* @__PURE__ */ new Map();
2234
+ transcriptCacheProvider = {
2235
+ getCachedTranscript: getCachedTranscriptFromIndexedDB,
2236
+ cacheTranscript: cacheTranscriptInIndexedDB
2237
+ };
2200
2238
  }
2201
2239
  });
2202
2240
 
@@ -2223,8 +2261,10 @@ __export(messages_exports, {
2223
2261
  sendEncryptedTextMessage: () => sendEncryptedTextMessage,
2224
2262
  sendGroupHowler: () => sendGroupHowler,
2225
2263
  sendGroupMessage: () => sendGroupMessage,
2264
+ sendGroupPlainTextMessage: () => sendGroupPlainTextMessage,
2226
2265
  sendGroupTextMessage: () => sendGroupTextMessage,
2227
2266
  sendHowler: () => sendHowler,
2267
+ sendPlainTextMessage: () => sendPlainTextMessage,
2228
2268
  updateHowlerTranscript: () => updateHowlerTranscript
2229
2269
  });
2230
2270
  function parseEncryptionMetadata(json) {
@@ -2285,6 +2325,36 @@ async function sendHowler(params) {
2285
2325
  });
2286
2326
  return data;
2287
2327
  }
2328
+ async function sendPlainTextMessage(params) {
2329
+ return sendHowler({
2330
+ recipientId: params.recipientId,
2331
+ recordingUrl: "text://plain",
2332
+ transcript: params.transcript,
2333
+ parentHowlerId: params.parentHowlerId
2334
+ });
2335
+ }
2336
+ async function sendGroupPlainTextMessage(params) {
2337
+ const { data: { user } } = await db.auth.getUser();
2338
+ if (!user) throw new Error("Not authenticated");
2339
+ const { data, error } = await db.from("howlers").insert({
2340
+ sender_id: user.id,
2341
+ group_id: params.groupId,
2342
+ recipient_id: null,
2343
+ recording_url: "text://plain",
2344
+ duration_seconds: 0,
2345
+ parent_howler_id: params.parentHowlerId,
2346
+ transcript: JSON.stringify(params.transcript)
2347
+ }).select().single();
2348
+ if (error) throw error;
2349
+ const { data: senderData } = await db.from("users").select("first_name").eq("id", user.id).single();
2350
+ const { data: groupData } = await db.from("groups").select("name").eq("id", params.groupId).single();
2351
+ sendGroupPushNotification({
2352
+ groupId: params.groupId,
2353
+ senderName: senderData?.first_name || void 0,
2354
+ groupName: groupData?.name || void 0
2355
+ });
2356
+ return data;
2357
+ }
2288
2358
  async function updateHowlerTranscript(howlerId, transcript) {
2289
2359
  const { error } = await db.from("howlers").update({ transcript: JSON.stringify(transcript) }).eq("id", howlerId);
2290
2360
  if (error) {
@@ -2751,12 +2821,13 @@ async function isEncryptionAvailable() {
2751
2821
  return hasLocalKeys();
2752
2822
  }
2753
2823
  async function getDecryptedTranscript(howlerId) {
2754
- const { getCachedTranscript: getCachedTranscript3 } = await Promise.resolve().then(() => (init_cache(), cache_exports));
2755
- return getCachedTranscript3(howlerId);
2824
+ const { getTranscriptCacheProvider: getTranscriptCacheProvider2 } = await Promise.resolve().then(() => (init_cache(), cache_exports));
2825
+ return getTranscriptCacheProvider2().getCachedTranscript(howlerId);
2756
2826
  }
2757
2827
  async function decryptTextOnlyTranscript(howlerId) {
2758
- const { getCachedTranscript: getCachedTranscript3, cacheTranscript: cacheTranscript3 } = await Promise.resolve().then(() => (init_cache(), cache_exports));
2759
- const cached = await getCachedTranscript3(howlerId);
2828
+ const { getTranscriptCacheProvider: getTranscriptCacheProvider2 } = await Promise.resolve().then(() => (init_cache(), cache_exports));
2829
+ const provider = getTranscriptCacheProvider2();
2830
+ const cached = await provider.getCachedTranscript(howlerId);
2760
2831
  if (cached) return cached;
2761
2832
  const { data: howler, error } = await db.from("howlers").select("id, sender_id, transcript, encryption_metadata").eq("id", howlerId).single();
2762
2833
  if (error || !howler) return null;
@@ -2800,7 +2871,7 @@ async function decryptTextOnlyTranscript(howlerId) {
2800
2871
  const plaintext = await decryptWithAES2(ciphertext, aesKey, iv);
2801
2872
  const json = new TextDecoder().decode(plaintext);
2802
2873
  const transcript = JSON.parse(json);
2803
- await cacheTranscript3(howlerId, transcript);
2874
+ await provider.cacheTranscript(howlerId, transcript);
2804
2875
  console.log("[E2E] Decrypted text-only transcript:", howlerId);
2805
2876
  return transcript;
2806
2877
  } catch (err) {
@@ -28613,6 +28684,7 @@ init_messages();
28613
28684
 
28614
28685
  // src/client.ts
28615
28686
  init_signal();
28687
+ init_cache();
28616
28688
 
28617
28689
  // src/signal/index.ts
28618
28690
  init_define_import_meta_env();
@@ -28681,6 +28753,8 @@ async function removeFile(path) {
28681
28753
  }
28682
28754
  var FileSignalStore = class _FileSignalStore {
28683
28755
  static instance = null;
28756
+ /** Callback invoked when a remote identity key changes. */
28757
+ onIdentityKeyChanged = null;
28684
28758
  static getInstance() {
28685
28759
  if (!_FileSignalStore.instance) {
28686
28760
  _FileSignalStore.instance = new _FileSignalStore();
@@ -28709,7 +28783,11 @@ var FileSignalStore = class _FileSignalStore {
28709
28783
  const newB64 = arrayBufferToBase644(publicKey);
28710
28784
  if (existing) {
28711
28785
  if (existing.identityKey !== newB64) {
28786
+ const previousKey = base64ToArrayBuffer3(existing.identityKey);
28712
28787
  await writeJSON(filePath, { identityKey: newB64 });
28788
+ if (this.onIdentityKeyChanged) {
28789
+ this.onIdentityKeyChanged({ address: encodedAddress, previousKey, newKey: publicKey });
28790
+ }
28713
28791
  return true;
28714
28792
  }
28715
28793
  return false;
@@ -28915,10 +28993,44 @@ var FileSenderKeyStore = class _FileSenderKeyStore {
28915
28993
  };
28916
28994
  var fileSenderKeyStore = FileSenderKeyStore.getInstance();
28917
28995
 
28996
+ // src/hooks/transcriptCache.ts
28997
+ init_define_import_meta_env();
28998
+ import { join as join4 } from "node:path";
28999
+ import { homedir as homedir4 } from "node:os";
29000
+ import { readFile as readFile4, writeFile as writeFile4, mkdir as mkdir4 } from "node:fs/promises";
29001
+ var CACHE_DIR = join4(homedir4(), ".howler", "transcript-cache");
29002
+ var dirEnsured = false;
29003
+ async function ensureDir4() {
29004
+ if (dirEnsured) return;
29005
+ await mkdir4(CACHE_DIR, { recursive: true, mode: 448 });
29006
+ dirEnsured = true;
29007
+ }
29008
+ async function getCachedTranscript2(howlerId) {
29009
+ try {
29010
+ await ensureDir4();
29011
+ const data = await readFile4(join4(CACHE_DIR, `${howlerId}.json`), "utf-8");
29012
+ return JSON.parse(data);
29013
+ } catch {
29014
+ return null;
29015
+ }
29016
+ }
29017
+ async function cacheTranscript2(howlerId, transcript) {
29018
+ try {
29019
+ await ensureDir4();
29020
+ await writeFile4(
29021
+ join4(CACHE_DIR, `${howlerId}.json`),
29022
+ JSON.stringify(transcript),
29023
+ { mode: 384 }
29024
+ );
29025
+ } catch {
29026
+ }
29027
+ }
29028
+
28918
29029
  // src/client.ts
28919
29030
  function initializeClient() {
28920
29031
  initializeSupabase(getSupabase());
28921
29032
  setSignalStore(fileSignalStore);
29033
+ setTranscriptCacheProvider({ getCachedTranscript: getCachedTranscript2, cacheTranscript: cacheTranscript2 });
28922
29034
  }
28923
29035
 
28924
29036
  // src/components/ChatList.tsx
@@ -29493,39 +29605,6 @@ function useRealtimeMessages(conversation, currentUserId, onNewMessage, onUpdate
29493
29605
  }, [conversation?.id, conversation?.type, currentUserId, retryTick]);
29494
29606
  }
29495
29607
 
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
29608
  // src/hooks/useMessages.ts
29530
29609
  async function getGroupMessages(groupId, limit = 50, beforeTimestamp) {
29531
29610
  const { data, error } = await supabase.rpc("get_group_messages", {
@@ -29561,16 +29640,8 @@ async function getGroupMessages(groupId, limit = 50, beforeTimestamp) {
29561
29640
  async function tryDecrypt(msg) {
29562
29641
  if (!msg.isEncrypted || msg.recording_url === "text://plain") return msg;
29563
29642
  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
29643
  const transcript = await decryptTextOnlyTranscript(msg.id);
29572
29644
  if (transcript && transcript.length > 0) {
29573
- await cacheTranscript2(msg.id, transcript);
29574
29645
  return {
29575
29646
  ...msg,
29576
29647
  transcript: JSON.stringify(transcript)
@@ -29581,12 +29652,26 @@ async function tryDecrypt(msg) {
29581
29652
  }
29582
29653
  return msg;
29583
29654
  }
29655
+ function isCacheableTranscriptJson(transcript) {
29656
+ if (!transcript) return false;
29657
+ try {
29658
+ const parsed = JSON.parse(transcript);
29659
+ return Array.isArray(parsed) || typeof parsed === "string" || !!parsed;
29660
+ } catch {
29661
+ return false;
29662
+ }
29663
+ }
29584
29664
  function useMessages(conversation, currentUserId) {
29585
29665
  const [messages, setMessages] = useState3([]);
29586
29666
  const [loading, setLoading] = useState3(!!conversation);
29587
29667
  const [error, setError] = useState3(null);
29588
29668
  const [hasMore, setHasMore] = useState3(false);
29589
29669
  const decryptedIds = useRef3(/* @__PURE__ */ new Set());
29670
+ const transcriptCache = useRef3(/* @__PURE__ */ new Map());
29671
+ const rememberResolvedTranscript = useCallback2((msg) => {
29672
+ if (!isCacheableTranscriptJson(msg.transcript)) return;
29673
+ transcriptCache.current.set(msg.id, msg.transcript);
29674
+ }, []);
29590
29675
  const fetchMessages = useCallback2(
29591
29676
  (beforeTimestamp) => {
29592
29677
  if (!conversation) return;
@@ -29612,27 +29697,35 @@ function useMessages(conversation, currentUserId) {
29612
29697
  conversation.name,
29613
29698
  "hasMore=" + result.hasMore
29614
29699
  );
29700
+ const withCached = result.messages.map((m) => {
29701
+ const cached = transcriptCache.current.get(m.id);
29702
+ return cached ? { ...m, transcript: cached } : m;
29703
+ });
29615
29704
  if (beforeTimestamp) {
29616
29705
  setMessages((prev) => {
29617
29706
  const ids = new Set(prev.map((m) => m.id));
29618
- const newMsgs = result.messages.filter(
29707
+ const newMsgs = withCached.filter(
29619
29708
  (m) => !ids.has(m.id)
29620
29709
  );
29621
29710
  return [...newMsgs, ...prev];
29622
29711
  });
29623
29712
  } else {
29624
- setMessages(result.messages);
29713
+ setMessages(withCached);
29625
29714
  }
29626
29715
  setHasMore(result.hasMore);
29627
29716
  setLoading(false);
29628
29717
  const encrypted = result.messages.filter(
29629
29718
  (m) => m.isEncrypted && !decryptedIds.current.has(m.id)
29630
29719
  );
29631
- if (encrypted.length > 0) {
29632
- Promise.all(encrypted.map(tryDecrypt)).then((decrypted) => {
29720
+ const needsDecrypt = encrypted.filter(
29721
+ (m) => !transcriptCache.current.has(m.id)
29722
+ );
29723
+ if (needsDecrypt.length > 0) {
29724
+ Promise.all(needsDecrypt.map(tryDecrypt)).then((decrypted) => {
29633
29725
  const decryptedMap = /* @__PURE__ */ new Map();
29634
29726
  for (const msg of decrypted) {
29635
29727
  decryptedIds.current.add(msg.id);
29728
+ rememberResolvedTranscript(msg);
29636
29729
  decryptedMap.set(msg.id, msg);
29637
29730
  }
29638
29731
  setMessages(
@@ -29663,6 +29756,7 @@ function useMessages(conversation, currentUserId) {
29663
29756
  if (msg.isEncrypted && !decryptedIds.current.has(msg.id)) {
29664
29757
  decryptedIds.current.add(msg.id);
29665
29758
  tryDecrypt(msg).then((processed) => {
29759
+ rememberResolvedTranscript(processed);
29666
29760
  setMessages((prev) => {
29667
29761
  if (prev.some((m) => m.id === processed.id)) return prev;
29668
29762
  return [...prev, processed];
@@ -29674,7 +29768,7 @@ function useMessages(conversation, currentUserId) {
29674
29768
  if (prev.some((m) => m.id === msg.id)) return prev;
29675
29769
  return [...prev, msg];
29676
29770
  });
29677
- }, []);
29771
+ }, [rememberResolvedTranscript]);
29678
29772
  const handleUpdateMessage = useCallback2(
29679
29773
  (id, updates) => {
29680
29774
  setMessages(
@@ -29686,7 +29780,9 @@ function useMessages(conversation, currentUserId) {
29686
29780
  if (!msg || !msg.isEncrypted || msg.recording_url === "text://plain") return prev;
29687
29781
  decryptedIds.current.delete(id);
29688
29782
  decryptedIds.current.add(id);
29783
+ transcriptCache.current.delete(id);
29689
29784
  tryDecrypt(msg).then((processed) => {
29785
+ rememberResolvedTranscript(processed);
29690
29786
  setMessages(
29691
29787
  (p) => p.map((m) => m.id === id ? processed : m)
29692
29788
  );
@@ -29695,7 +29791,7 @@ function useMessages(conversation, currentUserId) {
29695
29791
  });
29696
29792
  }
29697
29793
  },
29698
- []
29794
+ [rememberResolvedTranscript]
29699
29795
  );
29700
29796
  useRealtimeMessages(
29701
29797
  conversation,
@@ -29714,11 +29810,15 @@ function useMessages(conversation, currentUserId) {
29714
29810
  fetchMessages();
29715
29811
  }, [fetchMessages]);
29716
29812
  const addMessage = useCallback2((msg) => {
29813
+ if (msg.isEncrypted && msg.recording_url !== "text://plain" && isCacheableTranscriptJson(msg.transcript)) {
29814
+ decryptedIds.current.add(msg.id);
29815
+ rememberResolvedTranscript(msg);
29816
+ }
29717
29817
  setMessages((prev) => {
29718
29818
  if (prev.some((m) => m.id === msg.id)) return prev;
29719
29819
  return [...prev, msg];
29720
29820
  });
29721
- }, []);
29821
+ }, [rememberResolvedTranscript]);
29722
29822
  return { messages, loading, error, hasMore, loadMore, refresh, addMessage };
29723
29823
  }
29724
29824
 
@@ -29726,7 +29826,7 @@ function useMessages(conversation, currentUserId) {
29726
29826
  init_define_import_meta_env();
29727
29827
  init_messages();
29728
29828
  import { useState as useState4, useCallback as useCallback3 } from "react";
29729
- function useSendMessage(conversation, currentUserId) {
29829
+ function useSendMessage(conversation, currentUserId, encrypted = true) {
29730
29830
  const [sending, setSending] = useState4(false);
29731
29831
  const [error, setError] = useState4(null);
29732
29832
  const send = useCallback3(
@@ -29739,14 +29839,27 @@ function useSendMessage(conversation, currentUserId) {
29739
29839
  ];
29740
29840
  const isGroup = conversation.type === "group";
29741
29841
  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
- });
29842
+ let data;
29843
+ if (encrypted) {
29844
+ data = isGroup ? await sendGroupTextMessage({
29845
+ groupId: conversation.id,
29846
+ transcript,
29847
+ parentHowlerId: options?.parentHowlerId
29848
+ }) : await sendEncryptedTextMessage({
29849
+ recipientId: conversation.id,
29850
+ transcript
29851
+ });
29852
+ } else {
29853
+ data = isGroup ? await sendGroupPlainTextMessage({
29854
+ groupId: conversation.id,
29855
+ transcript,
29856
+ parentHowlerId: options?.parentHowlerId
29857
+ }) : await sendPlainTextMessage({
29858
+ recipientId: conversation.id,
29859
+ transcript,
29860
+ parentHowlerId: options?.parentHowlerId
29861
+ });
29862
+ }
29750
29863
  const msg = {
29751
29864
  ...data,
29752
29865
  transcript: JSON.stringify(transcript),
@@ -29757,7 +29870,7 @@ function useSendMessage(conversation, currentUserId) {
29757
29870
  last_name: null,
29758
29871
  avatar_url: null
29759
29872
  },
29760
- isEncrypted: true
29873
+ isEncrypted: encrypted
29761
29874
  };
29762
29875
  setSending(false);
29763
29876
  return msg;
@@ -29768,7 +29881,7 @@ function useSendMessage(conversation, currentUserId) {
29768
29881
  return null;
29769
29882
  }
29770
29883
  },
29771
- [conversation, currentUserId]
29884
+ [conversation, currentUserId, encrypted]
29772
29885
  );
29773
29886
  return { send, sending, error };
29774
29887
  }
@@ -30455,11 +30568,12 @@ var ConversationPanel = React2.forwardRef(
30455
30568
  panelWidth: panelWidthProp
30456
30569
  }, ref) {
30457
30570
  const { messages, loading, error, hasMore, loadMore, addMessage } = useMessages(conversation, currentUserId);
30571
+ const [encryptionEnabled, setEncryptionEnabled] = React2.useState(true);
30458
30572
  const {
30459
30573
  send,
30460
30574
  sending,
30461
30575
  error: sendError
30462
- } = useSendMessage(conversation, currentUserId);
30576
+ } = useSendMessage(conversation, currentUserId, encryptionEnabled);
30463
30577
  const { branches, loading: branchesLoading, selectedBranch, selectBranch, filteredMessages, threadMap } = useBranches(conversation, messages);
30464
30578
  const {
30465
30579
  execute: executeCommand,
@@ -30558,6 +30672,17 @@ var ConversationPanel = React2.forwardRef(
30558
30672
  }
30559
30673
  return;
30560
30674
  }
30675
+ if (text.trim().toLowerCase() === "/encrypt") {
30676
+ setEncryptionEnabled((prev) => {
30677
+ const next = !prev;
30678
+ setStatusMsg({
30679
+ text: next ? "Encryption ON" : "Encryption OFF (diagnostic mode)",
30680
+ color: next ? "green" : "yellow"
30681
+ });
30682
+ return next;
30683
+ });
30684
+ return;
30685
+ }
30561
30686
  if (isCommand(text)) {
30562
30687
  await executeCommand(text);
30563
30688
  return;
@@ -30786,6 +30911,7 @@ var SLASH_COMMANDS = [
30786
30911
  { name: "/bot", args: "<name> <msg>", description: "Send to a bot" },
30787
30912
  { name: "/change-branch", args: "[name]", description: "Switch branch" },
30788
30913
  { name: "/current-branch", args: "", description: "Show current branch" },
30914
+ { name: "/encrypt", args: "", description: "Toggle encryption on/off" },
30789
30915
  { name: "/exit", args: "", description: "Quit Howler TUI" },
30790
30916
  { name: "/vault", args: "<query>", description: "Search your vault" }
30791
30917
  ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@howler/cli",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "description": "Terminal UI client for Howler encrypted messaging",
6
6
  "bin": {