clearctx 3.1.0 → 3.2.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/CHANGELOG.md +33 -0
- package/README.md +40 -3
- package/bin/setup.js +29 -1
- package/package.json +1 -1
- package/src/channel-hub.js +341 -0
- package/src/mcp-server.js +504 -2
- package/src/notebook-store.js +391 -0
- package/src/prompts.js +109 -0
- package/src/user-escalation.js +330 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,39 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to clearctx will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [3.2.0] - 2026-02-15
|
|
6
|
+
### Added
|
|
7
|
+
- **Cognition Layer** — Think-Plan-Execute cognitive protocol for workers
|
|
8
|
+
- Communication Channels — 5 new tools: `channel_create`, `channel_post`, `channel_subscribe`, `channel_read`, `channel_list`
|
|
9
|
+
- Project Notebook — 4 new tools: `notebook_write`, `notebook_read`, `notebook_search`, `notebook_list`
|
|
10
|
+
- User Escalation — 2 new tools: `ask_user`, `user_respond`
|
|
11
|
+
- 6-phase cognitive protocol injected into worker system prompts
|
|
12
|
+
- 5 default channels: `#design-decisions`, `#blockers`, `#discoveries`, `#conventions`, `#questions`
|
|
13
|
+
- 7 notebook categories: research, decision, gotcha, pattern, plan, investigation, reference
|
|
14
|
+
- Orchestrator Rule 9: Surface worker questions to the user
|
|
15
|
+
- 3 new modules: `src/channel-hub.js`, `src/notebook-store.js`, `src/user-escalation.js`
|
|
16
|
+
- 15 new enforcement tests (73 total, 100% pass rate)
|
|
17
|
+
- Updated all 3 sources of truth (prompts.js, ORCHESTRATOR-CLAUDE.md, setup.js)
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
- Total MCP tools: 71 → 82
|
|
21
|
+
- Total tests: 58 → 73
|
|
22
|
+
|
|
23
|
+
## [3.1.0] - 2026-02-15
|
|
24
|
+
### Added
|
|
25
|
+
- Expertise skills system with 8 bundled domain skills: postgresql, nodejs-backend, react-frontend, testing-qa, api-design, devops, security, typescript
|
|
26
|
+
- 3 new MCP tools: skill_list, skill_get, skill_detect (68 → 71 tools)
|
|
27
|
+
- Auto-detection of relevant skills from task description keywords
|
|
28
|
+
- Role-based skill fallback in team_spawn (e.g., role "backend" → nodejs-backend + api-design)
|
|
29
|
+
- Skill injection via team_spawn `skills` parameter
|
|
30
|
+
- Rule 8: Use expertise skills for domain work (added to orchestrator guide)
|
|
31
|
+
- 9 new tests in Skills System group (49 → 58 tests)
|
|
32
|
+
|
|
33
|
+
### Fixed
|
|
34
|
+
- ENAMETOOLONG on Windows when injecting skills: system prompts now written to temp files and passed via --append-system-prompt-file instead of CLI args
|
|
35
|
+
- Role-based fallback no longer triggers when skills: [] is explicitly passed (opt-out)
|
|
36
|
+
- getWorkerContext edge case: Worker Context as last section in SKILL.md now extracted correctly
|
|
37
|
+
|
|
5
38
|
## [3.0.0] - 2026-02-15
|
|
6
39
|
### BREAKING CHANGES
|
|
7
40
|
- Renamed package from `claude-multi-session` to `clearctx`
|
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
[](https://github.com/)
|
|
5
5
|
[](https://nodejs.org)
|
|
6
6
|
|
|
7
|
-
> Multi-session orchestration system for Claude Code CLI. Spawn parallel workers that coordinate via artifacts and phase gates.
|
|
7
|
+
> Multi-session orchestration system for Claude Code CLI. Spawn parallel workers that coordinate via artifacts and phase gates. 82 MCP tools. 8 bundled expertise skills. Cognition Layer. v3.2.0.
|
|
8
8
|
|
|
9
9
|
---
|
|
10
10
|
|
|
@@ -39,7 +39,7 @@ After installing, run the setup wizard to register the MCP server with Claude Co
|
|
|
39
39
|
clearctx setup
|
|
40
40
|
```
|
|
41
41
|
|
|
42
|
-
This adds
|
|
42
|
+
This adds 82 native tools to Claude Code (spawn_session, delegate_task, skill_detect, etc.) so Claude can manage sessions directly — no Bash commands needed.
|
|
43
43
|
|
|
44
44
|
**Non-interactive options:**
|
|
45
45
|
```bash
|
|
@@ -450,7 +450,7 @@ Add to your Claude Code MCP config (`~/.claude/settings.json` or project `.claud
|
|
|
450
450
|
{
|
|
451
451
|
"mcpServers": {
|
|
452
452
|
"multi-session": {
|
|
453
|
-
"command": "
|
|
453
|
+
"command": "ctx-mcp"
|
|
454
454
|
}
|
|
455
455
|
}
|
|
456
456
|
}
|
|
@@ -505,6 +505,28 @@ Once configured, Claude sees these tools:
|
|
|
505
505
|
|------|-------------|
|
|
506
506
|
| `batch_spawn` | Spawn multiple sessions in parallel |
|
|
507
507
|
|
|
508
|
+
**Expertise Skills:**
|
|
509
|
+
| Tool | Description |
|
|
510
|
+
|------|-------------|
|
|
511
|
+
| `skill_list` | List all available expertise skills |
|
|
512
|
+
| `skill_get` | Read a skill's full content (conventions, patterns, anti-patterns) |
|
|
513
|
+
| `skill_detect` | Auto-detect relevant skills from a task description |
|
|
514
|
+
|
|
515
|
+
**Cognition Layer:**
|
|
516
|
+
| Tool | Description |
|
|
517
|
+
|------|-------------|
|
|
518
|
+
| `channel_create` | Create a topic-based communication channel |
|
|
519
|
+
| `channel_post` | Post a message to a team channel |
|
|
520
|
+
| `channel_subscribe` | Subscribe a session to a channel |
|
|
521
|
+
| `channel_read` | Read messages from a channel |
|
|
522
|
+
| `channel_list` | List all channels with subscriber counts |
|
|
523
|
+
| `notebook_write` | Write or append to a shared notebook entry |
|
|
524
|
+
| `notebook_read` | Read a notebook entry with all entries |
|
|
525
|
+
| `notebook_search` | Search notebook by content, title, or tags |
|
|
526
|
+
| `notebook_list` | List notebook entries with filters |
|
|
527
|
+
| `ask_user` | Ask the human user a question (worker escalation) |
|
|
528
|
+
| `user_respond` | Relay the human's answer to a worker |
|
|
529
|
+
|
|
508
530
|
### How Claude Uses It
|
|
509
531
|
|
|
510
532
|
With MCP configured, Claude can autonomously:
|
|
@@ -633,6 +655,21 @@ clearctx team-replay pre-graphql --overrides '{"build-auth-api":{"inputs":{"cont
|
|
|
633
655
|
|
|
634
656
|
---
|
|
635
657
|
|
|
658
|
+
## Cognition Layer (v3.2.0)
|
|
659
|
+
|
|
660
|
+
Workers now follow a **Think-Plan-Execute cognitive protocol** that teaches them to orient before coding, research ambiguities, share plans before implementing, document discoveries, and self-review before delivering.
|
|
661
|
+
|
|
662
|
+
### Communication Channels (5 tools)
|
|
663
|
+
Persistent topic-based discussions. 5 default channels: `#design-decisions`, `#blockers`, `#discoveries`, `#conventions`, `#questions`. Workers post plans and discoveries to channels so teammates stay informed without orchestrator relay.
|
|
664
|
+
|
|
665
|
+
### Project Notebook (4 tools)
|
|
666
|
+
Shared knowledge scratchpad with 7 categories: research, decision, gotcha, pattern, plan, investigation, reference. Multiple workers can append to the same note, enabling collaborative knowledge building.
|
|
667
|
+
|
|
668
|
+
### User Escalation (2 tools)
|
|
669
|
+
Workers can ask the human user questions when genuinely stuck on ambiguous decisions that can't be resolved by checking artifacts, conventions, channels, notebook, or asking teammates. The orchestrator relays answers.
|
|
670
|
+
|
|
671
|
+
---
|
|
672
|
+
|
|
636
673
|
## Layer 1: Chat (8 MCP Tools)
|
|
637
674
|
|
|
638
675
|
Sessions can communicate directly without routing through the orchestrator.
|
package/bin/setup.js
CHANGED
|
@@ -205,6 +205,30 @@ When spawning workers, assign relevant expertise skills to improve output qualit
|
|
|
205
205
|
- security → security
|
|
206
206
|
- fullstack → nodejs-backend, react-frontend, typescript
|
|
207
207
|
|
|
208
|
+
### Rule 9: Surface worker questions to the user
|
|
209
|
+
|
|
210
|
+
Workers can ask the human user questions via \`ask_user\` when genuinely stuck on ambiguous decisions that can't be resolved by checking artifacts, conventions, channels, notebook, or asking teammates.
|
|
211
|
+
|
|
212
|
+
- After spawning workers, periodically check for pending questions: use \`user_list_pending\` to see unanswered escalations
|
|
213
|
+
- When you see a pending question, present it to the user clearly with the worker's context
|
|
214
|
+
- After the user answers, relay it with \`user_respond\`
|
|
215
|
+
- Do NOT answer on behalf of the user — relay their actual response
|
|
216
|
+
- If multiple questions are pending, prioritize by priority level (urgent > high > normal > low)
|
|
217
|
+
|
|
218
|
+
## Cognition Layer (v3.2.0)
|
|
219
|
+
|
|
220
|
+
Workers now follow a **Think-Plan-Execute cognitive protocol** that teaches them to orient before coding, research ambiguities, share plans in channels before implementing, document discoveries in the notebook, and self-review before delivering. This reduces convention mismatches, duplicate work, and silent failures.
|
|
221
|
+
|
|
222
|
+
Three new subsystems support this:
|
|
223
|
+
|
|
224
|
+
| Category | Tools | Purpose |
|
|
225
|
+
|----------|-------|---------|
|
|
226
|
+
| **Channels** | \`channel_create\`, \`channel_post\`, \`channel_read\`, \`channel_subscribe\`, \`channel_list\` | Persistent topic-based discussion. Default channels: \`#design-decisions\`, \`#blockers\`, \`#discoveries\`, \`#conventions\`, \`#questions\` |
|
|
227
|
+
| **Notebook** | \`notebook_write\`, \`notebook_read\`, \`notebook_search\`, \`notebook_list\` | Shared knowledge scratchpad for research, gotchas, patterns, plans, and investigations |
|
|
228
|
+
| **User Escalation** | \`ask_user\`, \`user_respond\` | Worker-to-human escalation for genuinely ambiguous decisions. Workers ask, orchestrator relays answer |
|
|
229
|
+
|
|
230
|
+
**Orchestrator role:** Monitor channels (\`channel_read\`) and notebook (\`notebook_list\`, \`notebook_read\`) to stay informed. Check \`user_list_pending\` for unanswered worker questions and relay answers with \`user_respond\`. You do NOT need to post to channels yourself — workers handle their own collaboration.
|
|
231
|
+
|
|
208
232
|
## Auto-Behaviors (v2.7.0)
|
|
209
233
|
|
|
210
234
|
These happen automatically — no action needed from you or the workers:
|
|
@@ -215,7 +239,7 @@ These happen automatically — no action needed from you or the workers:
|
|
|
215
239
|
- **Convention completeness check**: \`phase_gate\` warns if \`shared-conventions\` artifact is missing or has incomplete fields
|
|
216
240
|
- **Skills System**: When you provide a \`role\` to \`team_spawn\` without explicit \`skills\`, the system auto-detects relevant expertise skills based on the role (e.g., "database" → postgresql, "backend" → nodejs-backend + api-design). You can override this by passing explicit \`skills\` array.
|
|
217
241
|
|
|
218
|
-
## Quick Reference (
|
|
242
|
+
## Quick Reference (82 tools)
|
|
219
243
|
|
|
220
244
|
| You want to... | Use this tool |
|
|
221
245
|
|----------------|---------------|
|
|
@@ -234,6 +258,10 @@ These happen automatically — no action needed from you or the workers:
|
|
|
234
258
|
| List available skills | \`skill_list\` |
|
|
235
259
|
| Read a skill's content | \`skill_get\` |
|
|
236
260
|
| Spawn worker with skills | \`team_spawn\` with \`skills\` parameter |
|
|
261
|
+
| Monitor team discussions | \`channel_read\`, \`channel_list\` |
|
|
262
|
+
| Monitor shared knowledge | \`notebook_read\`, \`notebook_list\` |
|
|
263
|
+
| Check pending user questions | \`user_list_pending\` |
|
|
264
|
+
| Relay user's answer to worker | \`user_respond\` |
|
|
237
265
|
| Clean up between runs | \`team_reset\` |
|
|
238
266
|
|
|
239
267
|
### When NOT to Delegate
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "clearctx",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.2.0",
|
|
4
4
|
"description": "Multi-session orchestrator for Claude Code CLI — spawn, control, pause, resume, and send multiple inputs to Claude Code sessions programmatically",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* channel-hub.js
|
|
3
|
+
* Cognition Layer: Topic-based persistent channels for worker collaboration.
|
|
4
|
+
*
|
|
5
|
+
* Provides Slack-like channels where workers can discuss, share findings,
|
|
6
|
+
* and debate approaches. Each channel is an append-only JSONL message log
|
|
7
|
+
* with a shared index tracking channel metadata and subscriptions.
|
|
8
|
+
*
|
|
9
|
+
* Storage structure:
|
|
10
|
+
* team/{teamName}/channels/
|
|
11
|
+
* channels-index.json - registry of all channels
|
|
12
|
+
* {channelName}.jsonl - append-only message log per channel
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const fs = require('fs');
|
|
16
|
+
const path = require('path');
|
|
17
|
+
const os = require('os');
|
|
18
|
+
|
|
19
|
+
const { atomicWriteJson, readJsonSafe } = require('./atomic-io');
|
|
20
|
+
const { acquireLock, releaseLock } = require('./file-lock');
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Default channels auto-created for new teams.
|
|
24
|
+
* Exported so mcp-server.js can create them on team_spawn if they don't exist.
|
|
25
|
+
*/
|
|
26
|
+
const DEFAULT_CHANNELS = [
|
|
27
|
+
'design-decisions',
|
|
28
|
+
'blockers',
|
|
29
|
+
'discoveries',
|
|
30
|
+
'conventions',
|
|
31
|
+
'questions'
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Validate a channel name.
|
|
36
|
+
* Must be lowercase, alphanumeric + hyphens only, max 50 chars.
|
|
37
|
+
* @param {string} name - Channel name to validate
|
|
38
|
+
* @throws {Error} If name is invalid
|
|
39
|
+
* @private
|
|
40
|
+
*/
|
|
41
|
+
function _validateChannelName(name) {
|
|
42
|
+
if (!name || typeof name !== 'string') {
|
|
43
|
+
throw new Error('Channel name is required and must be a string');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (name.length > 50) {
|
|
47
|
+
throw new Error(`Channel name '${name}' exceeds 50 character limit`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (!/^[a-z0-9-]+$/.test(name)) {
|
|
51
|
+
throw new Error(`Channel name '${name}' must be lowercase alphanumeric with hyphens only`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* ChannelHub manages topic-based persistent channels for worker collaboration.
|
|
57
|
+
*
|
|
58
|
+
* Directory structure:
|
|
59
|
+
* team/{teamName}/channels/
|
|
60
|
+
* channels-index.json - channel registry (locked for writes)
|
|
61
|
+
* {channelName}.jsonl - append-only message log per channel
|
|
62
|
+
*/
|
|
63
|
+
class ChannelHub {
|
|
64
|
+
/**
|
|
65
|
+
* Create a new ChannelHub instance
|
|
66
|
+
* @param {string} teamName - Name of the team (default: 'default')
|
|
67
|
+
*/
|
|
68
|
+
constructor(teamName = 'default') {
|
|
69
|
+
const baseDir = path.join(os.homedir(), '.clearctx');
|
|
70
|
+
this.teamDir = path.join(baseDir, 'team', teamName);
|
|
71
|
+
this.channelsDir = path.join(this.teamDir, 'channels');
|
|
72
|
+
this.indexPath = path.join(this.channelsDir, 'channels-index.json');
|
|
73
|
+
this.locksDir = path.join(this.teamDir, 'locks');
|
|
74
|
+
|
|
75
|
+
this._ensureDirectories();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Ensure all required directories exist
|
|
80
|
+
* @private
|
|
81
|
+
*/
|
|
82
|
+
_ensureDirectories() {
|
|
83
|
+
[this.channelsDir, this.locksDir].forEach(dir => {
|
|
84
|
+
if (!fs.existsSync(dir)) {
|
|
85
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Get the JSONL file path for a channel
|
|
92
|
+
* @param {string} channelName - The channel name
|
|
93
|
+
* @returns {string} Absolute path to the channel's JSONL file
|
|
94
|
+
* @private
|
|
95
|
+
*/
|
|
96
|
+
_channelPath(channelName) {
|
|
97
|
+
return path.join(this.channelsDir, `${channelName}.jsonl`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Create a new channel
|
|
102
|
+
* @param {string} channelName - Unique channel name (lowercase, alphanumeric + hyphens, max 50 chars)
|
|
103
|
+
* @param {Object} options - Channel options
|
|
104
|
+
* @param {string} options.description - Human-readable description
|
|
105
|
+
* @param {string} options.creator - Session name that created the channel
|
|
106
|
+
* @returns {Object} { channelName, created: true }
|
|
107
|
+
*/
|
|
108
|
+
createChannel(channelName, { description, creator }) {
|
|
109
|
+
// Step 1: Validate channel name
|
|
110
|
+
_validateChannelName(channelName);
|
|
111
|
+
|
|
112
|
+
if (!creator || typeof creator !== 'string') {
|
|
113
|
+
throw new Error('Channel creator is required');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Step 2: Acquire lock on the channels index
|
|
117
|
+
acquireLock(this.locksDir, 'channels-index');
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
// Step 3: Read the current index
|
|
121
|
+
const index = readJsonSafe(this.indexPath, {});
|
|
122
|
+
|
|
123
|
+
// Step 4: Check for duplicates
|
|
124
|
+
if (index[channelName]) {
|
|
125
|
+
throw new Error(`Channel '${channelName}' already exists`);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Step 5: Add channel to index
|
|
129
|
+
index[channelName] = {
|
|
130
|
+
name: channelName,
|
|
131
|
+
description: description || '',
|
|
132
|
+
creator,
|
|
133
|
+
createdAt: new Date().toISOString(),
|
|
134
|
+
subscribers: [creator]
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
// Step 6: Save the index atomically
|
|
138
|
+
atomicWriteJson(this.indexPath, index);
|
|
139
|
+
|
|
140
|
+
// Step 7: Create empty JSONL file
|
|
141
|
+
const jsonlPath = this._channelPath(channelName);
|
|
142
|
+
if (!fs.existsSync(jsonlPath)) {
|
|
143
|
+
fs.writeFileSync(jsonlPath, '', 'utf-8');
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Step 8: Release the lock
|
|
147
|
+
releaseLock(this.locksDir, 'channels-index');
|
|
148
|
+
|
|
149
|
+
return { channelName, created: true };
|
|
150
|
+
|
|
151
|
+
} catch (err) {
|
|
152
|
+
releaseLock(this.locksDir, 'channels-index');
|
|
153
|
+
throw err;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Post a message to a channel
|
|
159
|
+
* @param {string} channelName - Target channel name
|
|
160
|
+
* @param {Object} options - Message options
|
|
161
|
+
* @param {string} options.from - Sender session name
|
|
162
|
+
* @param {string} options.content - Message content
|
|
163
|
+
* @param {Object} [options.metadata] - Optional metadata
|
|
164
|
+
* @returns {Object} { messageId, channelName, posted: true }
|
|
165
|
+
*/
|
|
166
|
+
postMessage(channelName, { from, content, metadata }) {
|
|
167
|
+
// Step 1: Validate channel exists
|
|
168
|
+
const index = readJsonSafe(this.indexPath, {});
|
|
169
|
+
if (!index[channelName]) {
|
|
170
|
+
throw new Error(`Channel '${channelName}' does not exist`);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (!from || typeof from !== 'string') {
|
|
174
|
+
throw new Error('Message sender (from) is required');
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (!content || typeof content !== 'string') {
|
|
178
|
+
throw new Error('Message content is required');
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Step 2: Build message object
|
|
182
|
+
const messageId = `msg_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
183
|
+
const message = {
|
|
184
|
+
id: messageId,
|
|
185
|
+
channelName,
|
|
186
|
+
from,
|
|
187
|
+
content,
|
|
188
|
+
metadata: metadata || null,
|
|
189
|
+
timestamp: new Date().toISOString()
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
// Step 3: Acquire lock for safe concurrent append
|
|
193
|
+
acquireLock(this.locksDir, `channel-${channelName}`);
|
|
194
|
+
|
|
195
|
+
try {
|
|
196
|
+
// Step 4: Append JSONL line
|
|
197
|
+
const jsonlPath = this._channelPath(channelName);
|
|
198
|
+
fs.appendFileSync(jsonlPath, JSON.stringify(message) + '\n', 'utf-8');
|
|
199
|
+
|
|
200
|
+
// Step 5: Release the lock
|
|
201
|
+
releaseLock(this.locksDir, `channel-${channelName}`);
|
|
202
|
+
|
|
203
|
+
return { messageId, channelName, posted: true };
|
|
204
|
+
|
|
205
|
+
} catch (err) {
|
|
206
|
+
releaseLock(this.locksDir, `channel-${channelName}`);
|
|
207
|
+
throw err;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Subscribe a session to a channel
|
|
213
|
+
* @param {string} channelName - Channel to subscribe to
|
|
214
|
+
* @param {string} sessionName - Session name to subscribe
|
|
215
|
+
* @returns {Object} { channelName, subscriber: sessionName, subscribed: true }
|
|
216
|
+
*/
|
|
217
|
+
subscribe(channelName, sessionName) {
|
|
218
|
+
if (!sessionName || typeof sessionName !== 'string') {
|
|
219
|
+
throw new Error('Session name is required for subscription');
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Acquire lock on the channels index
|
|
223
|
+
acquireLock(this.locksDir, 'channels-index');
|
|
224
|
+
|
|
225
|
+
try {
|
|
226
|
+
const index = readJsonSafe(this.indexPath, {});
|
|
227
|
+
|
|
228
|
+
if (!index[channelName]) {
|
|
229
|
+
throw new Error(`Channel '${channelName}' does not exist`);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Add subscriber (no duplicates)
|
|
233
|
+
if (!index[channelName].subscribers.includes(sessionName)) {
|
|
234
|
+
index[channelName].subscribers.push(sessionName);
|
|
235
|
+
atomicWriteJson(this.indexPath, index);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
releaseLock(this.locksDir, 'channels-index');
|
|
239
|
+
|
|
240
|
+
return { channelName, subscriber: sessionName, subscribed: true };
|
|
241
|
+
|
|
242
|
+
} catch (err) {
|
|
243
|
+
releaseLock(this.locksDir, 'channels-index');
|
|
244
|
+
throw err;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Read messages from a channel
|
|
250
|
+
* @param {string} channelName - Channel to read from
|
|
251
|
+
* @param {Object} [options={}] - Read options
|
|
252
|
+
* @param {number} [options.limit=50] - Max messages to return (returns last N)
|
|
253
|
+
* @param {string} [options.after] - Only return messages after this ISO timestamp
|
|
254
|
+
* @param {string} [options.subscriber] - If provided, track last-read timestamp in index
|
|
255
|
+
* @returns {Object} { channelName, messages: [...], total }
|
|
256
|
+
*/
|
|
257
|
+
readMessages(channelName, { limit = 50, after, subscriber } = {}) {
|
|
258
|
+
// Step 1: Validate channel exists
|
|
259
|
+
const index = readJsonSafe(this.indexPath, {});
|
|
260
|
+
if (!index[channelName]) {
|
|
261
|
+
throw new Error(`Channel '${channelName}' does not exist`);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Step 2: Read JSONL file and parse each line
|
|
265
|
+
const jsonlPath = this._channelPath(channelName);
|
|
266
|
+
let messages = [];
|
|
267
|
+
|
|
268
|
+
try {
|
|
269
|
+
const content = fs.readFileSync(jsonlPath, 'utf-8');
|
|
270
|
+
const lines = content.split('\n').filter(line => line.trim());
|
|
271
|
+
|
|
272
|
+
for (const line of lines) {
|
|
273
|
+
try {
|
|
274
|
+
messages.push(JSON.parse(line));
|
|
275
|
+
} catch (parseErr) {
|
|
276
|
+
// Skip corrupted lines
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
} catch (err) {
|
|
280
|
+
// File doesn't exist or can't be read — return empty
|
|
281
|
+
messages = [];
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const total = messages.length;
|
|
285
|
+
|
|
286
|
+
// Step 3: Filter by 'after' timestamp if provided
|
|
287
|
+
if (after) {
|
|
288
|
+
messages = messages.filter(msg => msg.timestamp > after);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Step 4: Apply limit (return last N messages)
|
|
292
|
+
if (limit && messages.length > limit) {
|
|
293
|
+
messages = messages.slice(-limit);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Step 5: Track last-read timestamp if subscriber provided
|
|
297
|
+
if (subscriber) {
|
|
298
|
+
acquireLock(this.locksDir, 'channels-index');
|
|
299
|
+
|
|
300
|
+
try {
|
|
301
|
+
const freshIndex = readJsonSafe(this.indexPath, {});
|
|
302
|
+
|
|
303
|
+
if (freshIndex[channelName]) {
|
|
304
|
+
if (!freshIndex[channelName].lastRead) {
|
|
305
|
+
freshIndex[channelName].lastRead = {};
|
|
306
|
+
}
|
|
307
|
+
freshIndex[channelName].lastRead[subscriber] = new Date().toISOString();
|
|
308
|
+
atomicWriteJson(this.indexPath, freshIndex);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
releaseLock(this.locksDir, 'channels-index');
|
|
312
|
+
} catch (err) {
|
|
313
|
+
releaseLock(this.locksDir, 'channels-index');
|
|
314
|
+
// Non-critical — don't fail the read
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
return { channelName, messages, total };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* List all channels with subscriber counts
|
|
323
|
+
* @returns {Object} { channels: [...] }
|
|
324
|
+
*/
|
|
325
|
+
listChannels() {
|
|
326
|
+
const index = readJsonSafe(this.indexPath, {});
|
|
327
|
+
|
|
328
|
+
const channels = Object.values(index).map(channel => ({
|
|
329
|
+
name: channel.name,
|
|
330
|
+
description: channel.description,
|
|
331
|
+
creator: channel.creator,
|
|
332
|
+
createdAt: channel.createdAt,
|
|
333
|
+
subscriberCount: channel.subscribers ? channel.subscribers.length : 0,
|
|
334
|
+
subscribers: channel.subscribers || []
|
|
335
|
+
}));
|
|
336
|
+
|
|
337
|
+
return { channels };
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
module.exports = { ChannelHub, DEFAULT_CHANNELS };
|