dsh-ptc-plus 0.2.1
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/LICENSE +21 -0
- package/README.md +137 -0
- package/README.zh.md +137 -0
- package/assets/dsh-ptc-plus-banner.webp +0 -0
- package/assets/ptc-plus-repair.png +0 -0
- package/assets/ptc-plus-settings.png +0 -0
- package/client.js +366 -0
- package/cordis.patch.yml +5 -0
- package/docs/README.md +21 -0
- package/docs/adr/0001-delegate-governance-to-dsh.md +3 -0
- package/docs/adr/0002-limit-work-map-scope.md +3 -0
- package/docs/adr/0003-prefer-native-program-surfaces.md +3 -0
- package/docs/adr/0004-declare-program-bindings-at-the-host.md +5 -0
- package/docs/adr/0005-temporary-rejected-cell-edit-transport.md +129 -0
- package/docs/adr/0006-rewrite-module-syntax-with-an-ast.md +45 -0
- package/docs/adr/0007-separate-worker-transport-from-session-semantics.md +39 -0
- package/docs/adr/0008-define-agent-operating-contract.md +25 -0
- package/docs/adr/0009-bound-expensive-acceptance-default.md +39 -0
- package/docs/adr/0010-session-log-derived-recovery-tips.md +26 -0
- package/docs/adr/0011-isolate-the-local-recovery-tip-provider.md +20 -0
- package/docs/adr/0012-preserve-mixed-destructuring-initialization.md +23 -0
- package/docs/adr/0013-isolate-repl-redeclaration-convenience.md +21 -0
- package/docs/adr/0014-persist-cell-rewrite-policy.md +25 -0
- package/docs/adr/0015-preserve-source-positions-through-rewrites.md +25 -0
- package/docs/adr/0016-archive-resolved-review-findings.md +43 -0
- package/docs/adr/0017-track-the-latest-dsh-public-surface.md +23 -0
- package/docs/adr/0018-use-versioned-fixture-for-ab-benchmark.md +25 -0
- package/docs/adr/0019-plugin-settings-and-kill-switch.md +32 -0
- package/docs/adr/0020-optional-cordis-tools-in-ptc-mode.md +36 -0
- package/docs/adr/0021-separate-host-bootstrap-from-session-runtime.md +68 -0
- package/docs/adr/README.md +18 -0
- package/docs/architecture.md +134 -0
- package/docs/capability-projection.md +48 -0
- package/docs/client-ui.md +47 -0
- package/docs/durability-design.md +265 -0
- package/docs/evaluation.md +62 -0
- package/docs/installation.md +82 -0
- package/docs/program-data-plane.md +61 -0
- package/docs/publishing.md +37 -0
- package/docs/runtime-reference.md +119 -0
- package/docs/value-wire.md +61 -0
- package/index.js +532 -0
- package/internal/ast-traversal.js +20 -0
- package/internal/binding-descriptors.js +95 -0
- package/internal/binding-pattern.js +54 -0
- package/internal/builtin-esm-sync.js +9 -0
- package/internal/cell-analysis.js +467 -0
- package/internal/cell-rewriter.js +520 -0
- package/internal/config-spec.js +179 -0
- package/internal/cordis-tools-owner.js +363 -0
- package/internal/diagnostic.js +133 -0
- package/internal/direct-surface-owner.js +326 -0
- package/internal/edit-transport-owner.js +287 -0
- package/internal/failure-reporting.js +139 -0
- package/internal/kernel-worker.js +484 -0
- package/internal/module-policy.js +46 -0
- package/internal/program-bindings.js +187 -0
- package/internal/record-utils.js +53 -0
- package/internal/recovery-tips.js +81 -0
- package/internal/rejected-cell-editor.js +328 -0
- package/internal/repl-convenience.js +201 -0
- package/internal/runtime-bridge-owner.js +417 -0
- package/internal/runtime-config.js +77 -0
- package/internal/runtime-contexts.js +31 -0
- package/internal/sdk-projection.js +88 -0
- package/internal/session-cell-executor.js +612 -0
- package/internal/session-journal.js +889 -0
- package/internal/session-log-view.js +80 -0
- package/internal/session-runtime.js +536 -0
- package/internal/session-state.js +45 -0
- package/internal/source-position-map.js +224 -0
- package/internal/tool-call-canonicalizer.js +339 -0
- package/internal/value-wire.js +421 -0
- package/internal/worker-client.js +218 -0
- package/internal/worker-cwd-virtualization.js +155 -0
- package/package.json +100 -0
- package/src/client.js +165 -0
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/** Deterministic capability metadata derived from the current DSH tool view. */
|
|
2
|
+
import { record, text } from './record-utils.js'
|
|
3
|
+
|
|
4
|
+
const COMPLETENESS = new Set(['complete', 'bounded', 'incremental', 'open-world', 'unknown'])
|
|
5
|
+
const SOURCE_KINDS = new Set(['authored', 'runtime', 'tests', 'docs'])
|
|
6
|
+
const REPLAY = new Set(['recorded-value', 'owner-replay', 'volatile', 'unknown'])
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
function metadataSnapshot(value, label) {
|
|
10
|
+
let clone
|
|
11
|
+
try {
|
|
12
|
+
clone = structuredClone(value)
|
|
13
|
+
} catch (error) {
|
|
14
|
+
throw new TypeError(`ptc-plus: ${label} must be structured data: ${error.message}`)
|
|
15
|
+
}
|
|
16
|
+
if (clone === null || typeof clone !== 'object') return clone
|
|
17
|
+
const pending = [clone]
|
|
18
|
+
const seen = new Set()
|
|
19
|
+
while (pending.length > 0) {
|
|
20
|
+
const current = pending.pop()
|
|
21
|
+
if (seen.has(current)) continue
|
|
22
|
+
seen.add(current)
|
|
23
|
+
for (const child of Object.values(current)) {
|
|
24
|
+
if (child !== null && typeof child === 'object') pending.push(child)
|
|
25
|
+
}
|
|
26
|
+
Object.freeze(current)
|
|
27
|
+
}
|
|
28
|
+
return clone
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function sourceRef(value, label) {
|
|
32
|
+
if (value === undefined) return undefined
|
|
33
|
+
const source = record(value, label)
|
|
34
|
+
const kind = text(source.kind, `${label}.kind`)
|
|
35
|
+
if (!SOURCE_KINDS.has(kind)) throw new TypeError(`ptc-plus: ${label}.kind is invalid`)
|
|
36
|
+
const path = text(source.path, `${label}.path`)
|
|
37
|
+
const result = { kind, path }
|
|
38
|
+
if (source.symbol !== undefined) result.symbol = text(source.symbol, `${label}.symbol`)
|
|
39
|
+
if (source.revision !== undefined) result.revision = text(source.revision, `${label}.revision`)
|
|
40
|
+
return Object.freeze(result)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Convert DSH's live tool schemas into descriptive metadata without wrapping invocation. */
|
|
44
|
+
export function toolCapabilityMetadata(schemas, annotations = {}) {
|
|
45
|
+
if (!Array.isArray(schemas)) throw new TypeError('ptc-plus: tool schemas must be an array')
|
|
46
|
+
const overrides = record(annotations, 'tool annotations')
|
|
47
|
+
const members = schemas
|
|
48
|
+
.filter(schema => schema?.name !== 'run_code')
|
|
49
|
+
.map((schema) => {
|
|
50
|
+
const value = record(schema, 'tool schema')
|
|
51
|
+
const name = text(value.name, 'tool schema name')
|
|
52
|
+
const annotation = overrides[name] === undefined ? {} : record(overrides[name], `${name} annotation`)
|
|
53
|
+
return Object.freeze({
|
|
54
|
+
name,
|
|
55
|
+
...(value.description === undefined ? {} : { description: text(value.description, `${name} description`) }),
|
|
56
|
+
parameters: metadataSnapshot(value.parameters ?? {}, `${name} parameters`),
|
|
57
|
+
returns: metadataSnapshot(value.output ?? {}, `${name} output`),
|
|
58
|
+
effect: annotation.effect ?? 'unknown',
|
|
59
|
+
authority: annotation.authority ?? 'dsh-tool-dispatch',
|
|
60
|
+
completeness: annotation.completeness ?? 'unknown',
|
|
61
|
+
replay: annotation.replay ?? 'unknown',
|
|
62
|
+
...(annotation.sourceRef === undefined ? {} : { sourceRef: sourceRef(annotation.sourceRef, `${name} sourceRef`) }),
|
|
63
|
+
...(annotation.revision === undefined ? {} : { revision: text(annotation.revision, `${name} revision`) }),
|
|
64
|
+
...(annotation.fingerprint === undefined ? {} : { fingerprint: text(annotation.fingerprint, `${name} fingerprint`) }),
|
|
65
|
+
})
|
|
66
|
+
})
|
|
67
|
+
.sort((left, right) => left.name.localeCompare(right.name))
|
|
68
|
+
for (const member of members) {
|
|
69
|
+
if (!COMPLETENESS.has(member.completeness)) {
|
|
70
|
+
throw new TypeError(`ptc-plus: ${member.name}.completeness is invalid`)
|
|
71
|
+
}
|
|
72
|
+
if (!REPLAY.has(member.replay)) {
|
|
73
|
+
throw new TypeError(`ptc-plus: ${member.name}.replay is invalid`)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return Object.freeze([{ namespace: 'tools', members: Object.freeze(members) }])
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function metadataEntries(metadata) {
|
|
80
|
+
if (!Array.isArray(metadata)) throw new TypeError('ptc-plus: capability metadata must be an array')
|
|
81
|
+
return metadata.map((entry) => {
|
|
82
|
+
const value = record(entry, 'capability metadata entry')
|
|
83
|
+
const namespace = text(value.namespace, 'capability namespace')
|
|
84
|
+
const members = Array.isArray(value.members)
|
|
85
|
+
? value.members.map((member) => {
|
|
86
|
+
const item = record(member, 'capability member')
|
|
87
|
+
return { ...item, name: text(item.name, `${namespace} capability member name`) }
|
|
88
|
+
})
|
|
89
|
+
: []
|
|
90
|
+
return { ...value, namespace, members }
|
|
91
|
+
})
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function searchTokens(value) {
|
|
95
|
+
const separated = value
|
|
96
|
+
.replace(/(\p{Lu}+)(\p{Lu}\p{Ll})/gu, '$1 $2')
|
|
97
|
+
.replace(/([\p{Ll}\p{N}])(\p{Lu})/gu, '$1 $2')
|
|
98
|
+
return separated.toLowerCase().match(/[\p{L}\p{N}]+/gu) ?? []
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function includesTokenSequence(tokens, queryTokens) {
|
|
102
|
+
if (queryTokens.length === 0 || queryTokens.length > tokens.length) return false
|
|
103
|
+
return tokens.some((_, start) => queryTokens.every(
|
|
104
|
+
(token, offset) => tokens[start + offset] === token,
|
|
105
|
+
))
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function capabilityMatchRank(namespace, member, query, queryTokens) {
|
|
109
|
+
const normalizedNamespace = namespace.toLowerCase()
|
|
110
|
+
const normalizedMember = member.name.toLowerCase()
|
|
111
|
+
const symbol = `${normalizedNamespace}.${normalizedMember}`
|
|
112
|
+
if (symbol === query) return 0
|
|
113
|
+
if (normalizedNamespace === query) return 1
|
|
114
|
+
if (normalizedMember === query) return 2
|
|
115
|
+
if (includesTokenSequence(searchTokens(`${namespace}.${member.name}`), queryTokens)) return 3
|
|
116
|
+
if (typeof member.description === 'string'
|
|
117
|
+
&& includesTokenSequence(searchTokens(member.description), queryTokens)) return 4
|
|
118
|
+
return undefined
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Deterministic hierarchy only. Details remain behind inspect. */
|
|
122
|
+
export function capabilityTree(metadata) {
|
|
123
|
+
return metadataEntries(metadata).map((value) => {
|
|
124
|
+
return {
|
|
125
|
+
namespace: value.namespace,
|
|
126
|
+
members: value.members.map(member => member.name),
|
|
127
|
+
...(value.revision === undefined ? {} : { revision: value.revision }),
|
|
128
|
+
...(value.fingerprint === undefined ? {} : { fingerprint: value.fingerprint }),
|
|
129
|
+
}
|
|
130
|
+
})
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Find lightweight candidates without returning full schemas or source. */
|
|
134
|
+
export function capabilityFind(metadata, query) {
|
|
135
|
+
const trimmedQuery = text(query, 'capability query').trim()
|
|
136
|
+
const normalizedQuery = trimmedQuery.toLowerCase()
|
|
137
|
+
const queryTokens = searchTokens(trimmedQuery)
|
|
138
|
+
const candidates = []
|
|
139
|
+
let order = 0
|
|
140
|
+
for (const entry of metadataEntries(metadata)) {
|
|
141
|
+
for (const member of entry.members) {
|
|
142
|
+
const rank = capabilityMatchRank(entry.namespace, member, normalizedQuery, queryTokens)
|
|
143
|
+
if (rank !== undefined) {
|
|
144
|
+
candidates.push({
|
|
145
|
+
rank,
|
|
146
|
+
order,
|
|
147
|
+
value: {
|
|
148
|
+
symbol: `${entry.namespace}.${member.name}`,
|
|
149
|
+
...(member.description === undefined ? {} : { description: member.description }),
|
|
150
|
+
completeness: member.completeness ?? 'unknown',
|
|
151
|
+
effect: member.effect ?? 'unknown',
|
|
152
|
+
replay: member.replay ?? 'unknown',
|
|
153
|
+
},
|
|
154
|
+
})
|
|
155
|
+
}
|
|
156
|
+
order += 1
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return candidates
|
|
160
|
+
.sort((left, right) => left.rank - right.rank || left.order - right.order)
|
|
161
|
+
.map(candidate => candidate.value)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Batch, budgeted inspection; omitted items remain explicit to the caller. */
|
|
165
|
+
export function capabilityInspect(metadata, symbols = undefined, budget = 50) {
|
|
166
|
+
if (!Number.isSafeInteger(budget) || budget < 1) throw new TypeError('ptc-plus: capability inspection budget must be a positive integer')
|
|
167
|
+
const entries = metadataEntries(metadata)
|
|
168
|
+
const requested = symbols === undefined
|
|
169
|
+
? entries.flatMap(entry => entry.members.map(member => `${entry.namespace}.${member.name}`))
|
|
170
|
+
: symbols
|
|
171
|
+
if (!Array.isArray(requested)) throw new TypeError('ptc-plus: capability symbols must be an array')
|
|
172
|
+
const bySymbol = new Map(entries.flatMap(entry => entry.members.map(member => [
|
|
173
|
+
`${entry.namespace}.${member.name}`,
|
|
174
|
+
{ namespace: entry.namespace, ...member },
|
|
175
|
+
])))
|
|
176
|
+
const selected = requested.slice(0, budget).flatMap((symbol) => {
|
|
177
|
+
const key = text(symbol, 'capability symbol')
|
|
178
|
+
const found = bySymbol.get(key)
|
|
179
|
+
return found === undefined ? [] : [found]
|
|
180
|
+
})
|
|
181
|
+
return {
|
|
182
|
+
symbols: selected,
|
|
183
|
+
omitted: Math.max(0, requested.length - budget),
|
|
184
|
+
unknown: requested.slice(0, budget).filter(symbol => !bySymbol.has(symbol)),
|
|
185
|
+
budget,
|
|
186
|
+
}
|
|
187
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/** Shared structural guards for closed PTC metadata boundaries. */
|
|
2
|
+
|
|
3
|
+
export function isRecord(value) {
|
|
4
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function assertOwnFields(value, allowed, label) {
|
|
8
|
+
for (const key of Reflect.ownKeys(value)) {
|
|
9
|
+
if (typeof key !== 'string' || !allowed.has(key)
|
|
10
|
+
|| !Object.prototype.propertyIsEnumerable.call(value, key)) {
|
|
11
|
+
throw new TypeError(`invalid ${label} field ${String(key)}`)
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function assertFields(value, allowed, label) {
|
|
17
|
+
if (!isRecord(value)) throw new TypeError(`invalid ${label}`)
|
|
18
|
+
assertOwnFields(value, allowed, label)
|
|
19
|
+
if (Reflect.ownKeys(value).length !== allowed.size) throw new TypeError(`invalid ${label} fields`)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function record(value, label) {
|
|
23
|
+
if (!isRecord(value)) throw new TypeError(`ptc-plus: ${label} must be an object`)
|
|
24
|
+
return value
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function text(value, label) {
|
|
28
|
+
if (typeof value !== 'string' || value.trim().length === 0) {
|
|
29
|
+
throw new TypeError(`ptc-plus: ${label} must be a non-empty string`)
|
|
30
|
+
}
|
|
31
|
+
return value
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Freeze one static object graph without invoking accessors or recursing. */
|
|
35
|
+
export function deepFreeze(value) {
|
|
36
|
+
if (value === null || typeof value !== 'object') return value
|
|
37
|
+
const pending = [value]
|
|
38
|
+
const seen = new Set()
|
|
39
|
+
while (pending.length > 0) {
|
|
40
|
+
const current = pending.pop()
|
|
41
|
+
if (seen.has(current)) continue
|
|
42
|
+
seen.add(current)
|
|
43
|
+
for (const key of Reflect.ownKeys(current)) {
|
|
44
|
+
const descriptor = Object.getOwnPropertyDescriptor(current, key)
|
|
45
|
+
if (descriptor !== undefined && Object.hasOwn(descriptor, 'value')) {
|
|
46
|
+
const child = descriptor.value
|
|
47
|
+
if (child !== null && typeof child === 'object') pending.push(child)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
Object.freeze(current)
|
|
51
|
+
}
|
|
52
|
+
return value
|
|
53
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
const PLATFORM_CAUSE_CODES = new Set(['EACCES', 'EINVAL', 'ENOTDIR', 'ENOENT', 'UNKNOWN'])
|
|
2
|
+
const TIP_CONTEXT_PREFIX = 'tools:ptc-plus-tip/'
|
|
3
|
+
export const LONG_FAILED_CELL_TIP_CODE_UNITS = 2_000
|
|
4
|
+
const TIP_PREFIXES = Object.freeze({
|
|
5
|
+
'repeated-binding-failure': 'The same binding failure has recurred.',
|
|
6
|
+
'platform-command-failure': 'An executable, shell, or path failed in the current execution world.',
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
// TODO(dsh-tips-api): replace this local provider with an adapter after dsh-tips publishes a stable facts and decision interface.
|
|
10
|
+
function tipHistory(view) {
|
|
11
|
+
const active = new Map()
|
|
12
|
+
const seen = new Set()
|
|
13
|
+
const history = []
|
|
14
|
+
for (const snapshot of view.systemPromptSnapshots) {
|
|
15
|
+
const next = new Map(snapshot.sections.map(section => [section.name, section.text]))
|
|
16
|
+
for (const [name] of next) {
|
|
17
|
+
if (active.has(name) || seen.has(name)) continue
|
|
18
|
+
const match = /^tools:ptc-plus-tip\/(repeated-binding-failure|platform-command-failure)\/([1-9][0-9]*)$/.exec(name)
|
|
19
|
+
if (match === null) continue
|
|
20
|
+
const ordinal = Number(match[2])
|
|
21
|
+
if (!Number.isSafeInteger(ordinal)) continue
|
|
22
|
+
seen.add(name)
|
|
23
|
+
history.push({ id: match[1], ordinal, index: snapshot.index, contextStep: snapshot.contextStep })
|
|
24
|
+
}
|
|
25
|
+
active.clear()
|
|
26
|
+
for (const entry of next) active.set(...entry)
|
|
27
|
+
}
|
|
28
|
+
return history
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function hasPlatformCommandDiagnostic(diagnostics) {
|
|
32
|
+
return diagnostics.some(diagnostic => {
|
|
33
|
+
if (diagnostic.code !== 'PTC-X001') return false
|
|
34
|
+
const parts = [diagnostic.message, diagnostic.cause?.code, diagnostic.cause?.message]
|
|
35
|
+
.filter(value => typeof value === 'string')
|
|
36
|
+
const text = parts.join('\n')
|
|
37
|
+
const causeCode = typeof diagnostic.cause?.code === 'string' ? diagnostic.cause.code.toUpperCase() : undefined
|
|
38
|
+
if (causeCode !== undefined && PLATFORM_CAUSE_CODES.has(causeCode)
|
|
39
|
+
&& /\b(?:spawn|exec(?:ute)?|command|executable|shell|path|file)\b/i.test(text)) return true
|
|
40
|
+
return /(?:spawn|exec(?:ute)?|child process)\b.*(?:failed|error|not found|cannot find)/i.test(text)
|
|
41
|
+
|| /command not found|no such file|not recognized as (?:an )?internal|cannot find (?:the )?(?:path|file)/i.test(text)
|
|
42
|
+
})
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function tipCandidate(view) {
|
|
46
|
+
const { args, journal } = view.latestRun ?? {}
|
|
47
|
+
if (args === undefined || journal === undefined) return undefined
|
|
48
|
+
const codes = new Set(journal.diagnostics.map(diagnostic => diagnostic.code))
|
|
49
|
+
if (codes.has('PTC-W001')) return { id: 'repeated-binding-failure' }
|
|
50
|
+
if (hasPlatformCommandDiagnostic(journal.diagnostics)) return { id: 'platform-command-failure' }
|
|
51
|
+
return undefined
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function renderTip(id, detailed, candidate = {}) {
|
|
55
|
+
if (id === 'platform-command-failure') {
|
|
56
|
+
return detailed
|
|
57
|
+
? `${TIP_PREFIXES[id]} Re-check the active execution world and the actual executable before retrying. Use direct argv for a normal executable; use a shell only when its syntax or resolution is required. Windows, WSL, POSIX, and package shims have different paths and launch rules.`
|
|
58
|
+
: `${TIP_PREFIXES[id]} Inspect the executable or path in the current execution world and choose direct argv or a shell only when required; do not assume Windows, WSL, POSIX, or one shell.`
|
|
59
|
+
}
|
|
60
|
+
return detailed
|
|
61
|
+
? `${TIP_PREFIXES[id]} Inspect the live request with \`capabilities.tree()\`, \`capabilities.find()\`, or \`capabilities.inspect()\`, then call the typed member through \`tools.*\`. Do not invent hidden bindings or repeat the same failing expression.`
|
|
62
|
+
: `${TIP_PREFIXES[id]} Inspect the live request with \`capabilities.tree()\`, \`capabilities.find()\`, or \`capabilities.inspect()\`, then call the typed member through \`tools.*\`.`
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function latestRecoveryTip(view, config) {
|
|
66
|
+
if (!config.enabled) return undefined
|
|
67
|
+
const candidate = tipCandidate(view)
|
|
68
|
+
if (candidate === undefined) return undefined
|
|
69
|
+
const history = tipHistory(view)
|
|
70
|
+
const lastTip = history.at(-1)
|
|
71
|
+
if (lastTip !== undefined && view.contextStep - lastTip.contextStep < config.cooldownMessages) return undefined
|
|
72
|
+
const unresolved = history.filter(item => item.id === candidate.id && item.index > (view.lastSuccessfulRunIndex ?? -1))
|
|
73
|
+
const ordinal = history.reduce(
|
|
74
|
+
(highest, item) => item.id === candidate.id ? Math.max(highest, item.ordinal) : highest,
|
|
75
|
+
0,
|
|
76
|
+
) + 1
|
|
77
|
+
return {
|
|
78
|
+
name: `${TIP_CONTEXT_PREFIX}${candidate.id}/${ordinal}`,
|
|
79
|
+
text: renderTip(candidate.id, unresolved.length >= config.escalationFailures, candidate),
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
import { runInNewContext } from 'node:vm'
|
|
2
|
+
|
|
3
|
+
const DEFAULT_REGEX_TIMEOUT_MS = 100
|
|
4
|
+
export const EDIT_LIMITS = Object.freeze({
|
|
5
|
+
exactEdits: 16,
|
|
6
|
+
exactSearchCodeUnits: 16 * 1024 * 1024,
|
|
7
|
+
regexEdits: 16,
|
|
8
|
+
regexMatches: 4_096,
|
|
9
|
+
regexTemplateCodeUnits: 16 * 1024,
|
|
10
|
+
regexExpansionSteps: 64 * 1024,
|
|
11
|
+
regexCaptureSlots: 64 * 1024,
|
|
12
|
+
generatedCodeUnits: 1024 * 1024,
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
const EDIT_RUN_CODE_EXECUTION_DESCRIPTION = 'Edit and run TypeScript cell'
|
|
16
|
+
const REGEX_EDIT_RUN_CODE_EXECUTION_DESCRIPTION = 'Regex-edit and run TypeScript cell'
|
|
17
|
+
|
|
18
|
+
const REGEX_MATCH_SCRIPT = `(() => {
|
|
19
|
+
const replacements = []
|
|
20
|
+
let captureSlots = 0
|
|
21
|
+
for (let ruleIndex = 0; ruleIndex < rules.length; ruleIndex += 1) {
|
|
22
|
+
const rule = rules[ruleIndex]
|
|
23
|
+
let expression
|
|
24
|
+
try {
|
|
25
|
+
expression = new RegExp(rule.pattern, (rule.flags.includes('g') ? rule.flags : rule.flags + 'g') + 'd')
|
|
26
|
+
} catch (error) {
|
|
27
|
+
return { error: 'regex_edits[' + ruleIndex + '].pattern is invalid: ' + error.message }
|
|
28
|
+
}
|
|
29
|
+
let count = 0
|
|
30
|
+
for (const match of source.matchAll(expression)) {
|
|
31
|
+
if (match[0].length === 0) {
|
|
32
|
+
return { error: 'regex_edits[' + ruleIndex + '] must not produce zero-length matches' }
|
|
33
|
+
}
|
|
34
|
+
count += 1
|
|
35
|
+
if (count > rule.expected_matches) break
|
|
36
|
+
captureSlots += match.indices.length - 1
|
|
37
|
+
if (captureSlots > maxCaptureSlots) {
|
|
38
|
+
return { error: 'regex_edits exceed the ' + maxCaptureSlots + ' capture-slot budget' }
|
|
39
|
+
}
|
|
40
|
+
const named = match.indices.groups
|
|
41
|
+
replacements.push({
|
|
42
|
+
start: match.index,
|
|
43
|
+
end: match.index + match[0].length,
|
|
44
|
+
captures: Array.from(match.indices).slice(1),
|
|
45
|
+
groups: named === undefined
|
|
46
|
+
? undefined
|
|
47
|
+
: Object.fromEntries(Object.entries(named)),
|
|
48
|
+
ruleIndex,
|
|
49
|
+
})
|
|
50
|
+
}
|
|
51
|
+
if (count !== rule.expected_matches) {
|
|
52
|
+
return {
|
|
53
|
+
error: 'regex_edits[' + ruleIndex + '] expected ' + rule.expected_matches
|
|
54
|
+
+ (count > rule.expected_matches
|
|
55
|
+
? ' matches but found more than ' + rule.expected_matches
|
|
56
|
+
: ' matches but found ' + count),
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return { replacements }
|
|
61
|
+
})()`
|
|
62
|
+
|
|
63
|
+
function rejection(reason) {
|
|
64
|
+
return Object.freeze({ edited: false, reason })
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function exactReplacements(edits, source) {
|
|
68
|
+
if (edits.length === 0) return { error: 'edits must contain at least one replacement' }
|
|
69
|
+
if (edits.length > EDIT_LIMITS.exactEdits) {
|
|
70
|
+
return { error: `edits must contain at most ${EDIT_LIMITS.exactEdits} replacements` }
|
|
71
|
+
}
|
|
72
|
+
for (const [index, edit] of edits.entries()) {
|
|
73
|
+
if (edit === null || typeof edit !== 'object' || Array.isArray(edit)) {
|
|
74
|
+
return { error: `edits[${index}] must be an object` }
|
|
75
|
+
}
|
|
76
|
+
const keys = Object.keys(edit)
|
|
77
|
+
if (keys.length !== 2 || !keys.includes('old_string') || !keys.includes('new_string')
|
|
78
|
+
|| typeof edit.old_string !== 'string' || typeof edit.new_string !== 'string') {
|
|
79
|
+
return { error: `edits[${index}] expects exactly old_string and new_string strings` }
|
|
80
|
+
}
|
|
81
|
+
if (edit.old_string.length === 0) return { error: `edits[${index}].old_string must be non-empty` }
|
|
82
|
+
if (edit.old_string === edit.new_string) {
|
|
83
|
+
return { error: `edits[${index}].old_string and new_string must differ` }
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const searchWork = source.length * edits.length * 2
|
|
87
|
+
if (!Number.isSafeInteger(searchWork) || searchWork > EDIT_LIMITS.exactSearchCodeUnits) {
|
|
88
|
+
return { error: `edits exceed the ${EDIT_LIMITS.exactSearchCodeUnits} source-code-unit search budget` }
|
|
89
|
+
}
|
|
90
|
+
const replacements = []
|
|
91
|
+
for (const [index, edit] of edits.entries()) {
|
|
92
|
+
const start = source.indexOf(edit.old_string)
|
|
93
|
+
if (start < 0) return { error: `edits[${index}].old_string was not found in the target cell` }
|
|
94
|
+
if (source.indexOf(edit.old_string, start + 1) >= 0) {
|
|
95
|
+
return { error: `edits[${index}].old_string occurs more than once in the target cell` }
|
|
96
|
+
}
|
|
97
|
+
replacements.push({ start, end: start + edit.old_string.length, text: edit.new_string, index })
|
|
98
|
+
}
|
|
99
|
+
return { replacements }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function replacementParts(template) {
|
|
103
|
+
const parts = []
|
|
104
|
+
let cursor = 0
|
|
105
|
+
for (const token of template.matchAll(/\$(\$|&|`|'|\d{1,2}|<([^>]*)>)/g)) {
|
|
106
|
+
if (token.index > cursor) parts.push(template.slice(cursor, token.index))
|
|
107
|
+
parts.push({ token: token[0], marker: token[1], groupName: token[2] })
|
|
108
|
+
cursor = token.index + token[0].length
|
|
109
|
+
}
|
|
110
|
+
if (cursor < template.length) parts.push(template.slice(cursor))
|
|
111
|
+
return parts
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function sourcePart(range, suffix = '') {
|
|
115
|
+
return range === undefined
|
|
116
|
+
? { text: suffix }
|
|
117
|
+
: { start: range[0], end: range[1], suffix }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function resolvePart(part, match, sourceLength) {
|
|
121
|
+
if (typeof part === 'string') return { text: part }
|
|
122
|
+
const { token, marker, groupName } = part
|
|
123
|
+
if (marker === '$') return { text: '$' }
|
|
124
|
+
if (marker === '&') return sourcePart([match.start, match.end])
|
|
125
|
+
if (marker === '`') return sourcePart([0, match.start])
|
|
126
|
+
if (marker === "'") return sourcePart([match.end, sourceLength])
|
|
127
|
+
if (marker.startsWith('<')) {
|
|
128
|
+
if (match.groups === undefined) return { text: token }
|
|
129
|
+
return sourcePart(match.groups[groupName])
|
|
130
|
+
}
|
|
131
|
+
let captureIndex = Number(marker)
|
|
132
|
+
let suffix = ''
|
|
133
|
+
if (captureIndex > match.captures.length && marker.length === 2) {
|
|
134
|
+
captureIndex = Number(marker[0])
|
|
135
|
+
suffix = marker[1]
|
|
136
|
+
}
|
|
137
|
+
if (captureIndex === 0 || captureIndex > match.captures.length) return { text: token }
|
|
138
|
+
return sourcePart(match.captures[captureIndex - 1], suffix)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function regexReplacements(edits, source, timeoutMs) {
|
|
142
|
+
if (edits.length === 0) return { error: 'regex_edits must contain at least one replacement' }
|
|
143
|
+
if (edits.length > EDIT_LIMITS.regexEdits) {
|
|
144
|
+
return { error: `regex_edits must contain at most ${EDIT_LIMITS.regexEdits} replacements` }
|
|
145
|
+
}
|
|
146
|
+
let matches = 0
|
|
147
|
+
let templateLength = 0
|
|
148
|
+
let expansionSteps = 0
|
|
149
|
+
const parsed = []
|
|
150
|
+
for (const [index, edit] of edits.entries()) {
|
|
151
|
+
if (edit === null || typeof edit !== 'object' || Array.isArray(edit)) {
|
|
152
|
+
return { error: `regex_edits[${index}] must be an object` }
|
|
153
|
+
}
|
|
154
|
+
const keys = Object.keys(edit)
|
|
155
|
+
if (keys.length !== 4 || !keys.includes('pattern') || !keys.includes('flags')
|
|
156
|
+
|| !keys.includes('replacement') || !keys.includes('expected_matches')
|
|
157
|
+
|| typeof edit.pattern !== 'string' || typeof edit.flags !== 'string'
|
|
158
|
+
|| typeof edit.replacement !== 'string' || !Number.isSafeInteger(edit.expected_matches)) {
|
|
159
|
+
return { error: `regex_edits[${index}] expects exactly pattern, flags, replacement, and integer expected_matches` }
|
|
160
|
+
}
|
|
161
|
+
if (edit.pattern.length === 0) return { error: `regex_edits[${index}].pattern must be non-empty` }
|
|
162
|
+
if (!/^[gimsu]*$/.test(edit.flags) || new Set(edit.flags).size !== edit.flags.length) {
|
|
163
|
+
return { error: `regex_edits[${index}].flags must contain unique JavaScript g, i, m, s, or u flags` }
|
|
164
|
+
}
|
|
165
|
+
if (edit.expected_matches < 1 || edit.expected_matches > EDIT_LIMITS.regexMatches) {
|
|
166
|
+
return { error: `regex_edits[${index}].expected_matches must be between 1 and ${EDIT_LIMITS.regexMatches}` }
|
|
167
|
+
}
|
|
168
|
+
const parts = replacementParts(edit.replacement)
|
|
169
|
+
matches += edit.expected_matches
|
|
170
|
+
templateLength += edit.replacement.length
|
|
171
|
+
expansionSteps += edit.expected_matches * parts.length
|
|
172
|
+
if (matches > EDIT_LIMITS.regexMatches) {
|
|
173
|
+
return { error: `regex_edits must produce at most ${EDIT_LIMITS.regexMatches} matches in total` }
|
|
174
|
+
}
|
|
175
|
+
if (templateLength > EDIT_LIMITS.regexTemplateCodeUnits) {
|
|
176
|
+
return { error: `regex_edits replacement templates exceed the ${EDIT_LIMITS.regexTemplateCodeUnits} UTF-16 code-unit budget` }
|
|
177
|
+
}
|
|
178
|
+
if (expansionSteps > EDIT_LIMITS.regexExpansionSteps) {
|
|
179
|
+
return { error: `regex_edits exceed the ${EDIT_LIMITS.regexExpansionSteps} replacement-expansion step budget` }
|
|
180
|
+
}
|
|
181
|
+
parsed.push({
|
|
182
|
+
...edit,
|
|
183
|
+
parts,
|
|
184
|
+
})
|
|
185
|
+
}
|
|
186
|
+
let result
|
|
187
|
+
try {
|
|
188
|
+
result = runInNewContext(REGEX_MATCH_SCRIPT, {
|
|
189
|
+
source,
|
|
190
|
+
rules: parsed.map(({ parts: _parts, ...rule }) => rule),
|
|
191
|
+
maxCaptureSlots: EDIT_LIMITS.regexCaptureSlots,
|
|
192
|
+
}, { timeout: timeoutMs })
|
|
193
|
+
} catch (error) {
|
|
194
|
+
return { error: `regex editing failed or exceeded its ${timeoutMs}ms matching budget: ${error.message}` }
|
|
195
|
+
}
|
|
196
|
+
if (result.error !== undefined) return result
|
|
197
|
+
return { replacements: result.replacements.map(match => ({
|
|
198
|
+
start: match.start,
|
|
199
|
+
end: match.end,
|
|
200
|
+
parts: parsed[match.ruleIndex].parts,
|
|
201
|
+
match,
|
|
202
|
+
index: match.ruleIndex,
|
|
203
|
+
})) }
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function resolvedPartLength(part) {
|
|
207
|
+
return part.text === undefined ? part.end - part.start + part.suffix.length : part.text.length
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function materializePart(part, source) {
|
|
211
|
+
return part.text === undefined ? source.slice(part.start, part.end) + part.suffix : part.text
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function preflightAssembly(source, replacements) {
|
|
215
|
+
let generatedLength = source.length
|
|
216
|
+
let replacementLength = 0
|
|
217
|
+
const prepared = []
|
|
218
|
+
for (const replacement of replacements) {
|
|
219
|
+
const parts = replacement.text === undefined
|
|
220
|
+
? replacement.parts.map(part => resolvePart(part, replacement.match, source.length))
|
|
221
|
+
: [{ text: replacement.text }]
|
|
222
|
+
let textLength = 0
|
|
223
|
+
for (const part of parts) {
|
|
224
|
+
textLength += resolvedPartLength(part)
|
|
225
|
+
}
|
|
226
|
+
replacementLength += textLength
|
|
227
|
+
generatedLength += textLength - (replacement.end - replacement.start)
|
|
228
|
+
prepared.push({ ...replacement, parts })
|
|
229
|
+
}
|
|
230
|
+
if (replacementLength > EDIT_LIMITS.generatedCodeUnits) {
|
|
231
|
+
return { error: `replacement output exceeds the ${EDIT_LIMITS.generatedCodeUnits} UTF-16 code-unit budget` }
|
|
232
|
+
}
|
|
233
|
+
if (generatedLength > EDIT_LIMITS.generatedCodeUnits) {
|
|
234
|
+
return { error: `edited source exceeds the ${EDIT_LIMITS.generatedCodeUnits} UTF-16 code-unit budget` }
|
|
235
|
+
}
|
|
236
|
+
return { replacements: prepared }
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function assemble(source, replacements) {
|
|
240
|
+
const chunks = []
|
|
241
|
+
let cursor = 0
|
|
242
|
+
let changed = false
|
|
243
|
+
for (const replacement of replacements) {
|
|
244
|
+
const text = replacement.parts.map(part => materializePart(part, source)).join('')
|
|
245
|
+
chunks.push(source.slice(cursor, replacement.start), text)
|
|
246
|
+
cursor = replacement.end
|
|
247
|
+
if (text.length !== replacement.end - replacement.start
|
|
248
|
+
|| !source.startsWith(text, replacement.start)) changed = true
|
|
249
|
+
}
|
|
250
|
+
chunks.push(source.slice(cursor))
|
|
251
|
+
return changed ? { code: chunks.join('') } : { error: 'edits must change at least one matched fragment' }
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export function editRunCodeSchema() {
|
|
255
|
+
return {
|
|
256
|
+
name: 'edit_run_code',
|
|
257
|
+
description: 'Make a small exact or regular-expression change to the most recent eligible cell captured when this edit call is dispatched, then run the complete corrected cell. A successful edit becomes the next eligible cell. Use this only when replaying the whole cell is safe. If earlier code may already have caused an external effect, use a new run_code cell and its existing variables instead; this tool does not resume at the error location. Send exactly one atomic edits or regex_edits array. Choose edits for a few unique literal fragments; choose regex_edits when one counted pattern covers repeated fragments. Every resolved range must be non-overlapping in the original cell.',
|
|
258
|
+
parameters: {
|
|
259
|
+
type: 'object',
|
|
260
|
+
additionalProperties: false,
|
|
261
|
+
properties: {
|
|
262
|
+
edits: {
|
|
263
|
+
type: 'array', minItems: 1, maxItems: EDIT_LIMITS.exactEdits,
|
|
264
|
+
description: 'Atomic exact replacements, all resolved against the original target cell.',
|
|
265
|
+
items: {
|
|
266
|
+
type: 'object', additionalProperties: false,
|
|
267
|
+
properties: {
|
|
268
|
+
old_string: { type: 'string', minLength: 1, description: 'Exact text in the target cell. It must occur exactly once.' },
|
|
269
|
+
new_string: { type: 'string', description: 'Literal replacement text. Use an empty string to delete the match.' },
|
|
270
|
+
},
|
|
271
|
+
required: ['old_string', 'new_string'],
|
|
272
|
+
},
|
|
273
|
+
},
|
|
274
|
+
regex_edits: {
|
|
275
|
+
type: 'array', minItems: 1, maxItems: EDIT_LIMITS.regexEdits,
|
|
276
|
+
description: 'Atomic regular-expression replacements, all matched against the original target cell.',
|
|
277
|
+
items: {
|
|
278
|
+
type: 'object', additionalProperties: false,
|
|
279
|
+
properties: {
|
|
280
|
+
pattern: { type: 'string', minLength: 1, description: 'JavaScript regular-expression source. Zero-length matches are rejected.' },
|
|
281
|
+
flags: { type: 'string', pattern: '^(?!.*(.).*\\1)[gimsu]*$', description: 'Unique JavaScript g, i, m, s, or u flags. Global matching is always applied.' },
|
|
282
|
+
replacement: { type: 'string', description: 'JavaScript replacement template, including capture references such as $1 or $<name>.' },
|
|
283
|
+
expected_matches: { type: 'integer', minimum: 1, maximum: EDIT_LIMITS.regexMatches, description: 'Exact match count required before any replacement is applied.' },
|
|
284
|
+
},
|
|
285
|
+
required: ['pattern', 'flags', 'replacement', 'expected_matches'],
|
|
286
|
+
},
|
|
287
|
+
},
|
|
288
|
+
},
|
|
289
|
+
oneOf: [{ required: ['edits'] }, { required: ['regex_edits'] }],
|
|
290
|
+
},
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export function editRejectedCell(value, source, timeoutMs = DEFAULT_REGEX_TIMEOUT_MS) {
|
|
295
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
296
|
+
return rejection('edit_run_code expects an object with exactly one edits or regex_edits array')
|
|
297
|
+
}
|
|
298
|
+
const keys = Object.keys(value)
|
|
299
|
+
if (keys.length !== 1 || !['edits', 'regex_edits'].includes(keys[0]) || !Array.isArray(value[keys[0]])) {
|
|
300
|
+
return rejection('edit_run_code expects exactly one edits or regex_edits array')
|
|
301
|
+
}
|
|
302
|
+
if (source === undefined) return rejection('no run_code cell is currently eligible for safe editing')
|
|
303
|
+
const resolved = keys[0] === 'edits'
|
|
304
|
+
? exactReplacements(value.edits, source)
|
|
305
|
+
: regexReplacements(value.regex_edits, source, timeoutMs)
|
|
306
|
+
if (resolved.error !== undefined) return rejection(resolved.error)
|
|
307
|
+
const replacements = resolved.replacements.sort((left, right) => left.start - right.start)
|
|
308
|
+
for (let index = 1; index < replacements.length; index += 1) {
|
|
309
|
+
if (replacements[index].start < replacements[index - 1].end) {
|
|
310
|
+
return rejection(`${keys[0]}[${replacements[index].index}] overlaps ${keys[0]}[${replacements[index - 1].index}] in the target cell`)
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
const preflight = preflightAssembly(source, replacements)
|
|
314
|
+
if (preflight.error !== undefined) {
|
|
315
|
+
return rejection(keys[0] === 'regex_edits' ? preflight.error.replace(/^edits/, 'regex_edits') : preflight.error)
|
|
316
|
+
}
|
|
317
|
+
const assembled = assemble(source, preflight.replacements)
|
|
318
|
+
if (assembled.error !== undefined) {
|
|
319
|
+
return rejection(keys[0] === 'regex_edits' ? assembled.error.replace(/^edits/, 'regex_edits') : assembled.error)
|
|
320
|
+
}
|
|
321
|
+
return Object.freeze({
|
|
322
|
+
edited: true,
|
|
323
|
+
code: assembled.code,
|
|
324
|
+
description: keys[0] === 'edits'
|
|
325
|
+
? EDIT_RUN_CODE_EXECUTION_DESCRIPTION
|
|
326
|
+
: REGEX_EDIT_RUN_CODE_EXECUTION_DESCRIPTION,
|
|
327
|
+
})
|
|
328
|
+
}
|