Merge remote-tracking branch 'origin/master' into worktree-llm-tool-order
This commit is contained in:
@@ -56,6 +56,8 @@ export const SENSITIVE_ENV_PATTERN = /KEY|SECRET|TOKEN/i
|
||||
* by in-process plugins (the hooks bridges), not the model — `dsh-tool-bash`
|
||||
* builds its request from named fields only and does not forward model input
|
||||
* here (see its README, § "The tool builds its request from named args only").
|
||||
* @param extra - caller-supplied entries merged last; an explicit entry wins even against the scrub and the overrides.
|
||||
* @returns the environment to hand to `spawn` for the child process.
|
||||
*/
|
||||
export function childEnv(extra?: Record<string, string>): NodeJS.ProcessEnv {
|
||||
const env: NodeJS.ProcessEnv = {}
|
||||
@@ -147,6 +149,14 @@ export class OutputCollector {
|
||||
private readonly spillDir: string,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Ingest one stream chunk, counting it toward the whole-stream total. On
|
||||
* first overflow of the in-memory cap a spill file is opened and every chunk
|
||||
* (already-collected ones included) is appended there from then on; the
|
||||
* in-memory tail then drops whole chunks from its head (or the head of a
|
||||
* single over-cap chunk) until it fits the cap again.
|
||||
* @param chunk - the raw bytes from one stream 'data' event.
|
||||
*/
|
||||
push(chunk: Buffer): void {
|
||||
this.total += chunk.length
|
||||
const overflows = this.bytes + chunk.length > this.maxBytes
|
||||
@@ -190,7 +200,10 @@ export class OutputCollector {
|
||||
// the bottom of this file) and `totalBytes` is read only by a test. The live
|
||||
// background-poll path goes through `readFrom()`, so inline snapshot() into
|
||||
// finalize() and drop or privatize the totalBytes getter.
|
||||
/** Read the collected tail without finalizing (the final-result snapshot). */
|
||||
/**
|
||||
* Read the collected tail without finalizing (the final-result snapshot).
|
||||
* @returns the retained tail text, the truncation flag, and the spill path when one was created.
|
||||
*/
|
||||
snapshot(): CollectedOutput {
|
||||
return {
|
||||
text: Buffer.concat(this.chunks).toString('utf8'),
|
||||
@@ -209,6 +222,8 @@ export class OutputCollector {
|
||||
* pushed since `fromByte`. When `fromByte` has already slid out of the
|
||||
* in-memory tail window, the read is `lossy` — it returns the whole
|
||||
* retained tail and the gap is only recoverable from the spill file.
|
||||
* @param fromByte - whole-stream offset to resume from (a prior read's `nextOffset`; 0 for the first read).
|
||||
* @returns the delta text, the offset for the next read, the `lossy` flag, and the spill path when one was created.
|
||||
*/
|
||||
readFrom(fromByte: number): { text: string; nextOffset: number; lossy: boolean; spillPath?: string } {
|
||||
const windowStart = this.total - this.bytes
|
||||
@@ -223,7 +238,12 @@ export class OutputCollector {
|
||||
}
|
||||
}
|
||||
|
||||
/** Close the spill file (if any) and return the final output. */
|
||||
/**
|
||||
* Close the spill file (if any) and return the final output. A failed close
|
||||
* (delayed writeback fault) stops advertising the spill path — the file may
|
||||
* be missing its tail — but still returns the in-memory result.
|
||||
* @returns the final collected output: tail text, truncation flag, and the spill path when intact.
|
||||
*/
|
||||
finalize(): CollectedOutput {
|
||||
if (this.spillFd !== undefined) {
|
||||
try {
|
||||
@@ -249,6 +269,8 @@ export class OutputCollector {
|
||||
* host process — a kill that cannot be delivered is reported by the process
|
||||
* NOT dying, which callers already handle via escalation/timeouts. No-op for
|
||||
* non-positive pids (spawn never started a process).
|
||||
* @param pid - the group leader's pid; non-positive means the spawn failed and the call is a no-op.
|
||||
* @param sig - the signal to deliver to the whole group.
|
||||
*/
|
||||
export function killGroup(pid: number, sig: NodeJS.Signals): void {
|
||||
if (pid <= 0) return
|
||||
@@ -290,6 +312,9 @@ export interface RunningBash {
|
||||
* exec sessions addressable via session ids + stdin writes. We deliberately
|
||||
* spawn a fresh non-login `bash -c` per call for determinism (no rc files,
|
||||
* no inherited shell state); revisit when real workflows demand it.
|
||||
* @param spec - the fully-resolved run (command, cwd, limits); no defaulting happens here.
|
||||
* @param internals - test-only knobs; omitted fields fall back to the private per-process spill dir.
|
||||
* @returns the live handle: pid, the two live collectors, the outcome promise, and `kill()`.
|
||||
*/
|
||||
export function runBash(spec: SpawnSpec, internals: RunInternals = {}): RunningBash {
|
||||
const spillDir = internals.spillDir ?? privateSpillDir()
|
||||
|
||||
@@ -11,7 +11,11 @@ import type { Branded } from '@deepseek-ai/dsh-brand'
|
||||
/** Identifies one background task within an executor (generated `bash-N`). */
|
||||
export type BashTaskId = Branded<'BashTaskId'>
|
||||
|
||||
/** Brand a string as a {@link BashTaskId}. */
|
||||
/**
|
||||
* Brand a string as a {@link BashTaskId}.
|
||||
* @param id - the raw task-id string (the executor generates `bash-N`).
|
||||
* @returns the same string, branded; no validation is performed.
|
||||
*/
|
||||
export function BashTaskId(id: string): BashTaskId {
|
||||
return id as BashTaskId
|
||||
}
|
||||
@@ -26,7 +30,12 @@ export function BashTaskId(id: string): BashTaskId {
|
||||
*/
|
||||
export type OwnerToken = Branded<'OwnerToken'>
|
||||
|
||||
/** Brand a string as an {@link OwnerToken}. */
|
||||
/**
|
||||
* Brand a string as an {@link OwnerToken}. Only the consuming boundary
|
||||
* (`dsh-tool-bash`) should cast its own id vocabulary in — see the type's doc.
|
||||
* @param id - the consumer's raw owner identity (the tool layer passes the owning agent's session id).
|
||||
* @returns the same string, branded; no validation is performed.
|
||||
*/
|
||||
export function OwnerToken(id: string): OwnerToken {
|
||||
return id as OwnerToken
|
||||
}
|
||||
|
||||
@@ -99,6 +99,8 @@ function streamText(output: CollectedOutput): string {
|
||||
* stderr section, then exit-status markers. Non-zero exits are REPORTED, not
|
||||
* errored — the model decides how to react; only infrastructure failures
|
||||
* (spawn errors, aborts) surface as isError results.
|
||||
* @param result - the completed foreground run from the executor.
|
||||
* @returns the model-facing text: output body (or `(no output)`), then any timeout/signal/exit markers, each on its own line.
|
||||
*/
|
||||
export function renderResult(result: BashRunResult): string {
|
||||
const out = streamText(result.stdout)
|
||||
|
||||
@@ -216,6 +216,11 @@ export class BasicCompactService extends CompactService {
|
||||
* Estimate the token count of content blocks — chars divided by the
|
||||
* `charsPerToken` config, with per-block overhead. Override in a subclass to
|
||||
* plug in a real tokenizer.
|
||||
*
|
||||
* @param blocks - the blocks to estimate; `tool-result` blocks recurse into
|
||||
* their nested content, and unknown (merge-extended) types fall back to
|
||||
* their JSON-stringified length.
|
||||
* @returns the estimated token count.
|
||||
*/
|
||||
estimateContentTokens(blocks: readonly ContentBlock[]): number {
|
||||
const { charsPerToken } = this.config
|
||||
@@ -246,6 +251,11 @@ export class BasicCompactService extends CompactService {
|
||||
/**
|
||||
* Estimate token count for a single session event. Returns 0 for non-message
|
||||
* event types (boundaries, chunks, usage, errors, compact markers).
|
||||
*
|
||||
* @param event - any session event; only the message-bearing types carry
|
||||
* content to count.
|
||||
* @returns the estimated token count of the event's content, or 0 for a
|
||||
* non-message event.
|
||||
*/
|
||||
estimateEventTokens(event: SessionEvent): number {
|
||||
switch (event.type) {
|
||||
@@ -260,7 +270,14 @@ export class BasicCompactService extends CompactService {
|
||||
}
|
||||
}
|
||||
|
||||
/** Estimate total tokens across a list of messages plus optional system prompt. */
|
||||
/**
|
||||
* Estimate total tokens across a list of messages plus optional system prompt.
|
||||
*
|
||||
* @param messages - the derived conversation messages; each adds a fixed
|
||||
* role-framing overhead on top of its content estimate.
|
||||
* @param systemPrompt - counted at chars / `charsPerToken` when provided.
|
||||
* @returns the estimated token footprint of the whole request.
|
||||
*/
|
||||
estimateTokens(messages: readonly Message[], systemPrompt?: string): number {
|
||||
let total = 0
|
||||
for (const msg of messages) {
|
||||
@@ -293,6 +310,13 @@ export class BasicCompactService extends CompactService {
|
||||
* used (`model`, `maxTokens`) — the caller logs the envelope on the
|
||||
* `compact/summary` provenance event, so an overriding subclass (template
|
||||
* or remote summarizer) reports its own envelope honestly.
|
||||
*
|
||||
* @param text - plain-text rendering of the conversation region to condense.
|
||||
* @param agent - supplies the fallback model and the session id stamped on
|
||||
* the call; throws when neither it nor the config names a model.
|
||||
* @param signal - optional abort signal, forwarded into the model call.
|
||||
* @returns the text-only summary blocks plus the call envelope used
|
||||
* (`model`, and `maxTokens` when the summarizer has a cap).
|
||||
*/
|
||||
async summarize(
|
||||
text: string, agent: Agent, signal?: AbortSignal,
|
||||
|
||||
@@ -54,6 +54,9 @@ export type ResolvedConfig = Required<BasicCompactConfig>
|
||||
* each committed summary must be smaller than the content it shadows, and
|
||||
* `compactIfNeeded` may re-compact up to `compactionRetries` extra times before
|
||||
* throwing if the surface still exceeds the threshold.
|
||||
*
|
||||
* @param config - the raw, unresolved backend config.
|
||||
* @returns the validated config with `auto` and `charsPerToken` defaulted.
|
||||
*/
|
||||
export function resolveConfig(config: BasicCompactConfig): ResolvedConfig {
|
||||
const resolved: ResolvedConfig = { auto: true, charsPerToken: 4, ...config }
|
||||
|
||||
@@ -22,6 +22,10 @@ import { isTurnOpen, lastTurnNumber, runLoop } from './loop.ts'
|
||||
* the agent/* event taxonomy — plugins never need this class.
|
||||
*/
|
||||
export class ReactLoopAgent implements Agent {
|
||||
/**
|
||||
* The queued + steering FIFOs behind {@link send}/{@link steer}. Public so
|
||||
* the driver loop can drain it; {@link cancel} clears it wholesale.
|
||||
*/
|
||||
readonly inbox = new Inbox()
|
||||
|
||||
private _status: AgentStatus = 'idle'
|
||||
@@ -256,6 +260,8 @@ export class ReactLoopAgent implements Agent {
|
||||
* promise (unblocking the idle wait), releases any `whenIdle` waiters, and
|
||||
* aborts the current request if any. The returned `agent.done` promise
|
||||
* resolves once the loop exits.
|
||||
* @returns the disposer — idempotent and infallible (it runs inside the
|
||||
* fiber's LIFO disposal chain, where a throw would skip later disposers).
|
||||
*/
|
||||
start(): () => void {
|
||||
this.done = runLoop(this.ctx, this, {
|
||||
|
||||
@@ -24,30 +24,47 @@ export class Inbox {
|
||||
private steeringMessages: InboxMessage[] = []
|
||||
private wakeup: (() => void) | undefined
|
||||
|
||||
/** Resolves when a queued message arrives (used by the idle loop). */
|
||||
/** True while queued messages are pending — read by the idle wait's fast path and the loop's turn-start checks. */
|
||||
get hasQueued(): boolean {
|
||||
return this.queuedMessages.length > 0
|
||||
}
|
||||
|
||||
/** True while steering messages are pending — read by `cancel()`'s arm gate and the loop's stop-override check. */
|
||||
get hasSteering(): boolean {
|
||||
return this.steeringMessages.length > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a message to the queued FIFO and wake a parked {@link waitForQueued}.
|
||||
* @param message - the message to queue for the next turn start.
|
||||
*/
|
||||
enqueue(message: InboxMessage): void {
|
||||
this.queuedMessages.push(message)
|
||||
this.wakeup?.()
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a message to the steering FIFO. Deliberately no wakeup: steering is
|
||||
* drained between steps of a running turn, never by the idle wait —
|
||||
* `Agent.steer()` on an idle agent falls back to `send()` instead.
|
||||
* @param message - the message to inject between steps of the running turn.
|
||||
*/
|
||||
steer(message: InboxMessage): void {
|
||||
this.steeringMessages.push(message)
|
||||
}
|
||||
|
||||
/** Drain all queued messages (turn start). */
|
||||
/**
|
||||
* Drain all queued messages (turn start).
|
||||
* @returns the drained messages in arrival order; the queued FIFO is left empty.
|
||||
*/
|
||||
drainQueued(): InboxMessage[] {
|
||||
return this.queuedMessages.splice(0)
|
||||
}
|
||||
|
||||
/** Drain all steering messages (between steps). */
|
||||
/**
|
||||
* Drain all steering messages (between steps).
|
||||
* @returns the drained messages in arrival order; the steering FIFO is left empty.
|
||||
*/
|
||||
drainSteering(): InboxMessage[] {
|
||||
return this.steeringMessages.splice(0)
|
||||
}
|
||||
@@ -62,7 +79,12 @@ export class Inbox {
|
||||
this.steeringMessages.length = 0
|
||||
}
|
||||
|
||||
/** Wait until a queued message arrives or `cancel` resolves. */
|
||||
/**
|
||||
* Wait until a queued message arrives or `cancel` resolves.
|
||||
* @param cancel - a promise whose resolution abandons the wait without a
|
||||
* message (the driver loop passes the agent's disposed promise so a parked
|
||||
* loop can exit).
|
||||
*/
|
||||
waitForQueued(cancel: Promise<void>): Promise<void> {
|
||||
if (this.hasQueued) return Promise.resolve()
|
||||
const { promise, resolve } = Promise.withResolvers<void>()
|
||||
|
||||
@@ -29,6 +29,10 @@ declare module 'cordis' {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin config: the agents to create — or resume, via `resumeSessionId` —
|
||||
* declaratively at startup, so a cordis.yml deployment needs no code.
|
||||
*/
|
||||
export interface Config {
|
||||
/** Agents created from configuration at startup. */
|
||||
agents: (AgentOptions & {
|
||||
|
||||
@@ -185,6 +185,9 @@ export interface LoopHandle {
|
||||
* re-enqueue leftover steering as queued ⟵ steering is never stranded
|
||||
* idle (emit agent/status) unless more queued
|
||||
* ```
|
||||
* @param ctx - the plugin context the loop reaches events (agent/…, session/flush) and services (systemPrompt, llm, tools) through.
|
||||
* @param agent - the agent this invocation drives for its whole lifetime (its inbox, session, and options).
|
||||
* @param handle - the bridge to the agent's mutable state: status/abort setters plus the disposal and cancel-marker reads.
|
||||
*/
|
||||
export async function runLoop(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle): Promise<void> {
|
||||
// Per-instance transmission bookkeeping: whether THIS loop instance has
|
||||
@@ -875,7 +878,11 @@ function withoutToolCalls(message: Message): Message {
|
||||
return { ...message, content: message.content.filter(block => block.type !== 'tool-call') }
|
||||
}
|
||||
|
||||
/** The last turn number in a (possibly seeded) session log, or 0. */
|
||||
/**
|
||||
* The last turn number in a (possibly seeded) session log, or 0.
|
||||
* @param session - the session whose log is scanned for the latest `turn/start`.
|
||||
* @returns the latest `turn/start`'s turn number, or 0 when the log has none (the next turn is this plus one).
|
||||
*/
|
||||
export function lastTurnNumber(session: Session): number {
|
||||
const lastStart = session.events.findLast(event => event.type === 'turn/start')
|
||||
return lastStart?.data.turn ?? 0
|
||||
@@ -889,6 +896,8 @@ export function lastTurnNumber(session: Session): number {
|
||||
* returns to idle), so status is not a reliable open-turn signal. Used by
|
||||
* `inject()` to choose between appending into an open turn vs. wrapping the
|
||||
* injection in its own one-shot turn (the turn-enclosure RFC).
|
||||
* @param session - the session whose log is inspected.
|
||||
* @returns true when the log's last turn boundary is a `turn/start` with no matching `turn/end` yet.
|
||||
*/
|
||||
export function isTurnOpen(session: Session): boolean {
|
||||
const last = session.events.findLast(e => e.type === 'turn/start' || e.type === 'turn/end')
|
||||
|
||||
@@ -19,7 +19,10 @@ export interface TransmissionLog {
|
||||
loggedHeader: boolean
|
||||
}
|
||||
|
||||
/** Fresh bookkeeping for a newly-started loop instance. */
|
||||
/**
|
||||
* Fresh bookkeeping for a newly-started loop instance.
|
||||
* @returns state with `loggedHeader` false, so the instance's first request appends an anchoring snapshot.
|
||||
*/
|
||||
export function createTransmissionLog(): TransmissionLog {
|
||||
return { loggedHeader: false }
|
||||
}
|
||||
|
||||
@@ -50,7 +50,11 @@ import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
/** Identifies one live agent in the registry. */
|
||||
export type AgentId = Branded<'AgentId'>
|
||||
|
||||
/** Brand a string as an {@link AgentId}. */
|
||||
/**
|
||||
* Brand a string as an {@link AgentId}.
|
||||
* @param id - the raw agent id string.
|
||||
* @returns the same string, branded (a compile-time cast — no runtime cost).
|
||||
*/
|
||||
export function AgentId(id: string): AgentId {
|
||||
return id as AgentId
|
||||
}
|
||||
@@ -80,10 +84,22 @@ export interface AgentOptions {
|
||||
model?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for {@link Agent.send}/{@link Agent.steer}/{@link Agent.inject}. An
|
||||
* absent `source` resolves to `{ kind: 'user' }`, so a plugin supplying content
|
||||
* must label itself here or its message is recorded as a user prompt (see
|
||||
* {@link HookContext} on why that label is load-bearing).
|
||||
*/
|
||||
export interface SendOptions {
|
||||
source?: MessageSource
|
||||
}
|
||||
|
||||
/**
|
||||
* An agent's lifecycle state, emitted on every transition as `agent/status`:
|
||||
* `idle` (parked, waiting for queued work), `running` (a turn is in progress),
|
||||
* `disposed` (terminal — no transition leaves it, and `send`/`steer`/`inject`
|
||||
* throw).
|
||||
*/
|
||||
export type AgentStatus = 'idle' | 'running' | 'disposed'
|
||||
|
||||
/**
|
||||
|
||||
555
packages/core/agent/tests/verify-export-jsdoc.spec.ts
Normal file
555
packages/core/agent/tests/verify-export-jsdoc.spec.ts
Normal file
@@ -0,0 +1,555 @@
|
||||
/**
|
||||
* Negative-path tests for the export-surface JSDoc gate
|
||||
* (`scripts/verify-export-jsdoc.ts`).
|
||||
*
|
||||
* The gate's positive half runs against the real tree in CI (`pnpm run
|
||||
* verify-export-jsdoc`, part of doc-sync). What that run cannot prove is that
|
||||
* the walk REJECTS an undocumented surface the way it promises to — and that
|
||||
* every deliberate exemption (heritage members, plugin-protocol slots,
|
||||
* constructors, overload implementations, augmentation bodies, re-exports)
|
||||
* actually holds. These tests drive `collectExportJsdocViolations()` against
|
||||
* synthetic fixture packages, mirroring the gen-cordis-catalog negative
|
||||
* tests.
|
||||
*/
|
||||
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { collectExportJsdocViolations } from '../../../../scripts/verify-export-jsdoc.ts'
|
||||
|
||||
const roots: string[] = []
|
||||
|
||||
afterEach(() => {
|
||||
while (roots.length) rmSync(roots.pop()!, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
/** Write fixture files under `packages/group/fix/src/` and return the scan root. */
|
||||
function fixture(files: Record<string, string>): string {
|
||||
const root = mkdtempSync(join(tmpdir(), 'export-jsdoc-'))
|
||||
roots.push(root)
|
||||
for (const [rel, content] of Object.entries(files)) {
|
||||
const abs = join(root, 'packages', 'group', 'fix', 'src', rel)
|
||||
mkdirSync(dirname(abs), { recursive: true })
|
||||
writeFileSync(abs, content)
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
/** Single-file fixture shorthand: the content becomes `src/index.ts`. */
|
||||
const make = (content: string): string => fixture({ 'index.ts': content })
|
||||
|
||||
describe('verify-export-jsdoc functions and consts', () => {
|
||||
it('accepts a fully documented surface', () => {
|
||||
expect(collectExportJsdocViolations(make(`
|
||||
/**
|
||||
* Add one to a count.
|
||||
* @param n - the count to bump.
|
||||
* @returns the count plus one.
|
||||
*/
|
||||
export function bump(n: number): number { return n + 1 }
|
||||
|
||||
/**
|
||||
* Fire-and-forget (void needs no @returns).
|
||||
* @param flag - whether to arm.
|
||||
*/
|
||||
export function poke(flag: boolean): void { void flag }
|
||||
|
||||
/** The default retry budget. */
|
||||
export const RETRIES = 3
|
||||
|
||||
/**
|
||||
* Halve a count.
|
||||
* @param n - the count to halve.
|
||||
* @returns the count halved.
|
||||
*/
|
||||
export const halve = (n: number): number => n / 2
|
||||
`))).toEqual([])
|
||||
})
|
||||
|
||||
it('flags an exported function with no JSDoc at all', () => {
|
||||
expect(collectExportJsdocViolations(make(
|
||||
'export function bare(): void {}\n',
|
||||
))).toEqual([expect.stringMatching(/exported function 'bare' .* has no JSDoc\./)])
|
||||
})
|
||||
|
||||
it('flags a missing @param and a missing @returns', () => {
|
||||
const violations = collectExportJsdocViolations(make(
|
||||
'/** Docs without tags. */\nexport function f(x: number): number { return x }\n',
|
||||
))
|
||||
expect(violations).toEqual([
|
||||
expect.stringMatching(/exported function 'f' .* is missing @param x\./),
|
||||
expect.stringMatching(/exported function 'f' .* is missing @returns \(return type: number\)\./),
|
||||
])
|
||||
})
|
||||
|
||||
it('flags an unannotated (inferred) return type', () => {
|
||||
expect(collectExportJsdocViolations(make(
|
||||
'/**\n * Docs.\n * @param x - value.\n */\nexport function f(x: number) { return x }\n',
|
||||
))).toEqual([expect.stringMatching(/no return type annotation/)])
|
||||
})
|
||||
|
||||
it('flags tags-only JSDoc with no description prose', () => {
|
||||
expect(collectExportJsdocViolations(make(
|
||||
'/**\n * @param x - value.\n */\nexport function f(x: number): void {}\n',
|
||||
))).toEqual([expect.stringMatching(/no description prose above its block tags/)])
|
||||
})
|
||||
|
||||
it('flags a stale @param and a binding-pattern parameter', () => {
|
||||
const violations = collectExportJsdocViolations(make(
|
||||
'/**\n * Docs.\n * @param ghost - not real.\n */\nexport function f({ a }: { a: number }): void {}\n',
|
||||
))
|
||||
expect(violations).toEqual([
|
||||
expect.stringMatching(/parameter '\{ a \}' is a binding pattern; the export surface needs simple identifier parameters/),
|
||||
expect.stringMatching(/@param ghost does not match any parameter \(stale tag\?\)/),
|
||||
])
|
||||
})
|
||||
|
||||
it('exempts a `this` receiver annotation from @param', () => {
|
||||
expect(collectExportJsdocViolations(make(
|
||||
'/**\n * Docs.\n * @param x - value.\n */\nexport function f(this: object, x: number): void {}\n',
|
||||
))).toEqual([])
|
||||
})
|
||||
|
||||
it('waives @returns for a declarator-annotated const but not an unannotated one', () => {
|
||||
expect(collectExportJsdocViolations(make(`
|
||||
type Fn = (x: number) => number
|
||||
/**
|
||||
* Uses the named signature.
|
||||
* @param x - value.
|
||||
*/
|
||||
export const good: Fn = x => x
|
||||
/**
|
||||
* No signature anywhere.
|
||||
* @param x - value.
|
||||
*/
|
||||
export const bad = (x: number) => x
|
||||
`))).toEqual([expect.stringMatching(/exported const 'bad' .* has no return type annotation/)])
|
||||
})
|
||||
|
||||
it('requires description prose on a non-function const', () => {
|
||||
expect(collectExportJsdocViolations(make(
|
||||
'export const LIMIT = 10\n',
|
||||
))).toEqual([expect.stringMatching(/exported const 'LIMIT' .* has no JSDoc\./)])
|
||||
})
|
||||
})
|
||||
|
||||
describe('verify-export-jsdoc type-level exports', () => {
|
||||
it('requires description prose on interfaces, type aliases, and enums', () => {
|
||||
const violations = collectExportJsdocViolations(make(
|
||||
'export interface I { a: number }\nexport type T = number\nexport enum E { A }\n',
|
||||
))
|
||||
expect(violations).toEqual([
|
||||
expect.stringMatching(/exported interface 'I' .* has no JSDoc\./),
|
||||
expect.stringMatching(/exported type 'T' .* has no JSDoc\./),
|
||||
expect.stringMatching(/exported enum 'E' .* has no JSDoc\./),
|
||||
])
|
||||
})
|
||||
|
||||
it('skips `declare module` augmentation bodies (the cordis gate owns them)', () => {
|
||||
expect(collectExportJsdocViolations(make(
|
||||
"declare module 'cordis' {\n interface Events {\n 'fix/x'(): void\n }\n}\nexport {}\n",
|
||||
))).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('verify-export-jsdoc export forms', () => {
|
||||
it('resolves an `export { … }` list to the local declaration', () => {
|
||||
expect(collectExportJsdocViolations(make(
|
||||
'function f(): void {}\nexport { f }\n',
|
||||
))).toEqual([expect.stringMatching(/exported function 'f' .* has no JSDoc\./)])
|
||||
})
|
||||
|
||||
it('does not treat a never-exported sibling declarator as surface (review round 2)', () => {
|
||||
// `export { publicValue }` resolves to the whole variable statement; only
|
||||
// the named declarator is surface — the gate must not demand JSDoc for
|
||||
// the private sibling sharing the statement.
|
||||
expect(collectExportJsdocViolations(make(
|
||||
'/** The public knob. */\nconst publicValue = 1, privateHelper = 2\nexport { publicValue }\nvoid privateHelper\n',
|
||||
))).toEqual([])
|
||||
})
|
||||
|
||||
it('unions declarators across multiple export lists over one statement (review round 2)', () => {
|
||||
// Two lists each name one declarator of the same undocumented statement:
|
||||
// both are surface (deduplicating on first resolution would drop `b`),
|
||||
// while the never-exported `c` stays out.
|
||||
const violations = collectExportJsdocViolations(make(
|
||||
'const a = 1, b = 2, c = 3\nexport { a }\nexport { b }\nvoid c\n',
|
||||
))
|
||||
expect(violations).toEqual([
|
||||
expect.stringMatching(/exported const 'a' .* has no JSDoc\./),
|
||||
expect.stringMatching(/exported const 'b' .* has no JSDoc\./),
|
||||
])
|
||||
})
|
||||
|
||||
it('scopes a default-export identifier to its own declarator (review round 2)', () => {
|
||||
// `export default` of an identifier reaches the statement through the
|
||||
// same name lookup as an export list; the sibling stays private.
|
||||
expect(collectExportJsdocViolations(make(
|
||||
'/** The app entry. */\nconst app = 1, scratch = 2\nexport default app\nvoid scratch\n',
|
||||
))).toEqual([])
|
||||
})
|
||||
|
||||
it('reports a re-exported module once, at its defining file', () => {
|
||||
const violations = collectExportJsdocViolations(fixture({
|
||||
'index.ts': "export * from './other.ts'\n",
|
||||
'other.ts': 'export function f(): void {}\n',
|
||||
}))
|
||||
expect(violations).toEqual([expect.stringMatching(/other\.ts:1\) has no JSDoc\./)])
|
||||
})
|
||||
|
||||
it('exempts overload implementations when the signatures are documented', () => {
|
||||
expect(collectExportJsdocViolations(make(`
|
||||
/**
|
||||
* From a number.
|
||||
* @param x - the number.
|
||||
* @returns its text.
|
||||
*/
|
||||
export function f(x: number): string
|
||||
/**
|
||||
* From a flag.
|
||||
* @param x - the flag.
|
||||
* @returns its text.
|
||||
*/
|
||||
export function f(x: boolean): string
|
||||
export function f(x: number | boolean): string { return String(x) }
|
||||
`))).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('verify-export-jsdoc classes', () => {
|
||||
it('flags an undocumented class, method, property, and accessor', () => {
|
||||
const violations = collectExportJsdocViolations(make(`
|
||||
export class C {
|
||||
state = 1
|
||||
get view(): number { return this.state }
|
||||
run(x: number): number { return x }
|
||||
}
|
||||
`))
|
||||
expect(violations).toEqual([
|
||||
expect.stringMatching(/exported class 'C' .* has no JSDoc\./),
|
||||
expect.stringMatching(/exported class property 'C.state' .* has no JSDoc\./),
|
||||
expect.stringMatching(/exported class accessor 'C.view' .* has no JSDoc\./),
|
||||
expect.stringMatching(/exported class method 'C.run' .* has no JSDoc\./),
|
||||
])
|
||||
})
|
||||
|
||||
it('exempts members declared by an extends/implements heritage type', () => {
|
||||
expect(collectExportJsdocViolations(make(`
|
||||
/** Seam. */
|
||||
export abstract class Base {
|
||||
/**
|
||||
* Do it.
|
||||
* @param x - input.
|
||||
* @returns output.
|
||||
*/
|
||||
abstract run(x: number): number
|
||||
}
|
||||
/** Iface. */
|
||||
export interface Sized {
|
||||
/** Byte size. */
|
||||
size: number
|
||||
}
|
||||
/** Impl. */
|
||||
export class Impl extends Base implements Sized {
|
||||
size = 0
|
||||
run(x: number): number { return x }
|
||||
}
|
||||
`))).toEqual([])
|
||||
})
|
||||
|
||||
it('skips private/protected/#private members and constructors', () => {
|
||||
expect(collectExportJsdocViolations(make(`
|
||||
/** Documented. */
|
||||
export class C {
|
||||
#secret = 1
|
||||
private hidden(): void {}
|
||||
protected hook(): void {}
|
||||
constructor(x: number) { void x }
|
||||
}
|
||||
`))).toEqual([])
|
||||
})
|
||||
|
||||
it('exempts plugin-protocol statics but checks other statics', () => {
|
||||
const violations = collectExportJsdocViolations(make(`
|
||||
/** Plugin. */
|
||||
export class C {
|
||||
static Config = { a: 1 }
|
||||
static inject = ['bash']
|
||||
static reusable = true
|
||||
static other = 1
|
||||
}
|
||||
`))
|
||||
expect(violations).toEqual([expect.stringMatching(/exported class property 'C.other' .* has no JSDoc\./)])
|
||||
})
|
||||
|
||||
it("covers a set accessor by the getter's doc", () => {
|
||||
expect(collectExportJsdocViolations(make(`
|
||||
/** Documented. */
|
||||
export class C {
|
||||
/** The current width. */
|
||||
get width(): number { return 1 }
|
||||
set width(_v: number) {}
|
||||
}
|
||||
`))).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('verify-export-jsdoc plugin protocol and namespaces', () => {
|
||||
it('exempts top-level plugin-protocol exports', () => {
|
||||
expect(collectExportJsdocViolations(make(`
|
||||
export const name = 'fix'
|
||||
export const inject = ['bash']
|
||||
export const reusable = true
|
||||
export const Config = { parse: true }
|
||||
export function apply(): void {}
|
||||
`))).toEqual([])
|
||||
})
|
||||
|
||||
it('recurses into namespaces with qualified names and honors the merge idiom', () => {
|
||||
const violations = collectExportJsdocViolations(make(`
|
||||
/** The plugin class. */
|
||||
export class Fix {}
|
||||
export namespace Fix {
|
||||
export interface Config { a: number }
|
||||
}
|
||||
export namespace Loose {
|
||||
export const x = 1
|
||||
}
|
||||
`))
|
||||
expect(violations).toEqual([
|
||||
expect.stringMatching(/exported interface 'Fix.Config' .* has no JSDoc\./),
|
||||
expect.stringMatching(/exported namespace 'Loose' .* has no JSDoc\./),
|
||||
expect.stringMatching(/exported const 'Loose.x' .* has no JSDoc\./),
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('verify-export-jsdoc fail-closed forms (review round 1)', () => {
|
||||
it('checks the function contract on a non-identifier default export', () => {
|
||||
expect(collectExportJsdocViolations(make(
|
||||
'/** Doubles. */\nexport default (x: number): number => x * 2\n',
|
||||
))).toEqual([
|
||||
expect.stringMatching(/default export .* is missing @param x\./),
|
||||
expect.stringMatching(/default export .* is missing @returns \(return type: number\)\./),
|
||||
])
|
||||
expect(collectExportJsdocViolations(make(
|
||||
'/**\n * Doubles.\n * @param x - the input.\n * @returns twice the input.\n */\nexport default (x: number): number => x * 2\n',
|
||||
))).toEqual([])
|
||||
})
|
||||
|
||||
it('treats an inline function-type annotation as the surface signature', () => {
|
||||
expect(collectExportJsdocViolations(make(
|
||||
'/** Maps a number. */\nexport declare const f: (x: number) => number\n',
|
||||
))).toEqual([
|
||||
expect.stringMatching(/exported const 'f' .* is missing @param x\./),
|
||||
expect.stringMatching(/exported const 'f' .* is missing @returns \(return type: number\)\./),
|
||||
])
|
||||
expect(collectExportJsdocViolations(make(
|
||||
'/**\n * Maps a number.\n * @param x - the input.\n * @returns the mapped value.\n */\nexport const f: (x: number) => number = v => v\n',
|
||||
))).toEqual([])
|
||||
})
|
||||
|
||||
it('recurses into an ambient declare namespace where members export implicitly', () => {
|
||||
expect(collectExportJsdocViolations(make(
|
||||
'export declare namespace N {\n function f(x: number): number\n}\n',
|
||||
))).toEqual([
|
||||
expect.stringMatching(/exported namespace 'N' .* has no JSDoc\./),
|
||||
expect.stringMatching(/exported function 'N.f' .* has no JSDoc\./),
|
||||
])
|
||||
})
|
||||
|
||||
it('requires an export-import alias to document itself (its target may be unwalked)', () => {
|
||||
expect(collectExportJsdocViolations(make(
|
||||
'/** Holder. */\nexport namespace N {\n /** The value. */\n export const x = 1\n}\nexport import y = N.x\n',
|
||||
))).toEqual([expect.stringMatching(/exported alias 'y' .* has no JSDoc\./)])
|
||||
expect(collectExportJsdocViolations(make(
|
||||
'namespace N {\n export const x = 1\n}\n/** Alias surfacing the internal counter. */\nexport import y = N.x\n',
|
||||
))).toEqual([])
|
||||
})
|
||||
|
||||
it('refuses an export-import alias to a callable, class, or namespace target', () => {
|
||||
const refusal = /exported alias 'g' .* aliases a callable, class, or namespace target/
|
||||
expect(collectExportJsdocViolations(make(
|
||||
'namespace N {\n export function f(x: number): number { return x }\n}\n/** Alias. */\nexport import g = N.f\n',
|
||||
))).toEqual([expect.stringMatching(refusal)])
|
||||
expect(collectExportJsdocViolations(make(
|
||||
'namespace N {\n export class C {\n run(x: number): number { return x }\n }\n}\n/** Alias. */\nexport import g = N.C\n',
|
||||
))).toEqual([expect.stringMatching(refusal)])
|
||||
expect(collectExportJsdocViolations(make(
|
||||
'namespace N {\n export namespace Sub {\n export function f(x: number): number { return x }\n }\n}\n/** Alias. */\nexport import g = N.Sub\n',
|
||||
))).toEqual([expect.stringMatching(refusal)])
|
||||
})
|
||||
|
||||
it('classifies wrapped function initializers and default exports (parens, satisfies)', () => {
|
||||
expect(collectExportJsdocViolations(make(
|
||||
'type Fn = (x: number) => number\n/** Wrapped. */\nexport const f = (((x: number): number => x)) satisfies Fn\n',
|
||||
))).toEqual([
|
||||
expect.stringMatching(/exported const 'f' .* is missing @param x\./),
|
||||
expect.stringMatching(/exported const 'f' .* is missing @returns \(return type: number\)\./),
|
||||
])
|
||||
expect(collectExportJsdocViolations(make(
|
||||
'type Fn = (x: number) => number\n/** Wrapped. */\nexport default (((x: number): number => x * 2) satisfies Fn)\n',
|
||||
))).toEqual([
|
||||
expect.stringMatching(/default export .* is missing @param x\./),
|
||||
expect.stringMatching(/default export .* is missing @returns \(return type: number\)\./),
|
||||
])
|
||||
})
|
||||
|
||||
it('treats a single-call-signature type literal as the surface signature', () => {
|
||||
expect(collectExportJsdocViolations(make(
|
||||
'/** Maps. */\nexport declare const f: { (x: number): number }\n',
|
||||
))).toEqual([
|
||||
expect.stringMatching(/exported const 'f' .* is missing @param x\./),
|
||||
expect.stringMatching(/exported const 'f' .* is missing @returns \(return type: number\)\./),
|
||||
])
|
||||
})
|
||||
|
||||
it('refuses a hybrid callable type literal instead of narrowing the check', () => {
|
||||
expect(collectExportJsdocViolations(make(
|
||||
'/** Hybrid. */\nexport declare const f: { (x: number): number; flush: () => void }\n',
|
||||
))).toEqual([expect.stringMatching(/exported const 'f'.*callable type literal is not gate-classifiable; extract a named type/)])
|
||||
})
|
||||
|
||||
it('refuses an export-equals assignment instead of failing open', () => {
|
||||
expect(collectExportJsdocViolations(make(
|
||||
'const x = 1\nexport = x\n',
|
||||
))).toEqual([expect.stringMatching(/export-equals assignment .* is not a gate-supported export form/)])
|
||||
})
|
||||
})
|
||||
|
||||
describe('verify-export-jsdoc heritage refinement (review round 1)', () => {
|
||||
it('requires @param for parameters the base member never names', () => {
|
||||
const violations = collectExportJsdocViolations(make(`
|
||||
/** Seam. */
|
||||
export abstract class Base {
|
||||
/**
|
||||
* Do it.
|
||||
* @param x - input.
|
||||
* @returns output.
|
||||
*/
|
||||
abstract run(x: number): number
|
||||
}
|
||||
/** Impl. */
|
||||
export class Impl extends Base {
|
||||
override run(x: number, verbose?: boolean): number { return verbose ? x : -x }
|
||||
}
|
||||
`))
|
||||
expect(violations).toEqual([expect.stringMatching(/exported class method 'Impl.run' .* is missing @param verbose\./)])
|
||||
})
|
||||
|
||||
it('does not exempt a public override of a protected-only base member', () => {
|
||||
expect(collectExportJsdocViolations(make(`
|
||||
/** Seam. */
|
||||
export abstract class Base {
|
||||
/** Subclass hook. */
|
||||
protected hook(): void {}
|
||||
}
|
||||
/** Impl. */
|
||||
export class Impl extends Base {
|
||||
override hook(): void {}
|
||||
}
|
||||
`))).toEqual([expect.stringMatching(/exported class method 'Impl.hook' .* has no JSDoc\./)])
|
||||
})
|
||||
|
||||
it('treats an underscore-prefixed rename of a base parameter as the same parameter', () => {
|
||||
expect(collectExportJsdocViolations(make(`
|
||||
/** Seam. */
|
||||
export abstract class Base {
|
||||
/**
|
||||
* Load it.
|
||||
* @param cwd - the working directory to scope the lookup.
|
||||
* @returns the loaded value.
|
||||
*/
|
||||
abstract load(cwd: string): number
|
||||
}
|
||||
/** Impl (ignores cwd). */
|
||||
export class Impl extends Base {
|
||||
load(_cwd: string): number { return 1 }
|
||||
}
|
||||
`))).toEqual([])
|
||||
})
|
||||
|
||||
it('flags a binding-pattern parameter an override adds beyond the base', () => {
|
||||
expect(collectExportJsdocViolations(make(`
|
||||
/** Seam. */
|
||||
export abstract class Base {
|
||||
/**
|
||||
* Do it.
|
||||
* @param x - input.
|
||||
* @returns output.
|
||||
*/
|
||||
abstract run(x: number): number
|
||||
}
|
||||
/** Impl. */
|
||||
export class Impl extends Base {
|
||||
override run(x: number, { verbose }: { verbose?: boolean } = {}): number { return verbose ? x : -x }
|
||||
}
|
||||
`))).toEqual([expect.stringMatching(/exported class method 'Impl.run' .* is a binding pattern/)])
|
||||
})
|
||||
|
||||
it('revives the @returns duty when an override grows a concrete result over a void base', () => {
|
||||
const voidBase = `
|
||||
/** Seam. */
|
||||
export abstract class Base {
|
||||
/** Do it (fire-and-forget). */
|
||||
abstract run(): void
|
||||
}
|
||||
`
|
||||
expect(collectExportJsdocViolations(make(`${voidBase}
|
||||
/** Impl. */
|
||||
export class Impl extends Base {
|
||||
override run(): number { return 1 }
|
||||
}
|
||||
`))).toEqual([expect.stringMatching(/exported class method 'Impl.run' .* is missing @returns \(return type: number\)\./)])
|
||||
expect(collectExportJsdocViolations(make(`${voidBase}
|
||||
/** Impl. */
|
||||
export class Impl extends Base {
|
||||
/**
|
||||
* Do it and count.
|
||||
* @returns how many were done.
|
||||
*/
|
||||
override run(): number { return 1 }
|
||||
}
|
||||
`))).toEqual([])
|
||||
})
|
||||
|
||||
it('classifies an unannotated override return over a void base via the checker', () => {
|
||||
const voidBase = `
|
||||
/** Seam. */
|
||||
export abstract class Base {
|
||||
/** Do it (fire-and-forget). */
|
||||
abstract run(): void
|
||||
}
|
||||
`
|
||||
expect(collectExportJsdocViolations(make(`${voidBase}
|
||||
/** Impl. */
|
||||
export class Impl extends Base {
|
||||
override run() { return 1 }
|
||||
}
|
||||
`))).toEqual([expect.stringMatching(/exported class method 'Impl.run' .* non-void result its heritage declaration does not document/)])
|
||||
expect(collectExportJsdocViolations(make(`${voidBase}
|
||||
/** Impl (faithful void, no annotation needed). */
|
||||
export class Impl extends Base {
|
||||
override run() {}
|
||||
}
|
||||
`))).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps the full exemption when the base return already carries the @returns duty', () => {
|
||||
expect(collectExportJsdocViolations(make(`
|
||||
/** Seam. */
|
||||
export abstract class Base {
|
||||
/**
|
||||
* Count things.
|
||||
* @returns the count.
|
||||
*/
|
||||
abstract run(): number
|
||||
}
|
||||
/** Impl. */
|
||||
export class Impl extends Base {
|
||||
override run(): number { return 1 }
|
||||
}
|
||||
`))).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -153,10 +153,15 @@ export class Session {
|
||||
this.header = header ?? { version: SESSION_FORMAT_VERSION, id, createdAt: Date.now() }
|
||||
}
|
||||
|
||||
/**
|
||||
* The append-only event log, exposed live by reference (readonly-typed, not
|
||||
* a snapshot): later appends are visible through the same array.
|
||||
*/
|
||||
get events(): readonly SessionEvent[] {
|
||||
return this.log
|
||||
}
|
||||
|
||||
/** The next event's sequence number — always the log length (the `seq = log.length` contiguity contract). */
|
||||
get seq(): number {
|
||||
return this.log.length
|
||||
}
|
||||
@@ -175,6 +180,9 @@ export class Session {
|
||||
* declare how it joins the surface, the sole source of derived history) and
|
||||
* rejected by the compiler for non-surface types like `turn/start` or
|
||||
* `assistant/chunk`.
|
||||
* @returns the logged event — its assigned `seq`/`time` plus the SNAPSHOT of
|
||||
* `data` that entered the log, so reading `event.data` back sees the logged
|
||||
* value, never the caller's still-mutable input.
|
||||
* @throws if `data` is not losslessly JSON-serializable (BigInt, function,
|
||||
* symbol, undefined, non-finite number, circular ref, or an exotic object
|
||||
* like Map/Set/Date). The event log is the durable source of truth, so this
|
||||
@@ -365,6 +373,14 @@ export class Session {
|
||||
/** A fork source: either the live session object or its live store id. */
|
||||
export type SessionForkSource = Session | SessionId
|
||||
|
||||
/**
|
||||
* Rejection codes for session forking: the fork source id is unknown to the
|
||||
* live store (`SESSION_NOT_FOUND`) or names a session object that is not the
|
||||
* store's live instance (`SESSION_NOT_LIVE`); the requested child id is
|
||||
* already taken (`SESSION_ALREADY_EXISTS`); the boundary is not a contiguous
|
||||
* existing seq (`INVALID_BOUNDARY`); or the boundary event is not a
|
||||
* `turn/end` — a fork must cut on a closed turn (`OPEN_TURN`).
|
||||
*/
|
||||
export type SessionForkErrorCode =
|
||||
| 'SESSION_NOT_FOUND'
|
||||
| 'SESSION_NOT_LIVE'
|
||||
|
||||
@@ -40,6 +40,10 @@ export type JsonValue = null | boolean | number | string | JsonValue[] | { [key:
|
||||
* hiding under a symbol/non-enumerable key cannot make the round-trip lossy.
|
||||
* Getters are invoked during the check (again as `JSON.stringify` would), so the
|
||||
* contract is for plain data records, not objects with side-effecting accessors.
|
||||
* @param value - the candidate event data to test.
|
||||
* @param seen - objects on the current descent path, for circular-reference
|
||||
* detection; the recursion threads it — callers omit it.
|
||||
* @returns true when `value` survives a JSON round-trip losslessly.
|
||||
*/
|
||||
export function isJsonValue(value: unknown, seen: Set<object> = new Set()): boolean {
|
||||
if (value === null) return true
|
||||
|
||||
@@ -54,6 +54,8 @@ import type { SessionEvent } from './types.ts'
|
||||
* Only the LAST turn can be open: the invariants plugin guarantees a `turn/end`
|
||||
* before any later `turn/start`, so an interior open turn is impossible in a
|
||||
* valid committed log. Likewise at most one step is open within that turn.
|
||||
* @param events - the loaded durable log to scan (a valid committed prefix, possibly with a crash tail).
|
||||
* @returns the synthetic closer events to append after `events`, in order; empty when the log is already balanced.
|
||||
*/
|
||||
export function interruptedTurnClosers(events: readonly SessionEvent[]): SessionEvent[] {
|
||||
let openTurn: number | null = null
|
||||
|
||||
@@ -29,6 +29,8 @@ const SURFACE_EVENT_TYPES = new Set<string>([
|
||||
* surface-eligible event that is MISSING its mandatory marker (e.g. validating
|
||||
* a seed/load log); use {@link isSurfaceEvent} to narrow to a fully-formed
|
||||
* {@link SurfaceEvent} with `surfaceOp` present.
|
||||
* @param type - the event type string to test.
|
||||
* @returns true when the type is one of the five message-producing types.
|
||||
*/
|
||||
export function isSurfaceEligibleType(type: string): boolean {
|
||||
return SURFACE_EVENT_TYPES.has(type)
|
||||
@@ -38,6 +40,8 @@ export function isSurfaceEligibleType(type: string): boolean {
|
||||
* Narrow a {@link SessionEvent} to {@link SurfaceEvent}: checks that the
|
||||
* event's `type` is surface-eligible AND that `surfaceOp` is present.
|
||||
* The narrowed type has mandatory {@link SurfaceOp}.
|
||||
* @param event - the event to narrow.
|
||||
* @returns true when the event is surface-eligible and carries its `surfaceOp` marker.
|
||||
*/
|
||||
export function isSurfaceEvent(event: SessionEvent): event is SurfaceEvent {
|
||||
if (!SURFACE_EVENT_TYPES.has(event.type)) return false
|
||||
|
||||
@@ -74,6 +74,12 @@ function nodeDelta(event: SessionEvent): number {
|
||||
* surface successor (`SurfaceNode.next`), or `null` when `end` is the tail —
|
||||
* for the cut after `end`.
|
||||
*
|
||||
* @param nodes - the surface linked list in head→tail order.
|
||||
* @param events - the session log each node's `seq` indexes into.
|
||||
* @param beforeSeq - names the cut (the node it sits immediately before);
|
||||
* `null` — or any seq not on the surface — means the after-tail cut.
|
||||
* @returns true when every `tool-call` before the cut is answered before it
|
||||
* (the unanswered-call depth at the cut is zero).
|
||||
* @throws if the surface prefix drives the unanswered-call depth negative — a
|
||||
* `tool/result` with no preceding open `tool-call` on the surface. That is a
|
||||
* corrupt surface (a structural invariant violation), surfaced loudly here
|
||||
|
||||
@@ -4,7 +4,11 @@ import type { CallId, ContentBlock, LlmCallConfig, MessageSource, StreamChunk, T
|
||||
/** Identifies one session in the store (and its persistence artifacts). */
|
||||
export type SessionId = Branded<'SessionId'>
|
||||
|
||||
/** Brand a string as a {@link SessionId}. */
|
||||
/**
|
||||
* Brand a string as a {@link SessionId}.
|
||||
* @param id - the raw session id string.
|
||||
* @returns the same string, branded (a compile-time cast — no runtime cost).
|
||||
*/
|
||||
export function SessionId(id: string): SessionId {
|
||||
return id as SessionId
|
||||
}
|
||||
@@ -102,6 +106,7 @@ export interface TurnTriggerMap {
|
||||
injection: { kind: 'injection'; source: MessageSource }
|
||||
}
|
||||
|
||||
/** The union over {@link TurnTriggerMap} — what started a turn; plugins extend it by merging variants into the map. */
|
||||
export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap]
|
||||
|
||||
/**
|
||||
@@ -156,6 +161,7 @@ export interface TurnEndReasonMap {
|
||||
interrupted: { kind: 'interrupted' }
|
||||
}
|
||||
|
||||
/** The union over {@link TurnEndReasonMap} — why a turn ended; plugins extend it by merging variants into the map. */
|
||||
export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap]
|
||||
|
||||
/**
|
||||
@@ -361,6 +367,7 @@ export interface SessionEventMap {
|
||||
'request/header-delta': { system?: SystemDelta; tools?: ToolsDelta; config?: LlmCallConfig }
|
||||
}
|
||||
|
||||
/** The appendable event-type keys of {@link SessionEventMap}, plugin-merged extensions included. */
|
||||
export type SessionEventType = keyof SessionEventMap
|
||||
|
||||
/**
|
||||
|
||||
@@ -171,6 +171,7 @@ function compareToolNames(a: ToolSchema, b: ToolSchema): number {
|
||||
return a.name < b.name ? -1 : a.name > b.name ? 1 : 0
|
||||
}
|
||||
|
||||
/** Plugin config: the deployment-authored fragment of the system prompt (see {@link Config.persona} for its contract). */
|
||||
export interface Config {
|
||||
/**
|
||||
* The deployment's persona — the ONE deployment-authored fragment of the
|
||||
@@ -219,6 +220,10 @@ export interface Config {
|
||||
* while a `}}` still follows (e.g. `{{{model}}}`, `{{a{b}}`) all throw. A
|
||||
* lone `{{` with no `}}` anywhere after it is ordinary prose and passes
|
||||
* through verbatim. Substituted values are never re-scanned.
|
||||
* @param assembly - the assembly to render (typically the awaited result of
|
||||
* {@link SystemPrompt.assemble}); only `sections` and `variables` are read.
|
||||
* @returns the full system prompt text; `''` when every section renders empty
|
||||
* (the caller then sends no system prompt at all).
|
||||
*/
|
||||
export function renderPrompt(assembly: PromptAssembly): string {
|
||||
return assembly.sections
|
||||
|
||||
@@ -155,6 +155,9 @@ export interface JsonSchemaObject {
|
||||
* `properties`, `required` array).
|
||||
*
|
||||
* This is a plain function — no schemastery or other framework dependency.
|
||||
* @param spec - the author-facing per-property schema to convert.
|
||||
* @returns the wire-format JSON Schema; the top-level `required` array is
|
||||
* omitted entirely when no property is marked required.
|
||||
*/
|
||||
export function schemaSpecToJsonSchema(spec: SchemaSpec): JsonSchemaObject {
|
||||
const properties: Record<string, unknown> = {}
|
||||
@@ -269,6 +272,9 @@ function checkSpec(spec: SchemaSpec, value: unknown, path: string): string[] {
|
||||
* keys are allowed (no `additionalProperties: false`); `default` is not
|
||||
* applied; an `object`/`array` prop without `properties`/`items` only
|
||||
* type-checks; `enum` is membership (strings only).
|
||||
* @param spec - the declared parameter schema to validate against.
|
||||
* @param args - the model-generated arguments, however malformed.
|
||||
* @returns the violation messages in declaration order; empty means valid.
|
||||
*/
|
||||
export function validateArgs(spec: SchemaSpec, args: unknown): string[] {
|
||||
return checkSpec(spec, args, '')
|
||||
@@ -340,6 +346,13 @@ export interface DefineToolOptions<S extends SchemaSpec> {
|
||||
* Raw JSON-Schema tool definitions (from MCP servers) are still accepted
|
||||
* by `ToolRegistry.register()` directly — `defineTool` is sugar for
|
||||
* first-party plugin authors.
|
||||
* @param options - the tool's name, description, typed parameter schema,
|
||||
* execute body, and optional presenters.
|
||||
* @returns a registry-ready {@link ToolDefinition}: its `execute` validates the
|
||||
* raw args first (throwing {@link ToolArgsError} on mismatch, which the
|
||||
* registry turns into an isError result), and its presenters validate softly
|
||||
* (returning undefined on mismatch, since replay may feed them older-schema
|
||||
* args).
|
||||
*/
|
||||
export function defineTool<S extends SchemaSpec>(options: DefineToolOptions<S>): ToolDefinition {
|
||||
// Object-literal execute methods don't use `this`; the reference is safe.
|
||||
|
||||
@@ -129,6 +129,9 @@ export interface LocalDirEntry {
|
||||
* and intermediate directories are created by the write. Two input paths
|
||||
* reaching the same file via symlinks share one key. Falls back to the absolute
|
||||
* path only when no ancestor (not even the filesystem root) can be resolved.
|
||||
* @param cwd - base directory a relative `path` resolves against.
|
||||
* @param path - absolute or relative path; empty/whitespace-only throws `FS_NOT_FOUND`.
|
||||
* @returns the absolute display path plus the realpath-derived stable target key.
|
||||
*/
|
||||
export async function resolveLocalTarget(cwd: string, path: string): Promise<LocalTarget> {
|
||||
if (path.trim().length === 0) throw new FsError('file_path must be a non-empty string', 'FS_NOT_FOUND')
|
||||
@@ -165,7 +168,11 @@ export async function resolveLocalTarget(cwd: string, path: string): Promise<Loc
|
||||
}
|
||||
}
|
||||
|
||||
/** Probe a path for its version, mode, type, and size. Null if absent. */
|
||||
/**
|
||||
* Probe a path for its version, mode, type, and size. Null if absent.
|
||||
* @param absolutePath - the path to stat (typically a target key; symlinks are followed).
|
||||
* @returns the metadata, or null when the path — or a parent segment — does not exist.
|
||||
*/
|
||||
export async function probe(absolutePath: string): Promise<PathInfo | null> {
|
||||
try {
|
||||
const info = await stat(absolutePath)
|
||||
@@ -200,6 +207,9 @@ async function resolveListedChildTarget(parent: LocalTarget, name: string): Prom
|
||||
* List direct children of a directory in stable name order. Each child includes
|
||||
* a resolved target plus stat metadata when still available; file contents are
|
||||
* never read.
|
||||
* @param target - the resolved directory to list; a missing or non-directory target throws.
|
||||
* @param signal - aborts the listing, checked between children (`FS_ABORTED`).
|
||||
* @returns one entry per direct child, sorted by name.
|
||||
*/
|
||||
export async function listDirectory(target: LocalTarget, signal?: AbortSignal): Promise<LocalDirEntry[]> {
|
||||
throwIfAborted(signal, 'list')
|
||||
@@ -290,6 +300,9 @@ async function statRegularFile(target: LocalTarget, verb: 'read', signal?: Abort
|
||||
/**
|
||||
* Read a whole regular UTF-8 text file into a single decoded string. Rejects
|
||||
* non-regular files, invalid UTF-8, and NUL-byte binary samples.
|
||||
* @param target - the resolved file to read.
|
||||
* @param signal - aborts the read (`FS_ABORTED`).
|
||||
* @returns the full decoded text, byte-for-byte (no normalization).
|
||||
*/
|
||||
export async function readWholeText(target: LocalTarget, signal?: AbortSignal): Promise<string> {
|
||||
await statRegularFile(target, 'read', signal)
|
||||
@@ -305,6 +318,9 @@ export async function readWholeText(target: LocalTarget, signal?: AbortSignal):
|
||||
* Stream a whole regular UTF-8 text file as decoded text chunks. Same text
|
||||
* semantics as {@link readWholeText} (regular-file check, binary/NUL rejection,
|
||||
* cross-chunk UTF-8 decoding), but never holds the whole file in memory.
|
||||
* @param target - the resolved file to stream.
|
||||
* @param signal - aborts the stream, including between chunks (`FS_ABORTED`).
|
||||
* @returns decoded text chunks in file order; chunk boundaries carry no meaning.
|
||||
*/
|
||||
export async function* streamWholeText(target: LocalTarget, signal?: AbortSignal): AsyncIterable<string> {
|
||||
await statRegularFile(target, 'read', signal)
|
||||
@@ -352,6 +368,11 @@ async function removeStagingDirOrThrow(stagingDir: string, originalError: unknow
|
||||
* (`0o700`) staging directory, fsync, optionally chmod to the final mode while
|
||||
* still private, then rename over the target. `mode` (when given) preserves an
|
||||
* existing file's permissions across the replace.
|
||||
* @param absolutePath - the final destination (typically a target key); missing parent dirs are created.
|
||||
* @param content - the full UTF-8 text to write.
|
||||
* @param mode - final file mode applied before the rename (an existing file's, to preserve permissions); undefined leaves `0o600`.
|
||||
* @param signal - aborts the write (`FS_ABORTED`); checked before the rename, so the target is never left torn.
|
||||
* @param internals - test seam for pinning temp names and observing the staged file.
|
||||
*/
|
||||
export async function writeFileAtomic(
|
||||
absolutePath: string,
|
||||
@@ -409,6 +430,12 @@ export async function writeFileAtomic(
|
||||
/** Line ending style detected before LF normalization. */
|
||||
export type LineEndings = 'LF' | 'CRLF'
|
||||
|
||||
/**
|
||||
* Collapse CRLF to LF — the canonical in-memory form every edit/diff basis
|
||||
* uses. Lone `\r` bytes (not followed by `\n`) are left untouched.
|
||||
* @param content - decoded text in whatever line-ending style the file had.
|
||||
* @returns the text with every `\r\n` pair replaced by `\n`.
|
||||
*/
|
||||
function normalizeLineEndings(content: string): string {
|
||||
return content.replaceAll('\r\n', '\n')
|
||||
}
|
||||
@@ -420,6 +447,14 @@ function detectLineEndings(raw: string): LineEndings {
|
||||
return crlfCount > lfCount ? 'CRLF' : 'LF'
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert LF-normalized content back to the line-ending style detected at read
|
||||
* time, for write-back. `LF` returns the content unchanged; `CRLF` re-normalizes
|
||||
* first so an already-CRLF sequence is never doubled to `\r\r\n`.
|
||||
* @param content - the LF-normalized (edited) text.
|
||||
* @param lineEndings - the original file's style, as detected by {@link readForEdit}.
|
||||
* @returns the text in the original file's line-ending style.
|
||||
*/
|
||||
function restoreLineEndings(content: string, lineEndings: LineEndings): string {
|
||||
return lineEndings === 'LF' ? content : normalizeLineEndings(content).split('\n').join('\r\n')
|
||||
}
|
||||
@@ -438,6 +473,10 @@ function countOccurrences(content: string, needle: string): number {
|
||||
/**
|
||||
* Read and decode a file for editing: rejects binaries, returns LF-normalized
|
||||
* content plus the original line-ending style for write-back.
|
||||
* @param absolutePath - the file to read (typically a target key).
|
||||
* @param displayPath - the caller-facing path used in error messages.
|
||||
* @param signal - aborts the read (`FS_ABORTED`).
|
||||
* @returns the LF-normalized content and the detected style to restore on write-back.
|
||||
*/
|
||||
export async function readForEdit(
|
||||
absolutePath: string,
|
||||
@@ -459,6 +498,9 @@ export async function readForEdit(
|
||||
* prior bytes, so an undiffable prior file simply yields no contextual-hunk basis
|
||||
* (the caller treats `null` the same as an absent file: the result renders a
|
||||
* whole-file diff rather than an applied hunk).
|
||||
* @param absolutePath - the file to read (typically a target key); it must exist.
|
||||
* @param signal - aborts the read (`FS_ABORTED`).
|
||||
* @returns the LF-normalized text, or null for a binary or non-UTF-8 file.
|
||||
*/
|
||||
export async function readTextForDiff(absolutePath: string, signal?: AbortSignal): Promise<string | null> {
|
||||
const buffer = await readFileAbortable(absolutePath, 'read', signal)
|
||||
@@ -477,6 +519,12 @@ export async function readTextForDiff(absolutePath: string, signal?: AbortSignal
|
||||
* `FS_EDIT_NOT_FOUND` on empty `oldString` or zero matches and
|
||||
* `FS_AMBIGUOUS_EDIT` on multiple matches when `replaceAll` is false. Returns
|
||||
* the edited content (still LF-normalized) and the replacement count.
|
||||
* @param content - the current file content, already LF-normalized.
|
||||
* @param oldString - literal text to find; CRLF inside it is normalized to LF before matching.
|
||||
* @param newString - literal replacement text, normalized the same way.
|
||||
* @param replaceAll - replace every match instead of requiring exactly one.
|
||||
* @param displayPath - the caller-facing path used in error messages.
|
||||
* @returns the edited LF-normalized content plus how many occurrences were replaced.
|
||||
*/
|
||||
export function applyLiteralEdit(
|
||||
content: string,
|
||||
|
||||
@@ -73,6 +73,7 @@ export class LocalFileSystem extends FileSystem {
|
||||
cwd: z.string().default(process.cwd()),
|
||||
})
|
||||
|
||||
/** Validated config (schemastery applied the defaults before construction). */
|
||||
readonly config: ResolvedConfig
|
||||
/** Test seam forwarded to fsio (force streaming path, pin temp names). */
|
||||
internals: FsIoInternals = {}
|
||||
|
||||
@@ -29,7 +29,12 @@ import type { Branded } from '@deepseek-ai/dsh-brand'
|
||||
*/
|
||||
export type FsTargetKey = Branded<'FsTargetKey'>
|
||||
|
||||
/** Brand a string as an {@link FsTargetKey}. */
|
||||
/**
|
||||
* Brand a string as an {@link FsTargetKey}. For backend use only — a consumer
|
||||
* never manufactures a key, it receives one from `resolve()`.
|
||||
* @param key - the backend's raw key string (the local backend passes a realpath).
|
||||
* @returns the same string, branded; no validation is performed.
|
||||
*/
|
||||
export function FsTargetKey(key: string): FsTargetKey {
|
||||
return key as FsTargetKey
|
||||
}
|
||||
@@ -42,7 +47,12 @@ export function FsTargetKey(key: string): FsTargetKey {
|
||||
*/
|
||||
export type FsVersion = Branded<'FsVersion'>
|
||||
|
||||
/** Brand a string as an {@link FsVersion}. */
|
||||
/**
|
||||
* Brand a string as an {@link FsVersion}. For backend use only — a consumer
|
||||
* never manufactures a version, it receives one from `stat`/write/edit outcomes.
|
||||
* @param v - the backend's raw version string (the local backend derives it from mtime+size).
|
||||
* @returns the same string, branded; no validation is performed.
|
||||
*/
|
||||
export function FsVersion(v: string): FsVersion {
|
||||
return v as FsVersion
|
||||
}
|
||||
|
||||
@@ -40,6 +40,10 @@ export type FsDiffMeta = { diffs: FileDiff[] }
|
||||
* (a pure insertion) reports `oldText: null` (nothing to diff against), mirroring
|
||||
* the call-time card's new-file convention. The unified-diff "\ No newline at end
|
||||
* of file" markers are dropped — they annotate the patch, not file content.
|
||||
* @param path - the path stamped on every produced diff (the model-facing `file_path`; the bridge relativizes it).
|
||||
* @param before - the file text before the change (the backend's LF-normalized diff basis).
|
||||
* @param after - the file text after the change, on the same basis.
|
||||
* @returns one diff per applied hunk, in file order; empty when the texts are identical.
|
||||
*/
|
||||
export function computeHunkDiffs(path: string, before: string, after: string): FileDiff[] {
|
||||
const patch = structuredPatch('', '', before, after, undefined, undefined, { context: DIFF_CONTEXT })
|
||||
@@ -83,6 +87,8 @@ function isFileDiff(value: unknown): value is FileDiff {
|
||||
* it validates defensively rather than trusting the payload — a bad `meta` yields
|
||||
* `undefined`, and the caller decides the fallback (edit → the generic result
|
||||
* rendering; write → an args-derived whole-file diff), never a thrown presenter.
|
||||
* @param meta - the opaque `tool/result` meta payload (live or replayed from the session log).
|
||||
* @returns the validated non-empty hunk list, or undefined for an absent/empty/malformed payload.
|
||||
*/
|
||||
export function diffsFromMeta(meta: unknown): FileDiff[] | undefined {
|
||||
if (typeof meta !== 'object' || meta === null || Array.isArray(meta)) return undefined
|
||||
|
||||
@@ -29,7 +29,13 @@ interface EditInput {
|
||||
replaceAll: boolean
|
||||
}
|
||||
|
||||
/** Validate value constraints the schema DSL can't express. */
|
||||
/**
|
||||
* Validate value constraints the schema DSL can't express: a non-blank
|
||||
* `file_path`, a non-empty `old_string`, and `old_string !== new_string`
|
||||
* (an equal pair would be a guaranteed no-op edit).
|
||||
* @param args - the schema-validated raw tool arguments.
|
||||
* @returns the camelCased input with `replace_all` defaulted to false.
|
||||
*/
|
||||
export function parseEditArgs(args: { file_path: string; old_string: string; new_string: string; replace_all?: boolean }): EditInput {
|
||||
if (args.file_path.trim().length === 0) throw new Error('file_path must be a non-empty string')
|
||||
if (args.old_string.length === 0) throw new Error('old_string must be a non-empty string')
|
||||
@@ -42,14 +48,22 @@ export function parseEditArgs(args: { file_path: string; old_string: string; new
|
||||
}
|
||||
}
|
||||
|
||||
/** Format an edit success (single-match or replace-all) as a Claude-style model-facing message. */
|
||||
/**
|
||||
* Format an edit success (single-match or replace-all) as a Claude-style model-facing message.
|
||||
* @param displayPath - the backend-resolved path shown to the model.
|
||||
* @param replaceAll - selects the all-occurrences wording over the single-replacement one.
|
||||
* @returns the confirmation sentence the model sees as the tool result.
|
||||
*/
|
||||
export function formatEditOutput(displayPath: string, replaceAll: boolean): string {
|
||||
return replaceAll
|
||||
? `The file ${displayPath} has been updated. All occurrences were successfully replaced.`
|
||||
: `The file ${displayPath} has been updated successfully.`
|
||||
}
|
||||
|
||||
/** Register the `edit` tool and its system-prompt guidance. */
|
||||
/**
|
||||
* Register the `edit` tool and its system-prompt guidance.
|
||||
* @param ctx - the plugin context; registrations are effects scoped to it, and execution uses its `fs` service.
|
||||
*/
|
||||
export function applyEditTool(ctx: Context): void {
|
||||
ctx.systemPrompt.section({
|
||||
name: 'tool:edit',
|
||||
|
||||
@@ -119,6 +119,10 @@ function finish(acc: WindowAccumulator, request: ReadWindow, displayPath: string
|
||||
* path serves both. Scans for newlines with a capped line buffer (a newline-free
|
||||
* giant line is truncated, never buffered past `request.maxLineLength`),
|
||||
* enforces the byte cap, and throws `FS_NOT_FOUND` for an offset past EOF.
|
||||
* @param chunks - decoded text chunks in file order; chunk boundaries carry no meaning.
|
||||
* @param request - the resolved window; the caller has already applied its defaults and caps.
|
||||
* @param displayPath - the caller-facing path used in the offset-out-of-range error.
|
||||
* @returns the numbered window lines, the total line count seen, and the byte-cap truncation flag.
|
||||
*/
|
||||
export async function buildWindow(
|
||||
chunks: AsyncIterable<string> | Iterable<string>,
|
||||
@@ -156,7 +160,12 @@ export async function buildWindow(
|
||||
return finish(acc, request, displayPath)
|
||||
}
|
||||
|
||||
/** Format a read outcome as one OpenCode-style line-numbered text block body. */
|
||||
/**
|
||||
* Format a read outcome as one OpenCode-style line-numbered text block body.
|
||||
* @param displayPath - the backend-resolved path rendered in the envelope's `<path>` element.
|
||||
* @param outcome - the windowed read to render.
|
||||
* @returns the model-facing envelope: numbered lines plus a continuation or end-of-file footer.
|
||||
*/
|
||||
export function formatReadOutput(displayPath: string, outcome: FileReadOutcome): string {
|
||||
const endLine = outcome.lines.at(-1)?.number ?? Math.max(0, outcome.offset - 1)
|
||||
let footer: string
|
||||
|
||||
@@ -58,7 +58,12 @@ function parsePositiveInteger(value: number, name: string): number {
|
||||
return value
|
||||
}
|
||||
|
||||
/** Validate value constraints the schema DSL can't express. `maxLimit` is the deployment's line cap. */
|
||||
/**
|
||||
* Validate value constraints the schema DSL can't express. `maxLimit` is the deployment's line cap.
|
||||
* @param args - the schema-validated raw tool arguments; `offset`/`limit` must be positive integers when given.
|
||||
* @param maxLimit - the configured line cap: both the default `limit` and the largest one accepted.
|
||||
* @returns the validated input with `offset` defaulted to 1 and `limit` to `maxLimit`.
|
||||
*/
|
||||
export function parseReadArgs(args: { file_path: string; offset?: number; limit?: number }, maxLimit: number): ReadInput {
|
||||
if (args.file_path.trim().length === 0) throw new Error('file_path must be a non-empty string')
|
||||
const offset = args.offset === undefined ? 1 : parsePositiveInteger(args.offset, 'offset')
|
||||
@@ -67,7 +72,11 @@ export function parseReadArgs(args: { file_path: string; offset?: number; limit?
|
||||
return { filePath: args.file_path, offset, limit }
|
||||
}
|
||||
|
||||
/** Register the `read` tool and its system-prompt guidance. */
|
||||
/**
|
||||
* Register the `read` tool and its system-prompt guidance.
|
||||
* @param ctx - the plugin context; registrations are effects scoped to it, and execution uses its `fs` service.
|
||||
* @param caps - the deployment's resolved read caps (plugin config after defaulting).
|
||||
*/
|
||||
export function applyReadTool(ctx: Context, caps: ReadToolCaps): void {
|
||||
ctx.systemPrompt.section({
|
||||
name: 'tool:read',
|
||||
|
||||
@@ -18,7 +18,11 @@
|
||||
|
||||
import type { ToolExecution } from '@deepseek-ai/dsh-tools'
|
||||
|
||||
/** The session workspace cwd for this call, or `undefined` when none applies. */
|
||||
/**
|
||||
* The session workspace cwd for this call, or `undefined` when none applies.
|
||||
* @param exec - the tool-execution context; only its optional `agent` is read.
|
||||
* @returns the calling agent's session cwd, or undefined for a non-agent caller (the backend then applies its own default).
|
||||
*/
|
||||
export function sessionCwd(exec: ToolExecution): string | undefined {
|
||||
return exec.agent?.session.header.cwd
|
||||
}
|
||||
|
||||
@@ -21,13 +21,23 @@ import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
import { computeHunkDiffs, diffsFromMeta, type FsDiffMeta } from './diff.ts'
|
||||
import { sessionCwd } from './session-cwd.ts'
|
||||
|
||||
/** Validate value constraints the schema DSL can't express. */
|
||||
/**
|
||||
* Validate value constraints the schema DSL can't express: only a non-blank
|
||||
* `file_path` — an empty `content` is legitimate (it writes an empty file).
|
||||
* @param args - the schema-validated raw tool arguments.
|
||||
* @returns the camelCased input; `content` passes through untouched.
|
||||
*/
|
||||
export function parseWriteArgs(args: { file_path: string; content: string }): { filePath: string; content: string } {
|
||||
if (args.file_path.trim().length === 0) throw new Error('file_path must be a non-empty string')
|
||||
return { filePath: args.file_path, content: args.content }
|
||||
}
|
||||
|
||||
/** Format a write outcome as one model-facing text block body. */
|
||||
/**
|
||||
* Format a write outcome as one model-facing text block body.
|
||||
* @param displayPath - the backend-resolved path rendered in the envelope's `<path>` element.
|
||||
* @param outcome - the write outcome; its `operation` selects the Created/Updated wording.
|
||||
* @returns the model-facing confirmation envelope (no file content is echoed back).
|
||||
*/
|
||||
export function formatWriteOutput(displayPath: string, outcome: FsWriteOutcome): string {
|
||||
const verb = outcome.operation === 'create' ? 'Created' : 'Updated'
|
||||
return `<path>${displayPath}</path>
|
||||
@@ -37,7 +47,10 @@ ${verb} file
|
||||
</content>`
|
||||
}
|
||||
|
||||
/** Register the `write` tool and its system-prompt guidance. */
|
||||
/**
|
||||
* Register the `write` tool and its system-prompt guidance.
|
||||
* @param ctx - the plugin context; registrations are effects scoped to it, and execution uses its `fs` service.
|
||||
*/
|
||||
export function applyWriteTool(ctx: Context): void {
|
||||
ctx.systemPrompt.section({
|
||||
name: 'tool:write',
|
||||
|
||||
@@ -75,6 +75,12 @@ function permissionDecisionOf(value: string | undefined): HookOutput['decision']
|
||||
* (`decision`/`reason`/`continue`/`stopReason`/`systemMessage`)
|
||||
* are unaffected. Omit `expectedEventName` (or pass a matching one) to apply the
|
||||
* block as-is — a caller that doesn't key by event opts out of the check.
|
||||
*
|
||||
* @param exitCode - the process exit code; `undefined` when the hook could not be spawned at all.
|
||||
* @param stdout - the captured stdout stream; consulted for structured JSON only on a 0 exit.
|
||||
* @param stderr - the captured stderr stream; becomes the blocking `reason` on exit 2.
|
||||
* @param expectedEventName - the event the hook is firing for; omit to apply a `hookSpecificOutput` block as-is.
|
||||
* @returns the dialect-neutral decoded outcome.
|
||||
*/
|
||||
export function parseHookOutput(exitCode: number | undefined, stdout: string, stderr: string, expectedEventName?: string): HookOutput {
|
||||
const trimmedErr = stderr.trim()
|
||||
|
||||
@@ -66,6 +66,9 @@ export const DEFAULT_STDERR_SUMMARY_MAX_CHARS = 500
|
||||
* `undefined` when empty, cut at `maxChars` with an ellipsis when over. The
|
||||
* bound is a parameter — like `runHook`'s `defaultTimeoutMs`, each bridge owns
|
||||
* the config default and passes it in.
|
||||
* @param stderr - the hook's raw captured stderr.
|
||||
* @param maxChars - the character cap for the summary (the bridge's config value).
|
||||
* @returns the trimmed, capped summary, or `undefined` when stderr is blank.
|
||||
*/
|
||||
export function summarizeStderr(stderr: string, maxChars: number): string | undefined {
|
||||
const t = stderr.trim()
|
||||
@@ -73,7 +76,11 @@ export function summarizeStderr(stderr: string, maxChars: number): string | unde
|
||||
return t.length > maxChars ? t.slice(0, maxChars) + '…' : t
|
||||
}
|
||||
|
||||
/** Append a `hook/invoked` provenance event to `session`. */
|
||||
/**
|
||||
* Append a `hook/invoked` provenance event to `session`.
|
||||
* @param session - the session whose open turn records the event.
|
||||
* @param invocation - the invocation identity; an absent `matcher` is omitted from the payload.
|
||||
*/
|
||||
export function appendHookInvoked(session: Session, invocation: HookInvocation): void {
|
||||
session.append('hook/invoked', {
|
||||
turn: invocation.turn,
|
||||
@@ -91,6 +98,8 @@ export function appendHookInvoked(session: Session, invocation: HookInvocation):
|
||||
* else `'pass'`; `stderrSummary` is the trimmed stderr truncated to
|
||||
* `record.stderrSummaryMaxChars` characters (omitted when empty); `exitCode`
|
||||
* is omitted when the hook never ran.
|
||||
* @param session - the session whose open turn records the event.
|
||||
* @param record - the outcome to record: the decoded output plus the summary cap and duration.
|
||||
*/
|
||||
export function appendHookResult(session: Session, record: HookResultRecord): void {
|
||||
const { output } = record
|
||||
|
||||
@@ -34,6 +34,10 @@ const CLAUDE_LITERAL = /^[A-Za-z0-9_|]+$/
|
||||
* pattern exact-matches the query (splitting `|` into alternatives); every other
|
||||
* `claude` pattern and ALL `codex` patterns are tested as an unanchored regex.
|
||||
* An invalid regex matches nothing (never throws).
|
||||
* @param matcher - the configured pattern; absent/empty/`'*'` are the match-all sentinels.
|
||||
* @param query - the candidate value (a tool name, a session source, …).
|
||||
* @param mode - the dialect deciding literal-vs-regex interpretation of the pattern.
|
||||
* @returns `true` when the pattern selects the query; `false` on a non-match or an invalid regex.
|
||||
*/
|
||||
export function matchesMatcher(matcher: string | undefined, query: string, mode: MatcherMode): boolean {
|
||||
if (isMatchAll(matcher)) return true
|
||||
|
||||
@@ -71,6 +71,8 @@ function decisionForRank(maxRank: number): MergedDecision {
|
||||
* into one {@link MergedHookOutcome} by the precedence rules above. An empty list
|
||||
* yields a neutral outcome (`decision: 'none'`, no stop, empty context) — the
|
||||
* caller treats that as "no hook had anything to say".
|
||||
* @param outputs - every matched hook's decoded output, in hook order.
|
||||
* @returns the single folded outcome the bridge maps onto its seam.
|
||||
*/
|
||||
export function mergeHookOutputs(outputs: HookOutput[]): MergedHookOutcome {
|
||||
let maxRank = 0
|
||||
|
||||
@@ -70,6 +70,11 @@ export interface RunHookResult {
|
||||
* `exitCode: undefined`, so the caller's merge logic treats it as a
|
||||
* non-blocking error rather than crashing the turn. `now` is injected for
|
||||
* testable durations.
|
||||
* @param bash - the executor seam the command runs through.
|
||||
* @param hook - the configured command; its `timeoutSec` (wire unit: seconds) overrides the default timeout.
|
||||
* @param options - the invocation's payload, env, cwd, signal, stdin framing, and default timeout.
|
||||
* @param now - millisecond clock used for the reported duration.
|
||||
* @returns the decoded output plus the run's wall-clock duration.
|
||||
*/
|
||||
export async function runHook(
|
||||
bash: BashExecutor,
|
||||
|
||||
@@ -43,7 +43,12 @@ function asObject(value: unknown): Record<string, unknown> | undefined {
|
||||
: undefined
|
||||
}
|
||||
|
||||
/** Apply `${CLAUDE_PLUGIN_ROOT}` / `${CLAUDE_PROJECT_DIR}` substitution to a command string. */
|
||||
/**
|
||||
* Apply `${CLAUDE_PLUGIN_ROOT}` / `${CLAUDE_PROJECT_DIR}` substitution to a command string.
|
||||
* @param command - the raw command from config.
|
||||
* @param vars - the substitution values; a token whose variable is unset stays verbatim.
|
||||
* @returns the command with every occurrence of each set token replaced.
|
||||
*/
|
||||
export function substituteCommand(command: string, vars: SubstitutionVars): string {
|
||||
let out = command
|
||||
if (vars.pluginRoot !== undefined) out = out.split('${CLAUDE_PLUGIN_ROOT}').join(vars.pluginRoot)
|
||||
@@ -57,6 +62,9 @@ export function substituteCommand(command: string, vars: SubstitutionVars): stri
|
||||
* Non-command hooks and malformed entries are dropped (recorded in `skipped` /
|
||||
* silently ignored) rather than throwing — a bad hook config must not crash boot.
|
||||
* `vars` are substituted into every surviving `command`.
|
||||
* @param raw - the parsed JSON config: a settings object with a `hooks` key, or the bare event map.
|
||||
* @param vars - substitution values applied to every surviving `command` (defaults to none).
|
||||
* @returns the runnable per-event groups plus the skipped non-command hooks.
|
||||
*/
|
||||
export function parseClaudeConfig(raw: unknown, vars: SubstitutionVars = {}): ParsedClaudeConfig {
|
||||
const config: ClaudeHookConfig = {}
|
||||
|
||||
@@ -41,6 +41,8 @@ function asObject(value: unknown): Record<string, unknown> | undefined {
|
||||
* `type !== 'command'` and `async: true` command hooks are skipped (recorded in
|
||||
* `skipped`). Malformed entries are ignored rather than thrown — a bad config
|
||||
* must not crash boot. No command substitution (Codex does none).
|
||||
* @param raw - the parsed JSON config: a `{ hooks: … }` wrapper or the bare event map.
|
||||
* @returns the runnable per-event groups plus the skipped hooks with their reasons.
|
||||
*/
|
||||
export function parseCodexConfig(raw: unknown): ParsedCodexConfig {
|
||||
const config: CodexHookConfig = {}
|
||||
|
||||
@@ -13,7 +13,9 @@ import { parseSse } from './sse.ts'
|
||||
import { translate } from './translate.ts'
|
||||
import type { WireError } from './types.ts'
|
||||
|
||||
/** Constructor options for {@link DeepSeekAdapter}; the plugin's `apply` resolves them from Config + environment. */
|
||||
export interface DeepSeekAdapterOptions {
|
||||
/** Bearer token sent in the `authorization` header on every request. */
|
||||
apiKey: string
|
||||
/** Endpoint base; `/chat/completions` is appended. */
|
||||
baseURL: string
|
||||
@@ -21,7 +23,11 @@ export interface DeepSeekAdapterOptions {
|
||||
defaults?: RequestDefaults
|
||||
}
|
||||
|
||||
/** Map an HTTP status to a stable LlmError code. */
|
||||
/**
|
||||
* Map an HTTP status to a stable LlmError code.
|
||||
* @param status - status of a non-2xx provider response.
|
||||
* @returns `AUTH` (401/403), `RATE_LIMIT` (429), `INVALID_REQUEST` (400), `SERVER` (5xx), or `HTTP_<status>` for anything else.
|
||||
*/
|
||||
export function httpErrorCode(status: number): string {
|
||||
if (status === 401 || status === 403) return 'AUTH'
|
||||
if (status === 429) return 'RATE_LIMIT'
|
||||
|
||||
@@ -34,6 +34,12 @@ export type * from './types.ts'
|
||||
export const name = 'llm-deepseek'
|
||||
export const inject = ['llm']
|
||||
|
||||
/**
|
||||
* Plugin config, validated by the same-named schemastery schema. Every field
|
||||
* is optional in yml: credentials/endpoint fall back to the environment (a
|
||||
* missing API key fails plugin load, not the first call), and omitted
|
||||
* thinking fields send nothing on the wire, so the provider default applies.
|
||||
*/
|
||||
export interface Config {
|
||||
/** API key; falls back to $DEEPSEEK_API_KEY. Required one way or the other. */
|
||||
apiKey?: string
|
||||
|
||||
@@ -66,6 +66,8 @@ function serializeAssistant(message: Message): WireMessage {
|
||||
* `{role: 'tool'}` messages; the harness puts each tool result in its own
|
||||
* user-role message, so a mixed user message contributes its text first and
|
||||
* its tool results as separate wire messages after.
|
||||
* @param messages - the harness conversation, in order.
|
||||
* @returns the wire messages; order preserved, each tool result expanded into its own entry.
|
||||
*/
|
||||
export function serializeMessages(messages: Message[]): WireMessage[] {
|
||||
const wire: WireMessage[] = []
|
||||
@@ -97,7 +99,14 @@ export function serializeMessages(messages: Message[]): WireMessage[] {
|
||||
return wire
|
||||
}
|
||||
|
||||
/** Build the full wire request. */
|
||||
/**
|
||||
* Build the full wire request. Always streaming (`stream: true`, usage
|
||||
* reporting on); optional fields are omitted rather than sent as null, so
|
||||
* provider defaults apply.
|
||||
* @param options - the harness request (model, history, system, tools, sampling).
|
||||
* @param defaults - adapter-level thinking defaults; undefined fields put nothing on the wire.
|
||||
* @returns the chat-completions request body.
|
||||
*/
|
||||
export function serializeRequest(options: GenerateOptions, defaults: RequestDefaults = {}): WireRequest {
|
||||
const messages: WireMessage[] = []
|
||||
if (options.system !== undefined) {
|
||||
|
||||
@@ -37,6 +37,8 @@ function eventData(block: string): string | undefined {
|
||||
* Parse a byte stream into SSE data payloads. Yields `[DONE]` as the final
|
||||
* value and returns; throws `LlmError('STREAM_CLOSED')` when the stream ends
|
||||
* without it (truncated response — the model call cannot be trusted).
|
||||
* @param stream - raw SSE bytes; reads may split anywhere, including mid-UTF-8 sequence.
|
||||
* @returns each event's data payload in arrival order, the `[DONE]` sentinel last.
|
||||
*/
|
||||
export async function* parseSse(stream: AsyncIterable<Uint8Array>): AsyncGenerator<string> {
|
||||
const decoder = new TextDecoder()
|
||||
|
||||
@@ -29,7 +29,11 @@ interface OpenBlock {
|
||||
name?: string
|
||||
}
|
||||
|
||||
/** Map the wire finish_reason vocabulary to the harness FinishReason. */
|
||||
/**
|
||||
* Map the wire finish_reason vocabulary to the harness FinishReason.
|
||||
* @param reason - the wire `finish_reason` string.
|
||||
* @returns the mapped reason; unrecognized values (content_filter, …) become `{kind: 'error'}` with the uppercased value as `code`.
|
||||
*/
|
||||
export function mapFinishReason(reason: string): FinishReason {
|
||||
switch (reason) {
|
||||
case 'stop': return { kind: 'stop' }
|
||||
@@ -46,6 +50,8 @@ export function mapFinishReason(reason: string): FinishReason {
|
||||
* (`prompt_tokens = prompt_cache_hit_tokens + prompt_cache_miss_tokens`,
|
||||
* api/create-chat-completion); the harness TokenUsage convention is
|
||||
* DISJOINT counts, so cache reads are subtracted out of `inputTokens`.
|
||||
* @param usage - wire usage from the finish chunk or the trailing usage-only chunk.
|
||||
* @returns disjoint harness counts; cache/reasoning fields present only when the wire reported them.
|
||||
*/
|
||||
export function mapUsage(usage: WireUsage): TokenUsage {
|
||||
const cacheRead = usage.prompt_tokens_details?.cached_tokens ?? usage.prompt_cache_hit_tokens
|
||||
@@ -75,6 +81,8 @@ function closeBlock(block: OpenBlock): ContentBlock {
|
||||
/**
|
||||
* Consume SSE data payloads (ending with `[DONE]`) and yield StreamChunks.
|
||||
* Malformed JSON payloads abort the stream with `MALFORMED_RESPONSE`.
|
||||
* @param payloads - SSE data payloads from {@link parseSse}, `[DONE]`-terminated.
|
||||
* @returns deltas as they arrive; `block-end`s, `usage`, and `finish` are all deferred to the `[DONE]` sentinel.
|
||||
*/
|
||||
export async function* translate(payloads: AsyncIterable<string>): AsyncGenerator<StreamChunk> {
|
||||
let nextIndex = 0
|
||||
|
||||
@@ -48,12 +48,18 @@ export interface WireToolMessage {
|
||||
content: string
|
||||
}
|
||||
|
||||
/** One entry of the request `messages` array, discriminated on `role`. */
|
||||
export type WireMessage =
|
||||
| WireSystemMessage
|
||||
| WireUserMessage
|
||||
| WireAssistantMessage
|
||||
| WireToolMessage
|
||||
|
||||
/**
|
||||
* Assistant-role history message. The harness replays `content: ""` (never
|
||||
* null) on tool-call-only turns — some gateways reject null — and sends null
|
||||
* only when the turn carried neither text nor tool calls.
|
||||
*/
|
||||
export interface WireAssistantMessage {
|
||||
role: 'assistant'
|
||||
content: string | null
|
||||
@@ -66,12 +72,14 @@ export interface WireAssistantMessage {
|
||||
tool_calls?: WireToolCall[]
|
||||
}
|
||||
|
||||
/** A completed tool call replayed on an assistant history message; `arguments` is the raw JSON string. */
|
||||
export interface WireToolCall {
|
||||
id: string
|
||||
type: 'function'
|
||||
function: { name: string; arguments: string }
|
||||
}
|
||||
|
||||
/** One entry of the request `tools` array; `parameters` is a JSON Schema object. */
|
||||
export interface WireTool {
|
||||
type: 'function'
|
||||
function: {
|
||||
@@ -88,11 +96,13 @@ export interface WireChunk {
|
||||
usage?: WireUsage | null
|
||||
}
|
||||
|
||||
/** One streamed choice (requests always ask for a single one); `finish_reason` is non-null only on its terminal chunk. */
|
||||
export interface WireChoice {
|
||||
delta?: WireDelta
|
||||
finish_reason?: string | null
|
||||
}
|
||||
|
||||
/** The incremental content of one streamed choice; any subset of fields may be present per chunk. */
|
||||
export interface WireDelta {
|
||||
role?: string
|
||||
/** Visible text. Null/empty on reasoning/tool-call chunks. */
|
||||
@@ -105,6 +115,7 @@ export interface WireDelta {
|
||||
tool_calls?: WireToolCallDelta[]
|
||||
}
|
||||
|
||||
/** A streamed fragment of one tool call; fragments sharing an `index` concatenate into one call. */
|
||||
export interface WireToolCallDelta {
|
||||
/** Disambiguates parallel tool calls; stable across a call's deltas. */
|
||||
index: number
|
||||
@@ -119,6 +130,13 @@ export interface WireToolCallDelta {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire token accounting. `prompt_tokens` INCLUDES cache hits (it equals
|
||||
* `prompt_cache_hit_tokens + prompt_cache_miss_tokens`); `mapUsage` subtracts
|
||||
* them to keep the harness convention of disjoint counts.
|
||||
* `prompt_tokens_details.cached_tokens` is the OpenAI-compat spelling of the
|
||||
* hit count.
|
||||
*/
|
||||
export interface WireUsage {
|
||||
prompt_tokens: number
|
||||
completion_tokens: number
|
||||
|
||||
@@ -21,14 +21,22 @@ import { toPiContext, toStreamChunks } from './convert.ts'
|
||||
/** Reasoning levels surfaced by this adapter (DeepSeek wire: high|max). */
|
||||
export type PiAiReasoning = 'off' | 'high' | 'xhigh'
|
||||
|
||||
/** Constructor options for {@link PiAiAdapter}; the plugin's `apply` resolves them from Config + environment. */
|
||||
export interface PiAiAdapterOptions {
|
||||
/** Bearer token pi-ai sends on every request. */
|
||||
apiKey: string
|
||||
/** Endpoint base; `/chat/completions` is appended. */
|
||||
baseURL: string
|
||||
/** Thinking level applied to every request ('off' disables thinking). */
|
||||
reasoning?: PiAiReasoning | undefined
|
||||
}
|
||||
|
||||
/** Build the inline pi-ai model descriptor for one DeepSeek model name. */
|
||||
/**
|
||||
* Build the inline pi-ai model descriptor for one DeepSeek model name.
|
||||
* @param modelId - harness model name; sent verbatim on the wire.
|
||||
* @param options - adapter options; only `baseURL` is read here (key and reasoning apply per request, not per descriptor).
|
||||
* @returns a descriptor with every DeepSeek compat flag explicit — pi-ai's URL-based auto-detection is never relied on.
|
||||
*/
|
||||
export function buildModel(modelId: string, options: PiAiAdapterOptions): Model<'openai-completions'> {
|
||||
return {
|
||||
id: modelId,
|
||||
|
||||
@@ -55,6 +55,8 @@ function parseArguments(raw: string): Record<string, unknown> {
|
||||
* NAME (pi-ai's `toolName`), which the harness doesn't carry on the result
|
||||
* block — it is recovered from the preceding assistant tool-call with the
|
||||
* same id.
|
||||
* @param options - the harness request; `options.system` maps to pi-ai's single `systemPrompt` slot.
|
||||
* @returns the pi-ai context; `tools` is omitted entirely when the request declares none.
|
||||
*/
|
||||
export function toPiContext(options: GenerateOptions): PiContext {
|
||||
const toolNames = new Map<CallId, string>()
|
||||
@@ -159,7 +161,11 @@ function emptyPiUsage(): PiUsage {
|
||||
}
|
||||
}
|
||||
|
||||
/** Map pi-ai usage (reasoning folded into output by pi-ai). */
|
||||
/**
|
||||
* Map pi-ai usage (reasoning folded into output by pi-ai).
|
||||
* @param usage - cumulative usage from the terminal pi-ai event.
|
||||
* @returns harness counts; cache fields appear only when non-zero (pi-ai reports zeros, not absence).
|
||||
*/
|
||||
export function mapUsage(usage: PiUsage): TokenUsage {
|
||||
return {
|
||||
inputTokens: usage.input,
|
||||
@@ -177,7 +183,11 @@ function classifyPiAiError(message: string): string {
|
||||
return 'PI_AI_ERROR'
|
||||
}
|
||||
|
||||
/** Map a terminal pi-ai event to the harness finish reason. */
|
||||
/**
|
||||
* Map a terminal pi-ai event to the harness finish reason.
|
||||
* @param message - the assistant message carried by the `done` or `error` event.
|
||||
* @returns the harness reason; `error` yields `{kind: 'error'}` with a code classified from the error text.
|
||||
*/
|
||||
export function mapStopReason(message: AssistantMessage): FinishReason {
|
||||
switch (message.stopReason) {
|
||||
case 'stop': return { kind: 'stop' }
|
||||
@@ -195,6 +205,9 @@ export function mapStopReason(message: AssistantMessage): FinishReason {
|
||||
* Translate the pi-ai event stream into StreamChunks. pi-ai never throws
|
||||
* mid-stream — failures arrive as `error` events, which become error/aborted
|
||||
* `finish` chunks (the harness protocol's other error-delivery style).
|
||||
* @param events - one assistant turn's pi-ai event stream.
|
||||
* @returns the harness chunks, ending with `usage` then `finish`; throws
|
||||
* `LlmError` (`STREAM_CLOSED`) if the source ends without a terminal event.
|
||||
*/
|
||||
export async function* toStreamChunks(events: AsyncIterable<AssistantMessageEvent>): AsyncGenerator<StreamChunk> {
|
||||
// pi-ai contentIndex ↔ our block index map 1:1 (both count blocks from 0
|
||||
|
||||
@@ -29,6 +29,11 @@ export { mapStopReason, mapUsage, toPiContext, toStreamChunks } from './convert.
|
||||
export const name = 'llm-pi-ai'
|
||||
export const inject = ['llm']
|
||||
|
||||
/**
|
||||
* Plugin config, validated by the same-named schemastery schema. Every field
|
||||
* is optional in yml: credentials/endpoint fall back to the environment (a
|
||||
* missing API key fails plugin load, not the first call).
|
||||
*/
|
||||
export interface Config {
|
||||
/** API key; falls back to $DEEPSEEK_API_KEY. Required one way or the other. */
|
||||
apiKey?: string
|
||||
|
||||
@@ -40,6 +40,8 @@ export class BlockAssembler {
|
||||
/**
|
||||
* Feed one chunk. Returns the completed block when the chunk closes one
|
||||
* (an explicit `block-end`), otherwise undefined.
|
||||
* @param chunk - the next raw chunk, in stream order.
|
||||
* @returns the authoritative block from the first `block-end` at its index; undefined for every other chunk.
|
||||
*/
|
||||
push(chunk: StreamChunk): ContentBlock | undefined {
|
||||
switch (chunk.type) {
|
||||
@@ -123,20 +125,29 @@ export class BlockAssembler {
|
||||
return partial
|
||||
}
|
||||
|
||||
/** Assemble all blocks seen so far, in stream order. */
|
||||
/**
|
||||
* Assemble all blocks seen so far, in stream order.
|
||||
* @returns one block per seen index; an open block assembles from its
|
||||
* accumulated deltas (an unknown block type never closed by `block-end` throws).
|
||||
*/
|
||||
blocks(): ContentBlock[] {
|
||||
return this.order.map(index => this.assemble(this.mustGet(index), index))
|
||||
}
|
||||
|
||||
/** Usage from the `usage` chunk; undefined until one arrives. */
|
||||
get usage(): TokenUsage | undefined {
|
||||
return this._usage
|
||||
}
|
||||
|
||||
/** Finish reason from the `finish` chunk; `{kind: 'stop'}` when the stream ended without one. */
|
||||
get finish(): FinishReason {
|
||||
return this._finish ?? { kind: 'stop' }
|
||||
}
|
||||
|
||||
/** The assembled assistant message. */
|
||||
/**
|
||||
* The assembled assistant message.
|
||||
* @returns an assistant-role message over `blocks()` (same open-block assembly rules).
|
||||
*/
|
||||
message(): Message {
|
||||
return { role: 'assistant', content: this.blocks() }
|
||||
}
|
||||
|
||||
@@ -54,6 +54,8 @@ export const APP_IDENTITY: AppIdentity = {
|
||||
* The standard `User-Agent` value: `product/version (+url)`. The
|
||||
* parenthesized `+url` comment is the conventional self-identification form
|
||||
* (RFC 9110 §10.1.5 product + comment syntax).
|
||||
* @param identity - the identity to render; defaults to {@link APP_IDENTITY}.
|
||||
* @returns the ready-to-send header value.
|
||||
*/
|
||||
export function userAgent(identity: AppIdentity = APP_IDENTITY): string {
|
||||
return `${identity.product}/${identity.version} (+${identity.url})`
|
||||
@@ -63,6 +65,8 @@ export function userAgent(identity: AppIdentity = APP_IDENTITY): string {
|
||||
* Build the attribution headers an adapter must send on every provider
|
||||
* request. Header names are lowercase (HTTP field names are case-insensitive
|
||||
* on the wire).
|
||||
* @param identity - the identity to send; defaults to {@link APP_IDENTITY} — omission cannot suppress attribution.
|
||||
* @returns headers to merge into the provider request (currently just `user-agent`).
|
||||
*/
|
||||
export function attributionHeaders(
|
||||
identity: AppIdentity = APP_IDENTITY,
|
||||
|
||||
@@ -17,7 +17,11 @@ import type { Branded } from '@deepseek-ai/dsh-brand'
|
||||
*/
|
||||
export type CallId = Branded<'CallId'>
|
||||
|
||||
/** Brand a string as a {@link CallId}. */
|
||||
/**
|
||||
* Brand a string as a {@link CallId}.
|
||||
* @param id - the provider-issued (or synthesized) call id.
|
||||
* @returns the same string, branded; no validation is performed.
|
||||
*/
|
||||
export function CallId(id: string): CallId {
|
||||
return id as CallId
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
* `ErrorOptions`. `name` defaults to the subclass constructor name.
|
||||
*/
|
||||
export class HarnessError extends Error {
|
||||
/** Stable machine-routable failure class (e.g. `RATE_LIMIT`); route on this, never by parsing `message`. */
|
||||
readonly code: string
|
||||
|
||||
constructor(message: string, code: string, options?: ErrorOptions) {
|
||||
@@ -27,7 +28,11 @@ export class HarnessError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/** Narrow an arbitrary thrown value to a HarnessError (for `instanceof` at seams). */
|
||||
/**
|
||||
* Narrow an arbitrary thrown value to a HarnessError (for `instanceof` at seams).
|
||||
* @param value - the caught value (`unknown` in catch clauses).
|
||||
* @returns true only for real instances; duck-typed or cross-realm errors do not narrow.
|
||||
*/
|
||||
export function isHarnessError(value: unknown): value is HarnessError {
|
||||
return value instanceof HarnessError
|
||||
}
|
||||
|
||||
@@ -73,7 +73,11 @@ export class LlmError extends HarnessError {
|
||||
* same value to the wire.
|
||||
*/
|
||||
export abstract class LlmAdapter {
|
||||
/** Stream one model call as raw chunks. The only required method. */
|
||||
/**
|
||||
* Stream one model call as raw chunks. The only required method.
|
||||
* @param options - the fully-assembled request; implementations must honor `options.signal`.
|
||||
* @returns the chunk stream, obeying the adapter contract documented on `StreamChunk`.
|
||||
*/
|
||||
abstract stream(options: GenerateOptions): AsyncIterable<StreamChunk>
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,9 @@
|
||||
* variant was added without updating the switch (compile error at the call
|
||||
* site — the desired outcome) or a value escaped its type (runtime throw
|
||||
* with diagnostics — the safety net).
|
||||
* @param value - the impossible value; typed `never` so an unhandled variant fails compilation at the call site.
|
||||
* @param context - optional label (e.g. the switch site) prefixed into the throw message.
|
||||
* @returns never — it always throws, with the offending value JSON-rendered in the message.
|
||||
*/
|
||||
export function assertNever(value: never, context?: string): never {
|
||||
// JSON.stringify is typed string but returns undefined for undefined input;
|
||||
|
||||
@@ -70,7 +70,9 @@ export interface ContentBlockMap {
|
||||
'tool-result': ToolResultBlock
|
||||
}
|
||||
|
||||
/** The block `type` tag vocabulary; widens as plugins merge new shapes into {@link ContentBlockMap}. */
|
||||
export type ContentBlockType = keyof ContentBlockMap
|
||||
/** Any known content block, derived from {@link ContentBlockMap}; switch on `type` and fall through unknowns (merge-extensible). */
|
||||
export type ContentBlock = ContentBlockMap[ContentBlockType]
|
||||
|
||||
/** A single message in a conversation history. */
|
||||
@@ -88,6 +90,7 @@ export interface MessageSourceMap {
|
||||
plugin: { kind: 'plugin'; plugin: string }
|
||||
}
|
||||
|
||||
/** Any known message source, derived from {@link MessageSourceMap}; switch on `kind` and fall through unknowns (merge-extensible). */
|
||||
export type MessageSource = MessageSourceMap[keyof MessageSourceMap]
|
||||
|
||||
/**
|
||||
@@ -102,6 +105,7 @@ export interface FinishReasonMap {
|
||||
'error': { kind: 'error'; message: string; code?: string }
|
||||
}
|
||||
|
||||
/** Any known finish reason, derived from {@link FinishReasonMap}; switch on `kind` and fall through unknowns (merge-extensible). */
|
||||
export type FinishReason = FinishReasonMap[keyof FinishReasonMap]
|
||||
|
||||
/**
|
||||
|
||||
@@ -27,7 +27,11 @@ export interface HeaderLine {
|
||||
seedLength?: number
|
||||
}
|
||||
|
||||
/** Build the header line object from a {@link SessionHeader}. */
|
||||
/**
|
||||
* Build the header line object from a {@link SessionHeader}.
|
||||
* @param header - the immutable session metadata to serialize.
|
||||
* @returns the `type: 'session'`-tagged line object, absent optional fields omitted (never null).
|
||||
*/
|
||||
export function toHeaderLine(header: SessionHeader): HeaderLine {
|
||||
return {
|
||||
type: 'session',
|
||||
@@ -40,7 +44,11 @@ export function toHeaderLine(header: SessionHeader): HeaderLine {
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse a header line back into a {@link SessionHeader}. */
|
||||
/**
|
||||
* Parse a header line back into a {@link SessionHeader}.
|
||||
* @param line - the shape-checked first line of a log (see the `isHeaderLine` guard).
|
||||
* @returns the header, absent optional fields omitted.
|
||||
*/
|
||||
export function fromHeaderLine(line: HeaderLine): SessionHeader {
|
||||
return {
|
||||
version: line.version,
|
||||
@@ -77,6 +85,8 @@ function isHeaderLine(value: unknown): value is HeaderLine {
|
||||
* `Buffer.from(…, 'utf8')` would do, breaking injectivity). `.` is in the safe
|
||||
* set for readability but the whole-segment tokens `.`/`..` are escaped so they
|
||||
* can never traverse.
|
||||
* @param raw - the string to encode; must be non-empty (throws on `''`).
|
||||
* @returns the escaped single path segment, decodable back to `raw`.
|
||||
*/
|
||||
export function encodeSegment(raw: string): string {
|
||||
if (raw.length === 0) throw new Error('cannot encode an empty path segment')
|
||||
@@ -97,9 +107,12 @@ export function encodeSegment(raw: string): string {
|
||||
|
||||
/**
|
||||
* The directory a session's files live in: the configured root, then a per-cwd
|
||||
* subdirectory so sessions group by project. The cwd subdir is a stable hash
|
||||
* (short, collision-resistant, filesystem-safe) plus an encoded suffix for
|
||||
* readability; sessions without a cwd go in a shared `_no-cwd` bucket.
|
||||
* subdirectory so sessions group by project. The cwd subdir is a stable hash of
|
||||
* the cwd (short, collision-resistant, filesystem-safe); sessions without a
|
||||
* cwd go in a shared `_no-cwd` bucket.
|
||||
* @param root - the backend's session root directory.
|
||||
* @param cwd - the session's project directory; `undefined` selects the shared `_no-cwd` bucket.
|
||||
* @returns the per-cwd bucket directory path under `root`.
|
||||
*/
|
||||
export function sessionDir(root: string, cwd: string | undefined): string {
|
||||
if (cwd === undefined) return join(root, '_no-cwd')
|
||||
@@ -107,12 +120,22 @@ export function sessionDir(root: string, cwd: string | undefined): string {
|
||||
return join(root, `cwd-${hash}`)
|
||||
}
|
||||
|
||||
/** The append-only event-log file path for a session. */
|
||||
/**
|
||||
* The append-only event-log file path for a session.
|
||||
* @param root - the backend's session root directory.
|
||||
* @param cwd - the session's project directory (picks the per-cwd bucket; `undefined` → `_no-cwd`).
|
||||
* @param id - the session id, path-encoded via {@link encodeSegment} before filesystem use.
|
||||
* @returns the session's `.jsonl` log file path.
|
||||
*/
|
||||
export function logPath(root: string, cwd: string | undefined, id: SessionId): string {
|
||||
return join(sessionDir(root, cwd), `${encodeSegment(id)}.jsonl`)
|
||||
}
|
||||
|
||||
/** Serialize one event as a JSONL line (no trailing newline). */
|
||||
/**
|
||||
* Serialize one event as a JSONL line (no trailing newline).
|
||||
* @param event - the event to serialize verbatim.
|
||||
* @returns the event's single-line JSON text; the writer adds the newline.
|
||||
*/
|
||||
export function eventLine(event: SessionEvent): string {
|
||||
return JSON.stringify(event)
|
||||
}
|
||||
@@ -135,6 +158,9 @@ export function eventLine(event: SessionEvent): string {
|
||||
* This relies on the session-log invariant that every event lives inside a turn
|
||||
* (`Session.append` enforces it): only the final turn can be open, so the
|
||||
* preserved tail is at most one unclosed turn.
|
||||
* @param buffer - the raw bytes of the log file (header line first).
|
||||
* @returns the header, the preserved event prefix, and `committedBytes` — the
|
||||
* byte offset the next append truncates any torn tail to.
|
||||
*/
|
||||
export function scanLog(buffer: Buffer): { meta: SessionHeader; events: SessionEvent[]; committedBytes: number } {
|
||||
const text = buffer.toString('utf8')
|
||||
@@ -239,6 +265,8 @@ export function scanLog(buffer: Buffer): { meta: SessionHeader; events: SessionE
|
||||
* `undefined` if it is missing/not a header. Used by `list()` to read session
|
||||
* metadata WITHOUT parsing the whole log: a session picker scales with the
|
||||
* number of sessions, not the total size of every conversation.
|
||||
* @param firstLine - the first line of a log file (without its trailing newline).
|
||||
* @returns the parsed header, or `undefined` when the line is not a well-formed session header.
|
||||
*/
|
||||
export function parseHeaderMeta(firstLine: string): SessionHeader | undefined {
|
||||
let parsed: unknown
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
encodeSegment, eventLine, logPath, parseHeaderMeta, scanLog, sessionDir, toHeaderLine,
|
||||
} from './format.ts'
|
||||
|
||||
/** Plugin config: where the JSONL backend keeps its session logs (`root` is required — no default). */
|
||||
export interface Config {
|
||||
/**
|
||||
* Root directory for all session files. Required (no default): a default of
|
||||
|
||||
@@ -76,6 +76,9 @@ export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist'
|
||||
* is the merged layout carrying every column; bumping past the collided v3
|
||||
* makes the version check reject both sibling v3 databases instead of opening
|
||||
* one against columns it does not have.
|
||||
* @param path - the SQLite database file to open (created when absent).
|
||||
* @param journalMode - the journal pragma to apply — a closed in-code union, validated by the plugin Config.
|
||||
* @returns the open handle with pragmas applied and both tables ensured.
|
||||
*/
|
||||
export function openDatabase(path: string, journalMode: JournalMode): DatabaseSync {
|
||||
const db = new DatabaseSync(path)
|
||||
@@ -120,7 +123,11 @@ export function openDatabase(path: string, journalMode: JournalMode): DatabaseSy
|
||||
return db
|
||||
}
|
||||
|
||||
/** Reconstruct the {@link SessionHeader} from a `sessions` row. */
|
||||
/**
|
||||
* Reconstruct the {@link SessionHeader} from a `sessions` row.
|
||||
* @param row - the `sessions` table row.
|
||||
* @returns the header, `NULL` columns mapped to omitted optional fields.
|
||||
*/
|
||||
export function rowToMeta(row: SessionRow): SessionHeader {
|
||||
return {
|
||||
version: row.version,
|
||||
@@ -132,7 +139,12 @@ export function rowToMeta(row: SessionRow): SessionHeader {
|
||||
}
|
||||
}
|
||||
|
||||
/** Reconstruct a {@link SessionEvent} from an `events` row (parses `data`). */
|
||||
/**
|
||||
* Reconstruct a {@link SessionEvent} from an `events` row (parses `data`).
|
||||
* @param row - the `events` table row; `data` and the surface columns hold JSON text.
|
||||
* @returns the reconstructed event; throws when a JSON column fails to parse
|
||||
* ({@link scanRows} treats that as a hole, not corruption, in the tail).
|
||||
*/
|
||||
export function rowToEvent(row: EventRow): SessionEvent {
|
||||
// Surface-metadata fields are conditional on the event type in the type
|
||||
// system; spread them so each variant gets only the fields it declares.
|
||||
@@ -172,6 +184,9 @@ export function rowToEvent(row: EventRow): SessionEvent {
|
||||
* This relies on the session-log invariant that every event lives inside a turn
|
||||
* (`Session.append` enforces it): only the final turn can be open, so the
|
||||
* preserved tail is at most one unclosed turn.
|
||||
* @param rows - one session's event rows, ordered by seq ascending.
|
||||
* @returns the preserved event prefix, plus `tornFrom` — the seq the physical
|
||||
* delete starts at — when a torn tail exists.
|
||||
*/
|
||||
export function scanRows(rows: readonly EventRow[]): { preserved: SessionEvent[]; tornFrom?: number } {
|
||||
// Pass 1: parse each row's data; a row whose data is not valid JSON is a hole.
|
||||
|
||||
@@ -186,6 +186,7 @@ export class PersistenceCoordinator<TornMarker = unknown> {
|
||||
/**
|
||||
* Register a new session's metadata (lazy: no physical write until the first
|
||||
* {@link append}). Rejects if the id is already tracked or already persisted.
|
||||
* @param meta - the immutable header (id, version, cwd, lineage) to record; snapshotted at call time.
|
||||
*/
|
||||
create(meta: SessionHeader): Promise<void> {
|
||||
// Snapshot the metadata at call time: the op runs later (behind the
|
||||
@@ -216,6 +217,8 @@ export class PersistenceCoordinator<TornMarker = unknown> {
|
||||
/**
|
||||
* Durably persist a batch of events. Honors the append-only and contiguous-seq
|
||||
* contracts; rejects non-JSON-serializable `event.data`.
|
||||
* @param id - the session the batch belongs to.
|
||||
* @param events - the contiguous batch to persist, in seq order; deep-cloned at call time.
|
||||
*/
|
||||
async append(id: SessionId, events: readonly SessionEvent[]): Promise<void> {
|
||||
// Validate serializability BEFORE cloning so a bad event surfaces the typed
|
||||
@@ -252,6 +255,8 @@ export class PersistenceCoordinator<TornMarker = unknown> {
|
||||
* Reload a session: its {@link SessionHeader} plus the event log up to the last
|
||||
* durable checkpoint, with any interrupted final turn durably closed (synthetic
|
||||
* boundary events) during load.
|
||||
* @param id - the persisted session to reload.
|
||||
* @returns the header plus the event log, ending on a balanced `turn/end`.
|
||||
*/
|
||||
load(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
|
||||
return this.serialize(id, () => this.loadCore(id))
|
||||
|
||||
@@ -45,6 +45,9 @@ declare module 'cordis' {
|
||||
*
|
||||
* The comparison includes the full event payload, not just seq/type/time, so a
|
||||
* mutated seed cannot be grafted onto a durable log with the same envelope.
|
||||
* @param seed - the live session's creation-time event snapshot.
|
||||
* @param prefix - the persisted prefix the seed must reproduce.
|
||||
* @returns `true` when the prefix fits within the seed and every event matches by JSON text.
|
||||
*/
|
||||
export function seedCoversPrefix(seed: readonly SessionEvent[], prefix: readonly SessionEvent[]): boolean {
|
||||
return prefix.length <= seed.length
|
||||
@@ -58,6 +61,7 @@ export function seedCoversPrefix(seed: readonly SessionEvent[], prefix: readonly
|
||||
* Reject non-JSON-serializable event data before a backend serializes a batch.
|
||||
* Live session appends already enforce this; persistence append paths also
|
||||
* accept replay/fork batches that may bypass a live session instance.
|
||||
* @param events - the batch to validate; throws naming the offending event's type and seq.
|
||||
*/
|
||||
export function assertSerializable(events: readonly SessionEvent[]): void {
|
||||
for (const event of events) {
|
||||
|
||||
@@ -121,7 +121,12 @@ export const DEFAULT_DISPOSE_GRACE_MS = 3_000
|
||||
*/
|
||||
export const SENSITIVE_ENV_PATTERN = /KEY|SECRET|TOKEN/i
|
||||
|
||||
/** The ambient env minus credential-shaped vars, plus the spec's explicit env. */
|
||||
/**
|
||||
* The ambient env minus credential-shaped vars, plus the spec's explicit env.
|
||||
* @param extra - explicit vars layered on top AFTER the scrub, so a
|
||||
* credential-shaped name supplied deliberately still reaches the child.
|
||||
* @returns the environment to spawn the child with.
|
||||
*/
|
||||
export function buildChildEnv(extra: Record<string, string>): NodeJS.ProcessEnv {
|
||||
const env: NodeJS.ProcessEnv = {}
|
||||
for (const [key, value] of Object.entries(process.env)) {
|
||||
@@ -130,7 +135,12 @@ export function buildChildEnv(extra: Record<string, string>): NodeJS.ProcessEnv
|
||||
return { ...env, ...extra }
|
||||
}
|
||||
|
||||
/** Map an ACP {@link StopReason} to a harness {@link SubagentStopReason}. */
|
||||
/**
|
||||
* Map an ACP {@link StopReason} to a harness {@link SubagentStopReason}.
|
||||
* @param reason - the terminal reason from the child's `session/prompt` response.
|
||||
* @returns the harness equivalent; `max_turn_requests` and any unknown future
|
||||
* variant map to `error`, so an unclean stop is never reported as `completed`.
|
||||
*/
|
||||
export function acpStopReason(reason: StopReason): SubagentStopReason {
|
||||
switch (reason) {
|
||||
case 'end_turn':
|
||||
@@ -155,12 +165,20 @@ export function acpStopReason(reason: StopReason): SubagentStopReason {
|
||||
}
|
||||
}
|
||||
|
||||
/** Collect the text of an ACP content block (non-text blocks contribute nothing). */
|
||||
/**
|
||||
* Collect the text of an ACP content block (non-text blocks contribute nothing).
|
||||
* @param content - the content block off a streamed `agent_message_chunk`.
|
||||
* @returns the block's text, or `''` for a non-text block.
|
||||
*/
|
||||
export function acpContentText(content: AcpContentBlock): string {
|
||||
return content.type === 'text' ? content.text : ''
|
||||
}
|
||||
|
||||
/** Translate the harness prompt blocks into ACP prompt blocks (text only). */
|
||||
/**
|
||||
* Translate the harness prompt blocks into ACP prompt blocks (text only).
|
||||
* @param prompt - the harness prompt; non-text blocks are dropped.
|
||||
* @returns the ACP text blocks, in order.
|
||||
*/
|
||||
export function toAcpPrompt(prompt: ContentBlock[]): AcpContentBlock[] {
|
||||
const blocks: AcpContentBlock[] = []
|
||||
for (const block of prompt) {
|
||||
@@ -206,6 +224,11 @@ function exitsWithin(child: ChildProcess, ms: number): Promise<boolean> {
|
||||
* failure (a spawn/transport/RPC error resolves with `stopReason: 'error'`), per
|
||||
* the seam contract. `cancel()` sends `session/cancel`; `dispose()` kills the
|
||||
* subprocess and awaits its exit (quiescent teardown).
|
||||
* @param request - the start request; the driver consumes `prompt` and `signal`
|
||||
* (an already-aborted signal yields an inert `aborted` run with no spawn).
|
||||
* @param spec - the resolved spawn spec: command/args/cwd, env, permission
|
||||
* policy, dispose graces, and the optional error sink.
|
||||
* @returns the live run handle for the child subprocess.
|
||||
*/
|
||||
export function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpec): SubagentRun {
|
||||
const id = AgentId(randomUUID())
|
||||
|
||||
@@ -47,6 +47,8 @@ export const Config: z<Config> = z.object({
|
||||
* empty — i.e. fresh — child). The result is contiguous from seq 0 (the live
|
||||
* log keeps `seq === index`), so it is a valid session seed; the in-flight,
|
||||
* unbalanced turn is dropped so the invariants replay accepts it.
|
||||
* @param parent - the agent whose session log to slice.
|
||||
* @returns the seed events, contiguous from seq 0; empty when no turn has completed.
|
||||
*/
|
||||
export function completedTurnPrefix(parent: Agent): SessionEvent[] {
|
||||
const events = parent.session.events
|
||||
|
||||
@@ -34,7 +34,11 @@ declare module '@deepseek-ai/dsh-agent' {
|
||||
}
|
||||
}
|
||||
|
||||
/** Read an agent's delegation depth (absent ⇒ a top-level agent, depth 0). */
|
||||
/**
|
||||
* Read an agent's delegation depth (absent ⇒ a top-level agent, depth 0).
|
||||
* @param agent - the agent whose options may carry `subagentDepth`.
|
||||
* @returns 0 for a top-level agent, its parent's depth + 1 for a subagent.
|
||||
*/
|
||||
export function depthOf(agent: Agent): number {
|
||||
return agent.options.subagentDepth ?? 0
|
||||
}
|
||||
@@ -88,6 +92,13 @@ export interface InProcessRunOptions {
|
||||
* the matching `turn/end.reason` the stop reason. `dispose()` delegates to the
|
||||
* factory's {@link AgentHandle.dispose} (stop loop → await quiescence → remove
|
||||
* session); `cancel()` cancels the child's in-flight turn.
|
||||
*
|
||||
* Throws {@link SubagentDepthError} before creating anything when the child's
|
||||
* depth (parent depth + 1) would exceed `request.maxDepth`.
|
||||
* @param ctx - the context whose `agents` factory creates and owns the child.
|
||||
* @param request - the start request (prompt, parent, signal, per-child options).
|
||||
* @param options - the backend's inputs: provider name plus the optional seed.
|
||||
* @returns the live run handle for the child agent.
|
||||
*/
|
||||
export function startInProcessRun(
|
||||
ctx: Context,
|
||||
|
||||
@@ -93,6 +93,7 @@ export interface SubagentStopReasonMap {
|
||||
refusal: 'refusal'
|
||||
}
|
||||
|
||||
/** The union over {@link SubagentStopReasonMap} — widens automatically as backends merge in variants. */
|
||||
export type SubagentStopReason = SubagentStopReasonMap[keyof SubagentStopReasonMap]
|
||||
|
||||
/**
|
||||
|
||||
@@ -128,6 +128,8 @@ export interface SessionScript {
|
||||
* Parse a session `.jsonl` buffer into its event list. Line 0 is the session
|
||||
* header (a `{type:'session',…}` record), every subsequent non-empty line is a
|
||||
* {@link SessionEvent}. The header is skipped; malformed lines fail loud.
|
||||
* @param text - the raw `.jsonl` file contents.
|
||||
* @returns every event after the header, in log order.
|
||||
*/
|
||||
export function parseSessionLog(text: string): SessionEvent[] {
|
||||
const lines = text.split('\n').filter(line => line.trim().length > 0)
|
||||
@@ -151,6 +153,8 @@ export function parseSessionLog(text: string): SessionEvent[] {
|
||||
* own model calls; absent ⇒ 0). A header missing a field falls back to a stable
|
||||
* default (`''` / `0` / `0`) rather than throwing: a no-model fixture is
|
||||
* header-only and still orders fine as the single (primary) script.
|
||||
* @param text - the raw `.jsonl` file contents (only the header line is read).
|
||||
* @returns the header's `id`, `createdAt`, and `seedLength`, defaulted when absent.
|
||||
*/
|
||||
export function parseSessionHeader(text: string): { id: string; createdAt: number; seedLength: number } {
|
||||
const firstLine = text.split('\n').find(line => line.trim().length > 0) ?? '{}'
|
||||
@@ -180,6 +184,8 @@ export function parseSessionHeader(text: string): { id: string; createdAt: numbe
|
||||
* sidecar with an explicit `throw` (or `hang`) entry instead. {@link
|
||||
* deriveReplayScript} throws, naming the offending `(turn, step)`, so a missing
|
||||
* override fails loud rather than silently replaying a thrown call as success.
|
||||
* @param events - the recorded session's events; only `assistant/chunk` is consulted.
|
||||
* @returns one `chunks` entry per recorded model call, in call order.
|
||||
*/
|
||||
export function deriveReplayScript(events: SessionEvent[]): ReplayEntry[] {
|
||||
const script: ReplayEntry[] = []
|
||||
@@ -218,6 +224,8 @@ export function deriveReplayScript(events: SessionEvent[]): ReplayEntry[] {
|
||||
* Fail-loud if the JSONL fixture is missing (the scenario was never recorded) —
|
||||
* never silently returns an empty script, so a coverage hole can't masquerade
|
||||
* as a passing replay.
|
||||
* @param config - the fixture paths; only `file` and `overrideFile` are consulted.
|
||||
* @returns the primary session's replay entries.
|
||||
*/
|
||||
export function loadReplayScript(config: ReplayConfig): ReplayEntry[] {
|
||||
if (config.overrideFile !== undefined && existsSync(config.overrideFile)) {
|
||||
@@ -245,6 +253,8 @@ export function loadReplayScript(config: ReplayConfig): ReplayEntry[] {
|
||||
* the parent issues the FIRST model call (it must stream before it can delegate
|
||||
* in the synchronous nested cut), so binding it to the first live session is
|
||||
* correct regardless of a timestamp tie.
|
||||
* @param config - the fixture paths: the primary log plus any recorded child logs.
|
||||
* @returns the primary script first, then the child scripts in bind order.
|
||||
*/
|
||||
export function loadSessionScripts(config: ReplayConfig): SessionScript[] {
|
||||
const primaryEntries = loadReplayScript(config)
|
||||
@@ -359,6 +369,9 @@ async function* replayEntry(entry: ReplayEntry, signal: AbortSignal | undefined)
|
||||
* Each per-session cursor advances synchronously at listener-invocation time
|
||||
* (not lazily inside the generator) so call ORDER within a session, not
|
||||
* iteration order, fixes the mapping.
|
||||
* @param ctx - the context whose `llm/stream` waterfall the listener short-circuits.
|
||||
* @param config - the resolved fixture paths (env-var defaulting is `apply`'s job).
|
||||
* @returns the `ctx.on` disposer that removes the listener.
|
||||
*/
|
||||
export function installLlmReplay(ctx: Context, config: ReplayConfig): () => void {
|
||||
const scripts = loadSessionScripts(config)
|
||||
@@ -412,6 +425,7 @@ export function installLlmReplay(ctx: Context, config: ReplayConfig): () => void
|
||||
export const name = 'llm-replay'
|
||||
export const inject = ['llm']
|
||||
|
||||
/** Plugin config: the {@link ReplayConfig} inputs, each defaulting to its `DSH_SNAPSHOT_*` env var in `apply`. */
|
||||
export interface Config {
|
||||
/** Override the fixture path; defaults to `$DSH_SNAPSHOT_FILE`. */
|
||||
file?: string
|
||||
|
||||
@@ -39,6 +39,8 @@ import type { ContentBlock as AcpContentBlock, StopReason } from '@agentclientpr
|
||||
* hook before any step ran — ACP has no "rejected" reason, and a
|
||||
* blocked prompt is, from the client's view, the prompt not being
|
||||
* carried out; `cancelled` is the closest legal wire reason)
|
||||
* @param reason - the harness turn-end reason to translate.
|
||||
* @returns the legal ACP wire value per the mapping above.
|
||||
*/
|
||||
export function turnEndToStopReason(reason: TurnEndReason): StopReason {
|
||||
switch (reason.kind) {
|
||||
@@ -71,6 +73,8 @@ export function turnEndToStopReason(reason: TurnEndReason): StopReason {
|
||||
* `reasoning` is surfaced via `agent_thought_chunk`
|
||||
* streaming rather than as a message block, and `tool-call`/`tool-result`
|
||||
* are handled by the tool-call update path.
|
||||
* @param block - the harness content block to translate.
|
||||
* @returns the ACP block, or `undefined` for a kind with no message-content mapping.
|
||||
*/
|
||||
export function harnessBlockToAcpContent(block: ContentBlock): AcpContentBlock | undefined {
|
||||
switch (block.type) {
|
||||
@@ -89,6 +93,8 @@ export function harnessBlockToAcpContent(block: ContentBlock): AcpContentBlock |
|
||||
* concatenated verbatim; resource links become explicit textual references so
|
||||
* baseline ACP clients can point at files without the bridge silently dropping
|
||||
* that context.
|
||||
* @param prompt - the ACP prompt blocks to flatten.
|
||||
* @returns the concatenated text, with resource links rendered as bracketed references.
|
||||
*/
|
||||
export function acpPromptToText(prompt: readonly AcpContentBlock[]): string {
|
||||
return prompt
|
||||
@@ -109,6 +115,8 @@ export function acpPromptToText(prompt: readonly AcpContentBlock[]): string {
|
||||
* Whether an ACP prompt contains content the bridge cannot accept. Baseline ACP
|
||||
* requires `text` and `resource_link`; richer inline payloads (`resource`,
|
||||
* image, audio, …) are rejected rather than silently dropped.
|
||||
* @param prompt - the ACP prompt blocks to inspect.
|
||||
* @returns `true` when any block is neither `text` nor `resource_link`.
|
||||
*/
|
||||
export function promptHasUnsupportedContent(prompt: readonly AcpContentBlock[]): boolean {
|
||||
return prompt.some(block => block.type !== 'text' && block.type !== 'resource_link')
|
||||
|
||||
@@ -701,6 +701,8 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
* Build per-agent options from the plugin config, omitting absent fields
|
||||
* (exactOptionalPropertyTypes: never assign `undefined` to an optional key).
|
||||
* Exported for unit coverage of both the present and absent branches.
|
||||
* @param config - the plugin config carrying the optional model name.
|
||||
* @returns the per-agent options, with `model` present only when configured.
|
||||
*/
|
||||
export function agentOptions(config: AcpConfig): { model?: string } {
|
||||
return {
|
||||
@@ -764,6 +766,16 @@ function validateMcpServers(params: { mcpServers?: unknown[] }): void {
|
||||
*
|
||||
* Other event types (turn/step boundaries, context/message, …) produce
|
||||
* no client update.
|
||||
* @param sessionId - the ACP session id stamped on every emitted notification.
|
||||
* @param event - the harness session event to translate.
|
||||
* @param notify - sink for each produced `session/update` notification; called
|
||||
* zero or more times per event (best-effort UI feed, never load-bearing).
|
||||
* @param presenter - resolves tool-owned render intent for tool events;
|
||||
* defaults to the generic-fallback {@link nullToolPresenter}.
|
||||
* @param terminal - the connection's terminal-rendering context; defaults to
|
||||
* disabled (the plain-text console-block fallback).
|
||||
* @param options - `includeUserMessages` (default `true`): live streaming
|
||||
* passes `false` so a prompt the client just sent is not echoed back.
|
||||
*/
|
||||
export function streamSessionEventUpdate(
|
||||
sessionId: SessionId,
|
||||
@@ -825,6 +837,8 @@ export function streamSessionEventUpdate(
|
||||
* harness status triple IS `PlanEntryStatus`). The ACP client REPLACES its whole
|
||||
* plan on each `plan` update, matching the harness's whole-list-replace
|
||||
* semantics, so no per-entry diffing is needed.
|
||||
* @param todos - the harness todo list (the whole list, not a diff).
|
||||
* @returns the ACP plan body, one entry per todo.
|
||||
*/
|
||||
export function todosToPlan(todos: TodoItem[]): Plan {
|
||||
return { entries: todos.map((todo): PlanEntry => ({ content: todo.content, priority: 'medium', status: todo.status })) }
|
||||
@@ -885,7 +899,16 @@ export class ToolPresenter {
|
||||
private readonly onError: (message: string) => void = () => {},
|
||||
) {}
|
||||
|
||||
/** Pending-state render intent for a `tool/call`; remembers `(name, args, card)` for the matching result. */
|
||||
/**
|
||||
* Pending-state render intent for a `tool/call`; remembers `(name, args, card)`
|
||||
* for the matching result.
|
||||
* @param callId - the call id the matching `tool/result` will look up.
|
||||
* @param name - the tool name, resolved against the registry for `presentCall`.
|
||||
* @param argsJson - the raw arguments JSON from the event; parsed for the view
|
||||
* (a non-JSON string is surfaced raw).
|
||||
* @returns the tool-owned view, or the generic fallback (title = tool name,
|
||||
* kind `other`, parsed args as raw input) when the tool defines none or threw.
|
||||
*/
|
||||
call(callId: CallId, name: string, argsJson: string): ToolCallView {
|
||||
const args = parseToolArguments(argsJson)
|
||||
let present: ToolCallView | undefined
|
||||
@@ -905,7 +928,18 @@ export class ToolPresenter {
|
||||
return view
|
||||
}
|
||||
|
||||
/** Completed-state render intent for a `tool/result`; consumes the remembered `(name, args, card)`. */
|
||||
/**
|
||||
* Completed-state render intent for a `tool/result`; consumes the remembered
|
||||
* `(name, args, card)`.
|
||||
* @param callId - the id of the matching `tool/call`; an unknown or late id
|
||||
* falls back to the raw content.
|
||||
* @param content - the result's content blocks (the fallback and fill-in body).
|
||||
* @param isError - whether the result is an error, forwarded to `presentResult`.
|
||||
* @param meta - the result's machine-readable meta, forwarded when present.
|
||||
* @returns the tool-owned view — an orphaned `terminal` result (no terminal
|
||||
* call side) and a content-less `generic` are normalized — or the raw-content
|
||||
* generic card when the tool defines no `presentResult` or threw.
|
||||
*/
|
||||
result(callId: CallId, content: ContentBlock[], isError: boolean, meta?: unknown): ToolResultView {
|
||||
const call = this.pending.get(callId)
|
||||
this.pending.delete(callId)
|
||||
|
||||
@@ -36,6 +36,10 @@ import Loader from '@cordisjs/plugin-loader'
|
||||
* the SAME directory (the keyless replay tree). Other modes — including no
|
||||
* snapshot mode at all — use the path as-is. Returns an absolute path resolved
|
||||
* from `cwd`.
|
||||
* @param configPath - the requested config path (absolute, or relative to `cwd`).
|
||||
* @param snapshotMode - the bin's `$DSH_SNAPSHOT` value; only `'replay'` swaps the basename.
|
||||
* @param cwd - the base a relative `configPath` resolves against.
|
||||
* @returns the absolute path of the config to boot.
|
||||
*/
|
||||
export function resolveConfigPath(
|
||||
configPath: string, snapshotMode: string | undefined, cwd: string = process.cwd(),
|
||||
@@ -54,6 +58,9 @@ export function resolveConfigPath(
|
||||
* them via the `!!js` tag. A present-but-unreadable `.env` is a real
|
||||
* misconfiguration: surface it via `warn` (one line, default stderr) rather
|
||||
* than silently running with the wrong environment.
|
||||
* @param binName - the diagnostic prefix on the warn line.
|
||||
* @param dir - the directory whose `.env` to load.
|
||||
* @param warn - sink for the one-line misconfiguration diagnostic.
|
||||
*/
|
||||
export function loadEnv(
|
||||
binName: string, dir: string = process.cwd(),
|
||||
@@ -90,6 +97,9 @@ export interface FailLoudProcess {
|
||||
* STDERR (never stdout — for the ACP bin that channel carries JSON-RPC) and
|
||||
* guarantees `exit(1)`. Install before `boot()`. Returns the uninstaller
|
||||
* (tests use it; the bins run until exit and never do).
|
||||
* @param binName - the diagnostic prefix on the fatal-failure line.
|
||||
* @param proc - the process slice to register on; tests inject a fake.
|
||||
* @returns the uninstaller that removes the rejection handler.
|
||||
*/
|
||||
export function installFailLoud(binName: string, proc: FailLoudProcess = process): () => void {
|
||||
const handler = (err: unknown): void => {
|
||||
@@ -108,6 +118,8 @@ export function installFailLoud(binName: string, proc: FailLoudProcess = process
|
||||
* entry is the one legitimate fiber-less state: `Entry.refresh()` deliberately
|
||||
* skips `init()` for it — a valid "plugin turned off" config, not a failed
|
||||
* import — so it is excluded.
|
||||
* @param ctx - the settled context whose loader entries to audit.
|
||||
* @param binName - the diagnostic prefix on the thrown error.
|
||||
*/
|
||||
export function assertEntriesLoaded(ctx: Context, binName: string): void {
|
||||
const failed = [...ctx.loader.entries()].filter(entry => entry.fiber === undefined && !entry.disabled)
|
||||
@@ -139,6 +151,10 @@ export function assertEntriesLoaded(ctx: Context, binName: string): void {
|
||||
* active under `node --expose-internals`; a consumer running a built bin must
|
||||
* pass that flag (or install the plugins where node hoists them). Relative
|
||||
* specifiers resolve against the config directory with no flag.
|
||||
* @param binName - the diagnostic prefix for load-failure errors.
|
||||
* @param absoluteConfigPath - the config to include; must already be absolute
|
||||
* (see {@link resolveConfigPath}).
|
||||
* @returns the root context once every entry has started.
|
||||
*/
|
||||
export async function boot(binName: string, absoluteConfigPath: string): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
|
||||
@@ -63,6 +63,10 @@ function isTTYPair(input: Readable, output: Writable): boolean {
|
||||
* directly with fakes. Returns nothing — all registration is via `ctx.on`/
|
||||
* `ctx.effect`, so fiber disposal tears every listener and the readline
|
||||
* interface down.
|
||||
* @param ctx - the context supplying the `agents` service and the event feeds.
|
||||
* @param config - the plugin config; defaults are re-applied here for direct
|
||||
* callers that bypass Loader validation.
|
||||
* @param runtime - the process-I/O seam (line source, render sink, exit hook).
|
||||
*/
|
||||
export function createStdioChat(ctx: Context, config: Config, runtime: StdioRuntime): void {
|
||||
// Default here too (not just via schemastery's `.default()`): this helper is
|
||||
|
||||
@@ -14,7 +14,13 @@ import { assertNever } from '@deepseek-ai/dsh-llm'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
import { htmlToMarkdown } from './html.ts'
|
||||
|
||||
/** Validate value constraints the schema DSL can't express. */
|
||||
/**
|
||||
* Validate value constraints the schema DSL can't express: a non-blank `url`,
|
||||
* and a positive `timeout_ms` when present. Throws a plain `Error` otherwise.
|
||||
*
|
||||
* @param args - the schema-validated `web_fetch` arguments.
|
||||
* @returns the arguments renamed to the seam's camelCase request fields.
|
||||
*/
|
||||
export function parseFetchArgs(args: { url: string; timeout_ms?: number }): { url: string; timeoutMs?: number } {
|
||||
if (args.url.trim().length === 0) throw new Error('url must be a non-empty string')
|
||||
if (args.timeout_ms !== undefined && (!Number.isFinite(args.timeout_ms) || args.timeout_ms <= 0)) {
|
||||
@@ -23,7 +29,13 @@ export function parseFetchArgs(args: { url: string; timeout_ms?: number }): { ur
|
||||
return { url: args.url, ...args.timeout_ms !== undefined ? { timeoutMs: args.timeout_ms } : {} }
|
||||
}
|
||||
|
||||
/** Render a fetched body to model-facing markdown text. */
|
||||
/**
|
||||
* Render a fetched body to model-facing markdown text.
|
||||
*
|
||||
* @param body - the decoded body; `html` is converted via
|
||||
* {@link htmlToMarkdown}, `text` passes through verbatim.
|
||||
* @returns the text for the tool's output block.
|
||||
*/
|
||||
export function renderBody(body: WebFetchBody): string {
|
||||
switch (body.kind) {
|
||||
case 'html':
|
||||
@@ -36,19 +48,35 @@ export function renderBody(body: WebFetchBody): string {
|
||||
}
|
||||
}
|
||||
|
||||
/** Format a fetch result as one model-facing text block. */
|
||||
/**
|
||||
* Format a fetch result as one model-facing text block.
|
||||
*
|
||||
* @param result - the seam's fetch outcome.
|
||||
* @returns a `Fetched <url> (HTTP <status>)` header, the rendered body, and a
|
||||
* fetch-something-narrower notice when the provider truncated the content.
|
||||
*/
|
||||
export function formatFetchOutput(result: WebFetchResult): string {
|
||||
const header = `Fetched ${result.url} (HTTP ${result.statusCode})`
|
||||
const footer = result.truncated ? '\n\n(Content truncated. Fetch a more specific URL or section for the full text.)' : ''
|
||||
return `${header}\n\n${renderBody(result.body)}${footer}`
|
||||
}
|
||||
|
||||
/** Pending-call presentation: a fetch card titled by the URL. */
|
||||
/**
|
||||
* Pending-call presentation: a fetch card titled by the URL.
|
||||
*
|
||||
* @param args - the raw tool arguments; only `url` feeds the view.
|
||||
* @returns the generic card view (`kind: 'fetch'`) shown while the call runs.
|
||||
*/
|
||||
export function presentFetchCall(args: { url: string; timeout_ms?: number }): GenericCallView {
|
||||
return { card: 'generic', title: args.url, kind: 'fetch', rawInput: args.url }
|
||||
}
|
||||
|
||||
/** Register the `web_fetch` tool and its system-prompt guidance. */
|
||||
/**
|
||||
* Register the `web_fetch` tool and its system-prompt guidance.
|
||||
*
|
||||
* @param ctx - context whose `tools` and `systemPrompt` registries receive the
|
||||
* registrations; both are effect-scoped and unregister on plugin dispose.
|
||||
*/
|
||||
export function applyWebFetchTool(ctx: Context): void {
|
||||
ctx.systemPrompt.section({
|
||||
name: 'tool:web_fetch',
|
||||
|
||||
@@ -44,6 +44,10 @@ function safeFromCodePoint(code: number, fallback: string): string {
|
||||
* Convert an HTML document to a readable markdown-ish text approximation.
|
||||
* Best-effort and lossy by design — fidelity is the job of a future heavier
|
||||
* converter, not this fallback.
|
||||
*
|
||||
* @param html - the raw HTML source.
|
||||
* @returns plain text with markdown headings, list bullets, and links;
|
||||
* whitespace collapsed to at most one blank line and trimmed.
|
||||
*/
|
||||
export function htmlToMarkdown(html: string): string {
|
||||
let text = html
|
||||
|
||||
@@ -33,6 +33,7 @@ export const name = 'tool-web'
|
||||
/** Services required by the web tool suite. */
|
||||
export const inject = ['tools', 'web', 'systemPrompt']
|
||||
|
||||
/** Plugin config: which web tools to register, and the `web_search` source cap. */
|
||||
export interface Config {
|
||||
/** Register `web_search`. Defaults to true. */
|
||||
search?: boolean
|
||||
|
||||
@@ -20,7 +20,13 @@ import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
*/
|
||||
export const WEB_SEARCH_MAX_RESULTS = 8
|
||||
|
||||
/** Validate value constraints the schema DSL can't express. */
|
||||
/**
|
||||
* Validate value constraints the schema DSL can't express: a non-blank
|
||||
* `query`. Throws a plain `Error` otherwise.
|
||||
*
|
||||
* @param args - the schema-validated `web_search` arguments.
|
||||
* @returns the accepted arguments, passed through unchanged.
|
||||
*/
|
||||
export function parseSearchArgs(args: { query: string }): { query: string } {
|
||||
if (args.query.trim().length === 0) throw new Error('query must be a non-empty string')
|
||||
return { query: args.query }
|
||||
@@ -38,7 +44,14 @@ function sourceLabel(url: string, title: string | undefined): string {
|
||||
}
|
||||
}
|
||||
|
||||
/** Format a search result as one model-facing text block. */
|
||||
/**
|
||||
* Format a search result as one model-facing text block.
|
||||
*
|
||||
* @param result - the seam's search outcome.
|
||||
* @returns the provider answer (when any), a markdown source list with snippet
|
||||
* and date metadata (or `No results found.`), a refine-the-query note when
|
||||
* truncated, and a standing cite-your-sources instruction.
|
||||
*/
|
||||
export function formatSearchOutput(result: WebSearchResult): string {
|
||||
const parts: string[] = []
|
||||
if (result.content !== undefined && result.content.length > 0) parts.push(result.content)
|
||||
@@ -62,12 +75,24 @@ export function formatSearchOutput(result: WebSearchResult): string {
|
||||
return parts.join('\n\n')
|
||||
}
|
||||
|
||||
/** Pending-call presentation: a search card titled by the query. */
|
||||
/**
|
||||
* Pending-call presentation: a search card titled by the query.
|
||||
*
|
||||
* @param args - the raw tool arguments; only `query` feeds the view.
|
||||
* @returns the generic card view (`kind: 'search'`) shown while the call runs.
|
||||
*/
|
||||
export function presentSearchCall(args: { query: string }): GenericCallView {
|
||||
return { card: 'generic', title: args.query, kind: 'search', rawInput: args.query }
|
||||
}
|
||||
|
||||
/** Register the `web_search` tool and its system-prompt guidance. `maxResults` is the deployment's source cap. */
|
||||
/**
|
||||
* Register the `web_search` tool and its system-prompt guidance.
|
||||
*
|
||||
* @param ctx - context whose `tools` and `systemPrompt` registries receive the
|
||||
* registrations; both are effect-scoped and unregister on plugin dispose.
|
||||
* @param maxResults - the deployment's source cap, sent as every seam
|
||||
* request's `maxResults`.
|
||||
*/
|
||||
export function applyWebSearchTool(ctx: Context, maxResults: number): void {
|
||||
ctx.systemPrompt.section({
|
||||
name: 'tool:web_search',
|
||||
|
||||
@@ -30,6 +30,7 @@ export const name = 'web-fetch-local'
|
||||
/** The web seam this provider registers into. */
|
||||
export const inject = ['web']
|
||||
|
||||
/** Plugin config: the provider's transport and size limits plus its `User-Agent` (all defaulted). */
|
||||
export interface Config {
|
||||
/** Maximum accepted request URL length. */
|
||||
maxUrlLength?: number
|
||||
|
||||
@@ -16,6 +16,10 @@ export type FetchableKind = 'html' | 'text'
|
||||
* enforces before any network access: http(s) only, no embedded credentials,
|
||||
* bounded length. Returns the parsed `URL`. Throws {@link WebError} otherwise.
|
||||
* (SSRF / private-network blocking is deferred — see the package RFC.)
|
||||
*
|
||||
* @param input - the raw URL string from the fetch request.
|
||||
* @param maxUrlLength - inclusive upper bound on `input`'s length.
|
||||
* @returns the parsed `URL`.
|
||||
*/
|
||||
export function validateFetchUrl(input: string, maxUrlLength: number): URL {
|
||||
if (input.length > maxUrlLength) {
|
||||
@@ -40,6 +44,10 @@ export function validateFetchUrl(input: string, maxUrlLength: number): URL {
|
||||
* Two URLs are same-origin when scheme, hostname, and port match. A redirect
|
||||
* that crosses origins is refused so each new origin requires a fresh tool call
|
||||
* (and thus a fresh provider/permission decision).
|
||||
*
|
||||
* @param a - one of the two URLs to compare.
|
||||
* @param b - the other URL to compare.
|
||||
* @returns true when `a` and `b` share scheme, hostname, and port.
|
||||
*/
|
||||
export function isSameOrigin(a: URL, b: URL): boolean {
|
||||
return a.protocol === b.protocol && a.hostname === b.hostname && a.port === b.port
|
||||
@@ -49,6 +57,10 @@ export function isSameOrigin(a: URL, b: URL): boolean {
|
||||
* Classify a response `Content-Type` into a decodable body kind, or `undefined`
|
||||
* for an unsupported (e.g. binary) type. `text/html` and `application/xhtml+xml`
|
||||
* are `html`; other `text/*` plus a few structured text types are `text`.
|
||||
*
|
||||
* @param contentType - the raw `Content-Type` header, or `null` when the
|
||||
* response carries none (unsupported).
|
||||
* @returns the decodable kind, or `undefined` for an unsupported type.
|
||||
*/
|
||||
export function classifyContentType(contentType: string | null): FetchableKind | undefined {
|
||||
const mime = (contentType ?? '').replace(/;.*$/s, '').trim().toLowerCase()
|
||||
@@ -63,6 +75,10 @@ export function classifyContentType(contentType: string | null): FetchableKind |
|
||||
* or `undefined` when absent. The provider feeds this label to `TextDecoder`
|
||||
* so a non-UTF-8 response is decoded with its declared encoding rather than
|
||||
* silently mangled into replacement characters.
|
||||
*
|
||||
* @param contentType - the raw `Content-Type` header, or `null` when the
|
||||
* response carries none.
|
||||
* @returns the lower-cased charset label, or `undefined` when none is declared.
|
||||
*/
|
||||
export function parseCharset(contentType: string | null): string | undefined {
|
||||
const match = /;\s*charset\s*=\s*"?([^";]+)"?/i.exec(contentType ?? '')
|
||||
@@ -74,6 +90,10 @@ export function parseCharset(contentType: string | null): string | undefined {
|
||||
* none is declared. Throws {@link WebError} `WEB_UNSUPPORTED_CONTENT_TYPE` when
|
||||
* the label is present but not a charset `TextDecoder` recognizes — better to
|
||||
* fail loudly than return mojibake.
|
||||
*
|
||||
* @param charset - the declared charset label (from {@link parseCharset}), or
|
||||
* `undefined` to default to UTF-8.
|
||||
* @returns a decoder for the declared (or defaulted) encoding.
|
||||
*/
|
||||
export function decoderForCharset(charset: string | undefined): TextDecoder {
|
||||
if (charset === undefined) return new TextDecoder('utf-8')
|
||||
|
||||
@@ -44,6 +44,7 @@ export const name = 'web-search-deepseek'
|
||||
/** The web seam this provider registers into. */
|
||||
export const inject = ['web']
|
||||
|
||||
/** Plugin config (all optional — `apply` fills env-var and constant defaults). */
|
||||
export interface Config {
|
||||
/** DeepSeek API key. Falls back to `$DEEPSEEK_API_KEY`. Empty → unavailable. */
|
||||
apiKey?: string
|
||||
|
||||
@@ -62,6 +62,7 @@ export const DEEPSEEK_DEFAULT_MAX_USES = 5
|
||||
/** Attribution header sent on every request. Bump with the package version. */
|
||||
const USER_AGENT = 'deepseek-harness/0.0.1'
|
||||
|
||||
/** Resolved provider options (the plugin's `apply` supplies env-var and constant defaults). */
|
||||
export interface DeepSeekSearchProviderOptions {
|
||||
/** DeepSeek API key. Empty/absent → `status()` reports `missing-credential`. */
|
||||
apiKey: string
|
||||
@@ -82,6 +83,9 @@ export interface DeepSeekSearchProviderOptions {
|
||||
* is the snippet surface: Anthropic `web_search_result` items carry
|
||||
* `url`/`title`/`page_age` but typically NO inline snippet — the excerpt lives
|
||||
* in a separate `text` block's citation, keyed by `url` (first occurrence wins).
|
||||
*
|
||||
* @param blocks - the response's content blocks; non-`text` blocks are skipped.
|
||||
* @returns the `url → cited_text` map (empty when no citations are present).
|
||||
*/
|
||||
export function citationSnippets(blocks: readonly ContentBlock[]): Map<string, string> {
|
||||
const map = new Map<string, string>()
|
||||
@@ -106,6 +110,10 @@ export function citationSnippets(blocks: readonly ContentBlock[]): Map<string, s
|
||||
* Throws `WEB_PROVIDER_ERROR` (strict mode) when no `web_search_tool_result`
|
||||
* block is present — native search did not trigger, and prose-scraping is not a
|
||||
* fallback.
|
||||
*
|
||||
* @param query - the original request query, echoed on the result.
|
||||
* @param response - the parsed Messages response body.
|
||||
* @returns the normalized result with deduped, snippet-joined sources.
|
||||
*/
|
||||
export function mapAnthropicResponse(query: string, response: AnthropicResponse): WebSearchResult {
|
||||
const blocks = response.content ?? []
|
||||
|
||||
@@ -35,6 +35,7 @@ export const name = 'web-search-exa'
|
||||
/** The web seam this provider registers into. */
|
||||
export const inject = ['web']
|
||||
|
||||
/** Plugin config (all optional — `apply` fills env-var and constant defaults). */
|
||||
export interface Config {
|
||||
/** Exa API key. Falls back to `$EXA_API_KEY`. Empty → provider unavailable. */
|
||||
apiKey?: string
|
||||
|
||||
@@ -37,6 +37,7 @@ export const EXA_DEFAULT_HIGHLIGHTS_PER_RESULT = 1
|
||||
/** Attribution header sent on every request. Bump with the package version. */
|
||||
const USER_AGENT = 'deepseek-harness/0.0.1'
|
||||
|
||||
/** Resolved provider options (the plugin's `apply` supplies env-var and constant defaults). */
|
||||
export interface ExaSearchProviderOptions {
|
||||
/** Exa API key. Empty/absent → `status()` reports `missing-credential`. */
|
||||
apiKey: string
|
||||
@@ -54,6 +55,10 @@ export interface ExaSearchProviderOptions {
|
||||
* Map one Exa result to a normalized source, or `undefined` when it carries no
|
||||
* portable snippet (an entry with no highlight is dropped — the seam has no
|
||||
* other field to derive a snippet from, and inventing one would lie).
|
||||
*
|
||||
* @param result - one entry of Exa's `results[]`.
|
||||
* @returns the normalized source, or `undefined` when the entry has no
|
||||
* non-blank highlight.
|
||||
*/
|
||||
export function mapExaResult(result: ExaResult): WebSearchSource | undefined {
|
||||
const snippet = result.highlights?.find(highlight => highlight.trim().length > 0)
|
||||
@@ -66,7 +71,14 @@ export function mapExaResult(result: ExaResult): WebSearchSource | undefined {
|
||||
}
|
||||
}
|
||||
|
||||
/** Map an Exa response envelope to a normalized search result. */
|
||||
/**
|
||||
* Map an Exa response envelope to a normalized search result.
|
||||
*
|
||||
* @param query - the original request query, echoed on the result.
|
||||
* @param response - the parsed `POST /search` response body.
|
||||
* @returns the normalized result; snippet-less entries are dropped
|
||||
* ({@link mapExaResult}).
|
||||
*/
|
||||
export function mapExaResponse(query: string, response: ExaSearchResponse): WebSearchResult {
|
||||
const sources = (response.results ?? [])
|
||||
.map(mapExaResult)
|
||||
|
||||
@@ -29,6 +29,7 @@ export const name = 'web-search-perplexity'
|
||||
/** The web seam this provider registers into. */
|
||||
export const inject = ['web']
|
||||
|
||||
/** Plugin config (all optional — `apply` fills env-var and constant defaults). */
|
||||
export interface Config {
|
||||
/** Perplexity API key. Falls back to `$PERPLEXITY_API_KEY`. Empty → unavailable. */
|
||||
apiKey?: string
|
||||
|
||||
@@ -41,6 +41,7 @@ export type PerplexityRecency = 'day' | 'week' | 'month' | 'year'
|
||||
/** Attribution header sent on every request. Bump with the package version. */
|
||||
const USER_AGENT = 'deepseek-harness/0.0.1'
|
||||
|
||||
/** Resolved provider options (the plugin's `apply` supplies env-var and constant defaults). */
|
||||
export interface PerplexitySearchProviderOptions {
|
||||
/** Perplexity API key. Empty/absent → `status()` reports `missing-credential`. */
|
||||
apiKey: string
|
||||
@@ -54,7 +55,12 @@ export interface PerplexitySearchProviderOptions {
|
||||
searchRecency?: PerplexityRecency
|
||||
}
|
||||
|
||||
/** Map one structured Perplexity search result to a normalized source. */
|
||||
/**
|
||||
* Map one structured Perplexity search result to a normalized source.
|
||||
*
|
||||
* @param result - one entry of the response's `search_results[]`.
|
||||
* @returns the normalized source; blank fields are omitted rather than set empty.
|
||||
*/
|
||||
export function mapPerplexityResult(result: PerplexitySearchResult): WebSearchSource {
|
||||
return {
|
||||
url: result.url,
|
||||
@@ -68,6 +74,10 @@ export function mapPerplexityResult(result: PerplexitySearchResult): WebSearchSo
|
||||
* Map a Perplexity response envelope to a normalized search result. Prefers
|
||||
* structured `search_results[]`; falls back to URL-only `citations[]` (those
|
||||
* sources carry just a `url`) only when `search_results` is absent.
|
||||
*
|
||||
* @param query - the original request query, echoed on the result.
|
||||
* @param response - the parsed chat-completions response body.
|
||||
* @returns the normalized result; `content` is omitted when the answer is empty.
|
||||
*/
|
||||
export function mapPerplexityResponse(query: string, response: PerplexityResponse): WebSearchResult {
|
||||
const content = response.choices?.[0]?.message?.content
|
||||
|
||||
Reference in New Issue
Block a user