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.
- package/bin/cli.mjs +204 -2
- package/lib/doctor.mjs +38 -1
- package/lib/hydrate.mjs +15 -0
- package/lib/scaffold.mjs +5 -0
- package/lib/setup-wizard.mjs +35 -2
- package/package.json +1 -1
- package/scaffold/.context/project.yaml.example +19 -0
- package/scaffold/mcp-notification-server/package.json +18 -0
- package/scaffold/mcp-notification-server/src/index.ts +275 -0
- package/scaffold/mcp-notification-server/src/turso-client.ts +142 -0
- package/scaffold/mcp-notification-server/tsconfig.json +14 -0
- package/scaffold/mcp-pm/package.json +19 -0
- package/scaffold/mcp-pm/src/api-client.ts +69 -0
- package/scaffold/mcp-pm/src/index.ts +660 -0
- package/scaffold/mcp-pm/tsconfig.json +14 -0
- package/scaffold/pm-api/package.json +21 -0
- package/scaffold/pm-api/sql/001-memo-v2.sql +49 -0
- package/scaffold/pm-api/sql/002-notifications.sql +18 -0
- package/scaffold/pm-api/sql/003-content.sql +66 -0
- package/scaffold/pm-api/sql/004-agent-events.sql +21 -0
- package/scaffold/pm-api/sql/005-epic-sprint-decoupling.sql +6 -0
- package/scaffold/pm-api/sql/schema-core.sql +331 -0
- package/scaffold/pm-api/sql/schema-docs.sql +25 -0
- package/scaffold/pm-api/sql/schema-meetings.sql +17 -0
- package/scaffold/pm-api/sql/schema-rewards.sql +16 -0
- package/scaffold/pm-api/src/auth.ts +28 -0
- package/scaffold/pm-api/src/blockchain/adapter.ts +20 -0
- package/scaffold/pm-api/src/blockchain/tron.ts +62 -0
- package/scaffold/pm-api/src/db/adapter.ts +36 -0
- package/scaffold/pm-api/src/db/turso.ts +147 -0
- package/scaffold/pm-api/src/index.ts +114 -0
- package/scaffold/pm-api/src/mcp-tools/dashboard.ts +40 -0
- package/scaffold/pm-api/src/mcp-tools/epic.ts +67 -0
- package/scaffold/pm-api/src/mcp-tools/event.ts +89 -0
- package/scaffold/pm-api/src/mcp-tools/index.ts +11 -0
- package/scaffold/pm-api/src/mcp-tools/initiative.ts +51 -0
- package/scaffold/pm-api/src/mcp-tools/memo.ts +164 -0
- package/scaffold/pm-api/src/mcp-tools/notification.ts +37 -0
- package/scaffold/pm-api/src/mcp-tools/retro.ts +183 -0
- package/scaffold/pm-api/src/mcp-tools/sprint.ts +204 -0
- package/scaffold/pm-api/src/mcp-tools/standup.ts +136 -0
- package/scaffold/pm-api/src/mcp-tools/story.ts +230 -0
- package/scaffold/pm-api/src/mcp-tools/task.ts +187 -0
- package/scaffold/pm-api/src/mcp-tools/utils.ts +83 -0
- package/scaffold/pm-api/src/mcp.ts +871 -0
- package/scaffold/pm-api/src/nudge.ts +283 -0
- package/scaffold/pm-api/src/routes/auth.ts +32 -0
- package/scaffold/pm-api/src/routes/v2-activity.ts +27 -0
- package/scaffold/pm-api/src/routes/v2-admin.ts +165 -0
- package/scaffold/pm-api/src/routes/v2-dashboard.ts +189 -0
- package/scaffold/pm-api/src/routes/v2-docs.ts +34 -0
- package/scaffold/pm-api/src/routes/v2-initiatives.ts +118 -0
- package/scaffold/pm-api/src/routes/v2-kickoff.ts +265 -0
- package/scaffold/pm-api/src/routes/v2-meetings.ts +324 -0
- package/scaffold/pm-api/src/routes/v2-memos.ts +257 -0
- package/scaffold/pm-api/src/routes/v2-nav.ts +260 -0
- package/scaffold/pm-api/src/routes/v2-notifications.ts +79 -0
- package/scaffold/pm-api/src/routes/v2-page-content.ts +35 -0
- package/scaffold/pm-api/src/routes/v2-pm.ts +380 -0
- package/scaffold/pm-api/src/routes/v2-policy.ts +58 -0
- package/scaffold/pm-api/src/routes/v2-retro.ts +221 -0
- package/scaffold/pm-api/src/routes/v2-rewards.ts +132 -0
- package/scaffold/pm-api/src/routes/v2-scenarios.ts +48 -0
- package/scaffold/pm-api/src/routes/v2-search.ts +32 -0
- package/scaffold/pm-api/src/routes/v2-standup.ts +127 -0
- package/scaffold/pm-api/src/routes/v2-user.ts +38 -0
- package/scaffold/pm-api/src/types.ts +11 -0
- package/scaffold/pm-api/src/utils/activity.ts +22 -0
- package/scaffold/pm-api/src/utils/admin.ts +9 -0
- package/scaffold/pm-api/src/utils/agent-notify.ts +62 -0
- package/scaffold/pm-api/src/utils/assignee.ts +69 -0
- package/scaffold/pm-api/src/utils/db.ts +45 -0
- package/scaffold/pm-api/src/utils/initiative.ts +23 -0
- package/scaffold/pm-api/src/utils/retro-link.ts +32 -0
- package/scaffold/pm-api/src/utils/sprint-lifecycle.ts +96 -0
- package/scaffold/pm-api/tsconfig.json +15 -0
- package/scaffold/pm-api/wrangler.toml.hbs +11 -0
- package/scaffold/spec-site/package-lock.json +892 -0
- package/scaffold/spec-site/package.json +15 -1
- package/scaffold/spec-site/src/api/types.ts +6 -0
- package/scaffold/spec-site/src/components/AppHeader.vue +429 -55
- package/scaffold/spec-site/src/components/AuthGate.vue +117 -0
- package/scaffold/spec-site/src/components/BurndownChart.vue +78 -0
- package/scaffold/spec-site/src/components/DocComments.vue +137 -0
- package/scaffold/spec-site/src/components/DocEditor.vue +118 -0
- package/scaffold/spec-site/src/components/DocExportBar.vue +110 -0
- package/scaffold/spec-site/src/components/DocsSidebar.vue +309 -0
- package/scaffold/spec-site/src/components/EmptyState.vue +30 -0
- package/scaffold/spec-site/src/components/ErrorBanner.vue +38 -0
- package/scaffold/spec-site/src/components/Icon.vue +58 -0
- package/scaffold/spec-site/src/components/MemberSelect.vue +48 -0
- package/scaffold/spec-site/src/components/MemoChecklist.vue +88 -0
- package/scaffold/spec-site/src/components/MemoGraph.vue +75 -0
- package/scaffold/spec-site/src/components/MemoItem.vue +353 -0
- package/scaffold/spec-site/src/components/MemoRelations.vue +101 -0
- package/scaffold/spec-site/src/components/MemoTimeline.vue +53 -0
- package/scaffold/spec-site/src/components/MentionInput.vue +174 -0
- package/scaffold/spec-site/src/components/NotificationDropdown.vue +116 -0
- package/scaffold/spec-site/src/components/PriorityBadge.vue +23 -0
- package/scaffold/spec-site/src/components/SearchModal.vue +102 -0
- package/scaffold/spec-site/src/components/SlashCommand.ts +123 -0
- package/scaffold/spec-site/src/components/StateDisplay.vue +54 -0
- package/scaffold/spec-site/src/components/TreeNode.vue +82 -0
- package/scaffold/spec-site/src/components/UserAvatar.vue +24 -0
- package/scaffold/spec-site/src/components/VelocityChart.vue +77 -0
- package/scaffold/spec-site/src/composables/navTypes.ts +3 -0
- package/scaffold/spec-site/src/composables/pmTypes.ts +15 -2
- package/scaffold/spec-site/src/composables/useBottomSheet.ts +103 -0
- package/scaffold/spec-site/src/composables/useDashboard.ts +221 -0
- package/scaffold/spec-site/src/composables/useMediaQuery.ts +28 -0
- package/scaffold/spec-site/src/composables/useMemo.ts +39 -0
- package/scaffold/spec-site/src/composables/useNotification.ts +200 -0
- package/scaffold/spec-site/src/composables/usePmStore.ts +48 -1
- package/scaffold/spec-site/src/composables/useRetro.ts +6 -0
- package/scaffold/spec-site/src/composables/useStandup.ts +201 -0
- package/scaffold/spec-site/src/composables/useTheme.ts +37 -0
- package/scaffold/spec-site/src/composables/useTurso.ts +17 -0
- package/scaffold/spec-site/src/composables/useUser.ts +19 -1
- package/scaffold/spec-site/src/composables/useViewport.ts +26 -0
- package/scaffold/spec-site/src/features.ts +108 -0
- package/scaffold/spec-site/src/mockup/ComponentPalette.vue +61 -0
- package/scaffold/spec-site/src/mockup/MockupCanvas.vue +459 -0
- package/scaffold/spec-site/src/mockup/PropertyPanel.vue +217 -0
- package/scaffold/spec-site/src/mockup/componentCatalog.ts +68 -0
- package/scaffold/spec-site/src/mockup/useScenarios.ts +67 -0
- package/scaffold/spec-site/src/pages/AdminPage.vue +299 -0
- package/scaffold/spec-site/src/pages/DashboardPage.vue +650 -0
- package/scaffold/spec-site/src/pages/DocsEditor.vue +119 -0
- package/scaffold/spec-site/src/pages/DocsHub.vue +157 -0
- package/scaffold/spec-site/src/pages/DocsPage.vue +444 -0
- package/scaffold/spec-site/src/pages/InboxPage.vue +156 -0
- package/scaffold/spec-site/src/pages/MeetingsPage.vue +294 -0
- package/scaffold/spec-site/src/pages/MemosPage.vue +857 -0
- package/scaffold/spec-site/src/pages/MockupEditorPage.vue +611 -0
- package/scaffold/spec-site/src/pages/MockupListPage.vue +121 -0
- package/scaffold/spec-site/src/pages/MockupViewerPage.vue +199 -0
- package/scaffold/spec-site/src/pages/MyPage.vue +343 -0
- package/scaffold/spec-site/src/pages/NotificationSettingsPage.vue +59 -0
- package/scaffold/spec-site/src/pages/RewardsPage.vue +266 -0
- package/scaffold/spec-site/src/pages/SprintAdmin.vue +521 -0
- package/scaffold/spec-site/src/pages/SprintTimeline.vue +159 -0
- package/scaffold/spec-site/src/pages/board/BoardAdmin.vue +422 -0
- package/scaffold/spec-site/src/pages/board/BoardEpicSection.vue +54 -0
- package/scaffold/spec-site/src/pages/board/BoardPage.vue +884 -0
- package/scaffold/spec-site/src/pages/board/BoardStoryCard.vue +67 -0
- package/scaffold/spec-site/src/pages/board/BoardTaskItem.vue +52 -0
- package/scaffold/spec-site/src/pages/board/KanbanBoard.vue +93 -0
- package/scaffold/spec-site/src/pages/board/MyTasksPage.vue +202 -0
- package/scaffold/spec-site/src/pages/board/SprintClose.vue +167 -0
- package/scaffold/spec-site/src/pages/board/SprintColumn.vue +49 -0
- package/scaffold/spec-site/src/pages/board/SprintKickoff.vue +389 -0
- package/scaffold/spec-site/src/pages/board/StatusBadge.vue +52 -0
- package/scaffold/spec-site/src/pages/board/StoryDetailPanel.vue +495 -0
- package/scaffold/spec-site/src/pages/board/TaskCard.vue +42 -0
- package/scaffold/spec-site/src/pages/retro/RetroCard.vue +36 -2
- package/scaffold/spec-site/src/pages/retro/RetroHeader.vue +82 -66
- package/scaffold/spec-site/src/pages/retro/RetroPage.vue +47 -18
- package/scaffold/spec-site/src/pages/standup/StandupEntryCard.vue +551 -0
- package/scaffold/spec-site/src/pages/standup/StandupForm.vue +68 -0
- package/scaffold/spec-site/src/pages/standup/StandupList.vue +71 -0
- package/scaffold/spec-site/src/pages/standup/StandupPage.vue +225 -0
- package/scaffold/spec-site/src/router.ts +141 -0
- package/scaffold/spec-site/src/styles/buttons.css +124 -0
- package/scaffold/spec-site/src/utils/parseMentions.ts +56 -0
- 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
|