dsh-draw 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/drawer.ts CHANGED
@@ -13,7 +13,8 @@ import type { ResolvedConfig } from './config.ts'
13
13
  import type { EngineDeps, ProducedImage } from './engine.ts'
14
14
  import { checkQuotaBytes, checkQuotaGenerations, type QuotaLimits, type QuotaState } from './quota.ts'
15
15
  import { EngineRouter, type AttemptView } from './router.ts'
16
- import { appendDrawGenerated } from './session-events.ts'
16
+ import { commitDrawGenerated } from './session-events.ts'
17
+ import type { EventGate } from './event-gate.ts'
17
18
  import { normalizeRequest } from './translate.ts'
18
19
 
19
20
  /** One durable result image as the canonical value and wire carry it. */
@@ -102,6 +103,14 @@ export interface DrawerDeps {
102
103
  sessions?: () => SessionStore | undefined
103
104
  }
104
105
 
106
+ /** How the drawer commits the `draw/generated` accounting event. */
107
+ export interface DrawerEventSink {
108
+ /** The adaptive session-event gate, probed at mount. */
109
+ gate: EventGate
110
+ /** Log warning sink for append failures. */
111
+ warn: (message: string) => void
112
+ }
113
+
105
114
  /**
106
115
  * The shared generation path.
107
116
  */
@@ -110,11 +119,13 @@ export class Drawer {
110
119
  * @param config - resolved plugin configuration.
111
120
  * @param router - the engine router.
112
121
  * @param deps - per-call dependencies (public: the remote service reads them for probes).
122
+ * @param events - the gated session-event sink (log when safe, in-memory ledger otherwise).
113
123
  */
114
124
  constructor(
115
125
  private readonly config: ResolvedConfig,
116
126
  private readonly router: EngineRouter,
117
127
  readonly deps: DrawerDeps,
128
+ private readonly events: DrawerEventSink,
118
129
  ) {}
119
130
 
120
131
  /**
@@ -182,7 +193,7 @@ export class Drawer {
182
193
  ...(ref.name !== undefined ? { name: ref.name } : {}),
183
194
  }))
184
195
  const elapsedMs = Date.now() - startedAt
185
- appendDrawGenerated(session, {
196
+ commitDrawGenerated(session, {
186
197
  engine: routed.engine,
187
198
  model: routed.model,
188
199
  source: options.source,
@@ -193,7 +204,7 @@ export class Drawer {
193
204
  bytes: totalBytes,
194
205
  attachmentIds: images.map(image => image.attachmentId),
195
206
  elapsedMs,
196
- })
207
+ }, this.events.gate, this.events.warn)
197
208
  const after = checkQuotaGenerations(session, limits)
198
209
  return {
199
210
  ok: true,
@@ -0,0 +1,84 @@
1
+ /**
2
+ * The session-event adaptive gate. `draw/generated` is declared only in this
3
+ * package's types, so it sits outside every host's `KNOWN_SESSION_EVENT_TYPES`:
4
+ * hosts whose session reader validates a static whitelist (rc.6/rc.7) refuse to
5
+ * reopen a session containing the event unless it carries the `ignorable`
6
+ * envelope — and those hosts' `Session.append` cannot stamp it. The gate
7
+ * therefore appends only when the type is host-known or a mount-time probe
8
+ * proves envelope support; otherwise the accounting payload goes to the
9
+ * in-memory fallback ledger (see session-events.ts) so quota keeps working for
10
+ * the live session without poisoning the log.
11
+ *
12
+ * @module dsh-draw/event-gate
13
+ */
14
+
15
+ import { Context } from '@deepseek-ai/cordis'
16
+ import SessionStore, { KNOWN_SESSION_EVENT_TYPES } from '@deepseek-ai/dsh-session'
17
+
18
+ /** One gate decision: whether to append, and whether to mark ignorable. */
19
+ export interface EventGateDecision {
20
+ /** Append the event to the session log. */
21
+ append: boolean
22
+ /** Stamp the `ignorable` envelope (only meaningful when append). */
23
+ ignorable: boolean
24
+ }
25
+
26
+ /** Decision function over session event types. */
27
+ export type EventGate = (type: string) => EventGateDecision
28
+
29
+ /**
30
+ * Build the gate: host-known types append plainly; unknown types append with
31
+ * the ignorable envelope only when the host append honors it; everything else
32
+ * is skipped — an unknown, unmarked event would make the session refuse to
33
+ * load on restart under the rc.6/rc.7 static whitelist.
34
+ *
35
+ * @param knownTypes - the host's KNOWN_SESSION_EVENT_TYPES.
36
+ * @param ignorableAppend - whether the host append stamps the envelope.
37
+ * @returns the decision function.
38
+ */
39
+ export function makeEventGate(knownTypes: ReadonlySet<string>, ignorableAppend = false): EventGate {
40
+ return (type) => {
41
+ if (knownTypes.has(type)) return { append: true, ignorable: false }
42
+ if (ignorableAppend) return { append: true, ignorable: true }
43
+ return { append: false, ignorable: false }
44
+ }
45
+ }
46
+
47
+ /** The append face the probe needs; rc.6 types declare no envelope parameter. */
48
+ type EnvelopeAppend = (type: string, data: unknown, opts: { ignorable: boolean }) => { ignorable?: boolean } | undefined
49
+
50
+ /**
51
+ * Probe whether the host `Session.append` stamps the ignorable envelope. Runs
52
+ * on a fully detached SessionStore — never host persistence — appending one
53
+ * probe event with `{ ignorable: true }` and reading the marker back. rc.6's
54
+ * append silently drops the unknown options key (probe false); every failure
55
+ * is contained as "unsupported".
56
+ *
57
+ * @returns whether the envelope is honored.
58
+ */
59
+ export function probeIgnorableAppend(): boolean {
60
+ try {
61
+ const store = new SessionStore(new Context())
62
+ const session = store.create()
63
+ const event = (session.append as unknown as EnvelopeAppend)('draw/generated', {
64
+ engine: 'probe',
65
+ model: 'probe',
66
+ source: 'tool',
67
+ prompt: 'probe',
68
+ size: 'auto',
69
+ quality: 'auto',
70
+ count: 0,
71
+ bytes: 0,
72
+ attachmentIds: [],
73
+ elapsedMs: 0,
74
+ }, { ignorable: true })
75
+ return event?.ignorable === true
76
+ } catch {
77
+ return false
78
+ }
79
+ }
80
+
81
+ /** The gate over the host's known types plus the probed envelope support. */
82
+ export function makeHostEventGate(): EventGate {
83
+ return makeEventGate(KNOWN_SESSION_EVENT_TYPES, probeIgnorableAppend())
84
+ }
package/src/index.ts CHANGED
@@ -22,6 +22,7 @@ import type { SessionStore } from '@deepseek-ai/dsh-session'
22
22
  import type {} from '@deepseek-ai/dsh-tools'
23
23
  import { Config, resolveConfig } from './config.ts'
24
24
  import { Drawer } from './drawer.ts'
25
+ import { makeHostEventGate } from './event-gate.ts'
25
26
  import { defaultHttpTransport, type HttpTransport } from './http.ts'
26
27
  import { EngineRouter } from './router.ts'
27
28
  import { DrawService } from './service.ts'
@@ -42,7 +43,8 @@ export { callEngine, EngineCallError, type EngineDeps, type ProducedImage } from
42
43
  export { translateRequest, normalizeRequest, type StandardImageRequest } from './translate.ts'
43
44
  export { quotaState, checkQuotaGenerations, checkQuotaBytes, type QuotaLimits, type QuotaState } from './quota.ts'
44
45
  export { sanitizeUrl, sanitizeText, sanitizeError, REDACTED } from './sanitize.ts'
45
- export { appendDrawGenerated, drawGeneratedEvents, type DrawGeneratedEvent } from './session-events.ts'
46
+ export { appendDrawGenerated, commitDrawGenerated, drawGeneratedEvents, fallbackDrawGeneratedEvents, type DrawGeneratedEvent } from './session-events.ts'
47
+ export { makeEventGate, makeHostEventGate, probeIgnorableAppend, type EventGate, type EventGateDecision } from './event-gate.ts'
46
48
  export { PLUGIN_VERSION } from './version.ts'
47
49
  export { DRAW_INVOCATIONS, imageToWire, statusToView, probeToWire, type DrawStatusSnapshot } from './wire.ts'
48
50
 
@@ -72,6 +74,9 @@ export async function apply(ctx: Context, config: Config): Promise<void> {
72
74
 
73
75
  const credentials = () => ctx.get('credentials') as CredentialProvider | undefined
74
76
 
77
+ // The draw/generated event type is declared only by this package: append it
78
+ // only when the host knows the type or honors the ignorable envelope;
79
+ // otherwise the accounting payload rides the in-memory fallback ledger.
75
80
  const drawer = new Drawer(resolved, router, {
76
81
  engine: {
77
82
  transport,
@@ -84,7 +89,7 @@ export async function apply(ctx: Context, config: Config): Promise<void> {
84
89
  },
85
90
  attachments: () => ctx.get('attachments') as AttachmentStore | undefined,
86
91
  sessions: () => ctx.get('sessions') as SessionStore | undefined,
87
- })
92
+ }, { gate: makeHostEventGate(), warn: (message) => logger.warn(message) })
88
93
 
89
94
  ctx.effect(() => ctx.tools.register(imageGenerateTool(drawer, resolved)), 'dsh-draw: image_generate tool')
90
95
 
package/src/quota.ts CHANGED
@@ -1,14 +1,18 @@
1
1
  /**
2
2
  * Per-session quota accounting. The durable source of truth is the session
3
- * log: every completed generation appends one `draw/generated` event, and
4
- * quota is computed by folding those events, so usage survives restart and
5
- * fork and cannot drift from what the log records.
3
+ * log: every completed generation commits one `draw/generated` event, and
4
+ * quota folds those events, so usage survives restart and fork and cannot
5
+ * drift from what the log records. On hosts that cannot carry the event
6
+ * safely (rc.6/rc.7 static whitelist without the ignorable envelope), commits
7
+ * land in the in-memory fallback ledger instead and quota folds both sources —
8
+ * live-session accounting stays exact; cross-restart durability resumes when
9
+ * the host gains a plugin event surface.
6
10
  *
7
11
  * @module dsh-draw/quota
8
12
  */
9
13
 
10
14
  import type { Session } from '@deepseek-ai/dsh-session'
11
- import { drawGeneratedEvents } from './session-events.ts'
15
+ import { drawGeneratedEvents, fallbackDrawGeneratedEvents } from './session-events.ts'
12
16
 
13
17
  /** The two quota axes. */
14
18
  export interface QuotaLimits {
@@ -48,9 +52,11 @@ export interface QuotaAllowance {
48
52
  export type QuotaCheck = QuotaAllowance | QuotaDenial
49
53
 
50
54
  /**
51
- * Fold one session's `draw/generated` events into current usage.
55
+ * Fold one session's `draw/generated` events into current usage: logged events
56
+ * first, then the in-memory fallback ledger (disjoint — a commit lands in
57
+ * exactly one of them).
52
58
  *
53
- * @param session - session whose log is folded.
59
+ * @param session - session whose log and ledger are folded.
54
60
  * @returns generation and byte totals.
55
61
  */
56
62
  export function quotaState(session: Session): QuotaState {
@@ -60,6 +66,10 @@ export function quotaState(session: Session): QuotaState {
60
66
  generations += 1
61
67
  bytes += event.data.bytes
62
68
  }
69
+ for (const event of fallbackDrawGeneratedEvents(session)) {
70
+ generations += 1
71
+ bytes += event.bytes
72
+ }
63
73
  return { generations, bytes }
64
74
  }
65
75
 
@@ -6,10 +6,18 @@
6
6
  * output of the call ride the loop-owned `tool/call` and `tool/result`
7
7
  * events; this event carries the accounting facts those events do not.
8
8
  *
9
+ * The event type is declared only here, so plugin paths append it through
10
+ * {@link commitDrawGenerated}: the adaptive gate (event-gate.ts) appends only
11
+ * when the host knows the type or honors the `ignorable` envelope, and
12
+ * otherwise records the payload in the in-memory fallback ledger (live-session
13
+ * quota keeps working; the durable trail resumes once the host gains a plugin
14
+ * event surface).
15
+ *
9
16
  * @module dsh-draw/session-events
10
17
  */
11
18
 
12
19
  import type { Session } from '@deepseek-ai/dsh-session'
20
+ import type { EventGate } from './event-gate.ts'
13
21
 
14
22
  declare module '@deepseek-ai/dsh-session/types' {
15
23
  interface SessionEventMap {
@@ -46,18 +54,70 @@ export interface DrawGeneratedEvent {
46
54
  }
47
55
 
48
56
  /**
49
- * Append one `draw/generated` event to a session.
57
+ * Append one `draw/generated` event to a session, ungated. Plugin paths use
58
+ * {@link commitDrawGenerated} instead; this raw form remains for tests and
59
+ * hosts that know the type.
50
60
  *
51
61
  * @param session - owning session.
52
62
  * @param event - accounting payload.
53
63
  * @returns the logged event.
54
64
  */
55
65
  export function appendDrawGenerated(session: Session, event: DrawGeneratedEvent) {
56
- // Two-argument append: the pinned 0.1.0-rc.6 peers have no append-envelope
57
- // option; the two-argument form typechecks against rc.6 and newer builds.
58
66
  return session.append('draw/generated', event)
59
67
  }
60
68
 
69
+ /** The append face with the envelope option; rc.6 types declare none. */
70
+ type EnvelopeAppend = (type: string, data: unknown, opts: { ignorable: boolean }) => unknown
71
+
72
+ /**
73
+ * In-memory accounting ledger for hosts whose session log cannot carry
74
+ * `draw/generated` safely (rc.6/rc.7 static whitelist, no ignorable envelope).
75
+ * Keyed by Session identity: the entries live exactly as long as the session.
76
+ */
77
+ const fallbackLedger = new WeakMap<Session, DrawGeneratedEvent[]>()
78
+
79
+ /**
80
+ * Commit one completed generation: append the `draw/generated` event when the
81
+ * gate allows (with the ignorable envelope when required), otherwise record
82
+ * the payload in the in-memory fallback ledger. A failed append degrades to
83
+ * the same ledger and never breaks the draw.
84
+ *
85
+ * @param session - owning session.
86
+ * @param event - accounting payload.
87
+ * @param gate - the adaptive gate (event-gate.ts).
88
+ * @param warn - log warning sink for append failures.
89
+ */
90
+ export function commitDrawGenerated(session: Session, event: DrawGeneratedEvent, gate: EventGate, warn: (message: string) => void): void {
91
+ const decision = gate('draw/generated')
92
+ if (decision.append) {
93
+ try {
94
+ if (decision.ignorable) {
95
+ ;(session.append as unknown as EnvelopeAppend)('draw/generated', event, { ignorable: true })
96
+ } else {
97
+ session.append('draw/generated', event)
98
+ }
99
+ return
100
+ } catch (error) {
101
+ warn(`draw session event append failed: ${error instanceof Error ? error.message : String(error)}`)
102
+ }
103
+ }
104
+ const list = fallbackLedger.get(session) ?? []
105
+ list.push(event)
106
+ fallbackLedger.set(session, list)
107
+ }
108
+
109
+ /**
110
+ * The in-memory fallback records of a session, in commit order. Populated
111
+ * only while the host cannot carry `draw/generated` in the log; quota folds
112
+ * these on top of the logged events.
113
+ *
114
+ * @param session - session whose ledger is read.
115
+ * @returns the fallback payloads.
116
+ */
117
+ export function fallbackDrawGeneratedEvents(session: Session): readonly DrawGeneratedEvent[] {
118
+ return fallbackLedger.get(session) ?? []
119
+ }
120
+
61
121
  /**
62
122
  * Collect the `draw/generated` events of a session in log order. Declaration
63
123
  * merging narrows `event.type === 'draw/generated'` to the payload type.
package/src/version.ts CHANGED
@@ -7,4 +7,4 @@
7
7
  */
8
8
 
9
9
  /** Plugin version; must equal the `version` field in `package.json`. */
10
- export const PLUGIN_VERSION = '0.1.0'
10
+ export const PLUGIN_VERSION = '0.1.2'