@theokit/agents 9.4.0 → 10.1.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/dist/session.d.ts CHANGED
@@ -1,5 +1,36 @@
1
1
  import { TheokitAgentError } from '@theokit/sdk/errors';
2
2
 
3
+ /**
4
+ * Bounding an injected registry remover, in one place.
5
+ *
6
+ * ## Why this file exists, written after it was needed
7
+ *
8
+ * `deleteSession` and `runTranscriptGC` both take a `removeFromRegistry` and both must await it —
9
+ * the only agent registry in the ecosystem is `Agent.delete(id): Promise<void>`. The plan named this
10
+ * file (*"session/gc/registry-remover.ts (NEW) — the shared awaiting helper"*) and the first
11
+ * implementation skipped it, putting the bound inside `deleteSession` and leaving the sweep with a
12
+ * bare `await`.
13
+ *
14
+ * The divergence was not theoretical. A remover that never settled hung `runTranscriptGC`
15
+ * indefinitely — not one session, every session after it, with no error, no timeout and no output.
16
+ * The single-session path was already tested against exactly that; the sweep, which is the path that
17
+ * runs unattended over a whole project, was not. That is `system-design-guardrails.md` § G12 in its
18
+ * most expensive form: one rule, two call sites, and the one nobody was watching was the one that
19
+ * mattered.
20
+ */
21
+
22
+ /**
23
+ * The registry did not answer in time.
24
+ *
25
+ * T2.2 — this used to mean "you passed a Promise to a synchronous seam". The seam now awaits, so the
26
+ * only thing left that a caller cannot fix by awaiting is a registry that does not respond.
27
+ */
28
+ declare class SessionRegistryRemoverError extends TheokitAgentError {
29
+ readonly name = "SessionRegistryRemoverError";
30
+ readonly sessionId: string;
31
+ constructor(sessionId: string, timeoutMs: number);
32
+ }
33
+
3
34
  /**
4
35
  * M71 — the session LIFECYCLE vocabulary: list, delete, protect, fork.
5
36
  *
@@ -29,19 +60,32 @@ import { TheokitAgentError } from '@theokit/sdk/errors';
29
60
  * The seam is synchronous by contract because `deleteSession` is. A caller whose registry is async
30
61
  * (which, measured, is every real one) awaits its own removal and then calls this with the outcome.
31
62
  */
32
- declare class SessionRegistryRemoverError extends TheokitAgentError {
33
- readonly name = "SessionRegistryRemoverError";
34
- readonly sessionId: string;
35
- constructor(sessionId: string);
36
- }
37
63
  declare class SessionInUseError extends TheokitAgentError {
38
64
  readonly sessionId: string;
39
65
  /** Why it is protected — a writer lease, the resumable pointer, or being the most recent. */
40
66
  readonly reason: string;
67
+ /**
68
+ * Whether the registry half already happened before the refusal.
69
+ *
70
+ * `true` only when the session became protected DURING the registry removal — the re-check
71
+ * fires after the await, so the entry is already gone while the transcript stays. The caller
72
+ * needs this: retrying a removal that is already done returns `false` ("no entry to remove"),
73
+ * which reads as a failure and is not one.
74
+ */
75
+ readonly registryRemoved: boolean;
41
76
  readonly name = "SessionInUseError";
42
77
  constructor(sessionId: string,
43
78
  /** Why it is protected — a writer lease, the resumable pointer, or being the most recent. */
44
- reason: string);
79
+ reason: string,
80
+ /**
81
+ * Whether the registry half already happened before the refusal.
82
+ *
83
+ * `true` only when the session became protected DURING the registry removal — the re-check
84
+ * fires after the await, so the entry is already gone while the transcript stays. The caller
85
+ * needs this: retrying a removal that is already done returns `false` ("no entry to remove"),
86
+ * which reads as a failure and is not one.
87
+ */
88
+ registryRemoved?: boolean);
45
89
  }
46
90
  /** One session as the lifecycle vocabulary sees it. */
47
91
  interface SessionSummary {
@@ -74,14 +118,34 @@ interface DeleteSessionResult {
74
118
  readonly registryRemoved: boolean;
75
119
  /** Whether the transcript file was removed. */
76
120
  readonly transcriptRemoved: boolean;
121
+ /**
122
+ * Why the registry removal failed, when it did.
123
+ *
124
+ * Kept SEPARATE from `registryRemoved` on purpose: collapsing the two outcomes into one boolean is
125
+ * exactly how the original silent success hid. A caller that only checks `registryRemoved` sees
126
+ * `false` and can still surface the reason.
127
+ */
128
+ readonly registryError?: unknown;
77
129
  }
78
130
  interface DeleteSessionOptions {
79
131
  readonly cwd: string;
80
132
  readonly root?: string;
81
133
  /** Delete even when protected. The refusal is the default because the damage is unrecoverable. */
82
134
  readonly force?: boolean;
83
- /** Remove the registry entry too. Injected (DIP) — the registry is the runtime's, not ours. */
84
- readonly removeFromRegistry?: (sessionId: string) => boolean;
135
+ /**
136
+ * Remove the registry entry too. Injected (DIP) the registry is the runtime's, not ours.
137
+ *
138
+ * T2.2 — MAY be async. `Agent.delete(id): Promise<void>` is the only agent registry in this
139
+ * ecosystem, so a sync-only seam could be satisfied honestly by nobody. Returning `false` means
140
+ * "no entry to remove" and is not a failure; THROWING (or rejecting) is, and it stops the delete
141
+ * with the transcript still on disk.
142
+ */
143
+ readonly removeFromRegistry?: (sessionId: string) => unknown;
144
+ /**
145
+ * Ceiling on the injected remover. A registry that never answers must not hang a sweep; the
146
+ * timeout surfaces as `registryError` and the transcript is left alone.
147
+ */
148
+ readonly registryTimeoutMs?: number;
85
149
  }
86
150
  /**
87
151
  * Delete a session, refusing by default when it is protected.
@@ -92,7 +156,7 @@ interface DeleteSessionOptions {
92
156
  *
93
157
  * @throws {SessionInUseError} when the session is protected and `force` is not set.
94
158
  */
95
- declare function deleteSession(sessionId: string, options: DeleteSessionOptions): DeleteSessionResult;
159
+ declare function deleteSession(sessionId: string, options: DeleteSessionOptions): Promise<DeleteSessionResult>;
96
160
  /**
97
161
  * Fork `srcId` into `newId`, keeping everything BEFORE the `nth` user turn.
98
162
  *
@@ -108,6 +172,7 @@ declare function forkBeforeUserTurn(srcId: string, newId: string, nth: number, o
108
172
  }): {
109
173
  readonly transcript: string;
110
174
  readonly recordIndex: number;
175
+ readonly selectedText: string;
111
176
  };
112
177
 
113
178
  /** Where the pointer for `cwd` lives. */
@@ -281,6 +346,178 @@ declare function runTranscriptGC(plan: TranscriptGCPlan, options: {
281
346
  readonly apply: boolean;
282
347
  /** Same additive, fail-closed contract as on the plan — see `TranscriptGCOptions.protectedIds`. */
283
348
  readonly protectedIds?: () => ReadonlyMap<string, string>;
284
- }): RunTranscriptGCResult;
349
+ /**
350
+ * T2.2 — remove the registry entry as well as the transcript file.
351
+ *
352
+ * Without it this function deleted transcripts and left the agent registry pointing at files
353
+ * that no longer exist. Nothing repaired that: GC works FROM transcripts, so an entry whose
354
+ * transcript is gone is never seen again. `deleteSession` has had this seam; the sweep did not.
355
+ *
356
+ * Same contract as `deleteSession`: may be async (`Agent.delete` is the only registry there is),
357
+ * runs BEFORE the unlink, and a throw leaves the transcript on disk for the next sweep rather
358
+ * than producing an entry no run can repair.
359
+ */
360
+ readonly removeFromRegistry?: (sessionId: string) => unknown;
361
+ /**
362
+ * How long to wait for the registry per session before giving up on it and keeping the
363
+ * transcript. Absent means wait indefinitely — the pre-existing behaviour, kept as the default
364
+ * so adding the bound cannot change what a current caller experiences.
365
+ */
366
+ readonly registryTimeoutMs?: number;
367
+ }): Promise<RunTranscriptGCResult>;
368
+
369
+ /**
370
+ * A sentence explaining an empty session list, or `undefined` when there is nothing true to say.
371
+ *
372
+ * Returns `undefined` in every case where the hint would be noise or a guess: sessions were found,
373
+ * the root was not overridden, the override equals the previous root, the previous root cannot be
374
+ * read, or it holds no projects. A hint that fires spuriously is worse than none — people stop
375
+ * reading the ones that matter.
376
+ *
377
+ * @param found - how many sessions the caller's own listing returned.
378
+ * @param previousRoot - the root to look in, typically the default before the override.
379
+ * @param env - injected so a test needs no global mutation; defaults to the real environment.
380
+ */
381
+ declare function transcriptRootHint(found: number, previousRoot: string, env?: NodeJS.ProcessEnv): string | undefined;
382
+
383
+ /**
384
+ * Does the project behind `projects/<encoded>/` still exist?
385
+ *
386
+ * The question is hard because of a decision this package's layout made:
387
+ * `encodeProjectDir(cwd)` is `cwd.replace(/[^a-zA-Z0-9]/g, '-')`, a one-way street. `/a/b` and
388
+ * `/a-b` produce the same name, so a directory name cannot be turned back into a path — it can only
389
+ * be CHECKED against candidates. Every product that retains or garbage-collects transcripts has to
390
+ * answer this, and until now each one wrote the search itself: the consumer's 188 lines, whose own
391
+ * docstring measured 13.269 project directories, ~3.200 falling through to filesystem search, and
392
+ * ~64M syscalls without a shared budget.
393
+ *
394
+ * ## What is injected, and why exactly that
395
+ *
396
+ * `candidatePaths` — which directories are even candidates is PRODUCT policy (workspaces, ignore
397
+ * rules, mounted volumes). This module must not guess it. It returns REAL ABSOLUTE PATHS, and the
398
+ * name says so because the previous one (`listProjects`) did not: the only consumer's function of
399
+ * that name returns ENCODED DIRECTORY NAMES, and wiring one to the other classified 6 of 6 live
400
+ * projects `dead`.
401
+ *
402
+ * `fs` — so the budget is countable and the caller can supply a stat that matches its own retry and
403
+ * timeout posture. Every call is one operation.
404
+ *
405
+ * What is NOT injected is the encoding, because that is the thing this package owns and the whole
406
+ * reason the question exists.
407
+ *
408
+ * ## Two properties do the work
409
+ *
410
+ * **The result is three-valued and `undetermined` is not a soft `dead`.** Callers DELETE on `dead`.
411
+ * Every way of failing to find out — budget spent, unreadable directory, enumeration threw —
412
+ * resolves to `undetermined`, because deleting on "could not tell" is data loss and the fail-safe
413
+ * direction is not symmetric here (`rules/error-handling.md`).
414
+ *
415
+ * **The budget is shared across the whole sweep, not per project.** A bound that resets each
416
+ * iteration is not a bound; that is precisely what produced the 64M figure.
417
+ */
418
+
419
+ /**
420
+ * The filesystem, as this module needs it. Every call is ONE operation against the budget, and any
421
+ * of them may throw (EACCES) — a throw is a third outcome, never "absent".
422
+ */
423
+ interface FsSeam {
424
+ /**
425
+ * Does `path` exist? **Three-valued**: `undefined` means "could not determine", and it is NOT the
426
+ * same answer as `false`.
427
+ *
428
+ * The third state is in the RETURN TYPE rather than in prose because that is the only place an
429
+ * adapter author reliably reads it. The consumer's scar (B-020) is exactly this: its adapter
430
+ * mapped every `statSync` failure to `false`, and since the verdict branches on that value, a cwd
431
+ * that exists but cannot be stat-ed — EACCES on a non-traversable parent, ENOTDIR mid-path, EMFILE
432
+ * under a wide sweep — was classified DEAD, which is a deletion. A signature of `=> boolean`
433
+ * invites `try { return existsSync(p) } catch { return false }`, which reintroduces it silently.
434
+ *
435
+ * ENOENT is the only errno that means absence. Everything else is `undefined`. Throwing is also
436
+ * accepted and treated as `undefined`, so an adapter that does neither still cannot cause a
437
+ * deletion.
438
+ */
439
+ exists: (path: string) => boolean | undefined;
440
+ /** Entry names directly under `dir`. Used to find a transcript to read the recorded cwd from. */
441
+ listEntries: (dir: string) => readonly string[];
442
+ /** The first line of `file`. The transcript's first record carries the `cwd` it was written in. */
443
+ firstLine: (file: string) => string;
444
+ }
445
+ type Liveness = 'alive' | 'dead' | 'undetermined';
446
+ interface LivenessVerdict {
447
+ readonly liveness: Liveness;
448
+ /** Why — carried on every verdict, so an operator reading a GC log is never left guessing. */
449
+ readonly reason: string;
450
+ /**
451
+ * The cwd this verdict is ABOUT — present on `alive` and `dead`, absent on `undetermined`.
452
+ *
453
+ * `alive` reports the member of the collision class that was found to EXIST, not the first one
454
+ * read: the class can hold a gone path and a live one, and sending a caller's registry lookup to
455
+ * the gone sibling would defeat the point. `dead` reports the recorded cwd that was checked and
456
+ * found missing. `undetermined` established no path, so the field is absent rather than an empty
457
+ * string a caller might mistake for one.
458
+ *
459
+ * Added because the verdict was dropping the one fact a caller acts on. This function PROBES the
460
+ * path to decide `alive` — it has it in hand at the moment it returns — and kept only a prose
461
+ * `reason`. The consumer this was absorbed from uses the resolved cwd to consult the agent
462
+ * registry and the resumable pointer for that project (`all-sessions.ts:161,175`), so a verdict
463
+ * without it could not replace the function it exists to replace. Recovering it by string-matching
464
+ * `reason` would be the fragile coupling this module exists to remove: a sentence is not an API.
465
+ */
466
+ readonly cwd?: string;
467
+ }
468
+ interface ClassifyProjectsOptions {
469
+ /**
470
+ * Where `projects/<encoded>/` lives, so the recorded-cwd read can find a transcript.
471
+ * Use {@link projectsRoot} rather than joining the segment by hand — that segment had three
472
+ * owners once, and a wrong one makes every project look empty rather than erroring.
473
+ */
474
+ projectsRoot: string;
475
+ /**
476
+ * REAL ABSOLUTE PATHS that might be the project — not encoded directory names.
477
+ *
478
+ * The name is explicit because the previous one was not, and the ambiguity was a defect rather
479
+ * than a documentation gap: the only consumer's `listProjects` returns ENCODED NAMES (it keeps
480
+ * classification in a separate injected seam), so wiring the two together fed encoded names to a
481
+ * function expecting paths. Measured 2026-08-16: 6 of 6 live projects classified `dead`, on the
482
+ * path where the caller DELETES.
483
+ *
484
+ * PRODUCT policy: which directories are even candidates (workspaces, ignore rules, mounted
485
+ * volumes) is not this module's to guess. It is also only a HEURISTIC — see the fall-through in
486
+ * `searchPool`, which is why exhausting it can never prove absence.
487
+ */
488
+ candidatePaths: () => readonly string[];
489
+ /** Total filesystem operations allowed for the ENTIRE sweep. */
490
+ budget: number;
491
+ fs: FsSeam;
492
+ /** How many transcripts to read per project before giving up on the recorded cwd. Default 3. */
493
+ transcriptSamples?: number;
494
+ }
495
+ /**
496
+ * REMOVED 2026-08-16 — `likelyPath`, which turned every `-` back into `/` and was documented as
497
+ * "correct for the overwhelming majority of real paths". It is correct for no path containing a
498
+ * hyphen, which is most of them:
499
+ *
500
+ * encode('/home/op/Projetos/theo/theokit-framework')
501
+ * → '-home-op-Projetos-theo-theokit-framework'
502
+ * likelyPath(that)
503
+ * → '/home/op/Projetos/theo/theokit/framework' ← not the input
504
+ *
505
+ * The encoding is lossy on purpose (`/a/b` and `/a-b` collide), so no string transform can invert
506
+ * it. What replaces it is not a better guess but the actual answer: the transcript records the cwd
507
+ * it was written in, and reading it costs one line of one file.
508
+ */
509
+ /**
510
+ * Raised when `budget` cannot bound anything. Refusing beats clamping, for the same reason
511
+ * `transcript-gc.ts` states as its invariant 1: an operator who asked for a policy must not be
512
+ * silently given a different one.
513
+ */
514
+ declare class LivenessBudgetError extends TheokitAgentError {
515
+ constructor(budget: number);
516
+ }
517
+ /**
518
+ * Classify each encoded project directory. Every input appears in the output: a missing key would
519
+ * read to a caller as "not dead", which is safe only by accident.
520
+ */
521
+ declare function classifyProjects(encoded: readonly string[], opts: ClassifyProjectsOptions): Map<string, LivenessVerdict>;
285
522
 
286
- export { type DeleteSessionOptions, type DeleteSessionResult, type GCCandidate, type GCError, GCFloorError, type GCKept, GCProtectionUnavailableError, type RunTranscriptGCResult, SessionInUseError, SessionRegistryRemoverError, type SessionSummary, type TranscriptGCOptions, type TranscriptGCPlan, deleteSession, forkBeforeUserTurn, listSessions, loadOrCreateSessionId, persistSessionId, planTranscriptGC, projectDirFor, projectDirMatches, projectsRoot, protectedTranscripts, recordProjectDir, resolveProjectDir, runTranscriptGC, sessionPointerPath };
523
+ export { type ClassifyProjectsOptions, type DeleteSessionOptions, type DeleteSessionResult, type FsSeam, type GCCandidate, type GCError, GCFloorError, type GCKept, GCProtectionUnavailableError, type Liveness, LivenessBudgetError, type LivenessVerdict, type RunTranscriptGCResult, SessionInUseError, SessionRegistryRemoverError, type SessionSummary, type TranscriptGCOptions, type TranscriptGCPlan, classifyProjects, deleteSession, forkBeforeUserTurn, listSessions, loadOrCreateSessionId, persistSessionId, planTranscriptGC, projectDirFor, projectDirMatches, projectsRoot, protectedTranscripts, recordProjectDir, resolveProjectDir, runTranscriptGC, sessionPointerPath, transcriptRootHint };