slashvibe-mcp 0.8.20 → 0.8.22
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/config.js +94 -12
- package/incoming.js +40 -1
- package/index.js +18 -1
- package/package.json +4 -1
- package/scripts/verb-canary.mjs +1 -1
- package/store/api.js +138 -6
- package/store/local.js +116 -12
- package/store/profiles.js +29 -1
- package/tools/inbox.js +14 -2
- package/tools/init.js +6 -3
- package/tools/list-me.js +104 -0
- package/tools/people.js +68 -0
- package/tools/reply.js +5 -1
- package/tools/start.js +85 -250
- package/tools/token.js +5 -2
- package/tools/unlist-me.js +46 -0
- package/version.json +7 -7
package/config.js
CHANGED
|
@@ -82,22 +82,40 @@ function load() {
|
|
|
82
82
|
|
|
83
83
|
function save(config) {
|
|
84
84
|
ensureDir();
|
|
85
|
-
// Load existing to preserve fields we're not updating
|
|
85
|
+
// Load existing to preserve fields we're not updating.
|
|
86
|
+
//
|
|
87
|
+
// Every field below falls back to `existing`, so a file that failed to parse
|
|
88
|
+
// does not merge into this write — it VANISHES from it, and the auth token
|
|
89
|
+
// with it. Signing someone out is not a repair for a file we could not read.
|
|
86
90
|
let existing = {};
|
|
87
|
-
|
|
88
|
-
|
|
91
|
+
if (fs.existsSync(PRIMARY_CONFIG)) {
|
|
92
|
+
try {
|
|
89
93
|
existing = JSON.parse(fs.readFileSync(PRIMARY_CONFIG, 'utf8'));
|
|
94
|
+
} catch (e) {
|
|
95
|
+
console.error('Refusing to write config: the file on disk could not be read.', e.message);
|
|
96
|
+
return false;
|
|
90
97
|
}
|
|
91
|
-
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// "Did the caller mention this field?" — distinct from "is its value truthy".
|
|
101
|
+
// The fallbacks below are truthy-or-existing, which cannot express a removal:
|
|
102
|
+
// removeKeypair() deleted the keys and save() restored them from disk, so
|
|
103
|
+
// vibe_token's "old local keys removed" was never true. For the fields where
|
|
104
|
+
// an explicit empty value is a real instruction, presence decides.
|
|
105
|
+
const has = (k) => !!config && Object.prototype.hasOwnProperty.call(config, k);
|
|
92
106
|
|
|
93
107
|
// Save to primary config (~/.vibe/config.json)
|
|
94
108
|
const data = {
|
|
95
109
|
username: config.handle || config.username || existing.username,
|
|
96
|
-
|
|
110
|
+
// An empty one_liner is a person clearing what they're working on, not an
|
|
111
|
+
// absent update; the truthy chain kept showing the previous line forever.
|
|
112
|
+
workingOn: has('one_liner') ? config.one_liner
|
|
113
|
+
: has('workingOn') ? config.workingOn
|
|
114
|
+
: existing.workingOn,
|
|
97
115
|
createdAt: config.createdAt || existing.createdAt || new Date().toISOString().split('T')[0],
|
|
98
116
|
// AIRC keypair (persisted across sessions)
|
|
99
|
-
publicKey: config.publicKey
|
|
100
|
-
privateKey: config.privateKey
|
|
117
|
+
publicKey: has('publicKey') ? (config.publicKey ?? null) : (existing.publicKey || null),
|
|
118
|
+
privateKey: has('privateKey') ? (config.privateKey ?? null) : (existing.privateKey || null),
|
|
101
119
|
// Guided mode (AskUserQuestion menus)
|
|
102
120
|
guided_mode: config.guided_mode !== undefined ? config.guided_mode : existing.guided_mode,
|
|
103
121
|
// GitHub Activity settings
|
|
@@ -107,8 +125,43 @@ function save(config) {
|
|
|
107
125
|
authToken: config.authToken || config.privyToken || existing.authToken || existing.privyToken || null,
|
|
108
126
|
authMethod: config.authMethod || existing.authMethod || null
|
|
109
127
|
};
|
|
128
|
+
// Fields this function does not enumerate — x_credentials, firstDmSent,
|
|
129
|
+
// pendingAuth, visible — used to vanish on every save, because the object
|
|
130
|
+
// above is built field by field.
|
|
131
|
+
//
|
|
132
|
+
// Two layers are needed, not one. Spreading `existing` keeps what was already
|
|
133
|
+
// on disk (each key in `data` already falls back to its existing value, so the
|
|
134
|
+
// overlay never replaces a real value with a null it invented). But callers
|
|
135
|
+
// also SET these fields — `cfg.pendingAuth = true`, `cfg.visible = true`,
|
|
136
|
+
// `save({firstDmSent: true})` — and those writes were dropped just as
|
|
137
|
+
// silently. Keeping only `existing` would preserve the old value and still
|
|
138
|
+
// ignore the update, which reads as working and isn't. So the caller's own
|
|
139
|
+
// non-translated keys go on top of `existing` and under `data`.
|
|
140
|
+
//
|
|
141
|
+
// TRANSLATED names are excluded because they are aliases the block above
|
|
142
|
+
// already resolved; passing them through would write both spellings.
|
|
143
|
+
const TRANSLATED = new Set([
|
|
144
|
+
'handle', 'one_liner', 'username', 'workingOn', 'createdAt',
|
|
145
|
+
'publicKey', 'privateKey', 'guided_mode', 'authToken', 'privyToken',
|
|
146
|
+
'authMethod', 'github_activity_enabled', 'github_activity_privacy',
|
|
147
|
+
]);
|
|
148
|
+
const fromCaller = {};
|
|
149
|
+
for (const [k, v] of Object.entries(config || {})) {
|
|
150
|
+
if (!TRANSLATED.has(k)) fromCaller[k] = v;
|
|
151
|
+
}
|
|
152
|
+
const merged = { ...existing, ...fromCaller, ...data };
|
|
153
|
+
|
|
110
154
|
// 0600: this file carries the auth token — it is a credential, not a preference.
|
|
111
|
-
|
|
155
|
+
const tmp = `${PRIMARY_CONFIG}.${process.pid}.${Date.now()}.tmp`;
|
|
156
|
+
try {
|
|
157
|
+
fs.writeFileSync(tmp, JSON.stringify(merged, null, 2), { mode: 0o600 });
|
|
158
|
+
fs.renameSync(tmp, PRIMARY_CONFIG);
|
|
159
|
+
} catch (e) {
|
|
160
|
+
try { fs.unlinkSync(tmp); } catch {}
|
|
161
|
+
console.error('Failed to save config:', e.message);
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
return true;
|
|
112
165
|
}
|
|
113
166
|
|
|
114
167
|
function getHandle() {
|
|
@@ -198,6 +251,19 @@ function generateSessionId() {
|
|
|
198
251
|
return 'sess_' + Date.now().toString(36) + Math.random().toString(36).substring(2, 10);
|
|
199
252
|
}
|
|
200
253
|
|
|
254
|
+
// Distinguishes "no session yet" (absent) from "unreadable" (present, corrupt),
|
|
255
|
+
// which getSessionData() cannot: both come back as null.
|
|
256
|
+
function sessionFileIsReadable() {
|
|
257
|
+
if (!fs.existsSync(SESSION_FILE)) return true;
|
|
258
|
+
try {
|
|
259
|
+
const content = fs.readFileSync(SESSION_FILE, 'utf8').trim();
|
|
260
|
+
if (content.startsWith('{')) JSON.parse(content);
|
|
261
|
+
return true;
|
|
262
|
+
} catch (e) {
|
|
263
|
+
return false;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
201
267
|
function getSessionData() {
|
|
202
268
|
try {
|
|
203
269
|
if (fs.existsSync(SESSION_FILE)) {
|
|
@@ -215,7 +281,20 @@ function getSessionData() {
|
|
|
215
281
|
|
|
216
282
|
function saveSessionData(data) {
|
|
217
283
|
ensureDir();
|
|
218
|
-
|
|
284
|
+
if (!sessionFileIsReadable()) {
|
|
285
|
+
console.error('Refusing to write session data: the file on disk could not be read.');
|
|
286
|
+
return false;
|
|
287
|
+
}
|
|
288
|
+
const tmp = `${SESSION_FILE}.${process.pid}.${Date.now()}.tmp`;
|
|
289
|
+
try {
|
|
290
|
+
fs.writeFileSync(tmp, JSON.stringify(data, null, 2));
|
|
291
|
+
fs.renameSync(tmp, SESSION_FILE);
|
|
292
|
+
} catch (e) {
|
|
293
|
+
try { fs.unlinkSync(tmp); } catch {}
|
|
294
|
+
console.error('Failed to save session data:', e.message);
|
|
295
|
+
return false;
|
|
296
|
+
}
|
|
297
|
+
return true;
|
|
219
298
|
}
|
|
220
299
|
|
|
221
300
|
function getSessionId() {
|
|
@@ -375,9 +454,11 @@ const hasPrivyAuth = hasOAuth;
|
|
|
375
454
|
*/
|
|
376
455
|
function removeKeypair() {
|
|
377
456
|
const cfg = load();
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
457
|
+
// Explicitly null, not deleted: an absent key means "no instruction" to
|
|
458
|
+
// save(), and the old value came straight back off disk.
|
|
459
|
+
cfg.publicKey = null;
|
|
460
|
+
cfg.privateKey = null;
|
|
461
|
+
const saved = save(cfg);
|
|
381
462
|
|
|
382
463
|
// Also clear from session
|
|
383
464
|
const data = getSessionData();
|
|
@@ -386,6 +467,7 @@ function removeKeypair() {
|
|
|
386
467
|
delete data.privateKey;
|
|
387
468
|
saveSessionData(data);
|
|
388
469
|
}
|
|
470
|
+
return saved;
|
|
389
471
|
}
|
|
390
472
|
|
|
391
473
|
/**
|
package/incoming.js
CHANGED
|
@@ -111,4 +111,43 @@ function inertField(text, maxLen = 80) {
|
|
|
111
111
|
return flat.length > maxLen ? flat.slice(0, maxLen - 1) + '\u2026' : flat;
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
-
|
|
114
|
+
/**
|
|
115
|
+
* inertField, plus the markup a rendered surface can act on.
|
|
116
|
+
*
|
|
117
|
+
* inertField defangs terminal/agent structure (control chars, bidi, backticks,
|
|
118
|
+
* brackets). It deliberately leaves HTML and Markdown alone, which is fine for
|
|
119
|
+
* a plain terminal line and NOT fine anywhere a client renders markup: `<br>`
|
|
120
|
+
* forges a new row, `<details>` hides text, `**x**` and a leading bullet fake
|
|
121
|
+
* structure (review P1 on the people list). This makes those inert while
|
|
122
|
+
* keeping every word visible — defanged, never censored.
|
|
123
|
+
*/
|
|
124
|
+
function inertMarkup(text, maxLen = 80) {
|
|
125
|
+
return inertField(text, maxLen)
|
|
126
|
+
.replaceAll('<', '\u2039') // ‹ — no HTML element can form
|
|
127
|
+
.replaceAll('>', '\u203a') // ›
|
|
128
|
+
.replaceAll('*', '\u2217') // ∗ — no bold/italic/bullet
|
|
129
|
+
.replaceAll('|', '\u2502') // │ — no table row
|
|
130
|
+
.replace(/^[\s\u2022•\-+#]+/, '') // no forged list item or heading
|
|
131
|
+
// Emphasis pairs across the WHOLE rendered document, not within one field
|
|
132
|
+
// (review P1): a lone `_` here can close an emphasis the surface itself
|
|
133
|
+
// opened, or pair with another row. So every `_` and `~` in foreign PROSE
|
|
134
|
+
// is defanged unconditionally. Identity is handled separately — see
|
|
135
|
+
// inertIdentity, which preserves a handle exactly by other means.
|
|
136
|
+
.replace(/_/g, '\u2017')
|
|
137
|
+
.replace(/~/g, '\u223c');
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* A handle must render EXACTLY — mangling someone's identity to defang markup
|
|
142
|
+
* is its own dishonesty. So it is emitted inside an inline code span, where
|
|
143
|
+
* no emphasis, HTML or bullet can form, and where the fence itself cannot be
|
|
144
|
+
* broken (inertField has already neutralized backticks in foreign text).
|
|
145
|
+
*/
|
|
146
|
+
function inertIdentity(text, maxLen = 40) {
|
|
147
|
+
return '`' + inertField(text, maxLen)
|
|
148
|
+
.replaceAll('<', '\u2039')
|
|
149
|
+
.replaceAll('>', '\u203a')
|
|
150
|
+
.replace(/^[\s\u2022•\-+#]+/, '') + '`';
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
module.exports = { renderIncoming, neutralize, scrub, inertField, inertMarkup, inertIdentity, MSG_OPEN, MSG_CLOSE, MAX_BODY };
|
package/index.js
CHANGED
|
@@ -52,7 +52,16 @@ const SERVER_CAPABILITIES = {
|
|
|
52
52
|
// ambient footer performs platform requests, so remember/reflect/call and
|
|
53
53
|
// the manifest never carry it.
|
|
54
54
|
const SKIP_FOOTER_TOOLS = ['vibe_init', 'vibe_doctor', 'vibe_test', 'vibe_update',
|
|
55
|
-
'vibe_capabilities', 'vibe_remember', 'vibe_reflect', 'vibe_call'
|
|
55
|
+
'vibe_capabilities', 'vibe_remember', 'vibe_reflect', 'vibe_call',
|
|
56
|
+
// The people actions each end in ONE obvious next action; the ambient
|
|
57
|
+
// footer would stack a second one AND name a specific recipient to reply
|
|
58
|
+
// to — the choosing is the human's, so the footer stays off here.
|
|
59
|
+
'vibe_people', 'vibe_list_me', 'vibe_unlist_me',
|
|
60
|
+
// The first screen states the unread count itself and shows ids rather than
|
|
61
|
+
// bodies. The footer would state that count a SECOND time from a different
|
|
62
|
+
// (cached) source — the two disagreed in a real session — and re-render the
|
|
63
|
+
// message bodies the screen deliberately withholds.
|
|
64
|
+
'vibe_start'];
|
|
56
65
|
|
|
57
66
|
// Progressive disclosure: only these tools are visible before authentication
|
|
58
67
|
// After auth, the full toolset is revealed via tools/list_changed notification
|
|
@@ -326,6 +335,14 @@ const kernelTools = {
|
|
|
326
335
|
vibe_remember: require('./tools/remember'),
|
|
327
336
|
vibe_reflect: require('./tools/reflect'),
|
|
328
337
|
vibe_call: require('./tools/call'),
|
|
338
|
+
|
|
339
|
+
// ── People (opt-in discovery, platform#345 / vibe-mcp#28) ──────────────
|
|
340
|
+
// `vibe who` is who is present NOW; `vibe people` is who chose to be
|
|
341
|
+
// findable, online or not. Listing is always the person's own act — no
|
|
342
|
+
// path anywhere may set it for them.
|
|
343
|
+
vibe_people: require('./tools/people'),
|
|
344
|
+
vibe_list_me: require('./tools/list-me'),
|
|
345
|
+
vibe_unlist_me: require('./tools/unlist-me'),
|
|
329
346
|
};
|
|
330
347
|
|
|
331
348
|
// ─── Extras ──────────────────────────────────────────────────────────────
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "slashvibe-mcp",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.22",
|
|
4
4
|
"mcpName": "io.github.vibecodinginc/vibe",
|
|
5
5
|
"description": "Presence + messaging for terminal coding agents (Claude Code, Codex, Cursor) \u2014 the /vibe kernel",
|
|
6
6
|
"main": "index.js",
|
|
@@ -109,8 +109,10 @@
|
|
|
109
109
|
"tools/inbox.js",
|
|
110
110
|
"tools/init.js",
|
|
111
111
|
"tools/intro.js",
|
|
112
|
+
"tools/list-me.js",
|
|
112
113
|
"tools/mind.js",
|
|
113
114
|
"tools/patterns.js",
|
|
115
|
+
"tools/people.js",
|
|
114
116
|
"tools/play.js",
|
|
115
117
|
"tools/poem.js",
|
|
116
118
|
"tools/reflect.js",
|
|
@@ -122,6 +124,7 @@
|
|
|
122
124
|
"tools/summarize.js",
|
|
123
125
|
"tools/test.js",
|
|
124
126
|
"tools/token.js",
|
|
127
|
+
"tools/unlist-me.js",
|
|
125
128
|
"tools/update.js",
|
|
126
129
|
"tools/weave.js",
|
|
127
130
|
"tools/who.js",
|
package/scripts/verb-canary.mjs
CHANGED
|
@@ -12,7 +12,7 @@ import { join, dirname } from 'node:path';
|
|
|
12
12
|
import { fileURLToPath } from 'node:url';
|
|
13
13
|
|
|
14
14
|
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
15
|
-
const VERBS = ['vibe_capabilities', 'vibe_remember', 'vibe_reflect', 'vibe_call', 'vibe_dm', 'vibe_inbox', 'vibe_reply'];
|
|
15
|
+
const VERBS = ['vibe_capabilities', 'vibe_remember', 'vibe_reflect', 'vibe_call', 'vibe_dm', 'vibe_inbox', 'vibe_reply', 'vibe_people', 'vibe_list_me', 'vibe_unlist_me'];
|
|
16
16
|
const LEGAL = new Set(['granted', 'available', 'off', 'unavailable']);
|
|
17
17
|
|
|
18
18
|
function rpc(child, msg) {
|
package/store/api.js
CHANGED
|
@@ -354,11 +354,21 @@ async function getTypingUsers(forHandle) {
|
|
|
354
354
|
}
|
|
355
355
|
}
|
|
356
356
|
|
|
357
|
-
async function
|
|
357
|
+
async function getActiveUsersInner() {
|
|
358
358
|
try {
|
|
359
359
|
const endpoint = USE_V2_PRESENCE ? '/api/v2/presence' : '/api/presence';
|
|
360
360
|
const result = await request('GET', endpoint);
|
|
361
361
|
|
|
362
|
+
// request() RESOLVES on transport failure ({success:false, network:true}),
|
|
363
|
+
// so without this the lists below are simply absent and a dead network maps
|
|
364
|
+
// to a successful empty room. 401 is deliberately excluded: that is the
|
|
365
|
+
// "signed out, here are public counts" path handled below, not a failure.
|
|
366
|
+
if (result?.success === false && result.statusCode !== 401) {
|
|
367
|
+
const err = new Error(result.error || 'presence request failed');
|
|
368
|
+
err.code = result.network ? 'transport_failed' : `http_${result.statusCode || 'error'}`;
|
|
369
|
+
throw err;
|
|
370
|
+
}
|
|
371
|
+
|
|
362
372
|
// Combine active + away, plus any AGENTS currently live in a room (e.g.
|
|
363
373
|
// @coltrane hosting the cantina). Agents live in their own array; without
|
|
364
374
|
// this a live agent host never reached the footer's live-room line.
|
|
@@ -446,10 +456,38 @@ async function getActiveUsers() {
|
|
|
446
456
|
return mappedUsers;
|
|
447
457
|
} catch (e) {
|
|
448
458
|
console.error('Who failed:', e.message);
|
|
449
|
-
|
|
459
|
+
const err = new Error(e?.message || 'presence read failed');
|
|
460
|
+
err.code = e?.code || 'transport_failed';
|
|
461
|
+
throw err;
|
|
450
462
|
}
|
|
451
463
|
}
|
|
452
464
|
|
|
465
|
+
// The same outcome-preserving shape the inbox uses: flattening a failed
|
|
466
|
+
// presence read to [] made "0 others here" a claim nobody verified.
|
|
467
|
+
async function getActiveUsersResult() {
|
|
468
|
+
try {
|
|
469
|
+
const users = await getActiveUsersInner();
|
|
470
|
+
if (!Array.isArray(users)) return { ok: false, users: [], error: 'malformed_response' };
|
|
471
|
+
// Signed out is not an empty room either — the server told us it would not
|
|
472
|
+
// say who is here. A caller rendering a count must not treat that as zero.
|
|
473
|
+
//
|
|
474
|
+
// The ARRAY ITSELF is returned, not a fresh []: it carries `anonymous` and
|
|
475
|
+
// `counts` as non-enumerable properties, and who.js reads them to say "4
|
|
476
|
+
// people are here, sign in to see who". Substituting a bare [] there turned
|
|
477
|
+
// that back into "Quiet right now — you're the only one here", the exact
|
|
478
|
+
// sentence those properties exist to prevent.
|
|
479
|
+
if (users.anonymous) return { ok: false, users, error: 'unauthenticated' };
|
|
480
|
+
return { ok: true, users };
|
|
481
|
+
} catch (e) {
|
|
482
|
+
return { ok: false, users: [], error: e?.code || 'transport_failed', message: e?.message };
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// Named callers keep the old shape: an empty list on failure, as before.
|
|
487
|
+
async function getActiveUsers() {
|
|
488
|
+
return (await getActiveUsersResult()).users;
|
|
489
|
+
}
|
|
490
|
+
|
|
453
491
|
async function setVisibility(handle, visible) {
|
|
454
492
|
try {
|
|
455
493
|
const endpoint = USE_V2_PRESENCE ? '/api/v2/presence' : '/api/presence';
|
|
@@ -600,7 +638,30 @@ async function sendMessage(from, to, body, type = 'dm', payload = null, options
|
|
|
600
638
|
}
|
|
601
639
|
}
|
|
602
640
|
|
|
641
|
+
/**
|
|
642
|
+
* The inbox, and whether it was actually read.
|
|
643
|
+
*
|
|
644
|
+
* getInbox() swallows transport failures into [] for its many callers, which
|
|
645
|
+
* makes "no threads" and "could not ask" the same value — and a caller that
|
|
646
|
+
* renders a claim from that (vibe_start did) states a fact nobody has
|
|
647
|
+
* (review P1). This is the same call with the outcome kept:
|
|
648
|
+
* { ok: true, threads } the server answered
|
|
649
|
+
* { ok: false, threads: [], error } nobody answered, or the API refused
|
|
650
|
+
*/
|
|
651
|
+
async function getInboxResult(handle) {
|
|
652
|
+
try {
|
|
653
|
+
const threads = await getInboxInner(handle);
|
|
654
|
+
return Array.isArray(threads) ? { ok: true, threads } : { ok: false, threads: [], error: 'malformed_response' };
|
|
655
|
+
} catch (e) {
|
|
656
|
+
return { ok: false, threads: [], error: e?.code || 'transport_failed', message: e?.message };
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
|
|
603
660
|
async function getInbox(handle) {
|
|
661
|
+
return (await getInboxResult(handle)).threads;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
async function getInboxInner(handle) {
|
|
604
665
|
try {
|
|
605
666
|
// V2: Use threads endpoint (Postgres-backed, cross-client sync)
|
|
606
667
|
if (USE_V2_MESSAGES) {
|
|
@@ -629,8 +690,10 @@ async function getInbox(handle) {
|
|
|
629
690
|
// V1 fallback
|
|
630
691
|
return getInboxV1(handle);
|
|
631
692
|
} catch (e) {
|
|
693
|
+
// Rethrow: getInboxResult owns the outcome now, and getInbox() still
|
|
694
|
+
// presents [] to every caller that only wants the list.
|
|
632
695
|
console.error('Inbox failed:', e.message);
|
|
633
|
-
|
|
696
|
+
throw e;
|
|
634
697
|
}
|
|
635
698
|
}
|
|
636
699
|
|
|
@@ -640,10 +703,15 @@ async function getInboxV1(handle) {
|
|
|
640
703
|
// /api/messages now returns V2 format: { threads, total_unread }
|
|
641
704
|
const result = await request('GET', `/api/messages?user=${handle}`);
|
|
642
705
|
|
|
643
|
-
//
|
|
706
|
+
// An API-level refusal (auth failure, server error) is a FAILED read, not
|
|
707
|
+
// an empty inbox. Returning [] here made "the server said no" and "you
|
|
708
|
+
// have no threads" the same value — the same swallow the transport path
|
|
709
|
+
// had, one layer down (review P1).
|
|
644
710
|
if (result.success === false) {
|
|
645
711
|
console.error('[getInbox] API error:', result.error, result.message);
|
|
646
|
-
|
|
712
|
+
const err = new Error(result.message || result.error || 'inbox_refused');
|
|
713
|
+
err.code = result.error || 'inbox_refused';
|
|
714
|
+
throw err;
|
|
647
715
|
}
|
|
648
716
|
|
|
649
717
|
// V2 format: map threads to expected format
|
|
@@ -678,7 +746,7 @@ async function getInboxV1(handle) {
|
|
|
678
746
|
}));
|
|
679
747
|
} catch (e) {
|
|
680
748
|
console.error('Inbox v1 failed:', e.message);
|
|
681
|
-
|
|
749
|
+
throw e;
|
|
682
750
|
}
|
|
683
751
|
}
|
|
684
752
|
|
|
@@ -1315,8 +1383,70 @@ async function setNotificationPace(pace) {
|
|
|
1315
1383
|
}
|
|
1316
1384
|
}
|
|
1317
1385
|
|
|
1386
|
+
/**
|
|
1387
|
+
* The people list (opt-in discovery, platform#345). Two contained calls:
|
|
1388
|
+
* flip your OWN listed flag, and read the people who chose to be findable.
|
|
1389
|
+
* Nothing here ranks, recommends, or lists anyone automatically.
|
|
1390
|
+
*/
|
|
1391
|
+
/**
|
|
1392
|
+
* The two decisions the people helpers make, as pure functions so the pins
|
|
1393
|
+
* exercise the REAL logic rather than a stubbed result (review P2).
|
|
1394
|
+
*/
|
|
1395
|
+
function interpretListedResponse(result, want) {
|
|
1396
|
+
if (!result || result.success === false) {
|
|
1397
|
+
return { ok: false, error: result?.error || 'request_failed', message: result?.message };
|
|
1398
|
+
}
|
|
1399
|
+
const echoed = result?.user?.listed;
|
|
1400
|
+
if (typeof echoed !== 'boolean') {
|
|
1401
|
+
return { ok: false, error: 'unconfirmed', message: 'the server did not say whether the change took' };
|
|
1402
|
+
}
|
|
1403
|
+
if (echoed !== want) {
|
|
1404
|
+
return { ok: false, error: 'unconfirmed', message: `the server reports listed=${echoed}` };
|
|
1405
|
+
}
|
|
1406
|
+
return { ok: true, listed: echoed };
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
function interpretDirectoryResponse(result) {
|
|
1410
|
+
if (!result || result.success === false) {
|
|
1411
|
+
return { ok: false, error: result?.error || 'request_failed', message: result?.message };
|
|
1412
|
+
}
|
|
1413
|
+
if (!Array.isArray(result.listings)) {
|
|
1414
|
+
return { ok: false, error: 'malformed_response', message: 'the list came back without entries' };
|
|
1415
|
+
}
|
|
1416
|
+
return { ok: true, listings: result.listings, count: result.count, note: result.note };
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
async function setListed(listed, building) {
|
|
1420
|
+
const want = listed === true;
|
|
1421
|
+
const body = { listed: want };
|
|
1422
|
+
// `building` rides along ONLY when the person supplied it with the action —
|
|
1423
|
+
// the platform's contained self-profile write owns both fields.
|
|
1424
|
+
if (typeof building === 'string' && building.trim()) body.building = building.trim();
|
|
1425
|
+
try {
|
|
1426
|
+
// The SERVER'S ECHO is the fact, not the absence of an error (review P1).
|
|
1427
|
+
return interpretListedResponse(await request('POST', '/api/users', body, { auth: true }), want);
|
|
1428
|
+
} catch (e) {
|
|
1429
|
+
return { ok: false, error: 'transport_failed', message: e.message };
|
|
1430
|
+
}
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
async function getPeople() {
|
|
1434
|
+
try {
|
|
1435
|
+
// A response without a listings ARRAY is malformed — reporting it as an
|
|
1436
|
+
// empty list would turn a read failure into "nobody is here" (review P2).
|
|
1437
|
+
return interpretDirectoryResponse(await request('GET', '/api/directory'));
|
|
1438
|
+
} catch (e) {
|
|
1439
|
+
return { ok: false, error: 'transport_failed', message: e.message };
|
|
1440
|
+
}
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1318
1443
|
module.exports = {
|
|
1319
1444
|
setNotificationPace,
|
|
1445
|
+
// People (opt-in discovery)
|
|
1446
|
+
setListed,
|
|
1447
|
+
getPeople,
|
|
1448
|
+
interpretListedResponse,
|
|
1449
|
+
interpretDirectoryResponse,
|
|
1320
1450
|
// Session
|
|
1321
1451
|
registerSession,
|
|
1322
1452
|
setSessionId,
|
|
@@ -1325,6 +1455,7 @@ module.exports = {
|
|
|
1325
1455
|
// Presence
|
|
1326
1456
|
heartbeat,
|
|
1327
1457
|
getActiveUsers,
|
|
1458
|
+
getActiveUsersResult,
|
|
1328
1459
|
setVisibility,
|
|
1329
1460
|
sendTypingIndicator,
|
|
1330
1461
|
getTypingUsers,
|
|
@@ -1332,6 +1463,7 @@ module.exports = {
|
|
|
1332
1463
|
// Messages
|
|
1333
1464
|
sendMessage,
|
|
1334
1465
|
getInbox,
|
|
1466
|
+
getInboxResult,
|
|
1335
1467
|
getRawInbox,
|
|
1336
1468
|
getUnreadCount,
|
|
1337
1469
|
getThread,
|
package/store/local.js
CHANGED
|
@@ -38,8 +38,33 @@ function loadPresence() {
|
|
|
38
38
|
return {};
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
function presenceIsReadable() {
|
|
42
|
+
if (!fs.existsSync(PRESENCE_FILE)) return true; // absent = a real first write
|
|
43
|
+
try {
|
|
44
|
+
JSON.parse(fs.readFileSync(PRESENCE_FILE, 'utf8'));
|
|
45
|
+
return true;
|
|
46
|
+
} catch (e) {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
41
51
|
function savePresence(presence) {
|
|
42
|
-
|
|
52
|
+
// loadPresence() turns an unreadable file into {}, so an unguarded save would
|
|
53
|
+
// drop every other person's presence record.
|
|
54
|
+
if (!presenceIsReadable()) {
|
|
55
|
+
console.error('Refusing to write presence: the file on disk could not be read.');
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
const tmp = `${PRESENCE_FILE}.${process.pid}.${Date.now()}.tmp`;
|
|
59
|
+
try {
|
|
60
|
+
fs.writeFileSync(tmp, JSON.stringify(presence, null, 2));
|
|
61
|
+
fs.renameSync(tmp, PRESENCE_FILE);
|
|
62
|
+
} catch (e) {
|
|
63
|
+
try { fs.unlinkSync(tmp); } catch {}
|
|
64
|
+
console.error('Failed to save presence:', e.message);
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
return true;
|
|
43
68
|
}
|
|
44
69
|
|
|
45
70
|
async function heartbeat(handle, one_liner) {
|
|
@@ -53,6 +78,11 @@ async function heartbeat(handle, one_liner) {
|
|
|
53
78
|
savePresence(presence);
|
|
54
79
|
}
|
|
55
80
|
|
|
81
|
+
async function getActiveUsersResult() {
|
|
82
|
+
if (!presenceIsReadable()) return { ok: false, users: [], error: 'local_corrupt' };
|
|
83
|
+
return { ok: true, users: await getActiveUsers() };
|
|
84
|
+
}
|
|
85
|
+
|
|
56
86
|
async function getActiveUsers() {
|
|
57
87
|
const presence = loadPresence();
|
|
58
88
|
const now = Date.now();
|
|
@@ -87,16 +117,38 @@ async function setVisibility(handle, visible) {
|
|
|
87
117
|
|
|
88
118
|
// ============ MESSAGES ============
|
|
89
119
|
|
|
120
|
+
/**
|
|
121
|
+
* The messages file, with failures preserved.
|
|
122
|
+
*
|
|
123
|
+
* A file that does not exist yet IS an empty inbox — that is a real answer.
|
|
124
|
+
* A file that cannot be read, or whose lines do not parse, is NOT: it is a
|
|
125
|
+
* failed read, and flattening it to [] makes "nothing has happened" and
|
|
126
|
+
* "something is wrong" the same value (review P1 — the same swallow the API
|
|
127
|
+
* store had, two layers down).
|
|
128
|
+
*/
|
|
129
|
+
function loadMessagesStrict() {
|
|
130
|
+
if (!fs.existsSync(MESSAGES_FILE)) return [];
|
|
131
|
+
const content = fs.readFileSync(MESSAGES_FILE, 'utf8');
|
|
132
|
+
return content.trim().split('\n')
|
|
133
|
+
.filter(line => line.length > 0)
|
|
134
|
+
.map((line, i) => {
|
|
135
|
+
try {
|
|
136
|
+
return JSON.parse(line);
|
|
137
|
+
} catch (e) {
|
|
138
|
+
const err = new Error(`messages.jsonl line ${i + 1} is not valid JSON`);
|
|
139
|
+
err.code = 'local_corrupt';
|
|
140
|
+
throw err;
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Unchanged contract for every caller that only wants the list.
|
|
90
146
|
function loadMessages() {
|
|
91
147
|
try {
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
.map(line => JSON.parse(line));
|
|
97
|
-
}
|
|
98
|
-
} catch (e) {}
|
|
99
|
-
return [];
|
|
148
|
+
return loadMessagesStrict();
|
|
149
|
+
} catch (e) {
|
|
150
|
+
return [];
|
|
151
|
+
}
|
|
100
152
|
}
|
|
101
153
|
|
|
102
154
|
function appendMessage(msg) {
|
|
@@ -127,6 +179,31 @@ async function getInbox(handle) {
|
|
|
127
179
|
.sort((a, b) => b.timestamp - a.timestamp);
|
|
128
180
|
}
|
|
129
181
|
|
|
182
|
+
/**
|
|
183
|
+
* The inbox, and whether it was actually read — the same contract the API
|
|
184
|
+
* store provides (store/api.js). Both implementations must answer it, or a
|
|
185
|
+
* caller that distinguishes "empty" from "could not ask" silently gets the
|
|
186
|
+
* wrong answer in the other mode (review P1: with VIBE_LOCAL=true, a missing
|
|
187
|
+
* method made every start claim the read had failed).
|
|
188
|
+
*
|
|
189
|
+
* A local file read either produces the list or throws; there is no partial
|
|
190
|
+
* or refused outcome to represent.
|
|
191
|
+
*/
|
|
192
|
+
async function getInboxResult(handle) {
|
|
193
|
+
try {
|
|
194
|
+
// The STRICT loader: getInbox() flattens a corrupt or unreadable file to
|
|
195
|
+
// [], which is exactly the fact this wrapper exists to preserve.
|
|
196
|
+
const messages = loadMessagesStrict();
|
|
197
|
+
const h = handle.toLowerCase().replace('@', '');
|
|
198
|
+
const threads = messages
|
|
199
|
+
.filter((m) => m.to === h)
|
|
200
|
+
.sort((a, b) => b.timestamp - a.timestamp);
|
|
201
|
+
return { ok: true, threads };
|
|
202
|
+
} catch (e) {
|
|
203
|
+
return { ok: false, threads: [], error: e?.code || 'local_read_failed', message: e?.message };
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
130
207
|
async function getUnreadCount(handle) {
|
|
131
208
|
const inbox = await getInbox(handle);
|
|
132
209
|
return inbox.filter(m => !m.read_at).length;
|
|
@@ -152,7 +229,19 @@ async function getThread(myHandle, theirHandle) {
|
|
|
152
229
|
}
|
|
153
230
|
|
|
154
231
|
async function markThreadRead(myHandle, theirHandle) {
|
|
155
|
-
|
|
232
|
+
// A READ-MODIFY-WRITE over the whole file must never run on a swallowed
|
|
233
|
+
// read (review P1 — DATA LOSS): loadMessages() returns [] for a corrupt or
|
|
234
|
+
// unreadable file, and the rewrite below would then replace every message,
|
|
235
|
+
// including the valid ones, with an empty file. A read that did not succeed
|
|
236
|
+
// is not permission to write; the mark is abandoned and the file is left
|
|
237
|
+
// exactly as it is.
|
|
238
|
+
let messages;
|
|
239
|
+
try {
|
|
240
|
+
messages = loadMessagesStrict();
|
|
241
|
+
} catch (e) {
|
|
242
|
+
console.error('[local] not marking read — the messages file could not be read:', e.message);
|
|
243
|
+
return { success: false, error: e.code || 'local_read_failed' };
|
|
244
|
+
}
|
|
156
245
|
const me = myHandle.toLowerCase().replace('@', '');
|
|
157
246
|
const them = theirHandle.toLowerCase().replace('@', '');
|
|
158
247
|
const now = Date.now();
|
|
@@ -165,8 +254,21 @@ async function markThreadRead(myHandle, theirHandle) {
|
|
|
165
254
|
return m;
|
|
166
255
|
});
|
|
167
256
|
|
|
168
|
-
// Rewrite the file
|
|
169
|
-
|
|
257
|
+
// Rewrite the file. Reached only from a read that actually succeeded, and
|
|
258
|
+
// written via a temp file + rename so an interrupted write cannot leave a
|
|
259
|
+
// half-file behind either.
|
|
260
|
+
// A per-write temp name: a fixed one collides between concurrent marks, and
|
|
261
|
+
// a failed rename would leave it behind (review P2).
|
|
262
|
+
const tmp = `${MESSAGES_FILE}.${process.pid}.${Date.now()}.tmp`;
|
|
263
|
+
try {
|
|
264
|
+
fs.writeFileSync(tmp, updated.map(m => JSON.stringify(m)).join('\n') + '\n');
|
|
265
|
+
fs.renameSync(tmp, MESSAGES_FILE);
|
|
266
|
+
} catch (e) {
|
|
267
|
+
try { fs.unlinkSync(tmp); } catch {}
|
|
268
|
+
console.error('[local] mark-read write failed; the file is unchanged:', e.message);
|
|
269
|
+
return { success: false, error: e.code || 'local_write_failed' };
|
|
270
|
+
}
|
|
271
|
+
return { success: true };
|
|
170
272
|
}
|
|
171
273
|
|
|
172
274
|
// ============ SKILL EXCHANGES ============
|
|
@@ -239,10 +341,12 @@ module.exports = {
|
|
|
239
341
|
// Presence
|
|
240
342
|
heartbeat,
|
|
241
343
|
getActiveUsers,
|
|
344
|
+
getActiveUsersResult,
|
|
242
345
|
setVisibility,
|
|
243
346
|
|
|
244
347
|
// Messages
|
|
245
348
|
sendMessage,
|
|
349
|
+
getInboxResult,
|
|
246
350
|
getInbox,
|
|
247
351
|
getRawInbox,
|
|
248
352
|
getUnreadCount,
|