dsh-ptc-plus 0.5.0 → 0.5.2

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 (74) hide show
  1. package/README.en.md +15 -3
  2. package/README.md +15 -3
  3. package/client.cjs +1983 -234
  4. package/compiler-core.cjs +132 -132
  5. package/compiler-core.cjs.map +3 -3
  6. package/docs/adr/0005-temporary-rejected-cell-edit-transport.md +2 -0
  7. package/docs/adr/0017-track-the-latest-dsh-public-surface.md +2 -2
  8. package/docs/adr/0023-global-user-bindings.md +1 -1
  9. package/docs/adr/0027-run-the-session-kernel-in-a-killable-helper-process.md +11 -1
  10. package/docs/adr/0032-own-session-log-facts-in-a-host-only-projection.md +31 -1
  11. package/docs/adr/0038-freeze-session-capability-plans.md +1 -1
  12. package/docs/adr/0044-coexist-with-the-official-persistent-repl.md +35 -0
  13. package/docs/adr/0045-customize-model-visible-prompt-text.md +38 -0
  14. package/docs/adr/0046-gate-the-commit-with-the-semantic-evidence-tier.md +35 -0
  15. package/docs/adr/0047-store-journal-argument-digests-in-the-projection.md +88 -0
  16. package/docs/adr/README.md +2 -0
  17. package/docs/architecture.md +2 -2
  18. package/docs/capability-planning-components.md +2 -2
  19. package/docs/client-ui.md +13 -10
  20. package/docs/client-visual-contract.md +1 -1
  21. package/docs/durability-design.md +7 -1
  22. package/docs/installation.md +5 -3
  23. package/docs/publishing.md +2 -2
  24. package/docs/releases/v0.5.2.md +79 -0
  25. package/docs/runtime-reference.md +42 -3
  26. package/docs/unmeasured-cell-recovery-repair-proposal.md +387 -0
  27. package/docs/verification.md +6 -2
  28. package/index.js +63 -19
  29. package/internal/bounded-literal.js +85 -0
  30. package/internal/capability-command-receipt.js +3 -1
  31. package/internal/capability-environment.js +171 -13
  32. package/internal/capability-plan.js +11 -8
  33. package/internal/capability-statistics.js +6 -2
  34. package/internal/cell-error.js +13 -0
  35. package/internal/config-spec.js +122 -2
  36. package/internal/direct-surface-owner.js +14 -10
  37. package/internal/edit-transport-owner.js +96 -17
  38. package/internal/execution-provider-owner.js +25 -0
  39. package/internal/execution-seam-compat.js +18 -0
  40. package/internal/isolated-worker.js +134 -17
  41. package/internal/kernel-worker.js +167 -3
  42. package/internal/legacy-cell-analysis.js +14 -2
  43. package/internal/legacy-repl-convenience.js +16 -3
  44. package/internal/official-repl-priority.js +51 -0
  45. package/internal/projection-retention.js +238 -0
  46. package/internal/prompt-text.js +858 -0
  47. package/internal/recovery-tips.js +50 -11
  48. package/internal/rejected-cell-editor.js +3 -2
  49. package/internal/runtime-bridge-owner.js +11 -8
  50. package/internal/runtime-config.js +2 -0
  51. package/internal/runtime-contexts.js +14 -5
  52. package/internal/runtime-messages.js +43 -15
  53. package/internal/sdk-projection.js +2 -2
  54. package/internal/session-cell-executor.js +132 -11
  55. package/internal/session-journal-recovery.js +1009 -99
  56. package/internal/session-journal-schema.js +7 -5
  57. package/internal/session-journal.js +177 -14
  58. package/internal/session-log-view.js +111 -24
  59. package/internal/session-runtime.js +674 -33
  60. package/internal/session-state.js +27 -0
  61. package/internal/stateful-root-compiler.js +11 -0
  62. package/internal/stateful-root-runtime.js +17 -0
  63. package/internal/user-binding-console.js +7 -0
  64. package/internal/user-bindings-owner.js +8 -27
  65. package/internal/user-bindings.js +2 -2
  66. package/internal/validated-parse-repair.js +6 -1
  67. package/internal/worker-client.js +27 -4
  68. package/package.json +5 -2
  69. package/src/client-activity.js +27 -6
  70. package/src/client-capabilities-view.js +774 -125
  71. package/src/client-copy.js +231 -18
  72. package/src/client-repl-view.js +6 -1
  73. package/src/client-styles.js +68 -2
  74. package/src/client-tool-view.js +9 -2
package/index.js CHANGED
@@ -20,12 +20,14 @@ import { createCordisToolsOwner } from './internal/cordis-tools-owner.js'
20
20
  import { createEditTransportOwner, EDIT_RUN_CODE } from './internal/edit-transport-owner.js'
21
21
  import { createRuntimeBridgeOwner, RUN_CODE } from './internal/runtime-bridge-owner.js'
22
22
  import { resolveConfig, watchVolatileConfig } from './internal/runtime-config.js'
23
+ import { configureRetention } from './internal/projection-retention.js'
23
24
  import {
24
25
  CONFIG_FIELDS,
25
26
  SETTINGS_NAMESPACE,
26
27
  } from './internal/config-spec.js'
27
28
  import { configFieldSchema, installSettingsSectionCompat } from './internal/settings-compat.js'
28
29
  import { installExecutionSeam } from './internal/execution-seam-compat.js'
30
+ import { createDeferredRuntime, defersToOfficialRepl } from './internal/official-repl-priority.js'
29
31
  import { valueLimitsFromConfig } from './internal/value-wire-schema.js'
30
32
  import { createReplMemoryProjection } from './internal/repl-memory-projection.js'
31
33
  import { createUserBindingDraftProjection } from './internal/user-binding-draft-projection.js'
@@ -34,6 +36,7 @@ import { createHostRpc } from './internal/host-rpc.js'
34
36
  import { RPC_CONTRACTS } from './internal/rpc-contract.js'
35
37
  import { createReplObservationInterest } from './internal/repl-observation-interest.js'
36
38
  import { guidancePolicies } from './internal/binding-update-policy.js'
39
+ import { PROMPT_SLOT_IDS, resolvePromptTextConfig } from './internal/prompt-text.js'
37
40
  import * as dshSettings from '@deepseek-ai/dsh-settings'
38
41
 
39
42
  const INSTALL_CLEANUP = Symbol('ptc-plus install cleanup')
@@ -58,7 +61,7 @@ export const Config = Schema.object(Object.fromEntries(
58
61
  */
59
62
  export const inject = ['tools', 'systemPrompt', 'agents']
60
63
 
61
- function replGuidance({ bindingPolicy, rewritesEnabled, languageSemantics, durableReplay, cordisToolsEnabled }) {
64
+ function replGuidance({ bindingPolicy, rewritesEnabled, languageSemantics, durableReplay, cordisToolsEnabled }, promptText) {
62
65
  const looseTopLevelRedeclarations = bindingPolicy.variableRedeclarations
63
66
  const looseTopLevelFunctionClassRedeclarations = bindingPolicy.functionClassRedeclarations
64
67
  const autoRewriteImports = rewritesEnabled.autoRewriteImports
@@ -91,18 +94,22 @@ function replGuidance({ bindingPolicy, rewritesEnabled, languageSemantics, durab
91
94
  const cordisRecovery = cordisToolsEnabled
92
95
  ? 'Keep large Cordis plugin source in a binding before the tool call so it can be reused after an error. A Cordis parse or validation failure can follow external effects; repeat the call only when its retry rules and current results establish that it is safe.'
93
96
  : ''
94
- return `\`run_code\` continues one persistent PTC REPL. Use Node.js modules in the cell; use external processes when their environment is needed. Ordinary top-level bindings remain available to later cells, so reuse them instead of resending setup code. Choose the smallest cell that answers the request and return only the value the next step needs.
95
-
96
- After an execution error, earlier statements may have taken effect. Inspect relevant values in a short cell, then continue from them; repeat external operations only when their retry rules and current results establish safety. \`edit_run_code\` edits and reruns a complete cell.
97
-
98
- ## Cell conventions
99
- Use \`return\` for an explicit result and \`console\` for logs. Return supported values directly, including undefined, NaN, infinities, -0 and BigInt; JSON.stringify loses distinctions or rejects BigInt. Do not mix BigInt with Number arithmetic; use a BigInt literal or convert explicitly. Keep functions, Date, Map and other unsupported values in bindings; return needed data or type observations. Native \`tools.*\` arguments must satisfy their own JSON schemas; PTC value transport does not widen those contracts. Keep large inspection results in bindings or reduce them to targeted excerpts: \`tools.read\` is bounded inspection, not a lossless whole-file reader. Relative filesystem, path and default child-process operations use the current working directory at the time they run; a successful in-cell \`tools.working_directory({ cd })\` applies to the rest of that cell, while cell imports and \`import.meta\` keep the directory the cell started in, and \`process.chdir\` is unavailable. ${cellConventions}
100
-
101
- ## Available capabilities
102
- Use \`capabilities.tree()\`, \`capabilities.find()\`, and \`capabilities.inspect()\` for unfamiliar program APIs; Reserve \`code.run\` for isolated execution of source held as data.
103
- Program API references expire when their cell ends. Reusable helpers must read the current namespace when called, for example \`async function readNow(args) { return tools.read(args) }\`, instead of retaining an earlier \`tools\` object or method. Completed-cell callbacks cannot call program APIs.
104
-
105
- Native tool availability depends on the current DSH profile and execution world; inspect executables, shells, and paths instead of assuming Windows, WSL, POSIX, or a particular shell. ${recovery}${cordisToolsEnabled ? ` ${cordisRecovery}` : ''}`
97
+ // Each paragraph is an editable asset; a derived policy sentence is appended to
98
+ // the paragraph that owns it, so rewriting prose can never contradict the
99
+ // configuration that produced it. Omitting a paragraph keeps its derived clause.
100
+ const prose = id => (promptText.omitted(id) ? '' : promptText.text(id))
101
+ const withDerived = (id, derived) => {
102
+ const base = prose(id)
103
+ return base === '' ? derived : `${base} ${derived}`
104
+ }
105
+ const paragraphs = [
106
+ prose(PROMPT_SLOT_IDS.guidanceOpening),
107
+ prose(PROMPT_SLOT_IDS.guidanceErrorRecovery),
108
+ withDerived(PROMPT_SLOT_IDS.guidanceCellConventions, cellConventions),
109
+ prose(PROMPT_SLOT_IDS.guidanceCapabilities),
110
+ withDerived(PROMPT_SLOT_IDS.guidanceExecutionWorld, `${recovery}${cordisToolsEnabled ? ` ${cordisRecovery}` : ''}`),
111
+ ]
112
+ return paragraphs.filter(paragraph => paragraph.trim().length > 0).join('\n\n')
106
113
  }
107
114
 
108
115
  /** Register the session-bound REPL runtime. */
@@ -344,6 +351,7 @@ function installPtCRuntime(ctx, resolvedConfig, seam, toolSchemasForAgent, toolS
344
351
  maxWallMs: activeConfig.maxWallMs,
345
352
  maxOutputBytes: activeConfig.maxOutputBytes,
346
353
  maxOldGenerationSizeMb: activeConfig.maxOldGenerationSizeMb,
354
+ workerReclaimMs: activeConfig.workerReclaimMs,
347
355
  valueLimits: valueLimitsFromConfig(activeConfig),
348
356
  })
349
357
  capabilityEnvironment = createCapabilityEnvironment({ ctx, config: activeConfig,
@@ -374,6 +382,7 @@ function installPtCRuntime(ctx, resolvedConfig, seam, toolSchemasForAgent, toolS
374
382
  presentationGeneration,
375
383
  sessionId,
376
384
  toolSchemasForAgent,
385
+ promptText: resolvePromptTextConfig(activeConfig),
377
386
  })
378
387
  directSurface = createDirectSurfaceOwner({
379
388
  sessionLogView: sessionLog.project,
@@ -393,7 +402,7 @@ function installPtCRuntime(ctx, resolvedConfig, seam, toolSchemasForAgent, toolS
393
402
  order: 98,
394
403
  text: context => {
395
404
  if (ctx.tools.get(RUN_CODE, context?.scope) === undefined) return ''
396
- return replGuidance(guidancePolicies(activeConfig))
405
+ return replGuidance(guidancePolicies(activeConfig), resolvePromptTextConfig(activeConfig))
397
406
  },
398
407
  }))
399
408
  disposers.push(ctx.on('system-prompt/assemble', async (assembly, context, next) => {
@@ -415,8 +424,6 @@ function installPtCRuntime(ctx, resolvedConfig, seam, toolSchemasForAgent, toolS
415
424
  editTransport.ensureInstalled(agent)
416
425
  }
417
426
  }
418
- disposers.push(ctx.on('agent/pre-step', (payload, next) => userBindings.preStep(payload, next)))
419
- disposers.push(ctx.on('session/event', (session, event) => userBindings.committedEvent(session, event)))
420
427
  disposers.push(ctx.on('tools/post-execute', (exec, result, next) => capabilityEnvironment.postExecute(exec, result, next), { prepend: true }))
421
428
  disposers.push(ctx.on('tools/ptc-dispatch-log', (dispatch, next) => capabilityEnvironment.dispatchLog(dispatch, next), { prepend: true }))
422
429
  disposers.push(ctx.on('agent/status', payload => { if (payload?.agent) capabilityEnvironment.status(payload.agent, payload.status) }))
@@ -471,7 +478,14 @@ function installPtCRuntime(ctx, resolvedConfig, seam, toolSchemasForAgent, toolS
471
478
  return { kind: 'error', text: receipt('unavailable') }
472
479
  }
473
480
  await capabilityEnvironment.refresh(agent, undefined, signal, commandId)
474
- return { kind: 'success', text: receipt(capabilityEnvironment.describe(agent).publication === 'pending' ? 'pending' : 'recorded') }
481
+ // A refresh always republishes the saved configuration; when that
482
+ // configuration has capability management or context off, report
483
+ // the off state instead of claiming an updated environment.
484
+ const described = capabilityEnvironment.describe(agent)
485
+ const state = described.effective?.enabled !== true
486
+ ? described.sourceSettings?.contextEnabled === false ? 'paused' : 'off'
487
+ : described.publication === 'pending' ? 'pending' : 'recorded'
488
+ return { kind: 'success', text: receipt(state) }
475
489
  }
476
490
  return { kind: 'success', text: JSON.stringify(capabilityEnvironment.describe(agent), null, 2) }
477
491
  } }), 'ptc-plus capability command')
@@ -618,8 +632,11 @@ export const apply = (ctx, config = {}) => {
618
632
  // Configuration is validated before the plugin waits for the host's seam, so
619
633
  // an unusable configuration fails at the load call rather than later.
620
634
  const resolvedConfig = resolveConfig(config)
635
+ // The retention window is user configuration; install it before any fold can run.
636
+ configureRetention(resolvedConfig)
621
637
  return installExecutionSeam(ctx, {
622
638
  services: inject,
639
+ attachmentTimeoutMs: resolvedConfig.executionSeamTimeoutMs,
623
640
  attach: (scope, seam) => applyWithExecutionSeam(scope, resolvedConfig, seam, ctx, config),
624
641
  reportFailure: (error, seamName) => ctx.logger?.warn?.(
625
642
  `ptc-plus: execution seam ${seamName} failed to attach`,
@@ -710,6 +727,11 @@ function applyWithExecutionSeam(ctx, resolvedConfig, seam, volatileHost, volatil
710
727
  )
711
728
  return cleanup
712
729
  }
730
+ // Execution ownership is a per-attachment property: `owned` installs the
731
+ // session runtime, `deferred` leaves run_code to the official provider.
732
+ const executionShape = nextConfig => (
733
+ defersToOfficialRepl(nextConfig, seam.provider) ? 'deferred' : 'owned'
734
+ )
713
735
  const startActivation = record => {
714
736
  if (record.cancelled || disposed || activating !== record) {
715
737
  record.completion.resolve({ status: 'superseded' })
@@ -720,7 +742,9 @@ function applyWithExecutionSeam(ctx, resolvedConfig, seam, volatileHost, volatil
720
742
  }
721
743
  let candidate
722
744
  try {
723
- candidate = installPtCRuntime(ctx, record.config, seam, toolSchemasForAgent, toolSdkSchemasForAgent, sessionId, sessionLog, () => settingsWriter)
745
+ candidate = record.shape === 'deferred'
746
+ ? createDeferredRuntime()
747
+ : installPtCRuntime(ctx, record.config, seam, toolSchemasForAgent, toolSdkSchemasForAgent, sessionId, sessionLog, () => settingsWriter)
724
748
  record.runtime = candidate
725
749
  } catch (error) {
726
750
  const failedRuntime = error?.[INSTALL_CLEANUP]
@@ -765,6 +789,7 @@ function applyWithExecutionSeam(ctx, resolvedConfig, seam, volatileHost, volatil
765
789
  const record = {
766
790
  config: nextConfig,
767
791
  generation,
792
+ shape: executionShape(nextConfig),
768
793
  runtime: undefined,
769
794
  disposal: undefined,
770
795
  disposed: false,
@@ -814,6 +839,15 @@ function applyWithExecutionSeam(ctx, resolvedConfig, seam, volatileHost, volatil
814
839
  return beginActivation(nextConfig, generation, cleanup)
815
840
  }
816
841
  const current = committed
842
+ if (current.shape !== executionShape(nextConfig)) {
843
+ // Ownership changes are installation changes: dispose the live kernels
844
+ // and install the other shape, so no committed cell can mix the two
845
+ // execution contracts or extend state the new owner cannot see.
846
+ current.retiring = true
847
+ void disposeRuntime(current)
848
+ const cleanup = disposeRetainedRuntimes()
849
+ return beginActivation(nextConfig, generation, cleanup)
850
+ }
817
851
  if (!nextConfig.cordisToolsEnabled) current.runtime.cancelCordisActivation()
818
852
  return enqueueTransition(async () => {
819
853
  if (disposed || committed !== current) return { status: 'superseded' }
@@ -863,9 +897,15 @@ function applyWithExecutionSeam(ctx, resolvedConfig, seam, volatileHost, volatil
863
897
  ctx.effect(() => async () => controller.dispose(), 'ptc-plus runtime lifecycle')
864
898
 
865
899
  const handbackOwners = Object.fromEntries(['sessions', 'sessionPersistence', 'sessionQuery'].map(name => [name, ctx.get(name)]))
900
+ // One spelling of "the current document": the same resolved configuration the
901
+ // reconciliation loop applies, so a delivered message and the assembled prompt can
902
+ // never read two different prompt-text documents. Read lazily: the settings section
903
+ // replaces `configSource` after activation.
904
+ const currentPromptText = () => resolvePromptTextConfig(configSource())
866
905
  const messageOwner = createRuntimeMessageOwner(context => (
867
906
  committed?.retiring ? [] : committed?.runtime.contextsForRequest(context) ?? []
868
- ), sessionLog.project, () => committed !== undefined && committed.retiring !== true, handbackOwners.sessionQuery)
907
+ ), sessionLog.project, () => committed !== undefined && committed.retiring !== true, handbackOwners.sessionQuery,
908
+ currentPromptText)
869
909
  ctx.effect(() => ctx.systemPrompt.context({
870
910
  name: PTC_DELIVERY_CONTEXT, order: 98, text: '',
871
911
  }), 'ptc-plus dynamic message delivery witness')
@@ -882,6 +922,8 @@ function applyWithExecutionSeam(ctx, resolvedConfig, seam, volatileHost, volatil
882
922
  await handBackRuntimeSessions(ctx, handbackOwners)
883
923
  }, 'ptc-plus provider handback')
884
924
 
925
+ // The settings section replaces this source after activation; the prompt-text
926
+ // accessor above reads it lazily for exactly that reason.
885
927
  let configSource = () => resolveConfig(volatileConfig ?? resolvedConfig)
886
928
  let configurationGeneration = 0
887
929
  let configurationRollback = false
@@ -912,6 +954,7 @@ function applyWithExecutionSeam(ctx, resolvedConfig, seam, volatileHost, volatil
912
954
  }
913
955
  const reconcile = (propagateFailure = false) => {
914
956
  const current = resolveConfig(configSource())
957
+ configureRetention(current)
915
958
  const generation = ++configurationGeneration
916
959
  if (!current.enabled) {
917
960
  const operation = controller.uninstall()
@@ -959,6 +1002,7 @@ function applyWithExecutionSeam(ctx, resolvedConfig, seam, volatileHost, volatil
959
1002
  }
960
1003
  ctx.effect(() => seam.onSourceChange(() => {
961
1004
  const current = resolveConfig(configSource())
1005
+ configureRetention(current)
962
1006
  if (!current.enabled) return undefined
963
1007
  if (seam.language !== 'typescript') {
964
1008
  const language = seam.language
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Compile-time evidence that a declaration's initializer is a bounded object
3
+ * literal.
4
+ *
5
+ * Recovery records a binding's value at settlement so it can be installed
6
+ * without re-executing the cell. Recording an arbitrary object is unsafe: the
7
+ * value codec enumerates an object's own keys before it can apply any budget,
8
+ * and enumerating a graph with millions of keys exhausts the worker heap before
9
+ * a cap can reject it (a four-million-key object aborts a 128 MiB worker).
10
+ *
11
+ * This module decides the question where it is cheap — on the syntax tree of the
12
+ * initializer — and returns the proven shape only for literals whose size is
13
+ * fixed by their source: an object literal with a bounded number of plain,
14
+ * non-computed properties whose values are primitives, array literals, or other
15
+ * such literals up to a fixed depth. Everything else (an identifier, a call, a
16
+ * class instance) yields no evidence, and its binding stays unrecorded and is
17
+ * named by recovery instead of being captured.
18
+ */
19
+
20
+ /** Maximum number of own properties a recordable literal may declare. */
21
+ export const RECORDED_LITERAL_MAX_PROPERTIES = 32
22
+
23
+ /** Maximum nesting depth of recordable literals, counting the outer object. */
24
+ export const RECORDED_LITERAL_MAX_DEPTH = 3
25
+
26
+ /** Maximum number of array elements a recordable array literal may declare. */
27
+ export const RECORDED_LITERAL_MAX_ELEMENTS = 64
28
+
29
+ const OBJECT_PROPERTY_TYPES = new Set(['ObjectProperty', 'Property'])
30
+ const LITERAL_KEY_TYPES = new Set(['StringLiteral', 'NumericLiteral', 'Literal', 'Identifier'])
31
+ const PRIMITIVE_LITERAL_TYPES = new Set(['StringLiteral', 'NumericLiteral', 'BooleanLiteral', 'NullLiteral',
32
+ 'BigIntLiteral', 'RegExpLiteral'])
33
+
34
+ function isRecordableValue(node, depth) {
35
+ if (node === null || node === undefined) return false
36
+ // ESTree parsers emit `Literal`; Babel emits the typed variants above.
37
+ if (node.type === 'Literal') {
38
+ const value = node.value
39
+ return value === null || ['number', 'string', 'boolean', 'bigint'].includes(typeof value)
40
+ || node.regex !== undefined
41
+ }
42
+ if (PRIMITIVE_LITERAL_TYPES.has(node.type)) return true
43
+ if (node.type === 'TemplateLiteral') return node.expressions.length === 0
44
+ if (node.type === 'UnaryExpression') {
45
+ return ['-', '+', '!', 'void'].includes(node.operator) && isRecordableValue(node.argument, depth)
46
+ }
47
+ if (node.type === 'ObjectExpression') return boundedLiteralEvidence(node, depth) !== undefined
48
+ if (node.type === 'ArrayExpression') {
49
+ return node.elements.length <= RECORDED_LITERAL_MAX_ELEMENTS
50
+ && node.elements.every(element => isRecordableValue(element, depth))
51
+ }
52
+ return false
53
+ }
54
+
55
+ /**
56
+ * Evidence for one object literal, or `undefined` when the literal's size is
57
+ * not fixed by its source.
58
+ *
59
+ * @param {object} node an ESTree/Babel `ObjectExpression`
60
+ * @param {number} [depth] nest level of this literal, 1 for the top object
61
+ * @returns {{ properties: number, depth: number } | undefined}
62
+ */
63
+ export function boundedLiteralEvidence(node, depth = 1) {
64
+ if (node?.type !== 'ObjectExpression') return undefined
65
+ if (depth > RECORDED_LITERAL_MAX_DEPTH) return undefined
66
+ if (node.properties.length > RECORDED_LITERAL_MAX_PROPERTIES) return undefined
67
+ let proven = 0
68
+ for (const property of node.properties) {
69
+ // `kind` is 'init' on most Babel versions and absent on some parses; an
70
+ // accessor or method arrives as `ObjectMethod`, which this branch rejects
71
+ // through the `type` check above.
72
+ if (!OBJECT_PROPERTY_TYPES.has(property.type) || property.computed === true
73
+ || (property.kind !== undefined && property.kind !== 'init') || property.method === true) return undefined
74
+ const key = property.key
75
+ if (!LITERAL_KEY_TYPES.has(key.type)) return undefined
76
+ const valueNode = property.value
77
+ if (valueNode.type === 'ObjectExpression') {
78
+ if (boundedLiteralEvidence(valueNode, depth + 1) === undefined) return undefined
79
+ } else if (!isRecordableValue(valueNode, depth)) {
80
+ return undefined
81
+ }
82
+ proven += 1
83
+ }
84
+ return { properties: proven, depth }
85
+ }
@@ -6,8 +6,10 @@ const FALLBACKS = Object.freeze({
6
6
  pending: 'Capabilities prepared; they take effect at the first accepted model step for session',
7
7
  unavailable: 'Select an active PTC session; the command did not update session',
8
8
  usage: 'Use /capabilities to inspect or /capabilities refresh to apply saved settings for session',
9
+ paused: 'Capabilities and context are off; the session stays with the Host capabilities for session',
10
+ off: 'Capability management is off; the session stays with the Host capabilities for session',
9
11
  })
10
- const STATES = new Set(['recorded', 'pending', 'unavailable', 'usage', 'saved-only'])
12
+ const STATES = new Set(['recorded', 'pending', 'unavailable', 'usage', 'saved-only', 'paused', 'off'])
11
13
 
12
14
  export function capabilityCommandReceipt(state, sessionId, error, proposal) {
13
15
  if (!STATES.has(state) || typeof sessionId !== 'string' || !sessionId) throw new TypeError('invalid capability command receipt')
@@ -14,6 +14,8 @@ import { PTC_MESSAGE_SOURCE_KIND } from './message-sources.js'
14
14
  import { deepFreeze } from './record-utils.js'
15
15
  import { capabilityProfileConfiguration } from './runtime-config.js'
16
16
  import { readRuntimeAvailability } from './runtime-messages.js'
17
+ import { PLAN_CATEGORY_LINES, PLAN_NAVIGATION, PLAN_OVERAGE, PLAN_OVERAGE_HOLE,
18
+ PROMPT_SLOT_IDS, planCategorySlotId, resolvePromptText, resolvePromptTextConfig } from './prompt-text.js'
17
19
 
18
20
  export const CAPABILITY_PLAN_FORM = 'capability-plan'
19
21
  export const CAPABILITY_EVIDENCE_KEY = 'dshPtcPlusCapabilities'
@@ -25,6 +27,54 @@ const LEGACY_NAVIGATION = 'Use capabilities.search({query}) to retrieve complete
25
27
  const NAVIGATION = 'Use capabilities.search({query}) to retrieve complete tool, Skill and MCP contracts. Unlisted capabilities remain discoverable. Skill descriptions are summaries; follow each Skill\'s declared invocation eligibility to load its body before following it.'
26
28
  const digest = value => createHash('sha256').update(JSON.stringify(value)).digest('hex')
27
29
  const messageText = message => message?.content?.filter(block => block.type === 'text').map(block => block.text).join('\n') ?? ''
30
+ const byteLength = value => Buffer.byteLength(value, 'utf8')
31
+
32
+ /**
33
+ * Delivered plan-channel text, resolved from the prompt-text document.
34
+ *
35
+ * The planner must receive the same words the panel shows, so the navigation
36
+ * sentence, the five category lines and the overage template are read once here
37
+ * rather than defaulting inside the planner. `isDefault` decides whether the
38
+ * snapshot can keep a historical generation: only an edited channel needs the v5
39
+ * record, so an untouched configuration stays byte-identical to before.
40
+ */
41
+ function planChannelText(config) {
42
+ const prompt = resolvePromptTextConfig(config)
43
+ const navigationSentence = prompt.text(PROMPT_SLOT_IDS.planNavigationSentence)
44
+ const overageSentence = prompt.text(PROMPT_SLOT_IDS.planOverageSentence)
45
+ const categoryLines = Object.keys(PLAN_CATEGORY_LINES)
46
+ .map(category => [category, prompt.text(planCategorySlotId(category))])
47
+ const isDefault = navigationSentence === PLAN_NAVIGATION
48
+ && overageSentence === PLAN_OVERAGE
49
+ && categoryLines.every(([category, line]) => line === PLAN_CATEGORY_LINES[category])
50
+ return { navigationSentence, overageSentence, categoryLines, isDefault }
51
+ }
52
+
53
+ /**
54
+ * Directory budget for the delivered guidance.
55
+ *
56
+ * Editing a navigation sentence changes how many bytes the directory spends before
57
+ * any capability entry, so the budget moves with the text: an unchanged document
58
+ * therefore keeps today's budget exactly, and a longer sentence does not silently
59
+ * shrink the catalog.
60
+ */
61
+ const planDirectoryBudget = (configured, navigationSentence) => (
62
+ configured + byteLength(navigationSentence) - byteLength(PLAN_NAVIGATION))
63
+
64
+ /**
65
+ * Recorded plan text; a v5 snapshot carries the words and the budget it was
66
+ * composed from.
67
+ *
68
+ * The budget belongs in the record because it depends on the navigation sentence:
69
+ * replaying it from the current default literal would let a later edit of that
70
+ * literal silently change what an old snapshot replays to.
71
+ */
72
+ const planPromptText = (channel, directoryBudgetBytes) => ({ promptText: {
73
+ navigationSentence: channel.navigationSentence,
74
+ categoryLines: channel.categoryLines.map(([category, line]) => [category, line]),
75
+ overageSentence: channel.overageSentence,
76
+ directoryBudgetBytes,
77
+ } })
28
78
 
29
79
  /** Saved inputs and explicit session choices are distinct; automatic cancels a saved override. */
30
80
  export function sessionCapabilitySettings(sourceSettings, sessionOverrides = {}) {
@@ -85,9 +135,19 @@ function sourcePlan(message, id) {
85
135
  const source = message?.source
86
136
  if (source?.kind !== PTC_MESSAGE_SOURCE_KIND || source.form !== CAPABILITY_PLAN_FORM || source.sessionId !== id) return undefined
87
137
  const snapshot = source.snapshot
88
- const generation = ['ptc-capability-environment/v1', 'ptc-capability-environment/v2', 'ptc-capability-environment/v3', 'ptc-capability-environment/v4'].indexOf(snapshot?.schema)
138
+ const generation = ['ptc-capability-environment/v1', 'ptc-capability-environment/v2', 'ptc-capability-environment/v3',
139
+ 'ptc-capability-environment/v4', 'ptc-capability-environment/v5'].indexOf(snapshot?.schema)
140
+ // Historical generations each had one planner generation for a given settings
141
+ // shape, so their plan schema is part of the record's identity. Generation 5
142
+ // records the plan words instead, and its plan schema follows from the recorded
143
+ // settings: the recomputation below proves the whole plan, which makes a separate
144
+ // schema guess both unnecessary and wrong (a v5 snapshot may carry context
145
+ // settings and therefore a v3 plan, or a closed plan and therefore a v2 one).
146
+ const expectedPlanSchema = generation === 2 ? 'ptc-capability-plan/v2'
147
+ : generation >= 3 && snapshot.settings.policy !== undefined ? 'ptc-capability-plan/v3' : 'ptc-capability-plan/v1'
89
148
  if (source.digest !== digest(snapshot) || generation < 0
90
- || !Array.isArray(snapshot.capabilities) || snapshot.plan?.schema !== (generation === 3 ? 'ptc-capability-plan/v3' : generation === 2 ? 'ptc-capability-plan/v2' : 'ptc-capability-plan/v1')) {
149
+ || !Array.isArray(snapshot.capabilities)
150
+ || (generation < 4 && snapshot.plan?.schema !== expectedPlanSchema)) {
91
151
  throw new Error('damaged session capability plan')
92
152
  }
93
153
  if (generation >= 1) {
@@ -100,30 +160,74 @@ function sourcePlan(message, id) {
100
160
  }
101
161
  if (JSON.stringify(merged) !== JSON.stringify(snapshot.settings)) throw new Error('inconsistent session capability choices')
102
162
  }
103
- if (generation === 3 ? typeof snapshot.settings.contextEnabled !== 'boolean' : snapshot.settings.contextEnabled !== undefined) {
163
+ // v4 implies a context generation; v5 only says the plan words were edited, so it
164
+ // may or may not carry context settings.
165
+ const contextGeneration = generation === 3 ? typeof snapshot.settings.contextEnabled === 'boolean'
166
+ : generation === 4 ? true : snapshot.settings.contextEnabled === undefined
167
+ if (!contextGeneration) {
104
168
  throw new Error('inconsistent capability context generation')
105
169
  }
106
- if (generation === 2 || generation === 3 && snapshot.settings.policy !== undefined) {
170
+ if (generation === 2 || generation >= 3 && snapshot.settings.policy !== undefined) {
107
171
  if (!snapshot.settings.policy || typeof snapshot.settings.recoveryEnabled !== 'boolean'
108
172
  || typeof snapshot.settings.previewEnabled !== 'boolean') throw new Error('damaged capability policy settings')
109
173
  } else if (snapshot.settings.policy !== undefined) throw new Error('inconsistent historical capability generation')
110
- if (snapshot.navigationText !== undefined && snapshot.navigationText !== NAVIGATION) throw new Error('unknown capability navigation contract')
174
+ // A v5 snapshot carries the plan words it was composed from; a historical
175
+ // generation may only claim the registry defaults it was delivered with.
176
+ const recorded = generation === 4 ? planRecord(snapshot) : undefined
177
+ if (recorded === undefined) {
178
+ if (snapshot.promptText !== undefined) throw new Error('inconsistent capability plan text generation')
179
+ if (snapshot.navigationText !== undefined && snapshot.navigationText !== NAVIGATION) throw new Error('unknown capability navigation contract')
180
+ } else if (snapshot.navigationText !== recorded.navigationSentence) {
181
+ throw new Error('damaged capability plan text')
182
+ }
111
183
  const expected = createCapabilityPlan({ ...snapshot.settings, capabilities: snapshot.capabilities,
112
184
  coverage: snapshot.coverage, navigationText: snapshot.navigationText ?? LEGACY_NAVIGATION,
185
+ ...(recorded === undefined ? {} : {
186
+ categories: recorded.categoryLines,
187
+ directoryBudgetBytes: recorded.directoryBudgetBytes,
188
+ }),
113
189
  configurationRevision: digest(snapshot.settings) })
114
- if (JSON.stringify(expected) !== JSON.stringify(snapshot.plan)
190
+ if ((generation >= 4 && snapshot.plan?.schema !== expected.schema)
191
+ || JSON.stringify(expected) !== JSON.stringify(snapshot.plan)
115
192
  || messageText(message) !== messageText(planMessage(snapshot, id, source.refresh === true))) {
116
193
  throw new Error('inconsistent session capability plan')
117
194
  }
118
195
  return deepFreeze(structuredClone(snapshot))
119
196
  }
120
197
 
198
+ /**
199
+ * Validated plan words of a v5 snapshot.
200
+ *
201
+ * Returns `undefined` only when the snapshot carries no record at all; a record that
202
+ * exists but cannot be replayed throws, because a half-recorded plan text would let
203
+ * a later replay silently deliver different words than the ones recorded here.
204
+ */
205
+ function planRecord(snapshot) {
206
+ const record = snapshot.promptText
207
+ if (record === undefined || record === null) return undefined
208
+ const categories = record.categoryLines
209
+ const known = Object.keys(PLAN_CATEGORY_LINES)
210
+ if (typeof record.navigationSentence !== 'string' || record.navigationSentence.length === 0
211
+ || typeof record.overageSentence !== 'string' || record.overageSentence.split(PLAN_OVERAGE_HOLE).length !== 2
212
+ || !Number.isSafeInteger(record.directoryBudgetBytes) || record.directoryBudgetBytes < 0
213
+ || !Array.isArray(categories) || categories.length !== known.length
214
+ || categories.some(pair => !Array.isArray(pair) || pair.length !== 2
215
+ || !known.includes(pair[0]) || typeof pair[1] !== 'string' || pair[1].length === 0)
216
+ || new Set(categories.map(pair => pair[0])).size !== categories.length) {
217
+ throw new Error('damaged capability plan text')
218
+ }
219
+ return { navigationSentence: record.navigationSentence, overageSentence: record.overageSentence,
220
+ directoryBudgetBytes: record.directoryBudgetBytes,
221
+ categoryLines: categories.map(([category, line]) => [category, line]) }
222
+ }
223
+
121
224
  function planMessage(snapshot, id, refresh = false) {
122
225
  const plan = snapshot.plan
226
+ const overage = snapshot.promptText?.overageSentence ?? PLAN_OVERAGE
123
227
  const text = plan.enabled
124
228
  ? ['PTC Plus session capability environment' + (refresh ? ' refreshed; replaces the preceding capability plan.' : ':'),
125
229
  plan.directoryText, plan.catalogText,
126
- ...(plan.budgets.overageBytes ? [`Explicit full contracts exceed the configured budget by ${plan.budgets.overageBytes} UTF-8 bytes.`] : [])]
230
+ ...(plan.budgets.overageBytes ? [overage.replace(PLAN_OVERAGE_HOLE, String(plan.budgets.overageBytes))] : [])]
127
231
  .filter(Boolean).join('\n\n')
128
232
  : snapshot.settings.contextEnabled === false
129
233
  ? 'PTC Plus capability and context settings are paused for this session. Use the host-provided capability contracts and result presentation.'
@@ -132,6 +236,43 @@ function planMessage(snapshot, id, refresh = false) {
132
236
  sessionId: id, refresh, digest: digest(snapshot), snapshot }, content: [{ type: 'text', text }] })
133
237
  }
134
238
 
239
+ /**
240
+ * Delivered plan text for a snapshot, without the message envelope.
241
+ *
242
+ * Replay and the surface gate both need the bytes the model reads; keeping the
243
+ * composition private to this module means a caller cannot assemble a different
244
+ * plan and call it the delivered one.
245
+ */
246
+ export function capabilityPlanText(snapshot, id, refresh = false) {
247
+ return planMessage(snapshot, id, refresh).content[0].text
248
+ }
249
+
250
+ /**
251
+ * Delivered plan message for a snapshot, envelope included.
252
+ *
253
+ * What this composition can claim is consistency, not provenance: the settings merge
254
+ * is a fixed point of the recorded settings, the recorded plan deep-equals a
255
+ * recomputation from them, the delivered text equals a recomposition of that plan and
256
+ * the snapshots it reads are deeply frozen. It recomputes the digest, so it cannot
257
+ * attest that these bytes are the ones a session log once carried.
258
+ */
259
+ export function capabilityPlanMessage(snapshot, id, refresh = false) {
260
+ return planMessage(snapshot, id, refresh)
261
+ }
262
+
263
+ /**
264
+ * Delivered plan text of a plan message, after the replay contract accepted it.
265
+ *
266
+ * The reader above stays a recognizer and hands out no composition state; a caller
267
+ * that needs the bytes the model read asks this projection instead. An unreplayable
268
+ * message throws, so a lane that compares bytes cannot pass by receiving `undefined`.
269
+ */
270
+ export function capabilityPlanTextFromMessage(message, sessionId) {
271
+ if (message?.source?.form !== CAPABILITY_PLAN_FORM) return undefined
272
+ sourcePlan(message, sessionId)
273
+ return message.content[0].text
274
+ }
275
+
135
276
  /** Recognize capability-owned presentation for bounded offline context accounting. */
136
277
  export function readCapabilityContextMessage(message, sessionId) {
137
278
  const source = message?.source
@@ -702,12 +843,24 @@ export function createCapabilityEnvironment({ ctx, config, toolSchemasForAgent,
702
843
  const capabilities = requiresCapabilityInventory(settings) ? await inventory(agent, sections ?? [], settings, signal) : []
703
844
  const effective = effectiveCapabilitySettings(settings)
704
845
  const coverage = (effective.policy ? effective.enabled && effective.policy.allocation.statistics : effective.statistics?.enabled === true) ? await store?.coverage(agent, capabilities, settings.statistics) : undefined
846
+ const planChannel = planChannelText(currentConfig)
847
+ const directoryBudgetBytes = planDirectoryBudget(settings.directoryBudgetBytes, planChannel.navigationSentence)
705
848
  const plan = createCapabilityPlan({ ...settings, configurationRevision: digest(settings),
706
- capabilities, coverage, navigationText: NAVIGATION })
849
+ capabilities, coverage, navigationText: planChannel.navigationSentence,
850
+ categories: planChannel.categoryLines,
851
+ directoryBudgetBytes })
707
852
  const hasChoices = settings.contextEnabled !== undefined || settings.policy !== undefined || choice !== undefined || previous?.schema === 'ptc-capability-environment/v2'
708
- const snapshot = deepFreeze({ schema: settings.contextEnabled !== undefined ? 'ptc-capability-environment/v4' : settings.policy ? 'ptc-capability-environment/v3' : hasChoices ? 'ptc-capability-environment/v2' : 'ptc-capability-environment/v1',
709
- ...(hasChoices ? { sourceSettings, sessionOverrides } : {}), navigationText: NAVIGATION, settings, capabilities, ...(coverage === undefined ? {} : { coverage }), plan })
710
- const message = planMessage(snapshot, id, force)
853
+ // An edited plan channel needs the v5 record, and that generation requires the
854
+ // saved inputs and session choices it was composed from.
855
+ const recordsText = !planChannel.isDefault
856
+ const snapshot = deepFreeze({ schema: recordsText ? 'ptc-capability-environment/v5'
857
+ : settings.contextEnabled !== undefined ? 'ptc-capability-environment/v4' : settings.policy ? 'ptc-capability-environment/v3'
858
+ : hasChoices ? 'ptc-capability-environment/v2' : 'ptc-capability-environment/v1',
859
+ ...(hasChoices || recordsText ? { sourceSettings, sessionOverrides } : {}),
860
+ ...(recordsText ? planPromptText(planChannel, directoryBudgetBytes) : {}),
861
+ navigationText: planChannel.navigationSentence, settings, capabilities, ...(coverage === undefined ? {} : { coverage }), plan })
862
+ const initialized = sessionEvents(agent.session).some(event => event.type === 'system/message')
863
+ const message = planMessage(snapshot, id, force && initialized)
711
864
  agent.signal?.throwIfAborted?.()
712
865
  signal?.throwIfAborted()
713
866
  // Initialized sessions commit a refresh and its notification atomically.
@@ -716,7 +869,7 @@ export function createCapabilityEnvironment({ ctx, config, toolSchemasForAgent,
716
869
  hostSections: sections ?? [], expanded: new Set(), used: new Map(),
717
870
  ...(requiresCapabilityInventory(settings) ? { liveCapabilities: new Map(capabilities.map(item => [item.id, item])) } : {}) }
718
871
  restoreEvidence(agent, state)
719
- if (choice?.commandId && !sessionEvents(agent.session).some(event => event.type === 'system/message')) {
872
+ if (choice?.commandId && !initialized) {
720
873
  state.pendingCommand = choice.commandId
721
874
  state.pendingPrevious = stateFor(agent)
722
875
  }
@@ -1186,7 +1339,12 @@ export function createCapabilityEnvironment({ ctx, config, toolSchemasForAgent,
1186
1339
  || String(agent.session.id ?? agent.id) !== sessionId) {
1187
1340
  throw new Error('ptc-plus: capability observation target is no longer available')
1188
1341
  }
1189
- return { schema: 'ptc-capability-session-catalog/v1', management, entries }
1342
+ const savedSettings = capabilityConfiguration(currentConfig)
1343
+ // The saved group and management switches gate session writes in change();
1344
+ // report them so the Client can block staged choices with the reason.
1345
+ return { schema: 'ptc-capability-session-catalog/v1', management, entries,
1346
+ savedSettings: { enabled: effectiveCapabilitySettings(savedSettings).enabled === true,
1347
+ contextEnabled: savedSettings.contextEnabled !== false } }
1190
1348
  },
1191
1349
  async observeAllocation({ sessionId, configuration } = {}, signal) {
1192
1350
  signal?.throwIfAborted()
@@ -1,16 +1,12 @@
1
1
  import { capabilityRule, validateCapabilityPolicy, effectiveCapabilitySettings } from './capability-policy.js'
2
2
  import { Buffer } from 'node:buffer'
3
+ import { PLAN_CATEGORY_LINES } from './prompt-text.js'
3
4
 
4
5
  const MANAGED_CATEGORIES = new Set(['tool', 'skill', 'resource', 'resource-template', 'task'])
5
6
  const EXCLUDED_CATEGORIES = new Set(['language', 'node', 'repl', 'ptc-plus', 'global-binding'])
6
7
  const SELECTIONS = new Set(['full', 'on-demand', 'closed'])
7
- const CATEGORY_NAVIGATION = new Map([
8
- ['tool', 'Tools: search by task or tools.<name> for parameters and results.'],
9
- ['skill', 'Skills: search by task or skills.<name> for when to use and how to load instructions.'],
10
- ['resource', 'MCP resources: search by server or resource purpose.'],
11
- ['resource-template', 'MCP templates: search by server or resource purpose.'],
12
- ['task', 'Tasks: search by the operation you need.'],
13
- ])
8
+ /** Category guidance defaults; the registry owns the delivered literals. */
9
+ const CATEGORY_NAVIGATION = new Map(Object.entries(PLAN_CATEGORY_LINES))
14
10
 
15
11
  function isRecord(value) { return value !== null && typeof value === 'object' && !Array.isArray(value) }
16
12
 
@@ -121,6 +117,13 @@ function createLegacyCapabilityPlan(input) {
121
117
  const budgetBytes = integer(options.budgetBytes, 'contract budget bytes')
122
118
  const directoryBudgetBytes = integer(options.directoryBudgetBytes, 'directory budget bytes')
123
119
  const navigationText = text(options.navigationText, 'bounded discovery guidance')
120
+ const categoryNavigation = options.categories === undefined
121
+ ? CATEGORY_NAVIGATION
122
+ : new Map(options.categories.map(pair => {
123
+ if (!Array.isArray(pair) || pair.length !== 2) throw new TypeError('ptc-plus: capability categories must be [category, guidance] pairs')
124
+ const category = text(pair[0], `capability category ${pair[0]}`)
125
+ return [category, text(pair[1], `capability guidance for ${category}`)]
126
+ }))
124
127
  if (bytes(navigationText) > directoryBudgetBytes) throw new RangeError('ptc-plus: discovery guidance exceeds directory byte budget')
125
128
  const manual = record(options.manual ?? {}, 'manual capability settings')
126
129
  const overrides = record(manual.overrides ?? {}, 'capability overrides')
@@ -220,7 +223,7 @@ function createLegacyCapabilityPlan(input) {
220
223
  const directoryCategories = []
221
224
  const availableCategories = new Set(capabilities.filter(capability => selections.get(capability.id).selection !== 'closed')
222
225
  .map(capability => capability.category))
223
- for (const [category, guidance] of CATEGORY_NAVIGATION) {
226
+ for (const [category, guidance] of categoryNavigation) {
224
227
  if (!availableCategories.has(category)) continue
225
228
  const proposed = `${directoryText}\n${guidance}`
226
229
  if (bytes(proposed) <= directoryBudgetBytes) {
@@ -198,12 +198,16 @@ export function capabilityCoverage(state, query) {
198
198
  const workspaceId = options.workspaceId === null ? null : identity(options.workspaceId, 'coverage workspace identity')
199
199
  if (state.status !== 'usable') return { status: state.status, windowSize: state.limits.windowSize, eligible: null, used: null, coverage: null }
200
200
  const window = state.windows.find(item => item.userId === userId && item.workspaceId === workspaceId && item.id === id && item.revision === revision)
201
- const families = window ? state.families.filter(item => qualifies(item, window)).sort(orderSamples).slice(0, state.limits.windowSize) : []
201
+ // "Nothing recorded yet" and "recorded families are not currently settled"
202
+ // are different facts from a measured window; neither may read as a zero.
203
+ if (window === undefined) return { status: 'no-samples', windowSize: state.limits.windowSize, eligible: null, used: null, coverage: null }
204
+ const families = state.families.filter(item => qualifies(item, window)).sort(orderSamples).slice(0, state.limits.windowSize)
202
205
  // A resumed family can expose a gap behind the irreversible eviction frontier.
203
206
  // Surviving samples do not prove the recent settled window until it is full again.
204
- if (window?.floor > 0 && families.length < state.limits.windowSize) {
207
+ if (window.floor > 0 && families.length < state.limits.windowSize) {
205
208
  return { status: 'incomplete-window', windowSize: state.limits.windowSize, eligible: null, used: null, coverage: null }
206
209
  }
210
+ if (families.length === 0) return { status: 'pending-settlement', windowSize: state.limits.windowSize, eligible: null, used: null, coverage: null }
207
211
  const used = families.filter(family => family.observations.some(item => item.id === id && item.revision === revision && item.used
208
212
  && (workspaceId === null || item.workspaceId === workspaceId))).length
209
213
  return { status: 'usable', windowSize: state.limits.windowSize, eligible: families.length, used, coverage: families.length ? used / families.length : null }