@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.
Files changed (55) hide show
  1. package/README.md +427 -101
  2. package/SKILL.md +4 -1
  3. package/contracts/interface/v2/schema.json +3 -3
  4. package/dist/attach-workflow.js +557 -0
  5. package/dist/attribution-engine.js +2 -3
  6. package/dist/attribution-scoring.js +70 -13
  7. package/dist/boundary-registry.js +25 -0
  8. package/dist/capture-intent.js +70 -0
  9. package/dist/capture-scope.js +135 -0
  10. package/dist/cas.js +61 -14
  11. package/dist/claude-root-transcript.js +18 -11
  12. package/dist/cli.js +426 -26
  13. package/dist/clip-function-parser.js +9 -2
  14. package/dist/clip-projection-service.js +28 -0
  15. package/dist/clip-projection-worker.js +4 -0
  16. package/dist/clip-worker-pool.js +13 -1
  17. package/dist/codex-root-transcript.js +46 -0
  18. package/dist/conductor-settings.js +178 -0
  19. package/dist/conductor.js +313 -0
  20. package/dist/control-protocol.js +2 -1
  21. package/dist/daemon.js +1718 -470
  22. package/dist/engine.js +26 -10
  23. package/dist/ensure-daemon.js +30 -0
  24. package/dist/http-reader.js +20 -8
  25. package/dist/intent-store.js +196 -0
  26. package/dist/interface-range-resolver.js +28 -1
  27. package/dist/launch-agent.js +179 -0
  28. package/dist/lock.js +1 -1
  29. package/dist/public-events.js +26 -2
  30. package/dist/question-hook.js +11 -1
  31. package/dist/questions.js +102 -3
  32. package/dist/recovery.js +62 -6
  33. package/dist/session.js +604 -104
  34. package/dist/store-reader.js +112 -7
  35. package/dist/task-forwarder.js +8 -8
  36. package/dist/transcript/discovery.js +242 -111
  37. package/dist/transcript/fs-io.js +10 -1
  38. package/dist/transcript/scheduler.js +114 -0
  39. package/dist/transcript/watcher.js +5 -3
  40. package/dist/withdraw-workflow.js +106 -0
  41. package/dist/workspace.js +133 -0
  42. package/package.json +3 -2
  43. package/schemas/slipstream.agent.binding.v1.json +90 -0
  44. package/schemas/slipstream.agent.connection.v1.json +34 -0
  45. package/schemas/slipstream.agent.unlinked.v1.json +68 -0
  46. package/schemas/slipstream.capture.gap.v1.json +1 -0
  47. package/schemas/slipstream.capture.ready.v1.json +55 -0
  48. package/schemas/slipstream.capture.scope.v1.json +85 -0
  49. package/schemas/slipstream.capture.stopped.v1.json +65 -0
  50. package/schemas/slipstream.question.dispatch_attempted.v1.json +11 -1
  51. package/schemas/slipstream.question.exchange_recorded.v1.json +65 -0
  52. package/schemas/slipstream.question.relay_updated.v1.json +64 -0
  53. package/schemas/slipstream.session.resumed.v1.json +4 -1
  54. package/schemas/slipstream.session.started.v1.json +4 -1
  55. 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') > MAX_ANSWER_BYTES) {
160
- throw new QuestionError('INVALID_ANSWER', `answer text must contain a non-whitespace character and be at most ${MAX_ANSWER_BYTES} UTF-8 bytes`);
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 attemptSeqByQuestion = new Map();
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
- throw new CorruptLogError(`${where}: referenced blob ${snap.sha256} is missing`);
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
- || attemptSeqByQuestion.has(event.data.question_id)) {
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
- attemptSeqByQuestion.set(event.data.question_id, event.seq);
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
- || attemptSeqByQuestion.get(event.data.question_id) !== event.data.attempt_seq
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
  }