popilot 0.6.0 → 0.8.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 (165) hide show
  1. package/bin/cli.mjs +204 -2
  2. package/lib/doctor.mjs +38 -1
  3. package/lib/hydrate.mjs +15 -0
  4. package/lib/scaffold.mjs +5 -0
  5. package/lib/setup-wizard.mjs +35 -2
  6. package/package.json +1 -1
  7. package/scaffold/.context/project.yaml.example +19 -0
  8. package/scaffold/mcp-notification-server/package.json +18 -0
  9. package/scaffold/mcp-notification-server/src/index.ts +275 -0
  10. package/scaffold/mcp-notification-server/src/turso-client.ts +142 -0
  11. package/scaffold/mcp-notification-server/tsconfig.json +14 -0
  12. package/scaffold/mcp-pm/package.json +19 -0
  13. package/scaffold/mcp-pm/src/api-client.ts +69 -0
  14. package/scaffold/mcp-pm/src/index.ts +660 -0
  15. package/scaffold/mcp-pm/tsconfig.json +14 -0
  16. package/scaffold/pm-api/package.json +21 -0
  17. package/scaffold/pm-api/sql/001-memo-v2.sql +49 -0
  18. package/scaffold/pm-api/sql/002-notifications.sql +18 -0
  19. package/scaffold/pm-api/sql/003-content.sql +66 -0
  20. package/scaffold/pm-api/sql/004-agent-events.sql +21 -0
  21. package/scaffold/pm-api/sql/005-epic-sprint-decoupling.sql +6 -0
  22. package/scaffold/pm-api/sql/schema-core.sql +331 -0
  23. package/scaffold/pm-api/sql/schema-docs.sql +25 -0
  24. package/scaffold/pm-api/sql/schema-meetings.sql +17 -0
  25. package/scaffold/pm-api/sql/schema-rewards.sql +16 -0
  26. package/scaffold/pm-api/src/auth.ts +28 -0
  27. package/scaffold/pm-api/src/blockchain/adapter.ts +20 -0
  28. package/scaffold/pm-api/src/blockchain/tron.ts +62 -0
  29. package/scaffold/pm-api/src/db/adapter.ts +36 -0
  30. package/scaffold/pm-api/src/db/turso.ts +147 -0
  31. package/scaffold/pm-api/src/index.ts +114 -0
  32. package/scaffold/pm-api/src/mcp-tools/dashboard.ts +40 -0
  33. package/scaffold/pm-api/src/mcp-tools/epic.ts +67 -0
  34. package/scaffold/pm-api/src/mcp-tools/event.ts +89 -0
  35. package/scaffold/pm-api/src/mcp-tools/index.ts +11 -0
  36. package/scaffold/pm-api/src/mcp-tools/initiative.ts +51 -0
  37. package/scaffold/pm-api/src/mcp-tools/memo.ts +164 -0
  38. package/scaffold/pm-api/src/mcp-tools/notification.ts +37 -0
  39. package/scaffold/pm-api/src/mcp-tools/retro.ts +183 -0
  40. package/scaffold/pm-api/src/mcp-tools/sprint.ts +204 -0
  41. package/scaffold/pm-api/src/mcp-tools/standup.ts +136 -0
  42. package/scaffold/pm-api/src/mcp-tools/story.ts +230 -0
  43. package/scaffold/pm-api/src/mcp-tools/task.ts +187 -0
  44. package/scaffold/pm-api/src/mcp-tools/utils.ts +83 -0
  45. package/scaffold/pm-api/src/mcp.ts +871 -0
  46. package/scaffold/pm-api/src/nudge.ts +283 -0
  47. package/scaffold/pm-api/src/routes/auth.ts +32 -0
  48. package/scaffold/pm-api/src/routes/v2-activity.ts +27 -0
  49. package/scaffold/pm-api/src/routes/v2-admin.ts +165 -0
  50. package/scaffold/pm-api/src/routes/v2-dashboard.ts +189 -0
  51. package/scaffold/pm-api/src/routes/v2-docs.ts +34 -0
  52. package/scaffold/pm-api/src/routes/v2-initiatives.ts +118 -0
  53. package/scaffold/pm-api/src/routes/v2-kickoff.ts +265 -0
  54. package/scaffold/pm-api/src/routes/v2-meetings.ts +324 -0
  55. package/scaffold/pm-api/src/routes/v2-memos.ts +257 -0
  56. package/scaffold/pm-api/src/routes/v2-nav.ts +260 -0
  57. package/scaffold/pm-api/src/routes/v2-notifications.ts +79 -0
  58. package/scaffold/pm-api/src/routes/v2-page-content.ts +35 -0
  59. package/scaffold/pm-api/src/routes/v2-pm.ts +380 -0
  60. package/scaffold/pm-api/src/routes/v2-policy.ts +58 -0
  61. package/scaffold/pm-api/src/routes/v2-retro.ts +221 -0
  62. package/scaffold/pm-api/src/routes/v2-rewards.ts +132 -0
  63. package/scaffold/pm-api/src/routes/v2-scenarios.ts +48 -0
  64. package/scaffold/pm-api/src/routes/v2-search.ts +32 -0
  65. package/scaffold/pm-api/src/routes/v2-standup.ts +127 -0
  66. package/scaffold/pm-api/src/routes/v2-user.ts +38 -0
  67. package/scaffold/pm-api/src/types.ts +11 -0
  68. package/scaffold/pm-api/src/utils/activity.ts +22 -0
  69. package/scaffold/pm-api/src/utils/admin.ts +9 -0
  70. package/scaffold/pm-api/src/utils/agent-notify.ts +62 -0
  71. package/scaffold/pm-api/src/utils/assignee.ts +69 -0
  72. package/scaffold/pm-api/src/utils/db.ts +45 -0
  73. package/scaffold/pm-api/src/utils/initiative.ts +23 -0
  74. package/scaffold/pm-api/src/utils/retro-link.ts +32 -0
  75. package/scaffold/pm-api/src/utils/sprint-lifecycle.ts +96 -0
  76. package/scaffold/pm-api/tsconfig.json +15 -0
  77. package/scaffold/pm-api/wrangler.toml.hbs +11 -0
  78. package/scaffold/spec-site/package-lock.json +892 -0
  79. package/scaffold/spec-site/package.json +15 -1
  80. package/scaffold/spec-site/src/api/types.ts +6 -0
  81. package/scaffold/spec-site/src/components/AppHeader.vue +429 -55
  82. package/scaffold/spec-site/src/components/AuthGate.vue +117 -0
  83. package/scaffold/spec-site/src/components/BurndownChart.vue +78 -0
  84. package/scaffold/spec-site/src/components/DocComments.vue +137 -0
  85. package/scaffold/spec-site/src/components/DocEditor.vue +118 -0
  86. package/scaffold/spec-site/src/components/DocExportBar.vue +110 -0
  87. package/scaffold/spec-site/src/components/DocsSidebar.vue +309 -0
  88. package/scaffold/spec-site/src/components/EmptyState.vue +30 -0
  89. package/scaffold/spec-site/src/components/ErrorBanner.vue +38 -0
  90. package/scaffold/spec-site/src/components/Icon.vue +58 -0
  91. package/scaffold/spec-site/src/components/MemberSelect.vue +48 -0
  92. package/scaffold/spec-site/src/components/MemoChecklist.vue +88 -0
  93. package/scaffold/spec-site/src/components/MemoGraph.vue +75 -0
  94. package/scaffold/spec-site/src/components/MemoItem.vue +353 -0
  95. package/scaffold/spec-site/src/components/MemoRelations.vue +101 -0
  96. package/scaffold/spec-site/src/components/MemoTimeline.vue +53 -0
  97. package/scaffold/spec-site/src/components/MentionInput.vue +174 -0
  98. package/scaffold/spec-site/src/components/NotificationDropdown.vue +116 -0
  99. package/scaffold/spec-site/src/components/PriorityBadge.vue +23 -0
  100. package/scaffold/spec-site/src/components/SearchModal.vue +102 -0
  101. package/scaffold/spec-site/src/components/SlashCommand.ts +123 -0
  102. package/scaffold/spec-site/src/components/StateDisplay.vue +54 -0
  103. package/scaffold/spec-site/src/components/TreeNode.vue +82 -0
  104. package/scaffold/spec-site/src/components/UserAvatar.vue +24 -0
  105. package/scaffold/spec-site/src/components/VelocityChart.vue +77 -0
  106. package/scaffold/spec-site/src/composables/navTypes.ts +3 -0
  107. package/scaffold/spec-site/src/composables/pmTypes.ts +15 -2
  108. package/scaffold/spec-site/src/composables/useBottomSheet.ts +103 -0
  109. package/scaffold/spec-site/src/composables/useDashboard.ts +221 -0
  110. package/scaffold/spec-site/src/composables/useMediaQuery.ts +28 -0
  111. package/scaffold/spec-site/src/composables/useMemo.ts +39 -0
  112. package/scaffold/spec-site/src/composables/useNotification.ts +200 -0
  113. package/scaffold/spec-site/src/composables/usePmStore.ts +48 -1
  114. package/scaffold/spec-site/src/composables/useRetro.ts +6 -0
  115. package/scaffold/spec-site/src/composables/useStandup.ts +201 -0
  116. package/scaffold/spec-site/src/composables/useTheme.ts +37 -0
  117. package/scaffold/spec-site/src/composables/useTurso.ts +17 -0
  118. package/scaffold/spec-site/src/composables/useUser.ts +19 -1
  119. package/scaffold/spec-site/src/composables/useViewport.ts +26 -0
  120. package/scaffold/spec-site/src/features.ts +108 -0
  121. package/scaffold/spec-site/src/mockup/ComponentPalette.vue +61 -0
  122. package/scaffold/spec-site/src/mockup/MockupCanvas.vue +459 -0
  123. package/scaffold/spec-site/src/mockup/PropertyPanel.vue +217 -0
  124. package/scaffold/spec-site/src/mockup/componentCatalog.ts +68 -0
  125. package/scaffold/spec-site/src/mockup/useScenarios.ts +67 -0
  126. package/scaffold/spec-site/src/pages/AdminPage.vue +299 -0
  127. package/scaffold/spec-site/src/pages/DashboardPage.vue +650 -0
  128. package/scaffold/spec-site/src/pages/DocsEditor.vue +119 -0
  129. package/scaffold/spec-site/src/pages/DocsHub.vue +157 -0
  130. package/scaffold/spec-site/src/pages/DocsPage.vue +444 -0
  131. package/scaffold/spec-site/src/pages/InboxPage.vue +156 -0
  132. package/scaffold/spec-site/src/pages/MeetingsPage.vue +294 -0
  133. package/scaffold/spec-site/src/pages/MemosPage.vue +857 -0
  134. package/scaffold/spec-site/src/pages/MockupEditorPage.vue +611 -0
  135. package/scaffold/spec-site/src/pages/MockupListPage.vue +121 -0
  136. package/scaffold/spec-site/src/pages/MockupViewerPage.vue +199 -0
  137. package/scaffold/spec-site/src/pages/MyPage.vue +343 -0
  138. package/scaffold/spec-site/src/pages/NotificationSettingsPage.vue +59 -0
  139. package/scaffold/spec-site/src/pages/RewardsPage.vue +266 -0
  140. package/scaffold/spec-site/src/pages/SprintAdmin.vue +521 -0
  141. package/scaffold/spec-site/src/pages/SprintTimeline.vue +159 -0
  142. package/scaffold/spec-site/src/pages/board/BoardAdmin.vue +422 -0
  143. package/scaffold/spec-site/src/pages/board/BoardEpicSection.vue +54 -0
  144. package/scaffold/spec-site/src/pages/board/BoardPage.vue +884 -0
  145. package/scaffold/spec-site/src/pages/board/BoardStoryCard.vue +67 -0
  146. package/scaffold/spec-site/src/pages/board/BoardTaskItem.vue +52 -0
  147. package/scaffold/spec-site/src/pages/board/KanbanBoard.vue +93 -0
  148. package/scaffold/spec-site/src/pages/board/MyTasksPage.vue +202 -0
  149. package/scaffold/spec-site/src/pages/board/SprintClose.vue +167 -0
  150. package/scaffold/spec-site/src/pages/board/SprintColumn.vue +49 -0
  151. package/scaffold/spec-site/src/pages/board/SprintKickoff.vue +389 -0
  152. package/scaffold/spec-site/src/pages/board/StatusBadge.vue +52 -0
  153. package/scaffold/spec-site/src/pages/board/StoryDetailPanel.vue +495 -0
  154. package/scaffold/spec-site/src/pages/board/TaskCard.vue +42 -0
  155. package/scaffold/spec-site/src/pages/retro/RetroCard.vue +36 -2
  156. package/scaffold/spec-site/src/pages/retro/RetroHeader.vue +82 -66
  157. package/scaffold/spec-site/src/pages/retro/RetroPage.vue +47 -18
  158. package/scaffold/spec-site/src/pages/standup/StandupEntryCard.vue +551 -0
  159. package/scaffold/spec-site/src/pages/standup/StandupForm.vue +68 -0
  160. package/scaffold/spec-site/src/pages/standup/StandupList.vue +71 -0
  161. package/scaffold/spec-site/src/pages/standup/StandupPage.vue +225 -0
  162. package/scaffold/spec-site/src/router.ts +141 -0
  163. package/scaffold/spec-site/src/styles/buttons.css +124 -0
  164. package/scaffold/spec-site/src/utils/parseMentions.ts +56 -0
  165. package/scaffold/spec-site/src/utils/timezone.ts +18 -0
@@ -0,0 +1,324 @@
1
+ import { Hono } from 'hono'
2
+ import type { AppEnv } from '../types.js'
3
+ import { query, execute } from '../db/adapter.js'
4
+ import { queryOrThrow, executeOrThrow } from '../utils/db.js'
5
+
6
+ const app = new Hono<AppEnv>()
7
+
8
+ // GET / - meeting minutes list
9
+ app.get('/', async (c) => {
10
+ const { rows } = await queryOrThrow(
11
+ 'SELECT id, title, date, participants, created_by, created_at FROM meetings ORDER BY date DESC LIMIT 50',
12
+ )
13
+ return c.json({ meetings: rows })
14
+ })
15
+
16
+ // GET /:id - meeting detail
17
+ app.get('/:id', async (c) => {
18
+ const id = Number(c.req.param('id'))
19
+ const { rows } = await queryOrThrow('SELECT * FROM meetings WHERE id = ?', [id])
20
+ if (!rows.length) return c.json({ error: 'Meeting not found' }, 404)
21
+ return c.json({ meeting: rows[0] })
22
+ })
23
+
24
+ // POST / - create meeting
25
+ app.post('/', async (c) => {
26
+ const body = await c.req.json<{
27
+ title: string; date: string; rawTranscript?: string
28
+ summary?: string; agenda?: string; decisions?: string
29
+ actionItems?: string; participants?: string
30
+ }>()
31
+ const createdBy = c.get('userName')
32
+
33
+ const { rowsAffected } = await executeOrThrow(
34
+ `INSERT INTO meetings (title, date, raw_transcript, summary, agenda, decisions, action_items, participants, created_by)
35
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
36
+ [body.title, body.date, body.rawTranscript ?? null, body.summary ?? null,
37
+ body.agenda ?? null, body.decisions ?? null, body.actionItems ?? null,
38
+ body.participants ?? null, createdBy],
39
+ )
40
+ return c.json({ ok: true }, 201)
41
+ })
42
+
43
+ // PATCH /:id - update meeting
44
+ app.patch('/:id', async (c) => {
45
+ const id = Number(c.req.param('id'))
46
+ const body = await c.req.json<Record<string, unknown>>()
47
+ const fieldMap: Record<string, string> = {
48
+ title: 'title', date: 'date', rawTranscript: 'raw_transcript',
49
+ summary: 'summary', agenda: 'agenda', decisions: 'decisions',
50
+ actionItems: 'action_items', participants: 'participants',
51
+ }
52
+ const sets: string[] = []
53
+ const args: (string | null)[] = []
54
+ for (const [key, col] of Object.entries(fieldMap)) {
55
+ if (body[key] !== undefined) { sets.push(`${col} = ?`); args.push(body[key] as string ?? null) }
56
+ }
57
+ if (!sets.length) return c.json({ ok: true })
58
+ sets.push('updated_at = CURRENT_TIMESTAMP')
59
+ args.push(String(id))
60
+ await execute(`UPDATE meetings SET ${sets.join(', ')} WHERE id = ?`, args)
61
+ return c.json({ ok: true })
62
+ })
63
+
64
+ // POST /:id/create-tasks - create tasks from action items
65
+ app.post('/:id/create-tasks', async (c) => {
66
+ const id = Number(c.req.param('id'))
67
+ const meeting = await query<{ action_items: string | null }>(
68
+ 'SELECT action_items FROM meetings WHERE id = ?', [id],
69
+ )
70
+ if (!meeting.rows.length) return c.json({ error: 'Meeting not found' }, 404)
71
+
72
+ const actionItems = meeting.rows[0].action_items
73
+ if (!actionItems) return c.json({ error: 'No action items' }, 400)
74
+
75
+ // Split by lines and create stories
76
+ const items = actionItems.split('\n').map(s => s.trim()).filter(Boolean)
77
+ let created = 0
78
+ for (const item of items) {
79
+ await execute(
80
+ 'INSERT INTO pm_stories (title, description, status, priority) VALUES (?, ?, ?, ?)',
81
+ [`[Meeting] ${item.slice(0, 60)}`, `Created from meeting #${id}.\n\n${item}`, 'backlog', 'medium'],
82
+ )
83
+ created++
84
+ }
85
+
86
+ return c.json({ ok: true, created })
87
+ })
88
+
89
+ // POST /:id/transcribe — audio file -> STT (Whisper)
90
+ app.post('/:id/transcribe', async (c) => {
91
+ const id = Number(c.req.param('id'))
92
+
93
+ // Get STT key from settings
94
+ const settingsResult = await query(
95
+ "SELECT key, value FROM settings WHERE key IN ('llm_api_key', 'llm_provider', 'stt_api_key')",
96
+ )
97
+ const sMap: Record<string, string> = {}
98
+ for (const r of (settingsResult.rows ?? []) as Array<{ key: string; value: string }>) {
99
+ sMap[r.key] = r.value
100
+ }
101
+ const sttKey = sMap.stt_api_key
102
+ const llmKey = sMap.llm_api_key
103
+ const provider = sMap.llm_provider || 'openai'
104
+
105
+ let apiKey: string | undefined
106
+ if (sttKey) {
107
+ apiKey = sttKey
108
+ } else if (provider === 'openai' && llmKey) {
109
+ apiKey = llmKey
110
+ }
111
+
112
+ if (!apiKey) {
113
+ return c.json({
114
+ error: 'STT requires OpenAI Whisper. Set stt_api_key or OpenAI API key in /admin settings.'
115
+ }, 400)
116
+ }
117
+
118
+ // Parse multipart/form-data
119
+ const formData = await c.req.formData()
120
+ const file = formData.get('file') as File | null
121
+ if (!file) return c.json({ error: 'Audio file required' }, 400)
122
+
123
+ // File size validation (25MB)
124
+ if (file.size > 25 * 1024 * 1024) {
125
+ return c.json({ error: 'File size exceeds 25MB' }, 413)
126
+ }
127
+
128
+ // Format validation
129
+ const ext = file.name.split('.').pop()?.toLowerCase()
130
+ if (!['mp3', 'wav', 'm4a', 'webm', 'ogg'].includes(ext || '')) {
131
+ return c.json({ error: 'Unsupported format. Supported: mp3, wav, m4a, webm, ogg' }, 400)
132
+ }
133
+
134
+ // Whisper API call
135
+ const whisperForm = new FormData()
136
+ whisperForm.append('file', file)
137
+ whisperForm.append('model', 'whisper-1')
138
+ whisperForm.append('response_format', 'text')
139
+
140
+ const whisperRes = await fetch('https://api.openai.com/v1/audio/transcriptions', {
141
+ method: 'POST',
142
+ headers: { 'Authorization': `Bearer ${apiKey}` },
143
+ body: whisperForm,
144
+ })
145
+
146
+ if (!whisperRes.ok) {
147
+ const errText = await whisperRes.text()
148
+ return c.json({ error: `Whisper API failed: ${errText.slice(0, 200)}` }, 500)
149
+ }
150
+
151
+ const transcript = await whisperRes.text()
152
+
153
+ // Save to DB
154
+ await execute(
155
+ 'UPDATE meetings SET raw_transcript = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
156
+ [transcript, id],
157
+ )
158
+
159
+ return c.json({ ok: true, transcript })
160
+ })
161
+
162
+ // POST /:id/transcribe-chunk — segment STT (for live recording)
163
+ app.post('/:id/transcribe-chunk', async (c) => {
164
+ const id = Number(c.req.param('id'))
165
+
166
+ const settingsResult = await query(
167
+ "SELECT key, value FROM settings WHERE key IN ('llm_api_key', 'llm_provider', 'stt_api_key')",
168
+ )
169
+ const sMap: Record<string, string> = {}
170
+ for (const r of (settingsResult.rows ?? []) as Array<{ key: string; value: string }>) sMap[r.key] = r.value
171
+
172
+ const sttKey = sMap.stt_api_key
173
+ const provider = sMap.llm_provider || 'openai'
174
+ const apiKey = sttKey || (provider === 'openai' ? sMap.llm_api_key : undefined)
175
+ if (!apiKey) return c.json({ error: 'STT API key required' }, 400)
176
+
177
+ const formData = await c.req.formData()
178
+ const file = formData.get('file') as File | null
179
+ if (!file) return c.json({ error: 'Audio file required' }, 400)
180
+
181
+ const whisperForm = new FormData()
182
+ whisperForm.append('file', file, 'chunk.webm')
183
+ whisperForm.append('model', 'whisper-1')
184
+ whisperForm.append('response_format', 'text')
185
+
186
+ const res = await fetch('https://api.openai.com/v1/audio/transcriptions', {
187
+ method: 'POST',
188
+ headers: { 'Authorization': `Bearer ${apiKey}` },
189
+ body: whisperForm,
190
+ })
191
+
192
+ if (!res.ok) return c.json({ error: 'Whisper failed' }, 500)
193
+ const text = await res.text()
194
+
195
+ // Append to existing transcript
196
+ const existing = await query('SELECT raw_transcript FROM meetings WHERE id = ?', [id])
197
+ const prev = (existing.rows?.[0] as any)?.raw_transcript || ''
198
+ await execute(
199
+ 'UPDATE meetings SET raw_transcript = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
200
+ [prev + ' ' + text, id],
201
+ )
202
+
203
+ return c.json({ ok: true, text })
204
+ })
205
+
206
+ // POST /:id/structurize — BYOM transcript structuring
207
+ app.post('/:id/structurize', async (c) => {
208
+ const id = Number(c.req.param('id'))
209
+ const body = await c.req.json<{ apiKey?: string; model?: string; provider?: string }>()
210
+
211
+ // Get API key, model, provider from settings
212
+ const settingsResult = await query<{ key: string; value: string }>(
213
+ "SELECT key, value FROM settings WHERE key IN ('llm_api_key', 'llm_model', 'llm_provider', 'gemini_client_email', 'gemini_private_key')",
214
+ )
215
+ const sMap: Record<string, string> = {}
216
+ for (const r of settingsResult.rows) sMap[r.key] = r.value
217
+
218
+ const apiKey = body.apiKey ?? sMap.llm_api_key
219
+ if (!apiKey) return c.json({ error: 'API key not configured. Set it in /admin settings.' }, 400)
220
+
221
+ const meeting = await query<{ raw_transcript: string | null }>(
222
+ 'SELECT raw_transcript FROM meetings WHERE id = ?', [id],
223
+ )
224
+ if (!meeting.rows.length) return c.json({ error: 'Meeting not found' }, 404)
225
+ if (!meeting.rows[0].raw_transcript) return c.json({ error: 'No transcript available' }, 400)
226
+
227
+ const transcript = meeting.rows[0].raw_transcript
228
+ const provider = body.provider ?? sMap.llm_provider ?? (apiKey.startsWith('sk-ant') ? 'anthropic' : apiKey.startsWith('AI') ? 'gemini' : 'openai')
229
+ const model = body.model ?? sMap.llm_model ?? (provider === 'openai' ? 'gpt-4o-mini' : provider === 'gemini' ? 'gemini-2.0-flash' : 'claude-sonnet-4-20250514')
230
+
231
+ const systemPrompt = `You are an expert at structuring meeting transcripts.
232
+ Analyze the transcript below and return JSON:
233
+ {
234
+ "summary": "One-line summary",
235
+ "agenda": "Agenda items (newline separated)",
236
+ "decisions": "Decisions made (newline separated)",
237
+ "action_items": "Action items (newline separated, include assignee per line)"
238
+ }
239
+ Return only JSON.`
240
+
241
+ try {
242
+ let result: { summary: string; agenda: string; decisions: string; action_items: string }
243
+
244
+ if (provider === 'openai') {
245
+ const res = await fetch('https://api.openai.com/v1/chat/completions', {
246
+ method: 'POST',
247
+ headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}` },
248
+ body: JSON.stringify({
249
+ model,
250
+ messages: [
251
+ { role: 'system', content: systemPrompt },
252
+ { role: 'user', content: transcript },
253
+ ],
254
+ response_format: { type: 'json_object' },
255
+ }),
256
+ })
257
+ const data = await res.json() as { choices?: Array<{ message?: { content?: string } }> }
258
+ result = JSON.parse(data.choices?.[0]?.message?.content ?? '{}')
259
+ } else {
260
+ const res = await fetch('https://api.anthropic.com/v1/messages', {
261
+ method: 'POST',
262
+ headers: {
263
+ 'Content-Type': 'application/json',
264
+ 'x-api-key': apiKey,
265
+ 'anthropic-version': '2023-06-01',
266
+ },
267
+ body: JSON.stringify({
268
+ model,
269
+ max_tokens: 4096,
270
+ system: systemPrompt,
271
+ messages: [{ role: 'user', content: transcript }],
272
+ }),
273
+ })
274
+ const data = await res.json() as { content?: Array<{ text?: string }> }
275
+ const text = data.content?.[0]?.text ?? '{}'
276
+ const jsonMatch = text.match(/\{[\s\S]*\}/)
277
+ result = JSON.parse(jsonMatch?.[0] ?? '{}')
278
+ }
279
+ if (provider === 'gemini') {
280
+ const geminiModel = model || 'gemini-2.0-flash'
281
+
282
+ let geminiUrl: string
283
+ let geminiHeaders: Record<string, string> = { 'Content-Type': 'application/json' }
284
+
285
+ const geminiClientEmail = sMap.gemini_client_email
286
+ const geminiPrivateKey = sMap.gemini_private_key
287
+
288
+ if (geminiClientEmail && geminiPrivateKey) {
289
+ const { getGeminiAccessToken } = await import('../utils/gemini-auth.js')
290
+ const accessToken = await getGeminiAccessToken(geminiClientEmail, geminiPrivateKey)
291
+ geminiUrl = `https://generativelanguage.googleapis.com/v1beta/models/${geminiModel}:generateContent`
292
+ geminiHeaders['Authorization'] = `Bearer ${accessToken}`
293
+ } else {
294
+ geminiUrl = `https://generativelanguage.googleapis.com/v1beta/models/${geminiModel}:generateContent?key=${apiKey}`
295
+ }
296
+
297
+ const res = await fetch(geminiUrl, {
298
+ method: 'POST',
299
+ headers: geminiHeaders,
300
+ body: JSON.stringify({
301
+ contents: [{ parts: [{ text: `${systemPrompt}\n\n${transcript}` }] }],
302
+ generationConfig: { responseMimeType: 'application/json' },
303
+ }),
304
+ },
305
+ )
306
+ const data = await res.json() as { candidates?: Array<{ content?: { parts?: Array<{ text?: string }> } }> }
307
+ const geminiText = data.candidates?.[0]?.content?.parts?.[0]?.text ?? '{}'
308
+ const jsonMatch = geminiText.match(/\{[\s\S]*\}/)
309
+ result = JSON.parse(jsonMatch?.[0] ?? '{}')
310
+ }
311
+
312
+ // Update DB
313
+ await execute(
314
+ 'UPDATE meetings SET summary = ?, agenda = ?, decisions = ?, action_items = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
315
+ [result.summary ?? null, result.agenda ?? null, result.decisions ?? null, result.action_items ?? null, id],
316
+ )
317
+
318
+ return c.json({ ok: true, result })
319
+ } catch (e) {
320
+ return c.json({ error: `LLM call failed: ${String(e)}` }, 500)
321
+ }
322
+ })
323
+
324
+ export default app
@@ -0,0 +1,257 @@
1
+ import { Hono } from 'hono'
2
+ import type { AppEnv } from '../types.js'
3
+ import { query, execute } from '../db/adapter.js'
4
+ import { queryOrThrow, executeOrThrow } from '../utils/db.js'
5
+
6
+ const app = new Hono<AppEnv>()
7
+
8
+ // GET /all — global memo inbox
9
+ app.get('/all', async (c) => {
10
+ const user = c.req.query('user')
11
+ const status = c.req.query('status')
12
+ const keyword = c.req.query('keyword')
13
+ const author = c.req.query('author')
14
+ const dateFrom = c.req.query('dateFrom')
15
+ const dateTo = c.req.query('dateTo')
16
+ const limit = Number(c.req.query('limit') ?? '50')
17
+
18
+ const conditions: string[] = []
19
+ const params: (string | number)[] = []
20
+
21
+ if (user) {
22
+ conditions.push('assigned_to = ?')
23
+ params.push(user)
24
+ }
25
+ if (status) {
26
+ conditions.push('status = ?')
27
+ params.push(status)
28
+ }
29
+ if (keyword) {
30
+ conditions.push('(content LIKE ? OR title LIKE ? OR created_by LIKE ?)')
31
+ params.push(`%${keyword}%`, `%${keyword}%`, `%${keyword}%`)
32
+ }
33
+ if (author) {
34
+ conditions.push('created_by = ?')
35
+ params.push(author)
36
+ }
37
+ if (dateFrom) {
38
+ conditions.push('created_at >= ?')
39
+ params.push(dateFrom)
40
+ }
41
+ if (dateTo) {
42
+ conditions.push('created_at <= ?')
43
+ params.push(dateTo + 'T23:59:59')
44
+ }
45
+ const memoType = c.req.query('memo_type')
46
+ if (memoType) {
47
+ conditions.push('memo_type = ?')
48
+ params.push(memoType)
49
+ }
50
+
51
+ const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : ''
52
+ const offset = Number(c.req.query('offset') ?? '0')
53
+
54
+ // total count
55
+ const countParams = [...params]
56
+ const countResult = await query(
57
+ `SELECT COUNT(*) as total FROM memos_v2 ${where}`,
58
+ countParams,
59
+ )
60
+ const total = (countResult.rows?.[0] as { total: number })?.total || 0
61
+
62
+ params.push(limit, offset)
63
+ const { rows } = await queryOrThrow(
64
+ `SELECT * FROM memos_v2 ${where} ORDER BY created_at DESC LIMIT ? OFFSET ?`,
65
+ params,
66
+ )
67
+ return c.json({ memos: rows, total, limit, offset })
68
+ })
69
+
70
+ // GET /unread-count
71
+ app.get('/unread-count', async (c) => {
72
+ const user = c.req.query('user')
73
+ if (!user) return c.json({ error: 'user query param required' }, 400)
74
+
75
+ const { rows } = await queryOrThrow<{ count: number }>(
76
+ `SELECT COUNT(*) as count FROM memos_v2
77
+ WHERE assigned_to = ? AND status = 'open'
78
+ AND created_at > COALESCE(
79
+ (SELECT last_memo_seen FROM user_activity WHERE user_name = ?),
80
+ '2000-01-01'
81
+ )`,
82
+ [user, user],
83
+ )
84
+ return c.json({ count: rows[0]?.count ?? 0 })
85
+ })
86
+
87
+ // ── Replies (static routes BEFORE dynamic /:pageId) ──
88
+
89
+ // GET /replies
90
+ app.get('/replies', async (c) => {
91
+ const memoIds = c.req.query('memoIds')
92
+ if (!memoIds) return c.json({ error: 'memoIds query param required' }, 400)
93
+
94
+ const ids = memoIds.split(',').map(Number)
95
+ const placeholders = ids.map(() => '?').join(', ')
96
+ const { rows } = await queryOrThrow(
97
+ `SELECT * FROM memo_replies WHERE memo_id IN (${placeholders}) ORDER BY created_at ASC`,
98
+ ids,
99
+ )
100
+ return c.json({ replies: rows })
101
+ })
102
+
103
+ // POST /replies
104
+ app.post('/replies', async (c) => {
105
+ const body = await c.req.json<{
106
+ memoId: number; content: string; createdBy?: string; reviewType?: string
107
+ }>()
108
+ const createdBy = c.get('userName') || body.createdBy || 'unknown'
109
+ const reviewType = body.reviewType || 'comment'
110
+ const { rowsAffected } = await executeOrThrow(
111
+ 'INSERT INTO memo_replies (memo_id, content, created_by, review_type) VALUES (?, ?, ?, ?)',
112
+ [body.memoId, body.content, createdBy, reviewType],
113
+ )
114
+
115
+ // Activity log
116
+ const { logActivity } = await import('../utils/activity.js')
117
+ await logActivity(createdBy, 'memo_reply', 'memo', body.memoId, body.content?.slice(0, 50) || '')
118
+
119
+ // Notify all conversation participants (excluding self)
120
+ const [memoResult, repliesResult] = await Promise.all([
121
+ query('SELECT created_by, assigned_to FROM memos_v2 WHERE id = ?', [body.memoId]),
122
+ query('SELECT DISTINCT created_by FROM memo_replies WHERE memo_id = ?', [body.memoId]),
123
+ ])
124
+
125
+ const participants = new Set<string>()
126
+ if (memoResult.rows?.length) {
127
+ const memo = memoResult.rows[0] as { created_by: string; assigned_to: string | null }
128
+ participants.add(memo.created_by)
129
+ if (memo.assigned_to) {
130
+ for (const a of memo.assigned_to.split(',').map(s => s.trim()).filter(Boolean)) {
131
+ participants.add(a)
132
+ }
133
+ }
134
+ }
135
+ if (repliesResult.rows?.length) {
136
+ for (const r of repliesResult.rows as Array<{ created_by: string }>) {
137
+ participants.add(r.created_by)
138
+ }
139
+ }
140
+ // @mention parsing
141
+ const membersResult = await query<{ display_name: string }>('SELECT display_name FROM members WHERE is_active = 1')
142
+ const memberNames = (membersResult.rows ?? []).map(r => r.display_name)
143
+ for (const name of memberNames) {
144
+ if (body.content.includes(`@${name}`)) {
145
+ participants.add(name)
146
+ }
147
+ }
148
+
149
+ participants.delete(createdBy) // Exclude self
150
+
151
+ if (participants.size > 0) {
152
+ const { notifyByName } = await import('../utils/agent-notify.js')
153
+ const preview = body.content.length > 50 ? body.content.slice(0, 50) + '...' : body.content
154
+ for (const p of participants) {
155
+ await notifyByName(p, `Reply: ${createdBy}`, `Memo #${body.memoId} reply\n${preview}`)
156
+ }
157
+ }
158
+
159
+ return c.json({ ok: true }, 201)
160
+ })
161
+
162
+ // DELETE /replies/:id
163
+ app.delete('/replies/:id', async (c) => {
164
+ const id = Number(c.req.param('id'))
165
+ const userName = c.get('userName')
166
+ const { rowsAffected } = await executeOrThrow(
167
+ 'DELETE FROM memo_replies WHERE id = ? AND created_by = ?',
168
+ [id, userName],
169
+ )
170
+ return c.json({ ok: true })
171
+ })
172
+
173
+ // ── Memos (dynamic /:pageId must come after static routes) ──
174
+
175
+ // GET /by-id/:id — single memo lookup (for deep links)
176
+ app.get('/by-id/:id', async (c) => {
177
+ const id = Number(c.req.param('id'))
178
+ const { rows } = await queryOrThrow('SELECT * FROM memos_v2 WHERE id = ?', [id])
179
+ return c.json({ memos: rows })
180
+ })
181
+
182
+ app.get('/:pageId', async (c) => {
183
+ const pageId = c.req.param('pageId')
184
+ const { rows } = await queryOrThrow(
185
+ 'SELECT * FROM memos_v2 WHERE page_id = ? ORDER BY created_at DESC',
186
+ [pageId],
187
+ )
188
+ return c.json({ memos: rows })
189
+ })
190
+
191
+ // POST /
192
+ app.post('/', async (c) => {
193
+ const body = await c.req.json<{
194
+ pageId: string; content: string; memoType: string
195
+ createdBy?: string; assignedTo?: string
196
+ }>()
197
+ const createdBy = c.get('userName') || body.createdBy || 'unknown'
198
+ // [D-XX] tag parsing -> related_decisions
199
+ const decisionMatches = body.content.match(/\[D-\d+\]/g)
200
+ const relatedDecisions = decisionMatches ? JSON.stringify([...new Set(decisionMatches.map((m: string) => m.slice(1, -1)))]) : null
201
+ const title = (body as any).title ?? null
202
+ const supersedesId = (body as any).supersedesId ?? null
203
+ const reviewRequired = (body as any).reviewRequired ? 1 : 0
204
+
205
+ const { rowsAffected } = await executeOrThrow(
206
+ 'INSERT INTO memos_v2 (page_id, content, memo_type, created_by, assigned_to, related_decisions, title, supersedes_id, review_required) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
207
+ [body.pageId, body.content, body.memoType, createdBy, body.assignedTo ?? null, relatedDecisions, title, supersedesId, reviewRequired],
208
+ )
209
+
210
+ // Notify recipients via webhook
211
+ if (body.assignedTo) {
212
+ const { notifyRecipients } = await import('../utils/agent-notify.js')
213
+ const recipients = body.assignedTo.split(',').map((s: string) => s.trim()).filter(Boolean)
214
+ const preview = body.content.length > 50 ? body.content.slice(0, 50) + '...' : body.content
215
+ await notifyRecipients(recipients, `New memo: ${createdBy}`, `${preview}\n\n${body.memoType}`)
216
+ }
217
+
218
+ // Activity log
219
+ const { logActivity } = await import('../utils/activity.js')
220
+ await logActivity(createdBy, 'memo_created', 'memo', 'new', body.content?.slice(0, 50) || '')
221
+
222
+ return c.json({ ok: true }, 201)
223
+ })
224
+
225
+ // PATCH /:id/resolve
226
+ app.patch('/:id/resolve', async (c) => {
227
+ const id = Number(c.req.param('id'))
228
+ const userName = c.get('userName')
229
+ const { rowsAffected } = await executeOrThrow(
230
+ `UPDATE memos_v2 SET status = 'resolved', resolved_by = ?, resolved_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
231
+ [userName, id],
232
+ )
233
+ return c.json({ ok: true })
234
+ })
235
+
236
+ // PATCH /:id/reopen
237
+ app.patch('/:id/reopen', async (c) => {
238
+ const id = Number(c.req.param('id'))
239
+ const { rowsAffected } = await executeOrThrow(
240
+ `UPDATE memos_v2 SET status = 'open', resolved_by = NULL, resolved_at = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
241
+ [id],
242
+ )
243
+ return c.json({ ok: true })
244
+ })
245
+
246
+ // DELETE /:id
247
+ app.delete('/:id', async (c) => {
248
+ const id = Number(c.req.param('id'))
249
+ const userName = c.get('userName')
250
+ const { rowsAffected } = await executeOrThrow(
251
+ 'DELETE FROM memos_v2 WHERE id = ? AND created_by = ?',
252
+ [id, userName],
253
+ )
254
+ return c.json({ ok: true })
255
+ })
256
+
257
+ export default app