dsh-hypatia-auto-memory 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +68 -7
- package/README.zh-CN.md +54 -9
- package/lib/client.js +720 -40
- package/lib/client.js.map +1 -1
- package/package.json +3 -2
- package/src/cascade.js +29 -4
- package/src/client/MemoryView.module.css +312 -0
- package/src/client/MemoryView.tsx +335 -0
- package/src/client/index.tsx +32 -2
- package/src/client/locales.ts +80 -0
- package/src/client/memory-client.ts +246 -0
- package/src/consolidator.js +159 -37
- package/src/index.js +74 -6
- package/src/memory-api.js +557 -0
- package/src/memory-status.js +167 -0
- package/src/model-log.js +246 -0
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Flattening the two state tables into what the conversation's Memory tab reads.
|
|
3
|
+
*
|
|
4
|
+
* Why this exists. Everything the background pipeline knows about a session —
|
|
5
|
+
* how far it has been logged, how far it has been consolidated, how many tokens
|
|
6
|
+
* are still waiting, whether the `session-<id>` node exists, which tasks are
|
|
7
|
+
* stuck — lives in the state domain, on disk, readable only by stopping DSH and
|
|
8
|
+
* opening a JSON file. The checklist in the README tells a user to confirm
|
|
9
|
+
* logging with a hand-typed `hypatia knowledge-get msg-<sessionId>-<n>`. That is
|
|
10
|
+
* the gap this closes: a conversation tab that answers "did this session get
|
|
11
|
+
* remembered, and is anything stuck" without leaving the browser.
|
|
12
|
+
*
|
|
13
|
+
* Pure, so it is tested without a storage domain, a cordis context or hypatia.
|
|
14
|
+
* The route that serves it lives in memory-api.js.
|
|
15
|
+
*
|
|
16
|
+
* @module dsh-hypatia-auto-memory/memory-status
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { EMPTY_PROGRESS } from './progress.js'
|
|
20
|
+
|
|
21
|
+
/** How many failed tasks the shared failure list carries. */
|
|
22
|
+
export const FAILED_LIMIT = 20
|
|
23
|
+
|
|
24
|
+
/** How long a task `error` may be in the payload. */
|
|
25
|
+
export const ERROR_MAX = 200
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Bound a task error the way every other diagnostic path in this plugin does.
|
|
29
|
+
* @param {unknown} value - a task's stored `error` field.
|
|
30
|
+
* @returns {string} the message, capped, or '' when it is not a string.
|
|
31
|
+
*/
|
|
32
|
+
export function boundedError(value) {
|
|
33
|
+
if (typeof value !== 'string') return ''
|
|
34
|
+
return value.length > ERROR_MAX ? `${value.slice(0, ERROR_MAX)}…` : value
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** A session row with no progress of its own — a session known only from a task. */
|
|
38
|
+
function emptyEntry(id) {
|
|
39
|
+
return {
|
|
40
|
+
id,
|
|
41
|
+
logged: EMPTY_PROGRESS.lastLoggedSeq,
|
|
42
|
+
consolidated: EMPTY_PROGRESS.lastConsolidatedSeq,
|
|
43
|
+
checkTurn: EMPTY_PROGRESS.lastCheckTurn,
|
|
44
|
+
pendingTokens: EMPTY_PROGRESS.pendingTokens,
|
|
45
|
+
sessionNode: EMPTY_PROGRESS.hasSessionNode === 1,
|
|
46
|
+
belongTo: EMPTY_PROGRESS.lastBelongToIndex,
|
|
47
|
+
deferred: 0,
|
|
48
|
+
failed: 0,
|
|
49
|
+
error: '',
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Fold the two state tables into per-session rows plus a shared failure list.
|
|
55
|
+
*
|
|
56
|
+
* Sessions come from `progress` (one row per session the shelf has seen) and are
|
|
57
|
+
* topped up from `tasks`, so a session whose only trace is a stuck task still
|
|
58
|
+
* appears with a reason rather than looking untouched. Task counts are folded
|
|
59
|
+
* onto their session; the failure list carries bounded error text, and
|
|
60
|
+
* `deferred` only counts, since a deferred task is waiting on a load, not on a
|
|
61
|
+
* person.
|
|
62
|
+
*
|
|
63
|
+
* With `sessionId` the fold covers that one session: the failure cap then
|
|
64
|
+
* bounds only its failures, so other sessions' backlog cannot crowd them out,
|
|
65
|
+
* and a per-request read does not build a row for every session on the shelf.
|
|
66
|
+
*
|
|
67
|
+
* @param {{
|
|
68
|
+
* progressEntries: Iterable<[string, any]>,
|
|
69
|
+
* taskEntries: Iterable<[string, any]>,
|
|
70
|
+
* sessionId?: string,
|
|
71
|
+
* }} deps - raw table entries for the shelf in use, and optionally the one
|
|
72
|
+
* session to keep.
|
|
73
|
+
* @returns {{sessions: object[], failed: object[], total: number}}
|
|
74
|
+
*/
|
|
75
|
+
export function buildStatus({ progressEntries, taskEntries, sessionId: only }) {
|
|
76
|
+
const bySession = new Map()
|
|
77
|
+
for (const [key, row] of progressEntries) {
|
|
78
|
+
if (only !== undefined && key !== only) continue
|
|
79
|
+
const current = { ...EMPTY_PROGRESS, ...(row ?? {}) }
|
|
80
|
+
bySession.set(key, {
|
|
81
|
+
id: key,
|
|
82
|
+
logged: current.lastLoggedSeq,
|
|
83
|
+
consolidated: current.lastConsolidatedSeq,
|
|
84
|
+
checkTurn: current.lastCheckTurn,
|
|
85
|
+
pendingTokens: current.pendingTokens,
|
|
86
|
+
sessionNode: current.hasSessionNode === 1,
|
|
87
|
+
belongTo: current.lastBelongToIndex,
|
|
88
|
+
deferred: 0,
|
|
89
|
+
failed: 0,
|
|
90
|
+
error: '',
|
|
91
|
+
})
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const failed = []
|
|
95
|
+
for (const [id, record] of taskEntries) {
|
|
96
|
+
const sessionId = String(record?.sessionId ?? '')
|
|
97
|
+
if (sessionId === '') continue
|
|
98
|
+
if (only !== undefined && sessionId !== only) continue
|
|
99
|
+
const entry = bySession.get(sessionId) ?? emptyEntry(sessionId)
|
|
100
|
+
const status = String(record?.status ?? '')
|
|
101
|
+
if (status === 'deferred') entry.deferred += 1
|
|
102
|
+
if (status === 'failed') {
|
|
103
|
+
entry.failed += 1
|
|
104
|
+
if (entry.error === '') entry.error = boundedError(record?.error)
|
|
105
|
+
failed.push({
|
|
106
|
+
kind: String(record?.kind ?? ''),
|
|
107
|
+
sessionId,
|
|
108
|
+
attempts: Number(record?.attempts ?? 0),
|
|
109
|
+
error: boundedError(record?.error),
|
|
110
|
+
})
|
|
111
|
+
}
|
|
112
|
+
bySession.set(sessionId, entry)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// The tasks table is keyed `<kind>:<sessionId>`, so insertion order is kind-major
|
|
116
|
+
// and not a recency order. Truncating is therefore arbitrary rather than
|
|
117
|
+
// "newest"; it is a bound on the payload, not a ranking.
|
|
118
|
+
return { sessions: [...bySession.values()], failed: failed.slice(0, FAILED_LIMIT), total: bySession.size }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Collapse the fold to the one session the tab is bound to.
|
|
123
|
+
*
|
|
124
|
+
* The derived flags are computed here rather than in the browser so there is one
|
|
125
|
+
* definition of "caught up" and one place to change it.
|
|
126
|
+
*
|
|
127
|
+
* @param {{sessions: object[], failed: object[]}} built - the output of {@link buildStatus}.
|
|
128
|
+
* @param {string} sessionId - the session the tab is showing.
|
|
129
|
+
* @returns {object} the session view; `known` is false for a session with no trace yet.
|
|
130
|
+
*/
|
|
131
|
+
export function sessionView(built, sessionId) {
|
|
132
|
+
const row = built.sessions.find((session) => session.id === sessionId)
|
|
133
|
+
const failedTasks = built.failed.filter((task) => task.sessionId === sessionId)
|
|
134
|
+
if (row === undefined) {
|
|
135
|
+
return {
|
|
136
|
+
sessionId,
|
|
137
|
+
known: false,
|
|
138
|
+
loggedSeq: 0,
|
|
139
|
+
consolidatedSeq: 0,
|
|
140
|
+
// Nothing logged and nothing consolidated is not a backlog.
|
|
141
|
+
caughtUp: true,
|
|
142
|
+
pendingTokens: 0,
|
|
143
|
+
sessionNode: false,
|
|
144
|
+
belongTo: 0,
|
|
145
|
+
deferred: 0,
|
|
146
|
+
failed: 0,
|
|
147
|
+
failedTasks,
|
|
148
|
+
error: '',
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return {
|
|
152
|
+
sessionId,
|
|
153
|
+
known: true,
|
|
154
|
+
loggedSeq: row.logged,
|
|
155
|
+
consolidatedSeq: row.consolidated,
|
|
156
|
+
// Both watermarks start at 0, so an unlogged session reads as caught up
|
|
157
|
+
// rather than as behind — `known` separates "nothing to do" from "nothing done".
|
|
158
|
+
caughtUp: row.consolidated >= row.logged,
|
|
159
|
+
pendingTokens: row.pendingTokens,
|
|
160
|
+
sessionNode: row.sessionNode,
|
|
161
|
+
belongTo: row.belongTo,
|
|
162
|
+
deferred: row.deferred,
|
|
163
|
+
failed: row.failed,
|
|
164
|
+
failedTasks,
|
|
165
|
+
error: row.error,
|
|
166
|
+
}
|
|
167
|
+
}
|
package/src/model-log.js
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable, bounded record of the model attempts the background pipeline makes.
|
|
3
|
+
*
|
|
4
|
+
* Why this exists. The route one consolidation attempt runs on is chosen by a
|
|
5
|
+
* process-local round-robin cursor (`createConsolidationRouteSelector`) and,
|
|
6
|
+
* before this, was dropped the moment the call was made. "Which model actually
|
|
7
|
+
* summarised that span?" was then unanswerable from outside the process: the
|
|
8
|
+
* settings card shows the configured route LIST, the host logger goes to a
|
|
9
|
+
* terminal that scrolls away, and the usage ledger only folds agent turns — it
|
|
10
|
+
* cannot see a plugin's direct `llm.stream` call at all (see
|
|
11
|
+
* `@linxin666/dsh-usage` `usage-service.ts`, which folds `assistant/message`).
|
|
12
|
+
*
|
|
13
|
+
* The record is written at SELECTION time and updated when the attempt settles,
|
|
14
|
+
* so a row left at `pending` is itself evidence: the process died, or the
|
|
15
|
+
* attempt is still in flight. That distinguishes the three states the cursor
|
|
16
|
+
* alone cannot — selected, completed, and consumed-but-produced-nothing.
|
|
17
|
+
*
|
|
18
|
+
* It lives in its OWN storage domain rather than in the one holding watermarks
|
|
19
|
+
* and queued tasks. In that domain a record failing its schema rejects the whole
|
|
20
|
+
* open and silently disables the plugin (see the note on `taskTable` in
|
|
21
|
+
* state.js); diagnostics must never be able to take the authoritative data down
|
|
22
|
+
* with them. Here the blast radius is one bounded table nobody's correctness
|
|
23
|
+
* depends on. `openModelLog` therefore degrades to a logger-only log when the
|
|
24
|
+
* domain will not open, and callers keep working.
|
|
25
|
+
*
|
|
26
|
+
* @module dsh-hypatia-auto-memory/model-log
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { defineDomain, domainTable } from '@deepseek-ai/dsh-storage-domain'
|
|
30
|
+
import { z } from 'zod'
|
|
31
|
+
|
|
32
|
+
/** How many attempts to keep. A ring large enough to answer "the last run(s)". */
|
|
33
|
+
export const MODEL_CALL_LIMIT = 100
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* One model attempt. `outcome` is the state that matters:
|
|
37
|
+
*
|
|
38
|
+
* - `pending` — selected, not settled (in flight, or the process died).
|
|
39
|
+
* - `ok` — the call settled AND produced a usable result.
|
|
40
|
+
* - `incomplete` — settled without one: output cap, abort, refusal, a reply
|
|
41
|
+
* that did not parse, or one that parsed into nothing usable. The cursor WAS
|
|
42
|
+
* consumed and nothing was stored. This is a deliberate catch-all: from
|
|
43
|
+
* outside, every one of those looks the same, and a caller that reported `ok`
|
|
44
|
+
* for a stop that yielded no summary would make the table lie.
|
|
45
|
+
* - `error` — the call itself threw (provider/transport failure).
|
|
46
|
+
*
|
|
47
|
+
* `detail` carries a finish kind, a fixed label, or a provider error message —
|
|
48
|
+
* never message content and never model output. The plugin's whole log path is
|
|
49
|
+
* built to keep conversation text out of places a later reader can stumble
|
|
50
|
+
* into, and a `JSON.parse` failure is exactly where that leaks by accident:
|
|
51
|
+
* Node quotes the offending input in the message (`Unexpected token 'H', "Here
|
|
52
|
+
* is th"...`). Parse sites therefore record a fixed label, and `finish` caps
|
|
53
|
+
* whatever it is handed, because a provider `failure.message` can carry a whole
|
|
54
|
+
* HTTP body.
|
|
55
|
+
*/
|
|
56
|
+
export const modelCallTable = domainTable(z.object({
|
|
57
|
+
/** Monotonic within the process; recovered from the stored rows on open. */
|
|
58
|
+
seq: z.number().step(1).min(1),
|
|
59
|
+
/** Epoch milliseconds at selection. */
|
|
60
|
+
at: z.number().default(0),
|
|
61
|
+
/** The `purpose` the call was made with ('memory-consolidation', …). */
|
|
62
|
+
purpose: z.string().default(''),
|
|
63
|
+
provider: z.string().default(''),
|
|
64
|
+
model: z.string().default(''),
|
|
65
|
+
outcome: z.string().default('pending'),
|
|
66
|
+
/** Wall time from selection to settle; 0 while pending. */
|
|
67
|
+
ms: z.number().min(0).default(0),
|
|
68
|
+
detail: z.string().default(''),
|
|
69
|
+
}))
|
|
70
|
+
|
|
71
|
+
/** Upper bound on `detail`; see the table doc. Applied on every write. */
|
|
72
|
+
export const DETAIL_MAX = 200
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Diagnostics domain. Separate from `hypatia_auto_memory` on purpose (module
|
|
76
|
+
* doc); its own version line, so the authoritative domain's format never has to
|
|
77
|
+
* move for a table only a human reads.
|
|
78
|
+
*/
|
|
79
|
+
export const diagnosticsSpec = defineDomain({
|
|
80
|
+
name: 'hypatia_auto_memory_diag',
|
|
81
|
+
version: 1,
|
|
82
|
+
// Table names must match /^[a-z][a-z0-9_]*$/ — the module-load guard rejects
|
|
83
|
+
// camelCase before any medium is touched.
|
|
84
|
+
tables: { model_calls: modelCallTable },
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Fixed-width key so lexicographic order equals sequence order. A bare numeric
|
|
89
|
+
* key would be re-ordered by the JSON object it is stored in (integer-like keys
|
|
90
|
+
* sort first, numerically), which is why ordering is carried by the padded key
|
|
91
|
+
* AND by the `seq` field the reader sorts on.
|
|
92
|
+
* @param {number} seq
|
|
93
|
+
*/
|
|
94
|
+
function keyOf(seq) {
|
|
95
|
+
return String(seq).padStart(12, '0')
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The log a caller gets when none was injected (unit tests, a composition
|
|
100
|
+
* without storage). Call sites default to it, so none of them branches on
|
|
101
|
+
* observability being available.
|
|
102
|
+
*/
|
|
103
|
+
export const NULL_MODEL_LOG = Object.freeze({
|
|
104
|
+
begin: () => ({ finish: async () => {} }),
|
|
105
|
+
recent: () => [],
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* @param {{
|
|
110
|
+
* table?: any,
|
|
111
|
+
* status: import('./status.js').StatusLog,
|
|
112
|
+
* limit?: number,
|
|
113
|
+
* now?: () => number,
|
|
114
|
+
* }} deps - `table` absent (or the domain failed to open) means: log lines
|
|
115
|
+
* still happen, nothing is stored, and no caller has to branch.
|
|
116
|
+
*/
|
|
117
|
+
export function createModelLog({ table, status, limit = MODEL_CALL_LIMIT, now = Date.now }) {
|
|
118
|
+
let counter = 0
|
|
119
|
+
if (table !== undefined) {
|
|
120
|
+
// Every stored row passed the schema at open, so `seq` is always the number
|
|
121
|
+
// the padded key was derived from.
|
|
122
|
+
for (const [, row] of table.entries()) {
|
|
123
|
+
if (row.seq > counter) counter = row.seq
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function onWriteError(error) {
|
|
128
|
+
status.warn(`model-call log write failed: ${String(error)}`)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Keep the newest `limit` rows; drop the rest oldest-first. */
|
|
132
|
+
async function prune() {
|
|
133
|
+
while (table.size > limit) {
|
|
134
|
+
let oldest
|
|
135
|
+
for (const [key, row] of table.entries()) {
|
|
136
|
+
if (oldest === undefined || row.seq < oldest.row.seq) oldest = { key, row }
|
|
137
|
+
}
|
|
138
|
+
if (oldest === undefined) return
|
|
139
|
+
await table.delete(oldest.key)
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function write(key, record) {
|
|
144
|
+
try {
|
|
145
|
+
await table.put(key, record)
|
|
146
|
+
await prune()
|
|
147
|
+
} catch (error) {
|
|
148
|
+
onWriteError(error)
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Record one attempt as it is selected, before the call is made.
|
|
154
|
+
*
|
|
155
|
+
* @param {string} purpose - The same string handed to `llm.stream`.
|
|
156
|
+
* @param {{provider: string, model: string}} route
|
|
157
|
+
* @returns {{seq: number, finish: (outcome: string, detail?: string) => Promise<void>}}
|
|
158
|
+
*/
|
|
159
|
+
function begin(purpose, route) {
|
|
160
|
+
counter += 1
|
|
161
|
+
const seq = counter
|
|
162
|
+
const at = now()
|
|
163
|
+
const key = keyOf(seq)
|
|
164
|
+
const record = {
|
|
165
|
+
seq,
|
|
166
|
+
at,
|
|
167
|
+
purpose,
|
|
168
|
+
provider: String(route?.provider ?? ''),
|
|
169
|
+
model: String(route?.model ?? ''),
|
|
170
|
+
outcome: 'pending',
|
|
171
|
+
ms: 0,
|
|
172
|
+
detail: '',
|
|
173
|
+
}
|
|
174
|
+
// Durable before the call runs, so an attempt that never settles still
|
|
175
|
+
// leaves a row. Awaited by `finish`, never by the caller.
|
|
176
|
+
const stored = table === undefined ? Promise.resolve() : write(key, record)
|
|
177
|
+
return {
|
|
178
|
+
seq,
|
|
179
|
+
async finish(outcome, detail = '') {
|
|
180
|
+
// Never rejects, and callers do not await it: every one of them calls
|
|
181
|
+
// this from a `finally`, where a throw here would replace the error the
|
|
182
|
+
// queue was going to see, and a storage stall would hold the queue task
|
|
183
|
+
// open after its own timeout has already been cleared.
|
|
184
|
+
try {
|
|
185
|
+
await stored
|
|
186
|
+
const ms = Math.max(0, now() - at)
|
|
187
|
+
const bounded = String(detail).slice(0, DETAIL_MAX)
|
|
188
|
+
if (table !== undefined) {
|
|
189
|
+
await write(key, { ...record, outcome, ms, detail: bounded })
|
|
190
|
+
}
|
|
191
|
+
const suffix = bounded === '' ? '' : ` (${bounded})`
|
|
192
|
+
status.info(`model call: ${purpose} ${record.provider}/${record.model} ${outcome} ${ms}ms${suffix}`)
|
|
193
|
+
} catch (error) {
|
|
194
|
+
onWriteError(error)
|
|
195
|
+
}
|
|
196
|
+
},
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Newest-first snapshot, for a human reading the state file or a card. */
|
|
201
|
+
function recent(count = 20) {
|
|
202
|
+
if (table === undefined) return []
|
|
203
|
+
return [...table.entries()]
|
|
204
|
+
.map(([, row]) => row)
|
|
205
|
+
.sort((a, b) => Number(b?.seq) - Number(a?.seq))
|
|
206
|
+
.slice(0, count)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return { begin, recent }
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Open the diagnostics domain and build the log. Never throws: a domain that
|
|
214
|
+
* will not open costs the stored history, not the memory pipeline.
|
|
215
|
+
*
|
|
216
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx - context injecting `storageDomain`.
|
|
217
|
+
* @param {{status: import('./status.js').StatusLog, limit?: number, now?: () => number}} deps
|
|
218
|
+
* @returns {Promise<{modelLog: ReturnType<typeof createModelLog>, domain?: any}>}
|
|
219
|
+
* `domain` is the open handle when there is one, so a caller that finds itself
|
|
220
|
+
* disposed can close it (mirrors `openState`).
|
|
221
|
+
*/
|
|
222
|
+
export async function openModelLog(ctx, { status, limit, now }) {
|
|
223
|
+
let domain
|
|
224
|
+
try {
|
|
225
|
+
domain = await ctx.storageDomain.open(diagnosticsSpec)
|
|
226
|
+
} catch (error) {
|
|
227
|
+
status.warn(`model-call log unavailable, attempts will only be logged: ${String(error)}`)
|
|
228
|
+
return { modelLog: createModelLog({ status, limit, now }) }
|
|
229
|
+
}
|
|
230
|
+
try {
|
|
231
|
+
ctx.effect(() => () => {
|
|
232
|
+
void domain.close().catch(() => {
|
|
233
|
+
// Closing during teardown must never take the fiber down.
|
|
234
|
+
})
|
|
235
|
+
}, 'hypatia-auto-memory: close diagnostics domain')
|
|
236
|
+
} catch {
|
|
237
|
+
// The fiber went away while the domain was opening, so no disposer will
|
|
238
|
+
// ever run for it — closing here is the only thing that prevents a leak.
|
|
239
|
+
// Deliberately silent: the plugin is being torn down, and a warning about
|
|
240
|
+
// an unavailable log would be noise at exactly the moment nothing can act
|
|
241
|
+
// on it.
|
|
242
|
+
await domain.close().catch(() => {})
|
|
243
|
+
return { modelLog: createModelLog({ status, limit, now }) }
|
|
244
|
+
}
|
|
245
|
+
return { modelLog: createModelLog({ table: domain.table('model_calls'), status, limit, now }), domain }
|
|
246
|
+
}
|