pi-tau-web-server 1.0.8

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 (98) hide show
  1. package/README.md +303 -0
  2. package/bin/auth.js +96 -0
  3. package/bin/config.js +99 -0
  4. package/bin/model-utils.js +134 -0
  5. package/bin/server-main.js +1189 -0
  6. package/bin/sessions.js +492 -0
  7. package/bin/tau.js +8 -0
  8. package/bin/tree.js +248 -0
  9. package/bin/types.js +2 -0
  10. package/package.json +74 -0
  11. package/public/app-main.js +2025 -0
  12. package/public/app-types.js +1 -0
  13. package/public/app.js +1 -0
  14. package/public/command-palette.js +42 -0
  15. package/public/dialogs.js +199 -0
  16. package/public/file-browser.js +196 -0
  17. package/public/icons/apple-touch-icon.png +0 -0
  18. package/public/icons/favicon-16.png +0 -0
  19. package/public/icons/favicon-32.png +0 -0
  20. package/public/icons/tau-192.png +0 -0
  21. package/public/icons/tau-512.png +0 -0
  22. package/public/icons/tau-logo.png +0 -0
  23. package/public/icons/tau-logo.svg +13 -0
  24. package/public/icons/tau-maskable-512.png +0 -0
  25. package/public/icons/tau-new.png +0 -0
  26. package/public/index.html +264 -0
  27. package/public/launcher-panel.js +54 -0
  28. package/public/launcher.js +84 -0
  29. package/public/manifest.json +28 -0
  30. package/public/markdown.js +336 -0
  31. package/public/message-renderer.js +268 -0
  32. package/public/model-picker.js +478 -0
  33. package/public/session-sidebar.js +460 -0
  34. package/public/session-stats-card.js +123 -0
  35. package/public/state.js +81 -0
  36. package/public/style.css +3864 -0
  37. package/public/sw.js +66 -0
  38. package/public/themes.js +72 -0
  39. package/public/tool-card.js +317 -0
  40. package/public/tree-view.js +474 -0
  41. package/public/vendor/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
  42. package/public/vendor/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
  43. package/public/vendor/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
  44. package/public/vendor/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
  45. package/public/vendor/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
  46. package/public/vendor/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
  47. package/public/vendor/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
  48. package/public/vendor/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
  49. package/public/vendor/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
  50. package/public/vendor/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
  51. package/public/vendor/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
  52. package/public/vendor/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
  53. package/public/vendor/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
  54. package/public/vendor/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
  55. package/public/vendor/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
  56. package/public/vendor/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
  57. package/public/vendor/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
  58. package/public/vendor/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
  59. package/public/vendor/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
  60. package/public/vendor/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
  61. package/public/vendor/katex/katex.min.css +1 -0
  62. package/public/vendor/katex/katex.min.js +1 -0
  63. package/public/voice-input.js +74 -0
  64. package/public/websocket-client.js +156 -0
  65. package/scripts/copy-katex.mjs +32 -0
  66. package/src/pi-extension/tau-tree.ts +71 -0
  67. package/src/public/app-main.ts +2190 -0
  68. package/src/public/app-types.ts +96 -0
  69. package/src/public/app.ts +1 -0
  70. package/src/public/command-palette.ts +53 -0
  71. package/src/public/dialogs.ts +251 -0
  72. package/src/public/file-browser.ts +224 -0
  73. package/src/public/launcher-panel.ts +68 -0
  74. package/src/public/launcher.ts +101 -0
  75. package/src/public/legacy-dom.d.ts +29 -0
  76. package/src/public/markdown.ts +372 -0
  77. package/src/public/message-renderer.ts +311 -0
  78. package/src/public/model-picker.ts +500 -0
  79. package/src/public/session-sidebar.ts +522 -0
  80. package/src/public/session-stats-card.ts +176 -0
  81. package/src/public/state.ts +96 -0
  82. package/src/public/sw.ts +79 -0
  83. package/src/public/themes.ts +73 -0
  84. package/src/public/tool-card.ts +375 -0
  85. package/src/public/tree-view.ts +527 -0
  86. package/src/public/voice-input.ts +98 -0
  87. package/src/public/websocket-client.ts +165 -0
  88. package/src/server/auth.ts +88 -0
  89. package/src/server/config.ts +88 -0
  90. package/src/server/model-utils.ts +122 -0
  91. package/src/server/server-main.ts +1004 -0
  92. package/src/server/sessions.ts +481 -0
  93. package/src/server/tau.ts +9 -0
  94. package/src/server/tree.ts +288 -0
  95. package/src/server/types.ts +68 -0
  96. package/tsconfig.json +3 -0
  97. package/tsconfig.public.json +15 -0
  98. package/tsconfig.server.json +16 -0
@@ -0,0 +1,74 @@
1
+ export function setupVoiceInput(micBtn, messageInput) {
2
+ let recognition = null;
3
+ let isRecording = false;
4
+ if ('webkitSpeechRecognition' in window || 'SpeechRecognition' in window) {
5
+ const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
6
+ if (!SpeechRecognition) {
7
+ micBtn.style.display = 'none';
8
+ return;
9
+ }
10
+ recognition = new SpeechRecognition();
11
+ recognition.continuous = true;
12
+ recognition.interimResults = true;
13
+ recognition.lang = 'en-AU';
14
+ let finalTranscript = '';
15
+ let interimTranscript = '';
16
+ recognition.addEventListener('result', (e) => {
17
+ const ev = e;
18
+ interimTranscript = '';
19
+ for (let i = ev.resultIndex; i < ev.results.length; i++) {
20
+ if (ev.results[i].isFinal) {
21
+ finalTranscript += ev.results[i][0].transcript;
22
+ }
23
+ else {
24
+ interimTranscript += ev.results[i][0].transcript;
25
+ }
26
+ }
27
+ messageInput.value = finalTranscript + interimTranscript;
28
+ messageInput.dispatchEvent(new Event('input'));
29
+ });
30
+ recognition.addEventListener('end', () => {
31
+ if (isRecording)
32
+ stopRecording();
33
+ });
34
+ recognition.addEventListener('error', (e) => {
35
+ const ev = e;
36
+ console.error('[Voice] Error:', ev.error);
37
+ stopRecording();
38
+ });
39
+ micBtn.addEventListener('click', () => {
40
+ if (isRecording) {
41
+ stopRecording();
42
+ }
43
+ else {
44
+ startRecording();
45
+ }
46
+ });
47
+ function startRecording() {
48
+ if (!recognition)
49
+ return;
50
+ finalTranscript = messageInput.value;
51
+ interimTranscript = '';
52
+ isRecording = true;
53
+ micBtn.classList.add('recording');
54
+ micBtn.title = 'Stop recording';
55
+ recognition.start();
56
+ messageInput.focus();
57
+ }
58
+ function stopRecording() {
59
+ isRecording = false;
60
+ micBtn.classList.remove('recording');
61
+ micBtn.title = 'Voice input';
62
+ try {
63
+ recognition?.stop();
64
+ }
65
+ catch { }
66
+ messageInput.value = finalTranscript;
67
+ messageInput.dispatchEvent(new Event('input'));
68
+ messageInput.focus();
69
+ }
70
+ }
71
+ else {
72
+ micBtn.style.display = 'none';
73
+ }
74
+ }
@@ -0,0 +1,156 @@
1
+ /**
2
+ * WebSocket Client - Handles connection to backend WebSocket server
3
+ */
4
+ export class WebSocketClient extends EventTarget {
5
+ url;
6
+ ws;
7
+ reconnectAttempts;
8
+ maxReconnectAttempts;
9
+ reconnectDelay;
10
+ maxReconnectDelay;
11
+ isIntentionallyClosed;
12
+ reconnectTimer;
13
+ connectionState;
14
+ constructor(url) {
15
+ super();
16
+ this.url = url;
17
+ this.ws = null;
18
+ this.reconnectAttempts = 0;
19
+ this.maxReconnectAttempts = Infinity;
20
+ this.reconnectDelay = 1000;
21
+ this.maxReconnectDelay = 10000;
22
+ this.isIntentionallyClosed = false;
23
+ this.reconnectTimer = null;
24
+ this.connectionState = 'idle';
25
+ }
26
+ connect() {
27
+ if (this.connectionState === 'connecting')
28
+ return;
29
+ if (this.ws && this.ws.readyState === WebSocket.OPEN)
30
+ return;
31
+ if (this.ws && this.ws.readyState === WebSocket.CONNECTING)
32
+ return;
33
+ this.isIntentionallyClosed = false;
34
+ this.connectionState = 'connecting';
35
+ if (this.reconnectTimer) {
36
+ clearTimeout(this.reconnectTimer);
37
+ this.reconnectTimer = null;
38
+ }
39
+ // Close only fully stale sockets before reconnecting
40
+ if (this.ws && (this.ws.readyState === WebSocket.CLOSING || this.ws.readyState === WebSocket.CLOSED)) {
41
+ this.ws = null;
42
+ }
43
+ this.ws = new WebSocket(this.url);
44
+ this.ws.onopen = () => {
45
+ console.log('[WS] Connected');
46
+ this.reconnectAttempts = 0;
47
+ this.connectionState = 'open';
48
+ this.dispatchEvent(new CustomEvent('connected'));
49
+ };
50
+ this.ws.onmessage = (event) => {
51
+ try {
52
+ const message = JSON.parse(event.data);
53
+ this.handleMessage(message);
54
+ }
55
+ catch (error) {
56
+ console.error('[WS] Failed to parse message:', error);
57
+ }
58
+ };
59
+ this.ws.onerror = (error) => {
60
+ console.error('[WS] Error:', error);
61
+ this.dispatchEvent(new CustomEvent('error', { detail: error }));
62
+ };
63
+ this.ws.onclose = (event) => {
64
+ console.log(`[WS] Disconnected (code=${event.code}, reason=${event.reason || 'n/a'})`);
65
+ this.connectionState = 'closed';
66
+ this.dispatchEvent(new CustomEvent('disconnected'));
67
+ if (!this.isIntentionallyClosed) {
68
+ this.attemptReconnect();
69
+ }
70
+ };
71
+ }
72
+ disconnect() {
73
+ this.isIntentionallyClosed = true;
74
+ this.connectionState = 'closed';
75
+ if (this.reconnectTimer) {
76
+ clearTimeout(this.reconnectTimer);
77
+ this.reconnectTimer = null;
78
+ }
79
+ if (this.ws) {
80
+ this.ws.close();
81
+ }
82
+ }
83
+ // Force reconnect — resets attempt counter and connects fresh
84
+ forceReconnect() {
85
+ this.reconnectAttempts = 0;
86
+ this.isIntentionallyClosed = false;
87
+ this.connectionState = 'closed';
88
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
89
+ try {
90
+ this.ws.close(1000, 'force reconnect');
91
+ }
92
+ catch (e) { }
93
+ }
94
+ if (this.reconnectTimer) {
95
+ clearTimeout(this.reconnectTimer);
96
+ this.reconnectTimer = null;
97
+ }
98
+ this.connect();
99
+ }
100
+ attemptReconnect() {
101
+ if (this.reconnectAttempts >= this.maxReconnectAttempts) {
102
+ console.error('[WS] Max reconnection attempts reached');
103
+ this.dispatchEvent(new CustomEvent('reconnectFailed'));
104
+ return;
105
+ }
106
+ this.reconnectAttempts++;
107
+ const delay = Math.min(this.maxReconnectDelay, this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1));
108
+ console.log(`[WS] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`);
109
+ this.reconnectTimer = setTimeout(() => {
110
+ this.reconnectTimer = null;
111
+ this.connect();
112
+ }, delay);
113
+ }
114
+ send(data) {
115
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
116
+ this.ws.send(JSON.stringify(data));
117
+ }
118
+ else {
119
+ console.error('[WS] Cannot send, not connected');
120
+ }
121
+ }
122
+ handleMessage(message) {
123
+ // Emit events based on message type
124
+ switch (message.type) {
125
+ case 'event':
126
+ this.dispatchEvent(new CustomEvent('rpcEvent', { detail: { sessionId: message.sessionId, event: message.event } }));
127
+ break;
128
+ case 'state':
129
+ this.dispatchEvent(new CustomEvent('stateUpdate', { detail: message }));
130
+ break;
131
+ case 'error':
132
+ this.dispatchEvent(new CustomEvent('serverError', { detail: message }));
133
+ break;
134
+ case 'session_switch':
135
+ this.dispatchEvent(new CustomEvent('sessionSwitch'));
136
+ break;
137
+ case 'live_session_snapshot':
138
+ this.dispatchEvent(new CustomEvent('liveSessionSnapshot', { detail: message }));
139
+ break;
140
+ case 'response':
141
+ this.dispatchEvent(new CustomEvent('rpcResponse', { detail: message }));
142
+ break;
143
+ case 'live_session_created':
144
+ this.dispatchEvent(new CustomEvent('liveSessionCreated', { detail: message.session }));
145
+ break;
146
+ case 'live_session_updated':
147
+ this.dispatchEvent(new CustomEvent('liveSessionUpdated', { detail: message.session }));
148
+ break;
149
+ case 'live_session_closed':
150
+ this.dispatchEvent(new CustomEvent('liveSessionClosed', { detail: message }));
151
+ break;
152
+ default:
153
+ console.warn('[WS] Unknown message type:', message.type);
154
+ }
155
+ }
156
+ }
@@ -0,0 +1,32 @@
1
+ // Copies the KaTeX runtime assets from node_modules into public/vendor/katex/
2
+ // so the served UI is self-contained and works offline. Only woff2 fonts are
3
+ // copied: KaTeX's CSS lists woff2 first in every @font-face src(), so browsers
4
+ // that can run this app never request the woff/ttf fallbacks.
5
+ import { createRequire } from 'node:module';
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import { fileURLToPath } from 'node:url';
9
+
10
+ const require = createRequire(import.meta.url);
11
+
12
+ let katexDist;
13
+ try {
14
+ katexDist = path.join(path.dirname(require.resolve('katex/package.json')), 'dist');
15
+ } catch {
16
+ console.error('copy-katex: cannot resolve the "katex" package — run `npm install` first.');
17
+ process.exit(1);
18
+ }
19
+
20
+ const projectRoot = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
21
+ const vendorDir = path.join(projectRoot, 'public', 'vendor', 'katex');
22
+ const fontsDir = path.join(vendorDir, 'fonts');
23
+
24
+ fs.mkdirSync(fontsDir, { recursive: true });
25
+ for (const file of ['katex.min.js', 'katex.min.css']) {
26
+ fs.copyFileSync(path.join(katexDist, file), path.join(vendorDir, file));
27
+ }
28
+ for (const font of fs.readdirSync(path.join(katexDist, 'fonts'))) {
29
+ if (font.endsWith('.woff2')) {
30
+ fs.copyFileSync(path.join(katexDist, 'fonts', font), path.join(fontsDir, font));
31
+ }
32
+ }
@@ -0,0 +1,71 @@
1
+ /*
2
+ * pi extension bundled with tau and loaded into every `pi --mode rpc` child
3
+ * via `--extension <this file>` (see PiRpcSession.start in
4
+ * src/server/sessions.ts). It gives tau's backend a way to MOVE the session
5
+ * leaf: pi's native RPC surface only exposes read-only tree commands
6
+ * (get_tree / get_entries), so tau sends
7
+ *
8
+ * {"type": "prompt", "message": "/tau-tree-navigate <entryId>"}
9
+ *
10
+ * and this command handler performs the navigation in-process with
11
+ * ctx.navigateTree() — the same API pi's own TUI /tree uses, including its
12
+ * selection semantics (user/custom message → leaf moves to the entry's
13
+ * PARENT; anything else → leaf moves to the entry itself). Matched extension
14
+ * commands never reach the LLM and never append a user message, so the
15
+ * /tau-tree-navigate text is invisible to the model and to the session file.
16
+ *
17
+ * Persistence: navigateTree({summarize: false}) only moves the in-memory
18
+ * leaf pointer, and on load pi derives the leaf from the LAST entry in the
19
+ * file — so a bare navigation would not survive a pi restart. When the leaf
20
+ * actually moved, the handler appends a small `custom` marker entry (same
21
+ * `tau:navigate-tree` customType tau has always used) whose parentId is the
22
+ * new leaf. Custom entries never participate in LLM context, so the marker
23
+ * is invisible to the model too.
24
+ *
25
+ * Errors thrown here do NOT fail the RPC `prompt` response; pi reports them
26
+ * as an `extension_error` event (extensionPath "command:tau-tree-navigate").
27
+ * tau's navigateTree (src/server/tree.ts) therefore verifies the move with a
28
+ * follow-up get_tree and surfaces the captured extension_error on mismatch.
29
+ */
30
+
31
+ // Type-only reference to pi's extension API. Written as an inline import()
32
+ // type (not an `import type` statement) because this file is type-checked as
33
+ // CommonJS by tsconfig.test.json while pi's package is ESM-only, and at
34
+ // runtime it is loaded by pi's jiti / node's type stripping, both of which
35
+ // erase pure type positions.
36
+ type ExtensionAPI = import('@earendil-works/pi-coding-agent', { with: { 'resolution-mode': 'import' } }).ExtensionAPI;
37
+
38
+ /** Command name tau's backend invokes; keep in sync with src/server/tree.ts. */
39
+ export const NAVIGATE_COMMAND = 'tau-tree-navigate';
40
+
41
+ /** customType of the marker entries that persist a leaf move across reloads. */
42
+ export const NAVIGATION_MARKER_TYPE = 'tau:navigate-tree';
43
+
44
+ export default function tauTreeExtension(pi: ExtensionAPI) {
45
+ pi.registerCommand(NAVIGATE_COMMAND, {
46
+ description: 'Move the session leaf to an entry (used by the tau web UI tree view)',
47
+ handler: async (args, ctx) => {
48
+ const entryId = args.trim();
49
+ if (!entryId) throw new Error(`/${NAVIGATE_COMMAND}: missing target entry id`);
50
+ if (!ctx.isIdle()) {
51
+ throw new Error('Cannot navigate the session tree while the agent is streaming; abort or wait for the turn to finish');
52
+ }
53
+ if (!ctx.sessionManager.getEntry(entryId)) {
54
+ throw new Error(`Entry ${entryId} not found in the session tree`);
55
+ }
56
+ const previousLeafId = ctx.sessionManager.getLeafId();
57
+ // Pass the ORIGINAL entry id: navigateTree applies the user-message →
58
+ // parent selection rule itself, so pre-resolving the target here would
59
+ // land the leaf one node too high.
60
+ const result = await ctx.navigateTree(entryId, { summarize: false });
61
+ if (result.cancelled) {
62
+ throw new Error('A pi extension cancelled the tree navigation');
63
+ }
64
+ // Persist the move only when the leaf actually changed (navigating to
65
+ // the current position appends nothing, mirroring pi's own /tree).
66
+ if (ctx.sessionManager.getLeafId() !== previousLeafId) {
67
+ pi.appendEntry(NAVIGATION_MARKER_TYPE, { navigatedTo: entryId });
68
+ }
69
+ },
70
+ });
71
+ }