create-walle 0.9.18 → 0.9.20

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 (112) hide show
  1. package/README.md +6 -6
  2. package/package.json +2 -2
  3. package/template/claude-task-manager/api-prompts.js +176 -29
  4. package/template/claude-task-manager/api-reviews.js +56 -3
  5. package/template/claude-task-manager/approval-agent.js +9 -2
  6. package/template/claude-task-manager/db.js +178 -55
  7. package/template/claude-task-manager/docs/conversation-import-freshness.md +24 -0
  8. package/template/claude-task-manager/docs/large-jsonl-transcript-ingestion.md +128 -0
  9. package/template/claude-task-manager/docs/mobile-live-streaming.md +59 -6
  10. package/template/claude-task-manager/docs/mobile-unified-live-timeline-design.md +20 -4
  11. package/template/claude-task-manager/docs/mobile-walle-chat-design.md +56 -0
  12. package/template/claude-task-manager/docs/obsidian-resource-map-design.md +141 -0
  13. package/template/claude-task-manager/docs/session-management-architecture.md +38 -1
  14. package/template/claude-task-manager/docs/session-search-corpus-architecture.md +121 -0
  15. package/template/claude-task-manager/docs/session-timeline-consistency-design.md +107 -0
  16. package/template/claude-task-manager/docs/smart-session-search-design.md +10 -3
  17. package/template/claude-task-manager/docs/walle-transcript-display-projection.md +42 -0
  18. package/template/claude-task-manager/lib/auth-rules.js +1 -0
  19. package/template/claude-task-manager/lib/background-llm.js +1 -0
  20. package/template/claude-task-manager/lib/coding-agent-models.js +2 -0
  21. package/template/claude-task-manager/lib/document-review.js +250 -0
  22. package/template/claude-task-manager/lib/remote-relay-protocol.js +16 -1
  23. package/template/claude-task-manager/lib/resource-links.js +500 -0
  24. package/template/claude-task-manager/lib/session-history.js +158 -17
  25. package/template/claude-task-manager/lib/session-jobs.js +46 -25
  26. package/template/claude-task-manager/lib/session-standup.js +49 -2
  27. package/template/claude-task-manager/lib/session-stream.js +8 -5
  28. package/template/claude-task-manager/lib/session-timeline.js +161 -0
  29. package/template/claude-task-manager/lib/setup-provider-config.js +9 -1
  30. package/template/claude-task-manager/lib/standup-incremental.js +144 -0
  31. package/template/claude-task-manager/lib/tailscale-setup.js +2 -1
  32. package/template/claude-task-manager/lib/transcript-store.js +647 -0
  33. package/template/claude-task-manager/lib/walle-ctm-history.js +74 -3
  34. package/template/claude-task-manager/lib/walle-external-actions.js +276 -0
  35. package/template/claude-task-manager/lib/walle-permission-policy.js +59 -0
  36. package/template/claude-task-manager/public/css/reviews.css +124 -0
  37. package/template/claude-task-manager/public/css/setup.css +48 -0
  38. package/template/claude-task-manager/public/css/walle-session.css +153 -2
  39. package/template/claude-task-manager/public/index.html +957 -86
  40. package/template/claude-task-manager/public/js/message-renderer.js +285 -12
  41. package/template/claude-task-manager/public/js/prompts.js +13 -1
  42. package/template/claude-task-manager/public/js/reviews.js +467 -18
  43. package/template/claude-task-manager/public/js/setup.js +145 -16
  44. package/template/claude-task-manager/public/js/stream-view.js +61 -16
  45. package/template/claude-task-manager/public/js/walle-session.js +453 -73
  46. package/template/claude-task-manager/public/js/walle.js +99 -22
  47. package/template/claude-task-manager/public/m/app.css +1313 -85
  48. package/template/claude-task-manager/public/m/app.js +3196 -286
  49. package/template/claude-task-manager/public/m/claim.html +1 -1
  50. package/template/claude-task-manager/public/m/index.html +38 -15
  51. package/template/claude-task-manager/public/m/sw.js +9 -2
  52. package/template/claude-task-manager/server.js +1573 -513
  53. package/template/claude-task-manager/session-integrity.js +130 -8
  54. package/template/claude-task-manager/session-search-ranking.js +79 -0
  55. package/template/claude-task-manager/session-utils.js +2 -1
  56. package/template/claude-task-manager/workers/headless-term-worker.js +31 -0
  57. package/template/docs/designs/2026-05-17-portkey-gateway-provider-ux.md +154 -0
  58. package/template/docs/designs/2026-05-18-walle-external-action-approval.md +57 -0
  59. package/template/package.json +1 -1
  60. package/template/wall-e/api-walle.js +74 -61
  61. package/template/wall-e/auth/flow-manager.js +133 -0
  62. package/template/wall-e/auth/provider-flows.js +114 -0
  63. package/template/wall-e/brain.js +2 -2
  64. package/template/wall-e/chat/capability-resolver.js +1 -0
  65. package/template/wall-e/chat.js +876 -64
  66. package/template/wall-e/coding/model-message.js +3 -2
  67. package/template/wall-e/coding/permission-service.js +1 -1
  68. package/template/wall-e/coding/provider-transform.js +1 -1
  69. package/template/wall-e/coding-orchestrator.js +5 -1
  70. package/template/wall-e/coding-prompts.js +3 -0
  71. package/template/wall-e/context/context-builder.js +3 -3
  72. package/template/wall-e/deploy.sh +1 -1
  73. package/template/wall-e/docs/external-action-controller.md +97 -0
  74. package/template/wall-e/eval/chat-eval.js +1 -1
  75. package/template/wall-e/eval/coding-agent-real.js +2 -0
  76. package/template/wall-e/eval/eval-orchestrator.js +2 -0
  77. package/template/wall-e/eval/head-to-head.js +5 -0
  78. package/template/wall-e/eval/provider-normalizer.js +1 -0
  79. package/template/wall-e/eval/run-coding-agent-real.js +1 -1
  80. package/template/wall-e/eval/run-eval.js +11 -1
  81. package/template/wall-e/evaluation/self-critique.js +1 -0
  82. package/template/wall-e/evaluation/tier-selector.js +1 -0
  83. package/template/wall-e/external-action-controller.js +419 -0
  84. package/template/wall-e/http/chat-api.js +18 -2
  85. package/template/wall-e/http/model-admin.js +20 -4
  86. package/template/wall-e/llm/anthropic.js +1 -1
  87. package/template/wall-e/llm/client.js +17 -7
  88. package/template/wall-e/llm/coding-availability.js +2 -1
  89. package/template/wall-e/llm/index.js +1 -0
  90. package/template/wall-e/llm/moonshot.js +149 -0
  91. package/template/wall-e/llm/moonshot.plugin.json +22 -0
  92. package/template/wall-e/llm/openai.js +5 -2
  93. package/template/wall-e/llm/portkey.js +124 -0
  94. package/template/wall-e/llm/provider-detector.js +24 -2
  95. package/template/wall-e/llm/provider-error.js +1 -0
  96. package/template/wall-e/llm/supported-models.js +80 -0
  97. package/template/wall-e/llm/text-tool-calls.js +103 -6
  98. package/template/wall-e/runtime/execution-trace.js +17 -2
  99. package/template/wall-e/runtime/live-capabilities.js +6 -6
  100. package/template/wall-e/scripts/smoke-coding-agent-jsonl.js +5 -0
  101. package/template/wall-e/skills/_bundled/gws-workspace/setup.js +92 -22
  102. package/template/wall-e/skills/skill-planner.js +1 -1
  103. package/template/wall-e/tools/local-tools.js +1358 -50
  104. package/template/wall-e/tools/permission-checker.js +1 -1
  105. package/template/wall-e/tools/permission-rules.js +1 -0
  106. package/template/wall-e/tools/slack-mcp.js +2 -2
  107. package/template/website/index.html +10 -9
  108. package/template/wall-e/docs/ab-screenshots/sl-web-v2-DESIGN-SPEC.md +0 -131
  109. package/template/wall-e/docs/ab-screenshots/sl-web-verdict.json +0 -44
  110. package/template/wall-e/docs/frontend-design-ab-test.md +0 -235
  111. package/template/wall-e/scripts/ab-rebuild-shanni.js +0 -248
  112. package/template/wall-e/scripts/ab-visual-review-shanni.js +0 -102
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # create-walle
2
2
 
3
- Set up **CTM + Wall-E** in one command — a browser-based command center for AI coding agents, phone access, and a personal AI agent that builds a searchable second brain from your work life.
3
+ Set up **CTM + Wall-E** in one command — a browser-based command center for AI coding agents, remote phone access, review workflows, and a personal AI agent that builds a searchable second brain from your work life.
4
4
 
5
5
  ## What You Get
6
6
 
@@ -12,8 +12,8 @@ A web dashboard for running and managing AI coding sessions across multiple prov
12
12
  - **Prompt Editor** — Save, version, and organize prompts with folders, tags, chains, templates, and AI search
13
13
  - **Task Queue** — Queue prompts for sequential execution with auto-advance when the agent finishes, or step through manually
14
14
  - **Approval Workflows** — Auto-approve tool-use requests based on learned rules; uncertain cases escalate to you
15
- - **Phone Access** — Pair your phone with a QR code and use a mobile CTM UI over Microsoft Dev Tunnels, Tailscale, Cloudflare Tunnel, or Walle Remote
16
- - **Code Review** — View git diffs from any project, staged or unstaged, with line-level detail
15
+ - **Remote Phone Access** — Pair your phone with a QR code and use a mobile CTM UI over Microsoft Dev Tunnels, Tailscale, Cloudflare Tunnel, or Walle Remote
16
+ - **Code & Doc Review** — Review git diffs and Markdown docs side by side, add anchored comments, and send feedback into an agent session or queue
17
17
  - **Model Registry** — Manage providers (Anthropic, OpenAI, Google, DeepSeek, Ollama, LM Studio, MLX, and CLI subscription providers), compare pricing, switch models per session
18
18
  - **Session Insights** — Analyze patterns across sessions to optimize prompts and workflows
19
19
 
@@ -35,7 +35,7 @@ An always-on AI agent that learns from your Slack, email, calendar, and coding s
35
35
  npx create-walle install ./walle
36
36
  ```
37
37
 
38
- This copies the project, installs dependencies, auto-detects your name and timezone, and starts the server. Open **http://localhost:3456** to finish setup in the browser, then pair your phone from Setup if you want mobile access.
38
+ This copies the project, installs dependencies, auto-detects your name and timezone, and starts the server. Open **http://localhost:3456** to finish setup in the browser, then pair your phone from Setup if you want remote access.
39
39
 
40
40
  ## Commands
41
41
 
@@ -62,7 +62,7 @@ On first launch, the browser setup page guides you through:
62
62
  1. **Owner name** — auto-detected from `git config`
63
63
  2. **API key** — enter manually, or click "Auto-detect" to find it from your shell environment, Claude Code OAuth, or corporate devbox
64
64
  3. **Integrations** — connect Slack (OAuth), email and calendar auto-detected on macOS
65
- 4. **Phone access** — optional QR pairing with Microsoft Dev Tunnels, Tailscale, Cloudflare Tunnel, or Walle Remote
65
+ 4. **Remote phone access** — optional QR pairing with Microsoft Dev Tunnels, Tailscale, Cloudflare Tunnel, or Walle Remote
66
66
 
67
67
  ## Custom Port
68
68
 
@@ -78,7 +78,7 @@ Everything runs locally. CTM serves the dashboard, Wall-E runs as a background a
78
78
 
79
79
  | Component | Default Port | What it does |
80
80
  |-----------|-------------|--------------|
81
- | CTM | 3456 | Dashboard, multi-agent terminal, prompt editor, queue, model registry, code review, phone UI |
81
+ | CTM | 3456 | Dashboard, multi-agent terminal, prompt editor, queue, model registry, code/doc review, remote phone UI |
82
82
  | Wall-E | 3457 | AI agent, brain database, skills engine, multi-model chat API |
83
83
 
84
84
  ## Links
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "create-walle",
3
- "version": "0.9.18",
4
- "description": "CTM + Wall-E \u2014 AI coding dashboard and personal digital twin agent. Multi-agent terminal for Claude Code, Codex, Gemini, Aider, OpenCode, and more, plus prompt editor, task queue, phone access, and an agent that learns from Slack, email & calendar.",
3
+ "version": "0.9.20",
4
+ "description": "CTM + Wall-E \u2014 AI coding dashboard and personal digital twin agent. Multi-agent terminal for Claude Code, Codex, Gemini, Aider, OpenCode, and more, plus prompt editor, task queue, remote phone access, code/doc review, and an agent that learns from Slack, email & calendar.",
5
5
  "bin": {
6
6
  "create-walle": "bin/create-walle.js"
7
7
  },
@@ -9,10 +9,19 @@ const permissionSync = require('./lib/permission-sync');
9
9
  const walleClient = require('./lib/walle-client');
10
10
  const claudeDesktopSessions = require('./lib/claude-desktop-sessions');
11
11
  const skillAutocomplete = require('./lib/skill-autocomplete');
12
+ const resourceLinks = require('./lib/resource-links');
13
+ const {
14
+ ingestJsonlFile,
15
+ normalizeProvider: normalizeTranscriptProvider,
16
+ sourceIdFromPath: transcriptSourceIdFromPath,
17
+ } = require('./lib/transcript-store');
12
18
  // AI search uses direct HTTP calls to Claude API (supports Portkey proxy)
13
19
 
14
20
  let dbMaintenanceRunner = null;
15
21
  const MOBILE_ATTACHMENT_MAX_BYTES = 10 * 1024 * 1024;
22
+ const TRANSCRIPT_IMPORT_MAX_BYTES = Math.max(1024 * 1024, Number(process.env.CTM_TRANSCRIPT_IMPORT_MAX_BYTES || 10 * 1024 * 1024));
23
+ const TRANSCRIPT_IMPORT_LARGE_FILE_BYTES = Math.max(1024 * 1024, Number(process.env.CTM_TRANSCRIPT_IMPORT_LARGE_FILE_BYTES || 64 * 1024 * 1024));
24
+ const CONVERSATION_IMPORT_RETRY_AFTER_MS = 30 * 1000;
16
25
 
17
26
  function setDbMaintenanceRunner(fn) {
18
27
  dbMaintenanceRunner = typeof fn === 'function' ? fn : null;
@@ -165,6 +174,8 @@ function handlePromptApi(req, res, url) {
165
174
 
166
175
  // --- Prompt Usage by Session ---
167
176
  if (p === '/api/session-prompts' && m === 'GET') return handleSessionPrompts(req, res, url);
177
+ if (p === '/api/resource-links' && m === 'GET') return handleResourceLinks(req, res, url);
178
+ if (p === '/api/resource-views' && m === 'GET') return handleResourceViews(req, res, url);
168
179
 
169
180
  // --- Settings ---
170
181
  if (p === '/api/settings' && m === 'GET') return handleGetSettings(req, res, url);
@@ -389,6 +400,31 @@ function handleSessionPrompts(req, res, url) {
389
400
  jsonResponse(res, 200, rows);
390
401
  }
391
402
 
403
+ function handleResourceLinks(req, res, url) {
404
+ try {
405
+ const type = url.searchParams.get('type') || '';
406
+ const id = url.searchParams.get('id') || '';
407
+ const limit = parseInt(url.searchParams.get('limit') || '30', 10);
408
+ const data = resourceLinks.getResourceContext(db, { type, id, limit });
409
+ jsonResponse(res, 200, data);
410
+ } catch (e) {
411
+ if (e.code === 'ENOENT') return jsonResponse(res, 404, { error: e.message });
412
+ if (e.code === 'EINVAL') return jsonResponse(res, 400, { error: e.message });
413
+ console.error('[resource-links] request failed:', e.message, e.stack);
414
+ jsonResponse(res, 500, { error: 'Failed to build resource map' });
415
+ }
416
+ }
417
+
418
+ function handleResourceViews(req, res, url) {
419
+ try {
420
+ const limit = parseInt(url.searchParams.get('limit') || '20', 10);
421
+ jsonResponse(res, 200, resourceLinks.getResourceViews(db, { limit }));
422
+ } catch (e) {
423
+ console.error('[resource-links] views failed:', e.message, e.stack);
424
+ jsonResponse(res, 500, { error: 'Failed to build resource views' });
425
+ }
426
+ }
427
+
392
428
  // --- Folders ---
393
429
  function handleListFolders(req, res) {
394
430
  jsonResponse(res, 200, db.listFolders());
@@ -850,13 +886,16 @@ const fsp = require('fs').promises;
850
886
  */
851
887
  const _RENAME_CMD_RE = /^\s*\/rename\s+(.+?)\s*$/m;
852
888
  const _LARGE_JSONL_LINE_BYTES = 512 * 1024;
889
+ const _CONVERSATION_CACHE_TEXT_LIMIT = 5000;
890
+ const _TITLE_SIGNAL_TEXT_LIMIT = 500;
853
891
 
854
- function _readJsonStringPrefix(line, marker, maxChars, fromIndex = 0) {
892
+ function _readJsonStringPrefix(line, marker, maxChars = Number.POSITIVE_INFINITY, fromIndex = 0) {
855
893
  const markerIndex = line.indexOf(marker, fromIndex);
856
894
  if (markerIndex === -1) return '';
857
895
  let pos = markerIndex + marker.length;
858
896
  let out = '';
859
- while (pos < line.length && out.length < maxChars) {
897
+ const limit = Number.isFinite(maxChars) && maxChars > 0 ? maxChars : Number.POSITIVE_INFINITY;
898
+ while (pos < line.length && out.length < limit) {
860
899
  const ch = line[pos++];
861
900
  if (ch === '"') break;
862
901
  if (ch !== '\\') {
@@ -888,9 +927,9 @@ function _parseLargeConversationLine(line) {
888
927
  let text = '';
889
928
  const textBlock = line.indexOf('"type":"text"');
890
929
  if (textBlock !== -1) {
891
- text = _readJsonStringPrefix(line, '"text":"', 5000, textBlock);
930
+ text = _readJsonStringPrefix(line, '"text":"', Number.POSITIVE_INFINITY, textBlock);
892
931
  } else if (!line.includes('"type":"tool_result"')) {
893
- text = _readJsonStringPrefix(line, '"content":"', 5000);
932
+ text = _readJsonStringPrefix(line, '"content":"', Number.POSITIVE_INFINITY);
894
933
  }
895
934
  if (!text) return null;
896
935
  return { role: 'user', text, timestamp };
@@ -898,11 +937,15 @@ function _parseLargeConversationLine(line) {
898
937
  if (line.includes('"type":"assistant"') && line.includes('"role":"assistant"')) {
899
938
  const parts = [];
900
939
  let scanFrom = 0;
901
- while (parts.join('\n').length < 5000) {
940
+ let compactLength = 0;
941
+ while (compactLength < _CONVERSATION_CACHE_TEXT_LIMIT) {
902
942
  const textBlock = line.indexOf('"type":"text"', scanFrom);
903
943
  if (textBlock === -1) break;
904
- const text = _readJsonStringPrefix(line, '"text":"', 5000, textBlock);
905
- if (text) parts.push(text);
944
+ const text = _readJsonStringPrefix(line, '"text":"', _CONVERSATION_CACHE_TEXT_LIMIT, textBlock);
945
+ if (text) {
946
+ parts.push(text);
947
+ compactLength += text.length + 1;
948
+ }
906
949
  scanFrom = textBlock + 13;
907
950
  }
908
951
  scanFrom = 0;
@@ -916,7 +959,7 @@ function _parseLargeConversationLine(line) {
916
959
  if (!parts.length) return null;
917
960
  return {
918
961
  role: 'assistant',
919
- text: parts.join('\n').slice(0, 5000),
962
+ text: parts.join('\n').slice(0, _CONVERSATION_CACHE_TEXT_LIMIT),
920
963
  timestamp,
921
964
  parent: _readJsonStringPrefix(line, '"parentUuid":"', 80),
922
965
  textOnly: parts.find(p => !p.startsWith('[Tool: ')) || '',
@@ -927,6 +970,7 @@ function _parseLargeConversationLine(line) {
927
970
 
928
971
  async function _parseConversationContent(content) {
929
972
  const messages = [];
973
+ const searchMessages = [];
930
974
  let assistantCount = 0;
931
975
  let firstUserContent = '';
932
976
  let lastUserContent = '';
@@ -959,18 +1003,26 @@ async function _parseConversationContent(content) {
959
1003
  if (line.length > _LARGE_JSONL_LINE_BYTES) {
960
1004
  const large = _parseLargeConversationLine(line);
961
1005
  if (large?.role === 'user') {
962
- messages.push({ role: 'user', text: large.text.slice(0, 5000), timestamp: large.timestamp });
963
- if (!firstUserContent) firstUserContent = large.text.slice(0, 500);
964
- lastUserContent = large.text.slice(0, 500);
1006
+ messages.push({ role: 'user', text: large.text.slice(0, _CONVERSATION_CACHE_TEXT_LIMIT), timestamp: large.timestamp });
1007
+ searchMessages.push({ role: 'user', text: large.text, timestamp: large.timestamp });
1008
+ if (!firstUserContent) firstUserContent = large.text.slice(0, _TITLE_SIGNAL_TEXT_LIMIT);
1009
+ lastUserContent = large.text.slice(0, _TITLE_SIGNAL_TEXT_LIMIT);
965
1010
  const m = large.text.match(_RENAME_CMD_RE);
966
1011
  if (m && m[1]) renameName = m[1].slice(0, 200);
967
1012
  } else if (large?.role === 'assistant') {
968
- if (large.textOnly && !firstAssistantText) firstAssistantText = large.textOnly.slice(0, 500);
1013
+ if (large.textOnly && !firstAssistantText) firstAssistantText = large.textOnly.slice(0, _TITLE_SIGNAL_TEXT_LIMIT);
969
1014
  const lastMsg = messages[messages.length - 1];
970
1015
  if (lastMsg && lastMsg.role === 'assistant' && lastMsg._parent === large.parent) {
971
- lastMsg.text = large.text.slice(0, 5000);
1016
+ lastMsg.text = large.text.slice(0, _CONVERSATION_CACHE_TEXT_LIMIT);
1017
+ const lastSearchMsg = searchMessages[searchMessages.length - 1];
1018
+ if (lastSearchMsg && lastSearchMsg.role === 'assistant' && lastSearchMsg._parent === large.parent) {
1019
+ lastSearchMsg.text = large.text;
1020
+ } else {
1021
+ searchMessages.push({ role: 'assistant', text: large.text, timestamp: large.timestamp, _parent: large.parent });
1022
+ }
972
1023
  } else {
973
- messages.push({ role: 'assistant', text: large.text.slice(0, 5000), timestamp: large.timestamp, _parent: large.parent });
1024
+ messages.push({ role: 'assistant', text: large.text.slice(0, _CONVERSATION_CACHE_TEXT_LIMIT), timestamp: large.timestamp, _parent: large.parent });
1025
+ searchMessages.push({ role: 'assistant', text: large.text, timestamp: large.timestamp, _parent: large.parent });
974
1026
  assistantCount++;
975
1027
  }
976
1028
  }
@@ -984,9 +1036,10 @@ async function _parseConversationContent(content) {
984
1036
  const text = typeof c === 'string' ? c
985
1037
  : Array.isArray(c) ? c.filter(b => b.type === 'text').map(b => b.text).join('\n') : '';
986
1038
  if (text) {
987
- messages.push({ role: 'user', text: text.slice(0, 5000), timestamp: entry.timestamp });
988
- if (!firstUserContent) firstUserContent = text.slice(0, 500);
989
- lastUserContent = text.slice(0, 500);
1039
+ messages.push({ role: 'user', text: text.slice(0, _CONVERSATION_CACHE_TEXT_LIMIT), timestamp: entry.timestamp });
1040
+ searchMessages.push({ role: 'user', text, timestamp: entry.timestamp });
1041
+ if (!firstUserContent) firstUserContent = text.slice(0, _TITLE_SIGNAL_TEXT_LIMIT);
1042
+ lastUserContent = text.slice(0, _TITLE_SIGNAL_TEXT_LIMIT);
990
1043
  // A later /rename overrides an earlier one — last-write-wins.
991
1044
  const m = text.match(_RENAME_CMD_RE);
992
1045
  if (m && m[1]) renameName = m[1].slice(0, 200);
@@ -1005,13 +1058,20 @@ async function _parseConversationContent(content) {
1005
1058
  // Prefer the first text-bearing assistant utterance; tool-only
1006
1059
  // replies like "[Tool: Bash]" are not useful as a title.
1007
1060
  const textOnly = c.filter(b => b.type === 'text' && b.text).map(b => b.text).join('\n');
1008
- if (textOnly) firstAssistantText = textOnly.slice(0, 500);
1061
+ if (textOnly) firstAssistantText = textOnly.slice(0, _TITLE_SIGNAL_TEXT_LIMIT);
1009
1062
  }
1010
1063
  const lastMsg = messages[messages.length - 1];
1011
1064
  if (lastMsg && lastMsg.role === 'assistant' && lastMsg._parent === entry.parentUuid) {
1012
- lastMsg.text = joined.slice(0, 5000);
1065
+ lastMsg.text = joined.slice(0, _CONVERSATION_CACHE_TEXT_LIMIT);
1066
+ const lastSearchMsg = searchMessages[searchMessages.length - 1];
1067
+ if (lastSearchMsg && lastSearchMsg.role === 'assistant' && lastSearchMsg._parent === entry.parentUuid) {
1068
+ lastSearchMsg.text = joined;
1069
+ } else {
1070
+ searchMessages.push({ role: 'assistant', text: joined, timestamp: entry.timestamp, _parent: entry.parentUuid });
1071
+ }
1013
1072
  } else {
1014
- messages.push({ role: 'assistant', text: joined.slice(0, 5000), timestamp: entry.timestamp, _parent: entry.parentUuid });
1073
+ messages.push({ role: 'assistant', text: joined.slice(0, _CONVERSATION_CACHE_TEXT_LIMIT), timestamp: entry.timestamp, _parent: entry.parentUuid });
1074
+ searchMessages.push({ role: 'assistant', text: joined, timestamp: entry.timestamp, _parent: entry.parentUuid });
1015
1075
  assistantCount++;
1016
1076
  }
1017
1077
  }
@@ -1020,8 +1080,10 @@ async function _parseConversationContent(content) {
1020
1080
  await maybeYield();
1021
1081
  }
1022
1082
  messages.forEach(m => delete m._parent);
1083
+ searchMessages.forEach(m => delete m._parent);
1023
1084
  return {
1024
1085
  messages,
1086
+ searchMessages,
1025
1087
  assistantCount,
1026
1088
  firstUserContent,
1027
1089
  lastUserContent,
@@ -1089,6 +1151,9 @@ async function _importCompactPair(parsed, jsonlPath, bakPath, jsonlSize, bakSize
1089
1151
  // possible mid-compact) end up in chronological order.
1090
1152
  const allMessages = bakParsed.messages.concat(jsonlParsed.messages);
1091
1153
  allMessages.sort((a, b) => (a.timestamp || '').localeCompare(b.timestamp || ''));
1154
+ const allSearchMessages = (bakParsed.searchMessages || bakParsed.messages)
1155
+ .concat(jsonlParsed.searchMessages || jsonlParsed.messages);
1156
+ allSearchMessages.sort((a, b) => (a.timestamp || '').localeCompare(b.timestamp || ''));
1092
1157
  const signals = _conversationSignalsFromMessages(allMessages);
1093
1158
 
1094
1159
  // Title-fallback chain: pre-compact (.bak) wins for first-message because
@@ -1108,6 +1173,7 @@ async function _importCompactPair(parsed, jsonlPath, bakPath, jsonlSize, bakSize
1108
1173
  session_id: parsed.sessionId,
1109
1174
  project_path: parsed.cwd || parsed.project,
1110
1175
  messages: allMessages,
1176
+ search_messages: allSearchMessages,
1111
1177
  user_msg_count: signals.userCount,
1112
1178
  assistant_msg_count: signals.assistantCount,
1113
1179
  title: parsed.title || (existing && existing.title) || '',
@@ -1134,6 +1200,25 @@ function _safeParseMessagesJson(json) {
1134
1200
  }
1135
1201
  }
1136
1202
 
1203
+ function _loadIndexedSessionMessages(sessionId) {
1204
+ const id = String(sessionId || '').trim();
1205
+ if (!id) return [];
1206
+ try {
1207
+ return db.getDb().prepare(
1208
+ `SELECT message_index, role, content, created_at
1209
+ FROM session_messages
1210
+ WHERE ctm_session_id = ? AND message_index >= 0
1211
+ ORDER BY message_index ASC`
1212
+ ).all(id).map(row => ({
1213
+ role: row.role,
1214
+ text: row.content || '',
1215
+ timestamp: row.created_at || '',
1216
+ })).filter(m => m.role && m.text);
1217
+ } catch {
1218
+ return [];
1219
+ }
1220
+ }
1221
+
1137
1222
  function _codexSeenUsersFromMessages(messages) {
1138
1223
  const seen = new Set();
1139
1224
  for (const msg of Array.isArray(messages) ? messages : []) {
@@ -1206,9 +1291,10 @@ async function _conversationImportCandidates(allFiles, lastScanAt) {
1206
1291
  const cacheShrankAfterChange = !!existing && effectiveSize < existingSize && changedSinceScan;
1207
1292
  const linkedMissingCache = linkedAgentIds.has(sessionId) && !existing;
1208
1293
  const missingModel = !!existing && !existing.model_provider;
1294
+ const changedColdFile = changedSinceScan && !existing;
1209
1295
 
1210
1296
  if (
1211
- !changedSinceScan &&
1297
+ !changedColdFile &&
1212
1298
  !cacheBehind &&
1213
1299
  !cacheShrankAfterChange &&
1214
1300
  !linkedMissingCache &&
@@ -1217,12 +1303,13 @@ async function _conversationImportCandidates(allFiles, lastScanAt) {
1217
1303
  continue;
1218
1304
  }
1219
1305
 
1220
- let priority = 5;
1306
+ let priority = 6;
1221
1307
  if (cacheBehind) priority = 0;
1222
1308
  else if (linkedMissingCache) priority = 1;
1223
- else if (hasCompactSibling && changedSinceScan) priority = 2;
1224
- else if (changedSinceScan) priority = 3;
1225
- else if (missingModel) priority = 4;
1309
+ else if (cacheShrankAfterChange) priority = 2;
1310
+ else if (hasCompactSibling && changedColdFile) priority = 3;
1311
+ else if (changedColdFile) priority = 4;
1312
+ else if (missingModel) priority = 5;
1226
1313
 
1227
1314
  candidates.push({
1228
1315
  filePath,
@@ -1267,6 +1354,12 @@ async function _importCodexSessionFile(parsed, filePath) {
1267
1354
  const baseMessages = (prevFileSize > 0 && parsed.fileSize > prevFileSize)
1268
1355
  ? _safeParseMessagesJson(existing.messages)
1269
1356
  : [];
1357
+ const indexedMessages = (prevFileSize > 0 && parsed.fileSize > prevFileSize)
1358
+ ? _loadIndexedSessionMessages(sessionId)
1359
+ : [];
1360
+ const baseSearchMessages = (prevFileSize > 0 && parsed.fileSize > prevFileSize)
1361
+ ? (indexedMessages.length ? indexedMessages : baseMessages)
1362
+ : [];
1270
1363
  const seenUsers = _codexSeenUsersFromMessages(baseMessages);
1271
1364
 
1272
1365
  const newMessages = [];
@@ -1279,7 +1372,9 @@ async function _importCodexSessionFile(parsed, filePath) {
1279
1372
  }
1280
1373
 
1281
1374
  const allMessages = baseMessages.concat(newMessages);
1375
+ const allSearchMessages = (baseSearchMessages.length ? baseSearchMessages : baseMessages).concat(newMessages);
1282
1376
  allMessages.sort((a, b) => (a.timestamp || '').localeCompare(b.timestamp || ''));
1377
+ allSearchMessages.sort((a, b) => (a.timestamp || '').localeCompare(b.timestamp || ''));
1283
1378
  const userMessages = allMessages.filter(m => m.role === 'user' && (m.text || m.content));
1284
1379
  const assistantMessages = allMessages.filter(m => m.role === 'assistant' && (m.text || m.content));
1285
1380
  if (allMessages.length === 0 || userMessages.length === 0) return false;
@@ -1294,11 +1389,13 @@ async function _importCodexSessionFile(parsed, filePath) {
1294
1389
  : '';
1295
1390
  const projectPath = meta.cwd || parsed.project || '';
1296
1391
  const model = meta.model || parsed.modelId || parsed.model || '';
1392
+ const modelProvider = meta.model_provider || parsed.modelProvider || (existing && existing.model_provider) || 'openai';
1297
1393
 
1298
1394
  db.importSessionConversation({
1299
1395
  session_id: sessionId,
1300
1396
  project_path: projectPath,
1301
1397
  messages: allMessages,
1398
+ search_messages: allSearchMessages,
1302
1399
  user_msg_count: userMessages.length,
1303
1400
  assistant_msg_count: assistantMessages.length,
1304
1401
  title: title || (existing && existing.title) || '',
@@ -1310,7 +1407,7 @@ async function _importCodexSessionFile(parsed, filePath) {
1310
1407
  file_size: parsed.fileSize,
1311
1408
  session_created_at: meta.timestamp || parsed.timestamp || '',
1312
1409
  hostname: parsed.hostname,
1313
- model_provider: model ? 'openai' : (existing && existing.model_provider) || '',
1410
+ model_provider: modelProvider,
1314
1411
  model_id: model || (existing && existing.model_id) || '',
1315
1412
  });
1316
1413
 
@@ -1339,8 +1436,38 @@ async function _importCodexSessionFile(parsed, filePath) {
1339
1436
  return true;
1340
1437
  }
1341
1438
 
1439
+ function _ingestTranscriptStoreForParsedFile(filePath, parsed) {
1440
+ try {
1441
+ const size = Number(parsed?.fileSize || fs.statSync(filePath).size || 0);
1442
+ const agentSessionId = String(parsed?.sessionId || transcriptSourceIdFromPath(filePath) || '').trim();
1443
+ if (!agentSessionId) return null;
1444
+ const provider = normalizeTranscriptProvider(parsed?.agent || parsed?.modelProvider || '', filePath);
1445
+ const largeColdMode = size >= TRANSCRIPT_IMPORT_LARGE_FILE_BYTES ? 'tail' : undefined;
1446
+ const result = ingestJsonlFile(db.getDb(), {
1447
+ filePath,
1448
+ agentSessionId,
1449
+ ctmSessionId: agentSessionId,
1450
+ provider,
1451
+ mode: largeColdMode,
1452
+ initialTailBytes: TRANSCRIPT_IMPORT_MAX_BYTES,
1453
+ maxBytes: largeColdMode ? TRANSCRIPT_IMPORT_MAX_BYTES : Math.min(size || TRANSCRIPT_IMPORT_MAX_BYTES, TRANSCRIPT_IMPORT_MAX_BYTES),
1454
+ });
1455
+ if ((result.inserted || 0) > 0 || (result.bytesRead || 0) > 0) {
1456
+ console.log(
1457
+ `[auto-import] transcript-store ${path.basename(filePath).slice(0, 32)}: ` +
1458
+ `inserted=${result.inserted || 0}, bytes=${result.bytesRead || 0}${largeColdMode ? ', mode=tail' : ''}`
1459
+ );
1460
+ }
1461
+ return result;
1462
+ } catch (e) {
1463
+ console.error(`[auto-import] transcript-store ingest failed for ${path.basename(filePath).slice(0, 32)}: ${e.message}`);
1464
+ return null;
1465
+ }
1466
+ }
1467
+
1342
1468
  async function importSessionFile(filePath, projectPath, projectEntry) {
1343
1469
  const parsed = parseSessionFile(filePath, projectPath, projectEntry);
1470
+ _ingestTranscriptStoreForParsedFile(filePath, parsed);
1344
1471
  if (parsed.agent === 'codex') {
1345
1472
  return _importCodexSessionFile(parsed, filePath);
1346
1473
  }
@@ -1425,10 +1552,20 @@ async function importSessionFile(filePath, projectPath, projectEntry) {
1425
1552
 
1426
1553
  const {
1427
1554
  messages: newMessages, assistantCount: newAssistants,
1555
+ searchMessages: newSearchMessages,
1428
1556
  firstUserContent: parsedFirstUser, lastUserContent: parsedLastUser,
1429
1557
  firstAssistantText: parsedFirstAssistant, renameName: parsedRename,
1430
1558
  } = await _parseConversationContent(content);
1431
1559
  const allMessages = baseMessages.concat(newMessages);
1560
+ const indexedMessages = prevFileSize > 0 && parsed.fileSize > prevFileSize
1561
+ ? _loadIndexedSessionMessages(parsed.sessionId)
1562
+ : [];
1563
+ const baseSearchMessages = prevFileSize > 0 && parsed.fileSize > prevFileSize
1564
+ ? (indexedMessages.length ? indexedMessages : baseMessages)
1565
+ : [];
1566
+ const allSearchMessages = (baseSearchMessages.length ? baseSearchMessages : baseMessages)
1567
+ .concat(newSearchMessages && newSearchMessages.length ? newSearchMessages : newMessages);
1568
+ allSearchMessages.sort((a, b) => (a.timestamp || '').localeCompare(b.timestamp || ''));
1432
1569
  const signals = _conversationSignalsFromMessages(allMessages);
1433
1570
  if (allMessages.length === 0 || signals.userCount === 0) return false;
1434
1571
 
@@ -1447,6 +1584,7 @@ async function importSessionFile(filePath, projectPath, projectEntry) {
1447
1584
  session_id: parsed.sessionId,
1448
1585
  project_path: parsed.cwd || parsed.project,
1449
1586
  messages: allMessages,
1587
+ search_messages: allSearchMessages,
1450
1588
  user_msg_count: signals.userCount,
1451
1589
  assistant_msg_count: signals.assistantCount || baseAssistantCount + newAssistants,
1452
1590
  title: parsed.title || (existing && existing.title) || '',
@@ -1488,7 +1626,13 @@ async function runIncrementalConversationImport() {
1488
1626
  let total = 0;
1489
1627
  let failed = 0;
1490
1628
  let hitLimit = false;
1629
+ let hitLimitLabel = '';
1491
1630
  const maxImportedPerRun = Math.max(1, Number(process.env.CTM_CONVERSATION_IMPORT_MAX_PER_RUN || 12));
1631
+ const maxProcessedPerRun = Math.max(1, Number(process.env.CTM_CONVERSATION_IMPORT_MAX_PROCESSED_PER_RUN || maxImportedPerRun));
1632
+ const retryAfterMsRaw = Number(process.env.CTM_CONVERSATION_IMPORT_RETRY_AFTER_MS || CONVERSATION_IMPORT_RETRY_AFTER_MS);
1633
+ const retryAfterMs = Number.isFinite(retryAfterMsRaw) && retryAfterMsRaw >= 1000
1634
+ ? retryAfterMsRaw
1635
+ : CONVERSATION_IMPORT_RETRY_AFTER_MS;
1492
1636
 
1493
1637
  try {
1494
1638
  // One-time backfill: force full rescan to populate model_provider for existing sessions
@@ -1510,8 +1654,11 @@ async function runIncrementalConversationImport() {
1510
1654
  if (error) throw error;
1511
1655
  scanned++;
1512
1656
  if (await importSessionFile(filePath, projectPath, projectEntry)) imported++;
1513
- if (imported >= maxImportedPerRun) {
1657
+ const importLimited = imported >= maxImportedPerRun;
1658
+ const processedLimited = scanned >= maxProcessedPerRun;
1659
+ if ((importLimited || processedLimited) && scanned < candidates.length) {
1514
1660
  hitLimit = true;
1661
+ hitLimitLabel = importLimited ? `${maxImportedPerRun} imports` : `${maxProcessedPerRun} files`;
1515
1662
  break;
1516
1663
  }
1517
1664
  await new Promise((resolve) => setImmediate(resolve));
@@ -1524,7 +1671,7 @@ async function runIncrementalConversationImport() {
1524
1671
  }
1525
1672
 
1526
1673
  if (imported > 0 || failed > 0) {
1527
- const suffix = hitLimit ? `, paused after ${maxImportedPerRun} imports` : '';
1674
+ const suffix = hitLimit ? `, paused after ${hitLimitLabel}` : '';
1528
1675
  console.log(`[auto-import] Imported ${imported}, failed ${failed} (scanned ${scanned}/${total}${suffix})`);
1529
1676
  }
1530
1677
  return {
@@ -1534,7 +1681,7 @@ async function runIncrementalConversationImport() {
1534
1681
  candidates: candidates.length,
1535
1682
  failed,
1536
1683
  remaining: hitLimit,
1537
- retry_after_ms: hitLimit ? 1000 : undefined,
1684
+ retry_after_ms: hitLimit ? retryAfterMs : undefined,
1538
1685
  };
1539
1686
  } catch (e) {
1540
1687
  console.error('[auto-import] Top-level error:', e.message);
@@ -3,6 +3,7 @@ const path = require('path');
3
3
  const fs = require('fs');
4
4
  const db = require('./db');
5
5
  const gitUtils = require('./git-utils');
6
+ const documentReview = require('./lib/document-review');
6
7
 
7
8
  const REVIEW_SEVERITIES = new Set(['comment', 'suggestion', 'issue', 'nit', 'question']);
8
9
  const REVIEW_STATUSES = new Set(['open', 'resolved']);
@@ -73,7 +74,7 @@ function sanitizeReviewCommentInput(reviewId, body) {
73
74
  const lineStart = positiveLineNumber(body.line_start, 'line_start');
74
75
  let lineEnd = body.line_end == null || body.line_end === '' ? lineStart : positiveLineNumber(body.line_end, 'line_end');
75
76
  if (lineEnd < lineStart) lineEnd = lineStart;
76
- return {
77
+ const out = {
77
78
  review_id: reviewId,
78
79
  file_path: filePath,
79
80
  line_start: lineStart,
@@ -83,6 +84,11 @@ function sanitizeReviewCommentInput(reviewId, body) {
83
84
  severity: normalizeReviewSeverity(body.severity),
84
85
  ai_generated: !!body.ai_generated,
85
86
  };
87
+ const anchorKind = String(body.anchor_kind || '').trim().toLowerCase();
88
+ if (['line', 'block', 'file', 'page'].includes(anchorKind)) out.anchor_kind = anchorKind;
89
+ const blockId = String(body.block_id || '').trim();
90
+ if (blockId) out.block_id = blockId.slice(0, 512);
91
+ return out;
86
92
  }
87
93
 
88
94
  function sanitizeReviewCommentUpdate(body) {
@@ -129,6 +135,50 @@ function handleReviewApi(req, res, url) {
129
135
  const p = url.pathname;
130
136
  const m = req.method;
131
137
 
138
+ // GET /api/reviews/document?path=...&line=...
139
+ if (p === '/api/reviews/document' && m === 'GET') {
140
+ try {
141
+ const filePath = url.searchParams.get('path');
142
+ const line = url.searchParams.get('line') || 1;
143
+ const document = documentReview.readDocument(filePath, { line });
144
+ return jsonResponse(res, 200, { document });
145
+ } catch (e) {
146
+ return jsonResponse(res, e.statusCode || 500, { error: e.message });
147
+ }
148
+ }
149
+
150
+ // POST /api/reviews/document-review - create/reuse a review record for one document
151
+ if (p === '/api/reviews/document-review' && m === 'POST') {
152
+ readBody(req).then(body => {
153
+ const document = documentReview.readDocument(body.path, { line: body.line || 1 });
154
+ const review = db.listReviews({
155
+ project_path: document.projectRoot,
156
+ review_type: 'doc',
157
+ artifact_path: document.path,
158
+ limit: 1,
159
+ })[0];
160
+ const id = review?.id || db.createReview({
161
+ session_id: body.session_id || null,
162
+ project_path: document.projectRoot,
163
+ review_type: 'doc',
164
+ artifact_path: document.path,
165
+ artifact_hash: document.hash,
166
+ base_ref: `doc:${document.relativePath}`,
167
+ });
168
+ db.updateReview(id, {
169
+ file_count: 1,
170
+ artifact_hash: document.hash,
171
+ summary: document.headings[0]?.text || path.basename(document.path),
172
+ });
173
+ return jsonResponse(res, review ? 200 : 201, {
174
+ id,
175
+ review: db.getReview(id),
176
+ document,
177
+ });
178
+ }).catch(e => jsonResponse(res, e.statusCode || 400, { error: e.message }));
179
+ return true;
180
+ }
181
+
132
182
  // GET /api/reviews/diff?project=...&base=...
133
183
  if (p === '/api/reviews/diff' && m === 'GET') {
134
184
  const project = url.searchParams.get('project');
@@ -332,7 +382,8 @@ function composeReviewPrompt(review) {
332
382
  byFile[c.file_path].push(c);
333
383
  }
334
384
 
335
- const lines = ['Please address these code review comments:\n'];
385
+ const reviewKind = review.review_type === 'doc' ? 'documentation review' : 'code review';
386
+ const lines = [`Please address these ${reviewKind} comments:\n`];
336
387
 
337
388
  for (const [filePath, fileComments] of Object.entries(byFile)) {
338
389
  lines.push(`## ${filePath}\n`);
@@ -340,9 +391,10 @@ function composeReviewPrompt(review) {
340
391
  const lineRef = c.line_end && c.line_end !== c.line_start
341
392
  ? `Lines ${c.line_start}-${c.line_end}`
342
393
  : `Line ${c.line_start}`;
394
+ const anchor = c.anchor_kind === 'block' && c.block_id ? ` (${c.block_id})` : '';
343
395
  const normalizedSeverity = normalizeReviewSeverity(c.severity);
344
396
  const severity = normalizedSeverity !== 'comment' ? ` [${normalizedSeverity}]` : '';
345
- lines.push(`**${lineRef}**${severity}: ${c.body}\n`);
397
+ lines.push(`**${lineRef}${anchor}**${severity}: ${c.body}\n`);
346
398
  }
347
399
  }
348
400
 
@@ -361,4 +413,5 @@ module.exports = {
361
413
  _normalizeReviewStatus: normalizeReviewStatus,
362
414
  _sanitizeReviewCommentInput: sanitizeReviewCommentInput,
363
415
  _sanitizeReviewCommentUpdate: sanitizeReviewCommentUpdate,
416
+ _documentReview: documentReview,
364
417
  };
@@ -563,6 +563,7 @@ Be pragmatic. Most development operations in a local dev environment are safe. O
563
563
  // The headless terminal still receives all data; after the window expires,
564
564
  // server.js sends a clean snapshot to the client.
565
565
  const APPROVAL_TRANSITION_MS = 300;
566
+ const APPROVAL_TRANSITION_SETTLE_MS = 150;
566
567
 
567
568
  // Phase 3 verification window. After writing the approval keystroke, we expect
568
569
  // Claude Code to transition state (cursor moves, alt-screen exits, or
@@ -608,8 +609,14 @@ function sendApprovalKeystroke(session, context, headlessWorker) {
608
609
  const sid = session.id ? session.id.slice(0, 8) : '?';
609
610
  const decidedBy = context._decidedBy || 'unknown';
610
611
  const ruleLabel = context._ruleLabel || context.toolName || 'Unknown';
611
- // Set transition flag BEFORE writing keystroke — flushOutput checks this
612
- session._approvalTransitionUntil = Date.now() + APPROVAL_TRANSITION_MS;
612
+ // Set transition flag BEFORE writing keystroke — flushOutput checks this.
613
+ // When headless verification is available, keep the suppression window open
614
+ // through the verification lifecycle so the browser does not receive half of
615
+ // the approval-dismiss redraw before server.js repairs it with a snapshot.
616
+ const transitionMs = headlessWorker && session.id
617
+ ? Math.max(APPROVAL_TRANSITION_MS, VERIFY_WINDOW_MS + APPROVAL_TRANSITION_SETTLE_MS)
618
+ : APPROVAL_TRANSITION_MS;
619
+ session._approvalTransitionUntil = Date.now() + transitionMs;
613
620
 
614
621
  // Phase 3: tell the worker to start a verification window
615
622
  if (headlessWorker && session.id) {