@forloop-cc/forloop-opencode-plugin-planner 2.2.1
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/CHANGELOG.md +225 -0
- package/CODE_OF_CONDUCT.md +77 -0
- package/CONTRIBUTING.md +176 -0
- package/LICENSE +21 -0
- package/README.md +211 -0
- package/SECURITY.md +69 -0
- package/docs/CONFIGURATION.md +283 -0
- package/docs/DEVELOPER-VS-USER.md +129 -0
- package/docs/IMPROVED-AUTHENTICATION.md +317 -0
- package/docs/INSTALLATION-SCRIPTS.md +304 -0
- package/docs/PLANNER-AGENT-IMPROVEMENTS.md +88 -0
- package/docs/PLUGIN-ARCHITECTURE.md +308 -0
- package/docs/QUICKSTART-NON-DEV.md +94 -0
- package/docs/README-FOR-HUMANS-AND-AI.md +109 -0
- package/docs/README.md +70 -0
- package/docs/SKILLS-GAP-ANALYSIS.md +157 -0
- package/docs/USER-INTERACTION-MODEL.md +106 -0
- package/docs/file-management/2026-04-12-forloop-aivy-documents-sync.md +395 -0
- package/docs/file-management/FILE_UPLOAD_ENHANCEMENT.md +431 -0
- package/docs/file-management/FILE_UPLOAD_FLOW.md +300 -0
- package/docs/images/forloop-opencode-flow.png +0 -0
- package/docs/images/forloop-opencode-header.png +0 -0
- package/docs/superpowers/plans/2026-05-03-sprint-isolated-directory-structure.md +1450 -0
- package/docs/superpowers/plans/2026-05-29-install-system.md +850 -0
- package/docs/superpowers/specs/2026-05-03-sprint-isolated-directory-structure-design.md +149 -0
- package/docs/superpowers/specs/2026-05-19-opencode-message-recording-design.md +468 -0
- package/docs/superpowers/specs/2026-05-29-install-system-design.md +135 -0
- package/install.sh +317 -0
- package/package.json +87 -0
- package/plugins/capabilities/api-client.ts +556 -0
- package/plugins/capabilities/auth.ts +120 -0
- package/plugins/capabilities/config.ts +195 -0
- package/plugins/capabilities/context-resolver.ts +106 -0
- package/plugins/capabilities/token-storage.ts +98 -0
- package/plugins/forloop-plugin.ts +186 -0
- package/plugins/hooks/message-recorder.ts +258 -0
- package/plugins/hooks/shell-env.ts +62 -0
- package/plugins/tools/agent-sprint-tools.ts +100 -0
- package/plugins/tools/agent-suggestion-tools.ts +275 -0
- package/plugins/tools/agent-tools.ts +114 -0
- package/plugins/tools/ai-trigger-tools.ts +79 -0
- package/plugins/tools/developer-status-tool.ts +97 -0
- package/plugins/tools/file-tools.ts +240 -0
- package/plugins/tools/folder-tools.ts +75 -0
- package/plugins/tools/organization-tools.ts +213 -0
- package/plugins/tools/schedule-tools.ts +173 -0
- package/plugins/tools/sprint-tools.ts +287 -0
- package/plugins/tools/story-tools.ts +320 -0
- package/plugins/tools/sync-tools.ts +509 -0
- package/plugins/tools/template-tools.ts +170 -0
- package/plugins/tools/user-tools.ts +146 -0
- package/scripts/migrate-tokens.js +90 -0
- package/scripts/use-prod.sh +11 -0
- package/tsconfig.json +17 -0
- package/update.sh +172 -0
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# Sprint-Isolated Directory Structure Design
|
|
2
|
+
|
|
3
|
+
## Problem
|
|
4
|
+
|
|
5
|
+
The ForLoop planner agent runs on both local machines and AWS Lambda. Currently, all sprint files (knowledge, plans, tasks) are stored in a flat `~/.forloop/` directory without sprint isolation. This causes file collisions when switching sprints and makes it impossible to have clean per-sprint working directories.
|
|
6
|
+
|
|
7
|
+
## Requirement
|
|
8
|
+
|
|
9
|
+
All sprint-specific files must be stored under `~/.forloop/sprint-{sprint_id}/`. The manifest stays at `~/.forloop/manifest.json` (shared across sprints) to track which sprint is currently active.
|
|
10
|
+
|
|
11
|
+
## Target Directory Structure
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
/tmp/home/.forloop/
|
|
15
|
+
├── manifest.json # Shared, tracks activeSprintId
|
|
16
|
+
├── sync/
|
|
17
|
+
│ └── manifest.json # Sync tracking metadata
|
|
18
|
+
└── sprint-14/ # Sprint-specific root
|
|
19
|
+
├── knowledge/
|
|
20
|
+
│ └── knowledge-auth-jwt-20260410-093015.md
|
|
21
|
+
├── plan/
|
|
22
|
+
│ └── plan-14-20260410-093015.md
|
|
23
|
+
└── task/
|
|
24
|
+
└── task-14-20260410-100000.md
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Manifest Format (Version 2)
|
|
28
|
+
|
|
29
|
+
```json
|
|
30
|
+
{
|
|
31
|
+
"version": 2,
|
|
32
|
+
"activeSprintId": 14,
|
|
33
|
+
"sprints": {
|
|
34
|
+
"14": {
|
|
35
|
+
"sprintDir": "sprint-14",
|
|
36
|
+
"plan": {
|
|
37
|
+
"file": "sprint-14/plan/plan-14-20260410-093015.md",
|
|
38
|
+
"createdAt": "2026-04-10T09:30:15Z"
|
|
39
|
+
},
|
|
40
|
+
"tasks": {
|
|
41
|
+
"file": "sprint-14/task/task-14-20260410-100000.md",
|
|
42
|
+
"storyIds": ["123", "124", "125"]
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Path Resolution Logic
|
|
50
|
+
|
|
51
|
+
### Core Function: `resolveForloopRoot(contextDir, sprintId?)`
|
|
52
|
+
|
|
53
|
+
```
|
|
54
|
+
1. If sprintId provided or resolvable (env/branch/manifest):
|
|
55
|
+
→ $HOME/.forloop/sprint-{sprintId}/
|
|
56
|
+
|
|
57
|
+
2. If no sprintId available:
|
|
58
|
+
→ $HOME/.forloop/ (root only, for manifest access)
|
|
59
|
+
|
|
60
|
+
3. Manifest always at:
|
|
61
|
+
→ $HOME/.forloop/manifest.json
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Files to Update
|
|
65
|
+
|
|
66
|
+
### 1. `plugins/capabilities/context-resolver.ts`
|
|
67
|
+
- Add `getSprintDir(sprintId)` → returns `"sprint-{id}"`
|
|
68
|
+
- Add `resolveSprintRoot(sprintId?)` → returns `$HOME/.forloop/sprint-{id}/` or `$HOME/.forloop/`
|
|
69
|
+
- `getActiveSprintIdFromManifest()` stays unchanged (reads root manifest)
|
|
70
|
+
- Export new functions for use by sync-tools and skills
|
|
71
|
+
|
|
72
|
+
### 2. `plugins/tools/sync-tools.ts`
|
|
73
|
+
- `resolveForloopRoot()`: Use sprint-aware resolution. Falls back to `$HOME/.forloop/` if no sprint
|
|
74
|
+
- `ensureForloopStructure()`: Create `sprint-{id}/knowledge/plan/task/` instead of flat dirs
|
|
75
|
+
- `getSyncManifestPath()`: Move to `$HOME/.forloop/sync/manifest.json` (shared, not per-sprint)
|
|
76
|
+
- File mapping: `knowledge/X.md` → `sprint-{id}/knowledge/X.md`
|
|
77
|
+
|
|
78
|
+
### 3. `plugins/hooks/shell-env.ts`
|
|
79
|
+
- No changes needed (doesn't reference .forloop paths directly)
|
|
80
|
+
|
|
81
|
+
### 4. `agent/forLoopPlanner.md`
|
|
82
|
+
- Update all path references:
|
|
83
|
+
- `~/.forloop/plan/` → `~/.forloop/sprint-{id}/plan/`
|
|
84
|
+
- `~/.forloop/knowledge/` → `~/.forloop/sprint-{id}/knowledge/`
|
|
85
|
+
- `~/.forloop/task/` → `~/.forloop/sprint-{id}/task/`
|
|
86
|
+
- `~/.forloop/manifest.json` → stays (shared)
|
|
87
|
+
- Update permission block: `/tmp/home/.forloop/sprint-*/**`
|
|
88
|
+
- Update examples and workflows to use sprint subdir
|
|
89
|
+
|
|
90
|
+
### 5. `agent/forLoopStoryEvaluator.md`
|
|
91
|
+
- Update permission block to include sprint subdirs
|
|
92
|
+
- No functional changes (evaluator doesn't write files)
|
|
93
|
+
|
|
94
|
+
### 6. `skills/forloop-context/SKILL.md`
|
|
95
|
+
- Step 1: Resolve to `~/.forloop/sprint-{id}/` after sprint is known
|
|
96
|
+
- Step 2: Create `sprint-{id}/` structure instead of flat `.forloop/knowledge/plan/task/`
|
|
97
|
+
- Update all example paths and commands
|
|
98
|
+
- Update manifest examples to v2 format
|
|
99
|
+
|
|
100
|
+
### 7. `skills/knowledge-management/SKILL.md`
|
|
101
|
+
- `.forloop/knowledge/` → `.forloop/sprint-{id}/knowledge/`
|
|
102
|
+
- Update file creation paths and S3 upload examples
|
|
103
|
+
- Update quality gates and anti-patterns
|
|
104
|
+
|
|
105
|
+
### 8. `skills/plan-documentation/SKILL.md`
|
|
106
|
+
- `.forloop/plan/` → `.forloop/sprint-{id}/plan/`
|
|
107
|
+
- Update mkdir, file write, manifest examples
|
|
108
|
+
- Update all workflow examples
|
|
109
|
+
|
|
110
|
+
### 9. `skills/task-tracking/SKILL.md`
|
|
111
|
+
- `.forloop/task/` → `.forloop/sprint-{id}/task/`
|
|
112
|
+
- Update file write, manifest examples
|
|
113
|
+
- Update S3 folder mapping section (local paths change, S3 paths stay the same)
|
|
114
|
+
|
|
115
|
+
### 10. `skills/aivy-documents-sync/SKILL.md`
|
|
116
|
+
- Update all path references to sprint subdirs
|
|
117
|
+
- Update sync examples
|
|
118
|
+
|
|
119
|
+
### 11. `skills/sprint-planning/SKILL.md`
|
|
120
|
+
- Update context loading paths
|
|
121
|
+
- Update manifest and file path references
|
|
122
|
+
|
|
123
|
+
### 12. Tests
|
|
124
|
+
- All unit tests with path assertions need updating
|
|
125
|
+
- Mock file paths should use sprint subdir format
|
|
126
|
+
- Integration tests verify correct directory creation
|
|
127
|
+
|
|
128
|
+
## Backward Compatibility
|
|
129
|
+
|
|
130
|
+
- On first access, check if old flat structure exists (`~/.forloop/knowledge/`, `~/.forloop/plan/`, etc.)
|
|
131
|
+
- If old structure exists:
|
|
132
|
+
- If manifest has an active sprint, create `sprint-{id}/` and move old files into it
|
|
133
|
+
- If no active sprint, leave old files in place; they will be orphaned when new sprint is selected
|
|
134
|
+
- Manifest version bump from 1 → 2 signals new structure
|
|
135
|
+
- The `sprintDir` field is added to manifest sprint entries; missing `sprintDir` implies v1 (flat structure) and triggers migration
|
|
136
|
+
|
|
137
|
+
## Error Handling
|
|
138
|
+
|
|
139
|
+
- No sprint ID available: agent works at `~/.forloop/` root only (manifest access)
|
|
140
|
+
- Sprint directory missing: auto-create on first file operation
|
|
141
|
+
- Old v1 manifest: upgrade to v2 by adding `sprintDir` field, preserve existing data, migrate files if present
|
|
142
|
+
- Corrupted sprint dir name in manifest: fall back to `sprint-{activeSprintId}` convention
|
|
143
|
+
|
|
144
|
+
## Testing Strategy
|
|
145
|
+
|
|
146
|
+
1. Unit tests: Verify `resolveSprintRoot()` with various inputs
|
|
147
|
+
2. Unit tests: Verify `ensureForloopStructure()` creates correct dirs
|
|
148
|
+
3. Integration: Create sprint, verify directory structure
|
|
149
|
+
4. Integration: Switch sprints, verify isolation
|
|
@@ -0,0 +1,468 @@
|
|
|
1
|
+
# OpenCode Message Recording Feature
|
|
2
|
+
|
|
3
|
+
**Date:** 2026-05-19
|
|
4
|
+
**Status:** Design
|
|
5
|
+
**Version:** 1.0
|
|
6
|
+
|
|
7
|
+
## Overview
|
|
8
|
+
|
|
9
|
+
Record the full opencode conversation (user messages + AI responses) to the ForLoop database. User messages are captured via the `chat.message` hook, assistant responses are assembled from streaming `message.part.updated` events, and deletions are handled via `message.removed`. Recording is active only when a working sprint is confirmed via `~/.forloop/manifest.json`.
|
|
10
|
+
|
|
11
|
+
## Motivation
|
|
12
|
+
|
|
13
|
+
The ForLoop web client already records messages from the `planner.chat` workflow. However, conversations happening inside the opencode IDE plugin are not captured. This feature bridges that gap, ensuring all sprint-related conversations — whether from the web client or the opencode IDE — are stored in one place for future features like conversation summarization.
|
|
14
|
+
|
|
15
|
+
## Architecture
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
opencode runtime
|
|
19
|
+
│
|
|
20
|
+
├── "chat.message" hook ──────────┐ (user messages with full text via parts)
|
|
21
|
+
│ │
|
|
22
|
+
├── event: "message.part.updated" ─┤ (assistant text parts stream)
|
|
23
|
+
├── event: "message.updated" ──────┤ (signal: assistant message complete)
|
|
24
|
+
├── event: "message.removed" ──────┤ (message deletion)
|
|
25
|
+
│ ▼
|
|
26
|
+
│ plugins/hooks/message-recorder.ts
|
|
27
|
+
│ │
|
|
28
|
+
│ reads ~/.forloop/manifest.json
|
|
29
|
+
│ │
|
|
30
|
+
│ if activeSprintId exists:
|
|
31
|
+
│ │
|
|
32
|
+
│ ForLoopAPIClient.recordMessage()
|
|
33
|
+
│ ForLoopAPIClient.removeMessage()
|
|
34
|
+
│ │
|
|
35
|
+
│ ▼
|
|
36
|
+
│ POST /api/opencode/messages
|
|
37
|
+
│ DELETE /api/opencode/messages/:messageId
|
|
38
|
+
│ │
|
|
39
|
+
└──────────────────────────────┘
|
|
40
|
+
▼
|
|
41
|
+
server_lambda (ForLoop API)
|
|
42
|
+
│
|
|
43
|
+
Conversation table (Prisma)
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### Hook Responsibilities
|
|
47
|
+
|
|
48
|
+
| Hook | Event | Action |
|
|
49
|
+
|------|-------|--------|
|
|
50
|
+
| `chat.message` | User sends a message | Extract text from `output.parts`, POST user message row |
|
|
51
|
+
| `event` | `message.part.updated` (type="text") | Buffer text by messageID into `textBuffer` map |
|
|
52
|
+
| `event` | `message.updated` (role="assistant") | Flush buffered text, POST assistant response update |
|
|
53
|
+
| `event` | `message.removed` | DELETE row from Conversation table |
|
|
54
|
+
|
|
55
|
+
### Why Not `message.updated` Alone for Text?
|
|
56
|
+
|
|
57
|
+
The `EventMessageUpdated` event provides a `Message` info object (UserMessage | AssistantMessage), which contains metadata (id, role, sessionID, agent, model, time) but does **not** include the message text body. Text content flows through `TextPart` objects delivered via `message.part.updated` events. For user messages, the `chat.message` hook provides parts directly. For assistant responses, we buffer streaming text parts until the message is complete.
|
|
58
|
+
|
|
59
|
+
## Data Model
|
|
60
|
+
|
|
61
|
+
### Reusing the Conversation Table
|
|
62
|
+
|
|
63
|
+
The existing `Conversation` table stores paired user message + agent response:
|
|
64
|
+
|
|
65
|
+
| Column | Type | Description |
|
|
66
|
+
|--------|------|-------------|
|
|
67
|
+
| `id` | Int (PK, auto) | Primary key |
|
|
68
|
+
| `sprintId` | Int | Sprint context |
|
|
69
|
+
| `conversationId` | String | Composite ID for grouping |
|
|
70
|
+
| `timestamp` | DateTime | When recorded |
|
|
71
|
+
| `userMessage` | String | User's message |
|
|
72
|
+
| `agentResponse` | String | Agent's response |
|
|
73
|
+
| `metadata` | String? | JSON blob |
|
|
74
|
+
|
|
75
|
+
Message flow mapped to the paired model:
|
|
76
|
+
|
|
77
|
+
1. **User message** (`chat.message` hook) → INSERT new row with `userMessage` filled, `agentResponse` empty
|
|
78
|
+
2. **Assistant parts** (`message.part.updated` events) → buffered in-memory by messageID
|
|
79
|
+
3. **Assistant complete** (`message.updated` with role "assistant") → UPDATE last row for `conversationId`, setting `agentResponse` from buffer
|
|
80
|
+
4. **Message removed** (`message.removed`) → DELETE the row for that messageID
|
|
81
|
+
|
|
82
|
+
### conversationId Format
|
|
83
|
+
|
|
84
|
+
Consistent `sprint:{id}:agent:{agent}:` prefix with source-identifying suffix:
|
|
85
|
+
|
|
86
|
+
| Source | Format | Example |
|
|
87
|
+
|--------|--------|---------|
|
|
88
|
+
| Web client (existing) | `sprint:{id}:agent:{agent}:web:user:{userId}` | `sprint:14:agent:forLoopPlanner:web:user:3` |
|
|
89
|
+
| opencode plugin (new) | `sprint:{id}:agent:{agent}:opencode:{sessionId}` | `sprint:14:agent:forLoopPlanner:opencode:ses_abc123` |
|
|
90
|
+
|
|
91
|
+
Agent keys are normalized to lowercase-kebab casing (e.g., `"forLoopPlanner"` → `"forLoopPlanner"`) to match the web client convention and enable cross-source queries.
|
|
92
|
+
|
|
93
|
+
### metadata Structure
|
|
94
|
+
|
|
95
|
+
```json
|
|
96
|
+
{
|
|
97
|
+
"source": "opencode",
|
|
98
|
+
"model": { "providerID": "anthropic", "modelID": "claude-sonnet-4" },
|
|
99
|
+
"messageId": "msg_xyz",
|
|
100
|
+
"sessionId": "ses_abc123",
|
|
101
|
+
"recordedAt": 1712345678000
|
|
102
|
+
}
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## API Contract
|
|
106
|
+
|
|
107
|
+
### POST `/api/opencode/messages`
|
|
108
|
+
|
|
109
|
+
Auth: `x-api-token` header with `agent:query` scope.
|
|
110
|
+
|
|
111
|
+
**Request:**
|
|
112
|
+
```json
|
|
113
|
+
{
|
|
114
|
+
"sprintId": 14,
|
|
115
|
+
"conversationId": "sprint:14:agent:forLoopPlanner:opencode:ses_abc123",
|
|
116
|
+
"messageId": "msg_xyz",
|
|
117
|
+
"role": "user",
|
|
118
|
+
"content": "Help me plan sprint 14",
|
|
119
|
+
"agent": "forLoopPlanner",
|
|
120
|
+
"model": { "providerID": "anthropic", "modelID": "claude-sonnet-4" },
|
|
121
|
+
"timestamp": 1712345678000
|
|
122
|
+
}
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
**Fields:**
|
|
126
|
+
|
|
127
|
+
| Field | Type | Required | Description |
|
|
128
|
+
|-------|------|----------|-------------|
|
|
129
|
+
| `sprintId` | number | Yes | Sprint context |
|
|
130
|
+
| `conversationId` | string | Yes | Composite conversation identifier |
|
|
131
|
+
| `messageId` | string | Yes | opencode message ID |
|
|
132
|
+
| `role` | `"user"` \| `"assistant"` | Yes | Message role |
|
|
133
|
+
| `content` | string | Yes | Full message text |
|
|
134
|
+
| `agent` | string | No | opencode agent name (lowercase-kebab) |
|
|
135
|
+
| `model` | object | No | `{ providerID, modelID }` |
|
|
136
|
+
| `timestamp` | number | No | Unix ms timestamp |
|
|
137
|
+
|
|
138
|
+
**Response (200):**
|
|
139
|
+
```json
|
|
140
|
+
{ "status": "ok", "id": 42 }
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
**Behavior by role:**
|
|
144
|
+
|
|
145
|
+
| Role | Action |
|
|
146
|
+
|------|--------|
|
|
147
|
+
| `"user"` | INSERT new row: `conversationId`, `sprintId`, `userMessage`, `metadata` |
|
|
148
|
+
| `"assistant"` | UPDATE last row for `conversationId`: set `agentResponse`, update `metadata` |
|
|
149
|
+
|
|
150
|
+
### DELETE `/api/opencode/messages/:messageId`
|
|
151
|
+
|
|
152
|
+
Auth: `x-api-token` header with `agent:query` scope.
|
|
153
|
+
|
|
154
|
+
**Request body:**
|
|
155
|
+
```json
|
|
156
|
+
{
|
|
157
|
+
"sprintId": 14,
|
|
158
|
+
"sessionId": "ses_abc123"
|
|
159
|
+
}
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
**Response (200):**
|
|
163
|
+
```json
|
|
164
|
+
{ "status": "ok" }
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
## Plugin Implementation
|
|
168
|
+
|
|
169
|
+
### New File: `plugins/hooks/message-recorder.ts`
|
|
170
|
+
|
|
171
|
+
Two exports:
|
|
172
|
+
|
|
173
|
+
1. `createChatMessageHook(client)` — the `chat.message` hook for user messages
|
|
174
|
+
2. `createEventHook(client)` — the `event` hook for parts/removals
|
|
175
|
+
|
|
176
|
+
```typescript
|
|
177
|
+
import { ForLoopAPIClient } from '../capabilities/api-client';
|
|
178
|
+
import fs from 'fs';
|
|
179
|
+
import path from 'path';
|
|
180
|
+
|
|
181
|
+
// Normalize agent key: "forLoopPlanner" -> "forLoopPlanner"
|
|
182
|
+
function normalizeAgentKey(agent: string): string {
|
|
183
|
+
return agent
|
|
184
|
+
.replace(/([a-z])([A-Z])/g, '$1-$2')
|
|
185
|
+
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1-$2')
|
|
186
|
+
.toLowerCase();
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function buildConversationId(sprintId: number, agent: string, sessionId: string): string {
|
|
190
|
+
return `sprint:${sprintId}:agent:${normalizeAgentKey(agent)}:opencode:${sessionId}`;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function readActiveSprintId(): number | null {
|
|
194
|
+
try {
|
|
195
|
+
const home = process.env.HOME || '/tmp/home';
|
|
196
|
+
const manifestPath = path.join(home, '.forloop', 'manifest.json');
|
|
197
|
+
if (!fs.existsSync(manifestPath)) return null;
|
|
198
|
+
const raw = fs.readFileSync(manifestPath, 'utf8');
|
|
199
|
+
const parsed = JSON.parse(raw);
|
|
200
|
+
const id = parsed?.activeSprintId;
|
|
201
|
+
return Number.isFinite(id) ? Number(id) : null;
|
|
202
|
+
} catch {
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Extract text from parts array (from chat.message hook)
|
|
208
|
+
function extractTextFromParts(parts: any[]): string {
|
|
209
|
+
return parts
|
|
210
|
+
.filter((p: any) => p.type === 'text')
|
|
211
|
+
.map((p: any) => p.text || '')
|
|
212
|
+
.join('');
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ─── chat.message hook: captures user messages ───
|
|
216
|
+
|
|
217
|
+
export function createChatMessageHook(client: ForLoopAPIClient) {
|
|
218
|
+
return async (input: any, output: any) => {
|
|
219
|
+
const sprintId = readActiveSprintId();
|
|
220
|
+
if (!sprintId) return;
|
|
221
|
+
|
|
222
|
+
const { sessionID, agent, model, messageID } = input;
|
|
223
|
+
const { message, parts } = output;
|
|
224
|
+
const content = extractTextFromParts(parts);
|
|
225
|
+
if (!content) return;
|
|
226
|
+
|
|
227
|
+
const conversationId = buildConversationId(sprintId, agent || 'unknown', sessionID);
|
|
228
|
+
|
|
229
|
+
try {
|
|
230
|
+
await client.recordMessage({
|
|
231
|
+
sprintId,
|
|
232
|
+
sessionId: sessionID,
|
|
233
|
+
messageId: messageID || message.id,
|
|
234
|
+
conversationId,
|
|
235
|
+
role: 'user',
|
|
236
|
+
content,
|
|
237
|
+
agent: agent ? normalizeAgentKey(agent) : undefined,
|
|
238
|
+
model,
|
|
239
|
+
timestamp: message.time?.created ?? Date.now(),
|
|
240
|
+
});
|
|
241
|
+
} catch (err) {
|
|
242
|
+
console.warn('[ForLoop] Failed to record user message:', (err as Error).message);
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ─── event hook: captures assistant text + removals ───
|
|
248
|
+
|
|
249
|
+
export function createEventHook(client: ForLoopAPIClient) {
|
|
250
|
+
const textBuffer = new Map<string, string>(); // messageID -> accumulated text
|
|
251
|
+
|
|
252
|
+
return async ({ event }: { event: { type: string; properties: any } }) => {
|
|
253
|
+
const sprintId = readActiveSprintId();
|
|
254
|
+
if (!sprintId) return;
|
|
255
|
+
|
|
256
|
+
switch (event.type) {
|
|
257
|
+
|
|
258
|
+
case 'message.part.updated': {
|
|
259
|
+
const part = event.properties?.part;
|
|
260
|
+
if (!part || part.type !== 'text') return;
|
|
261
|
+
|
|
262
|
+
const current = textBuffer.get(part.messageID) || '';
|
|
263
|
+
// part.text is the full accumulated text for streaming parts
|
|
264
|
+
textBuffer.set(part.messageID, part.text || current);
|
|
265
|
+
break;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
case 'message.updated': {
|
|
269
|
+
const info = event.properties?.info;
|
|
270
|
+
if (!info || info.role !== 'assistant') return;
|
|
271
|
+
|
|
272
|
+
const bufferedText = textBuffer.get(info.id);
|
|
273
|
+
if (!bufferedText) return;
|
|
274
|
+
// NOTE: do NOT delete from textBuffer — parts may still arrive after
|
|
275
|
+
// this event. Each subsequent message.updated will flush again with
|
|
276
|
+
// the latest text. Buffer is cleaned up on message.removed only.
|
|
277
|
+
|
|
278
|
+
const conversationId = buildConversationId(
|
|
279
|
+
sprintId,
|
|
280
|
+
info.agent || 'unknown',
|
|
281
|
+
info.sessionID
|
|
282
|
+
);
|
|
283
|
+
|
|
284
|
+
try {
|
|
285
|
+
await client.recordMessage({
|
|
286
|
+
sprintId,
|
|
287
|
+
sessionId: info.sessionID,
|
|
288
|
+
messageId: info.id,
|
|
289
|
+
conversationId,
|
|
290
|
+
role: 'assistant',
|
|
291
|
+
content: bufferedText,
|
|
292
|
+
agent: info.agent ? normalizeAgentKey(info.agent) : undefined,
|
|
293
|
+
model: info.model,
|
|
294
|
+
timestamp: info.time?.created ?? Date.now(),
|
|
295
|
+
});
|
|
296
|
+
} catch (err) {
|
|
297
|
+
console.warn('[ForLoop] Failed to record assistant message:', (err as Error).message);
|
|
298
|
+
}
|
|
299
|
+
break;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
case 'message.removed': {
|
|
303
|
+
const { sessionID, messageID } = event.properties || {};
|
|
304
|
+
if (!sessionID || !messageID) return;
|
|
305
|
+
|
|
306
|
+
textBuffer.delete(messageID); // clean up buffer too
|
|
307
|
+
|
|
308
|
+
try {
|
|
309
|
+
await client.removeMessage(messageID, sprintId, sessionID);
|
|
310
|
+
} catch (err) {
|
|
311
|
+
console.warn('[ForLoop] Failed to remove message:', (err as Error).message);
|
|
312
|
+
}
|
|
313
|
+
break;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
### Modified File: `plugins/forloop-plugin.ts`
|
|
321
|
+
|
|
322
|
+
Wire both hooks:
|
|
323
|
+
|
|
324
|
+
```diff
|
|
325
|
+
+ import { createChatMessageHook, createEventHook } from './hooks/message-recorder';
|
|
326
|
+
|
|
327
|
+
export default async (ctx: any) => {
|
|
328
|
+
// ... existing init ...
|
|
329
|
+
|
|
330
|
+
return {
|
|
331
|
+
tool: { ... },
|
|
332
|
+
+ event: createEventHook(client),
|
|
333
|
+
+ 'chat.message': createChatMessageHook(client),
|
|
334
|
+
'shell.env': injectEnvironment,
|
|
335
|
+
};
|
|
336
|
+
};
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
### Modified File: `plugins/capabilities/api-client.ts`
|
|
340
|
+
|
|
341
|
+
Two new methods on `ForLoopAPIClient`:
|
|
342
|
+
|
|
343
|
+
```typescript
|
|
344
|
+
async recordMessage(data: {
|
|
345
|
+
sprintId: number;
|
|
346
|
+
sessionId: string;
|
|
347
|
+
messageId: string;
|
|
348
|
+
conversationId: string;
|
|
349
|
+
role: 'user' | 'assistant';
|
|
350
|
+
content: string;
|
|
351
|
+
agent?: string;
|
|
352
|
+
model?: { providerID: string; modelID: string };
|
|
353
|
+
timestamp: number;
|
|
354
|
+
}): Promise<any> {
|
|
355
|
+
return this.request('/api/opencode/messages', {
|
|
356
|
+
method: 'POST',
|
|
357
|
+
body: JSON.stringify(data),
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
async removeMessage(messageId: string, sprintId: number, sessionId: string): Promise<void> {
|
|
362
|
+
await this.request(`/api/opencode/messages/${encodeURIComponent(messageId)}`, {
|
|
363
|
+
method: 'DELETE',
|
|
364
|
+
body: JSON.stringify({ sprintId, sessionId }),
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
## Server Implementation (server_lambda)
|
|
370
|
+
|
|
371
|
+
### New Routes
|
|
372
|
+
|
|
373
|
+
Add to `opencodeRoutes.ts`:
|
|
374
|
+
|
|
375
|
+
```typescript
|
|
376
|
+
router.post('/messages', authenticateApiToken, requireScopes(['agent:query']), recordMessage);
|
|
377
|
+
router.delete('/messages/:messageId', authenticateApiToken, requireScopes(['agent:query']), removeMessage);
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
### New Controller Methods (opencodeController.ts)
|
|
381
|
+
|
|
382
|
+
**`recordMessage`:**
|
|
383
|
+
1. Validate required fields (`sprintId`, `messageId`, `conversationId`, `role`, `content`)
|
|
384
|
+
2. If `role === "user"`:
|
|
385
|
+
- INSERT into `Conversation` with `userMessage = content`, `agentResponse = ''`, `metadata = {...}`
|
|
386
|
+
3. If `role === "assistant"`:
|
|
387
|
+
- UPDATE `Conversation SET agentResponse = content, metadata = metadata || {...}` WHERE `conversationId = :id` AND `id = (SELECT MAX(id) FROM Conversation WHERE conversationId = :id)`
|
|
388
|
+
4. Return `{ status: "ok", id: <row id> }`
|
|
389
|
+
|
|
390
|
+
**`removeMessage`:**
|
|
391
|
+
1. Parse `sprintId` and `sessionId` from body
|
|
392
|
+
2. Query for row matching the messageId in metadata JSON
|
|
393
|
+
3. `DELETE FROM Conversation WHERE id = :id`
|
|
394
|
+
4. Return `{ status: "ok" }`
|
|
395
|
+
|
|
396
|
+
## Error Handling
|
|
397
|
+
|
|
398
|
+
| Scenario | Plugin Behavior |
|
|
399
|
+
|----------|----------------|
|
|
400
|
+
| No manifest / no active sprint | Silent skip (return immediately) |
|
|
401
|
+
| Non-text part type | No-op |
|
|
402
|
+
| Non-assistant message.updated | No-op |
|
|
403
|
+
| No buffered text for assistant | Skip (return immediately) |
|
|
404
|
+
| API unreachable / network error | Log warning, drop (no retry) |
|
|
405
|
+
| Server returns 4xx | Log warning, drop |
|
|
406
|
+
| Server returns 5xx | Log warning, drop |
|
|
407
|
+
|
|
408
|
+
**Key principle:** All hooks are fire-and-forget. They must never throw — a failing message recorder must not disrupt the opencode session.
|
|
409
|
+
|
|
410
|
+
## Text Buffer Lifecycle
|
|
411
|
+
|
|
412
|
+
The in-memory `textBuffer` map accumulates text by messageID. Each `message.part.updated` with type "text" updates the buffer with the full accumulated `part.text`. Each `message.updated` with role "assistant" flushes the current buffer value to the API (overwriting the `agentResponse` column). The buffer is NOT deleted after flush — parts may arrive after `message.updated`, and each subsequent flush simply overwrites with the latest text. The last write always wins, ensuring the complete text is persisted.
|
|
413
|
+
|
|
414
|
+
Cleanup happens only on:
|
|
415
|
+
- `message.removed`: buffer entry deleted
|
|
416
|
+
- Plugin unload / process exit: buffer is garbage collected
|
|
417
|
+
|
|
418
|
+
## Feature Toggle
|
|
419
|
+
|
|
420
|
+
Always active when `activeSprintId` is present in `~/.forloop/manifest.json`. No explicit toggle — if there's no sprint, there's nothing to record.
|
|
421
|
+
|
|
422
|
+
## Testing
|
|
423
|
+
|
|
424
|
+
### Unit Tests: `test/suites/unit/message-recorder.test.ts`
|
|
425
|
+
|
|
426
|
+
| Test | Description |
|
|
427
|
+
|------|-------------|
|
|
428
|
+
| `chat.message` hook | Extracts text from parts, calls `recordMessage` with user role |
|
|
429
|
+
| `chat.message` hook — no sprint | No API calls |
|
|
430
|
+
| `chat.message` hook — no text | No API calls |
|
|
431
|
+
| `event` — `message.part.updated` (text) | Buffers text by messageID |
|
|
432
|
+
| `event` — `message.part.updated` (non-text) | No-op |
|
|
433
|
+
| `event` — `message.updated` (assistant) | Flushes buffer, calls `recordMessage` |
|
|
434
|
+
| `event` — `message.updated` (user) | No-op (user handled by `chat.message`) |
|
|
435
|
+
| `event` — `message.updated` (no buffer) | No-op |
|
|
436
|
+
| `event` — `message.removed` | Calls `removeMessage`, cleans buffer |
|
|
437
|
+
| No manifest | No API calls for any event |
|
|
438
|
+
| API error | Does not throw, logs warning |
|
|
439
|
+
| Agent key normalization | `"forLoopPlanner"` → `"forLoopPlanner"` |
|
|
440
|
+
| conversationId format | Matches `sprint:{id}:agent:{agent}:opencode:{sessionId}` |
|
|
441
|
+
| Buffer persists after flush | Subsequent `message.updated` flushes again with latest text |
|
|
442
|
+
| textBuffer cleanup after remove | Buffer entry is deleted |
|
|
443
|
+
|
|
444
|
+
### Integration Tests: `test/suites/integration/message-api.test.ts`
|
|
445
|
+
|
|
446
|
+
| Test | Description |
|
|
447
|
+
|------|-------------|
|
|
448
|
+
| POST user message | INSERT row, returns id |
|
|
449
|
+
| POST assistant message | UPDATE existing row |
|
|
450
|
+
| DELETE message | Removes row |
|
|
451
|
+
| Invalid sprintId | Returns 400 |
|
|
452
|
+
| No token | Returns 401 |
|
|
453
|
+
|
|
454
|
+
## Conversation Summarization Compatibility
|
|
455
|
+
|
|
456
|
+
The `conversationId` format difference (`web` vs `opencode`) does not affect summarization. The summarizer queries by `sprintId + conversationId` — the ID is a grouping key. The format difference is actually useful metadata for identifying the source. The shared `sprint:{id}:agent:{agent}:` prefix enables cross-source queries (e.g., "all conversations with forLoopPlanner").
|
|
457
|
+
|
|
458
|
+
## Files Changed
|
|
459
|
+
|
|
460
|
+
| File | Change |
|
|
461
|
+
|------|--------|
|
|
462
|
+
| `plugins/hooks/message-recorder.ts` | **New** — `createChatMessageHook` + `createEventHook` |
|
|
463
|
+
| `plugins/forloop-plugin.ts` | **Modified** — wire `event` and `chat.message` hooks |
|
|
464
|
+
| `plugins/capabilities/api-client.ts` | **Modified** — add `recordMessage`, `removeMessage` methods |
|
|
465
|
+
| `server_lambda/src/routes/opencodeRoutes.ts` | **Modified** — add message routes |
|
|
466
|
+
| `server_lambda/src/controllers/opencodeController.ts` | **Modified** — add controller methods |
|
|
467
|
+
| `test/suites/unit/message-recorder.test.ts` | **New** — unit tests |
|
|
468
|
+
| `test/suites/integration/message-api.test.ts` | **New** — integration tests |
|