@playola-radio/slipstream 0.1.0 → 0.3.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/README.md +427 -101
- package/SKILL.md +4 -1
- package/contracts/interface/v2/schema.json +3 -3
- package/dist/attach-workflow.js +557 -0
- package/dist/attribution-engine.js +2 -3
- package/dist/attribution-scoring.js +70 -13
- package/dist/boundary-registry.js +25 -0
- package/dist/capture-intent.js +70 -0
- package/dist/capture-scope.js +135 -0
- package/dist/cas.js +61 -14
- package/dist/claude-root-transcript.js +18 -11
- package/dist/cli.js +426 -26
- package/dist/clip-function-parser.js +9 -2
- package/dist/clip-projection-service.js +28 -0
- package/dist/clip-projection-worker.js +4 -0
- package/dist/clip-worker-pool.js +13 -1
- package/dist/codex-root-transcript.js +46 -0
- package/dist/conductor-settings.js +178 -0
- package/dist/conductor.js +313 -0
- package/dist/control-protocol.js +2 -1
- package/dist/daemon.js +1718 -470
- package/dist/engine.js +26 -10
- package/dist/ensure-daemon.js +30 -0
- package/dist/http-reader.js +20 -8
- package/dist/intent-store.js +196 -0
- package/dist/interface-range-resolver.js +28 -1
- package/dist/launch-agent.js +179 -0
- package/dist/lock.js +1 -1
- package/dist/public-events.js +26 -2
- package/dist/question-hook.js +11 -1
- package/dist/questions.js +102 -3
- package/dist/recovery.js +62 -6
- package/dist/session.js +604 -104
- package/dist/store-reader.js +112 -7
- package/dist/task-forwarder.js +8 -8
- package/dist/transcript/discovery.js +242 -111
- package/dist/transcript/fs-io.js +10 -1
- package/dist/transcript/scheduler.js +114 -0
- package/dist/transcript/watcher.js +5 -3
- package/dist/withdraw-workflow.js +106 -0
- package/dist/workspace.js +133 -0
- package/package.json +3 -2
- package/schemas/slipstream.agent.binding.v1.json +90 -0
- package/schemas/slipstream.agent.connection.v1.json +34 -0
- package/schemas/slipstream.agent.unlinked.v1.json +68 -0
- package/schemas/slipstream.capture.gap.v1.json +1 -0
- package/schemas/slipstream.capture.ready.v1.json +55 -0
- package/schemas/slipstream.capture.scope.v1.json +85 -0
- package/schemas/slipstream.capture.stopped.v1.json +65 -0
- package/schemas/slipstream.question.dispatch_attempted.v1.json +11 -1
- package/schemas/slipstream.question.exchange_recorded.v1.json +65 -0
- package/schemas/slipstream.question.relay_updated.v1.json +64 -0
- package/schemas/slipstream.session.resumed.v1.json +4 -1
- package/schemas/slipstream.session.started.v1.json +4 -1
- package/dist/transcript/runner.js +0 -62
package/dist/questions.js
CHANGED
|
@@ -154,12 +154,111 @@ export function questionResult(event) {
|
|
|
154
154
|
}
|
|
155
155
|
/** Answer text is stored verbatim, so it is checked but never trimmed. Unpaired
|
|
156
156
|
* surrogates are refused for the same reason as in question text. */
|
|
157
|
-
export function assertAnswerText(text) {
|
|
157
|
+
export function assertAnswerText(text, maxBytes = MAX_ANSWER_BYTES) {
|
|
158
158
|
if (typeof text !== 'string' || !/\S/u.test(text) || /[\uD800-\uDFFF]/u.test(text)
|
|
159
|
-
|| Buffer.byteLength(text, 'utf8') >
|
|
160
|
-
|
|
159
|
+
|| Buffer.byteLength(text, 'utf8') > maxBytes) {
|
|
160
|
+
const limit = Number.isFinite(maxBytes) ? ` and be at most ${maxBytes} UTF-8 bytes` : '';
|
|
161
|
+
throw new QuestionError('INVALID_ANSWER', `answer text must contain a non-whitespace character${limit}`);
|
|
161
162
|
}
|
|
162
163
|
}
|
|
164
|
+
const HARNESSES = ['claude-code', 'codex'];
|
|
165
|
+
const CONTROL_CHARS = /[\u0000-\u001F\u007F-\u009F]/;
|
|
166
|
+
function conversationId(value, name) {
|
|
167
|
+
if (typeof value !== 'string' || value.length < 1 || value.length > 256 || CONTROL_CHARS.test(value)
|
|
168
|
+
|| /[\uD800-\uDFFF]/u.test(value)) {
|
|
169
|
+
throw new QuestionError('PROTOCOL', `${name} must be 1–256 characters with no control characters or unpaired surrogates`);
|
|
170
|
+
}
|
|
171
|
+
return value;
|
|
172
|
+
}
|
|
173
|
+
/** Client-reported conversation ids are unverified: only their shape is checked. The answer is never trimmed or capped. */
|
|
174
|
+
export function normalizeExchange(value) {
|
|
175
|
+
const ask = normalizeAsk(value);
|
|
176
|
+
const req = record(value);
|
|
177
|
+
assertAnswerText(req.answer_text, Infinity);
|
|
178
|
+
const conv = req.conversation;
|
|
179
|
+
if (typeof conv !== 'object' || conv === null || Array.isArray(conv)) {
|
|
180
|
+
throw new QuestionError('PROTOCOL', 'exchange requires a conversation object');
|
|
181
|
+
}
|
|
182
|
+
const c = conv;
|
|
183
|
+
if (!HARNESSES.includes(c.harness)) {
|
|
184
|
+
throw new QuestionError('PROTOCOL', `conversation.harness must be one of ${HARNESSES.join(', ')}`);
|
|
185
|
+
}
|
|
186
|
+
const parent = conversationId(c.parent_session_id, 'conversation.parent_session_id');
|
|
187
|
+
const fork = conversationId(c.fork_session_id, 'conversation.fork_session_id');
|
|
188
|
+
if (parent === fork)
|
|
189
|
+
throw new QuestionError('PROTOCOL', 'conversation parent and fork ids must differ');
|
|
190
|
+
return { ...ask, answer_text: req.answer_text, conversation: {
|
|
191
|
+
harness: c.harness, parent_session_id: parent, fork_session_id: fork,
|
|
192
|
+
}, ...(req.relay !== undefined ? { relay: normalizeRelay(req.relay) } : {}) };
|
|
193
|
+
}
|
|
194
|
+
function normalizeRelay(value) {
|
|
195
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value))
|
|
196
|
+
throw new QuestionError('PROTOCOL', 'relay must be an object');
|
|
197
|
+
const r = value;
|
|
198
|
+
if (typeof r.relay_id !== 'string' || !UUID.test(r.relay_id))
|
|
199
|
+
throw new QuestionError('PROTOCOL', 'relay.relay_id must be a canonical lowercase UUID');
|
|
200
|
+
return { relay_id: r.relay_id, chat_session_id: conversationId(r.chat_session_id, 'relay.chat_session_id') };
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Idempotency fingerprint: the sha256 hex digest of the JSON of the ask body plus the answer, the
|
|
204
|
+
* reported conversation and the relay when present (an exchange without one keeps its original digest).
|
|
205
|
+
* A digest, not the JSON itself, so the capture does not retain every answer (up to ~1 MiB each) for
|
|
206
|
+
* its lifetime.
|
|
207
|
+
*/
|
|
208
|
+
export function exchangeBody(req) {
|
|
209
|
+
const c = req.conversation;
|
|
210
|
+
const relay = req.relay ? [req.relay.relay_id, req.relay.chat_session_id] : [];
|
|
211
|
+
const json = JSON.stringify([questionBody(req), req.answer_text, c.harness, c.parent_session_id, c.fork_session_id, ...relay]);
|
|
212
|
+
return createHash('sha256').update(json).digest('hex');
|
|
213
|
+
}
|
|
214
|
+
export function exchangeResult(event) {
|
|
215
|
+
const d = event.data;
|
|
216
|
+
return Object.freeze({ session_id: d.session_id, request_id: d.request_id, question_id: d.question_id,
|
|
217
|
+
seq: event.seq, duplicate: false });
|
|
218
|
+
}
|
|
219
|
+
function normalizeUpdate(value) {
|
|
220
|
+
const u = record(value);
|
|
221
|
+
switch (u.kind) {
|
|
222
|
+
case 'sent': return { kind: 'sent', chat_session_id: conversationId(u.chat_session_id, 'update.chat_session_id') };
|
|
223
|
+
case 'picked_up': return { kind: 'picked_up' };
|
|
224
|
+
case 'not_delivered': return { kind: 'not_delivered' };
|
|
225
|
+
case 'reported':
|
|
226
|
+
if (u.outcome !== 'done' && u.outcome !== 'not_done_yet')
|
|
227
|
+
throw new QuestionError('PROTOCOL', 'update.outcome must be done or not_done_yet');
|
|
228
|
+
// Stored verbatim, like answer text.
|
|
229
|
+
if (typeof u.text !== 'string' || !/\S/u.test(u.text) || /[\uD800-\uDFFF]/u.test(u.text)) {
|
|
230
|
+
throw new QuestionError('PROTOCOL', 'update.text must contain a non-whitespace character and no unpaired surrogates');
|
|
231
|
+
}
|
|
232
|
+
return { kind: 'reported', outcome: u.outcome, text: u.text };
|
|
233
|
+
default: throw new QuestionError('PROTOCOL', 'update.kind must be sent, picked_up, not_delivered or reported');
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
/** Copy only contract fields, as normalizeAsk does. */
|
|
237
|
+
export function normalizeRelayUpdate(value) {
|
|
238
|
+
const req = record(value);
|
|
239
|
+
for (const name of ['session_id', 'request_id', 'question_id', 'relay_id']) {
|
|
240
|
+
if (typeof req[name] !== 'string' || !UUID.test(req[name]))
|
|
241
|
+
throw new QuestionError('PROTOCOL', `${name} must be a canonical lowercase UUID`);
|
|
242
|
+
}
|
|
243
|
+
return { session_id: req.session_id, request_id: req.request_id, question_id: req.question_id,
|
|
244
|
+
relay_id: req.relay_id, update: normalizeUpdate(req.update) };
|
|
245
|
+
}
|
|
246
|
+
/** Idempotency fingerprint: a digest, as for exchanges, because a report's text is unbounded. */
|
|
247
|
+
export function relayUpdateBody(req) {
|
|
248
|
+
const u = req.update;
|
|
249
|
+
const fields = u.kind === 'sent' ? [u.chat_session_id] : u.kind === 'reported' ? [u.outcome, u.text] : [];
|
|
250
|
+
return createHash('sha256').update(JSON.stringify([req.question_id, req.relay_id, u.kind, ...fields])).digest('hex');
|
|
251
|
+
}
|
|
252
|
+
export function relayUpdateResult(event) {
|
|
253
|
+
const d = event.data;
|
|
254
|
+
return Object.freeze({ session_id: d.session_id, request_id: d.request_id, question_id: d.question_id,
|
|
255
|
+
relay_id: d.relay_id, seq: event.seq, duplicate: false });
|
|
256
|
+
}
|
|
257
|
+
/** A follow-up must restate its parent's exact source context. */
|
|
258
|
+
export function sameSourceContext(parent, c) {
|
|
259
|
+
return parent !== undefined && parent.change_seq === c.change_seq && parent.path === c.path && parent.snapshot_sha256 === c.snapshot_sha256
|
|
260
|
+
&& parent.line_start === c.line_start && parent.line_end === c.line_end;
|
|
261
|
+
}
|
|
163
262
|
export function answerResult(event) {
|
|
164
263
|
const d = event.data;
|
|
165
264
|
return Object.freeze({ session_id: d.session_id, question_id: d.question_id, event_id: event.seq, seq: event.seq,
|
package/dist/recovery.js
CHANGED
|
@@ -81,16 +81,23 @@ export async function recoverSession(logPath, expectedSessionId, cas) {
|
|
|
81
81
|
const questions = [];
|
|
82
82
|
const questionAttempts = [];
|
|
83
83
|
const questionAnswers = [];
|
|
84
|
+
const questionExchanges = [];
|
|
85
|
+
const questionRelayUpdates = [];
|
|
86
|
+
const relayQuestions = new Map();
|
|
87
|
+
const exchangeQuestionIds = new Set();
|
|
84
88
|
// Keep the ordered event arrays for callers, while replay validation uses
|
|
85
89
|
// indexes so a long-lived session is replayed in one pass.
|
|
86
90
|
const queuedSeqsByQuestion = new Map();
|
|
87
|
-
const
|
|
91
|
+
const attemptSeqsByQuestion = new Map();
|
|
88
92
|
const answeredQuestionIds = new Set();
|
|
89
93
|
const verifiedBlobs = new Map(); // sha256 -> verified byte length
|
|
90
94
|
let root;
|
|
91
95
|
let maxBytes;
|
|
92
96
|
let baselineCompleted = false;
|
|
93
97
|
let currentTaskId;
|
|
98
|
+
let resumeSeq = 0n;
|
|
99
|
+
let unlinkSeq = 0n;
|
|
100
|
+
const bindingGenerations = new Set();
|
|
94
101
|
let seq = 0n;
|
|
95
102
|
const verifyBlob = async (snap, where) => {
|
|
96
103
|
if (snap.kind !== 'content')
|
|
@@ -106,8 +113,14 @@ export async function recoverSession(logPath, expectedSessionId, cas) {
|
|
|
106
113
|
try {
|
|
107
114
|
bytes = await cas.read(snap.sha256);
|
|
108
115
|
}
|
|
109
|
-
catch {
|
|
110
|
-
|
|
116
|
+
catch (err) {
|
|
117
|
+
// A missing content-addressed path proves this history cannot be replayed.
|
|
118
|
+
// Other read errors (EACCES, EIO, EMFILE) only describe the store's current
|
|
119
|
+
// availability, so keep them retryable rather than recommending deletion.
|
|
120
|
+
if (err.code === 'ENOENT') {
|
|
121
|
+
throw new CorruptLogError(`${where}: referenced blob ${snap.sha256} is missing`);
|
|
122
|
+
}
|
|
123
|
+
throw err;
|
|
111
124
|
}
|
|
112
125
|
if (bytes.length !== snap.size) {
|
|
113
126
|
throw new CorruptLogError(`${where}: blob ${snap.sha256} size ${bytes.length} != ${snap.size}`);
|
|
@@ -224,18 +237,22 @@ export async function recoverSession(logPath, expectedSessionId, cas) {
|
|
|
224
237
|
break;
|
|
225
238
|
}
|
|
226
239
|
case 'slipstream.question.dispatch_attempted.v1': {
|
|
240
|
+
// Only an unlink offers an attempted question again.
|
|
241
|
+
const attemptSeqs = attemptSeqsByQuestion.get(event.data.question_id) ?? [];
|
|
242
|
+
const lastAttempt = attemptSeqs.at(-1);
|
|
227
243
|
if (event.subject !== `question/${event.data.question_id}`
|
|
228
244
|
|| !queuedSeqsByQuestion.get(event.data.question_id)?.has(event.data.queued_seq)
|
|
229
|
-
||
|
|
245
|
+
|| (lastAttempt !== undefined && BigInt(lastAttempt) > unlinkSeq)) {
|
|
230
246
|
throw new CorruptLogError(`${at}: dispatch attempt has no unique queued question`);
|
|
231
247
|
}
|
|
232
248
|
questionAttempts.push(event);
|
|
233
|
-
|
|
249
|
+
attemptSeqs.push(event.seq);
|
|
250
|
+
attemptSeqsByQuestion.set(event.data.question_id, attemptSeqs);
|
|
234
251
|
break;
|
|
235
252
|
}
|
|
236
253
|
case 'slipstream.question.answered.v1': {
|
|
237
254
|
if (event.subject !== `question/${event.data.question_id}`
|
|
238
|
-
||
|
|
255
|
+
|| !attemptSeqsByQuestion.get(event.data.question_id)?.includes(event.data.attempt_seq)
|
|
239
256
|
|| answeredQuestionIds.has(event.data.question_id)) {
|
|
240
257
|
throw new CorruptLogError(`${at}: answer has no unique attempted question`);
|
|
241
258
|
}
|
|
@@ -243,6 +260,30 @@ export async function recoverSession(logPath, expectedSessionId, cas) {
|
|
|
243
260
|
answeredQuestionIds.add(event.data.question_id);
|
|
244
261
|
break;
|
|
245
262
|
}
|
|
263
|
+
case 'slipstream.question.exchange_recorded.v1': {
|
|
264
|
+
if (event.subject !== `question/${event.data.question_id}`)
|
|
265
|
+
throw new CorruptLogError(`${at}: question subject disagrees with id`);
|
|
266
|
+
if (event.data.relay && relayQuestions.has(event.data.relay.relay_id))
|
|
267
|
+
throw new CorruptLogError(`${at}: relay_id was already sent`);
|
|
268
|
+
questionExchanges.push(event);
|
|
269
|
+
exchangeQuestionIds.add(event.data.question_id);
|
|
270
|
+
if (event.data.relay)
|
|
271
|
+
relayQuestions.set(event.data.relay.relay_id, event.data.question_id);
|
|
272
|
+
break;
|
|
273
|
+
}
|
|
274
|
+
case 'slipstream.question.relay_updated.v1': {
|
|
275
|
+
// The live checks in recordQuestionRelayUpdate, replayed.
|
|
276
|
+
const owner = relayQuestions.get(event.data.relay_id);
|
|
277
|
+
if (event.subject !== `question/${event.data.question_id}`
|
|
278
|
+
|| !exchangeQuestionIds.has(event.data.question_id)
|
|
279
|
+
|| (event.data.update.kind === 'sent' ? owner !== undefined : owner !== event.data.question_id)) {
|
|
280
|
+
throw new CorruptLogError(`${at}: relay update has no matching sent relay`);
|
|
281
|
+
}
|
|
282
|
+
questionRelayUpdates.push(event);
|
|
283
|
+
if (event.data.update.kind === 'sent')
|
|
284
|
+
relayQuestions.set(event.data.relay_id, event.data.question_id);
|
|
285
|
+
break;
|
|
286
|
+
}
|
|
246
287
|
case 'slipstream.task.started.v1': {
|
|
247
288
|
// Replayed in order: the last committed declaration is the current task,
|
|
248
289
|
// and its request_id indexes the committed result so a replayed duplicate
|
|
@@ -255,7 +296,16 @@ export async function recoverSession(logPath, expectedSessionId, cas) {
|
|
|
255
296
|
});
|
|
256
297
|
break;
|
|
257
298
|
}
|
|
299
|
+
case 'slipstream.agent.binding.v1':
|
|
300
|
+
bindingGenerations.add(event.data.binding_generation);
|
|
301
|
+
break;
|
|
258
302
|
case 'slipstream.session.resumed.v1':
|
|
303
|
+
currentTaskId = undefined;
|
|
304
|
+
resumeSeq = BigInt(event.seq);
|
|
305
|
+
break;
|
|
306
|
+
case 'slipstream.agent.unlinked.v1':
|
|
307
|
+
currentTaskId = undefined;
|
|
308
|
+
unlinkSeq = BigInt(event.seq);
|
|
259
309
|
break;
|
|
260
310
|
}
|
|
261
311
|
}
|
|
@@ -294,10 +344,16 @@ export async function recoverSession(logPath, expectedSessionId, cas) {
|
|
|
294
344
|
baselineUnknownDirs,
|
|
295
345
|
storageGapSeqByEpisode,
|
|
296
346
|
currentTaskId,
|
|
347
|
+
resumeSeq,
|
|
348
|
+
unlinkSeq,
|
|
297
349
|
taskDeclarations,
|
|
298
350
|
attributionEvents,
|
|
299
351
|
questions,
|
|
300
352
|
questionAttempts,
|
|
301
353
|
questionAnswers,
|
|
354
|
+
questionExchanges,
|
|
355
|
+
questionRelayUpdates,
|
|
356
|
+
relayQuestions,
|
|
357
|
+
bindingGenerations,
|
|
302
358
|
};
|
|
303
359
|
}
|