@deepwatch/dsh-contracts 0.1.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.
@@ -0,0 +1,204 @@
1
+ /**
2
+ * What a person is told when a conversation cannot run, and what they are not.
3
+ *
4
+ * A prompt failed and the Chat surface showed the reader
5
+ * `@deepseek-ai/dsh-system-prompt`, the route id `llm-deepseek`, the provider
6
+ * key `deepseek-official`, the environment variable `DEEPSEEK_API_KEY`, and a
7
+ * paragraph of sandbox-policy text. Every one of those is true, and not one of
8
+ * them told the reader what to do. Worse, two of them named a provider they had
9
+ * never chosen, so the message actively misled: it read as *DeepWatch is a
10
+ * DeepSeek product that is broken* rather than *nothing is bound yet*.
11
+ *
12
+ * This module draws the line. A raw provider or runtime failure is classified
13
+ * into one of a small closed set of {@link FailureKind}s, each of which becomes
14
+ * a card with a title, one sentence, and exactly one next action. The detailed
15
+ * technical facts are not destroyed — they go to Diagnostics and the Session
16
+ * Log, redacted — but they never render in an ordinary conversation.
17
+ *
18
+ * **Two rules hold everything here together.**
19
+ *
20
+ * The first is that a card carries no implementation identity.
21
+ * {@link assertNoInternalDisclosure} is the executable form of that rule, and
22
+ * it is applied to every card this module produces rather than trusted to
23
+ * review.
24
+ *
25
+ * The second is that nothing here ever carries credential material — and
26
+ * "material" includes the things people reach for when they are trying to be
27
+ * helpful about a key without printing it: a prefix, a suffix, a length, a
28
+ * fingerprint, a hash. A reader does not need to be told their key is 51
29
+ * characters long, and an attacker reading a shared screenshot does.
30
+ *
31
+ * @module @deepwatch/dsh-contracts/failures
32
+ */
33
+ /** The card each kind renders as. */
34
+ const CARDS = {
35
+ not_configured: {
36
+ title: 'Chat model is not configured',
37
+ detail: 'Choose a provider and a model, then assign one to Chat. Nothing is sent until you do.',
38
+ action: 'Choose models and roles',
39
+ target: 'role-bindings',
40
+ },
41
+ not_tested: {
42
+ title: 'Chat provider has not been tested',
43
+ detail: 'The provider and model are assigned, but no provider request has succeeded yet.',
44
+ action: 'Run provider test',
45
+ target: 'role-bindings',
46
+ },
47
+ credential_unavailable: {
48
+ title: 'Credential is unavailable',
49
+ detail: 'A credential is assigned to this provider, and it could not be read on this machine.',
50
+ action: 'Review the provider credential',
51
+ target: 'provider-credential',
52
+ },
53
+ credential_rejected: {
54
+ title: 'Provider rejected the credential',
55
+ detail: 'The provider answered and would not accept the credential saved for it.',
56
+ action: 'Update the provider credential',
57
+ target: 'provider-credential',
58
+ },
59
+ model_unavailable: {
60
+ title: 'Model is no longer available',
61
+ detail: 'The provider no longer offers the model assigned to this capability.',
62
+ action: 'Choose another model',
63
+ target: 'model-selection',
64
+ },
65
+ provider_unreachable: {
66
+ title: 'Provider is temporarily unreachable',
67
+ detail: 'No usable answer came back from the provider. Nothing was charged and no turn was recorded.',
68
+ action: 'Try again',
69
+ target: 'retry',
70
+ },
71
+ rate_limited: {
72
+ title: 'Request was rate-limited',
73
+ detail: 'The provider declined this request for rate reasons. Waiting and retrying usually clears it.',
74
+ action: 'Try again',
75
+ target: 'retry',
76
+ },
77
+ policy_forbids: {
78
+ title: 'This machine’s policy blocked the request',
79
+ detail: 'A policy in force here does not permit this request. Diagnostics records which one.',
80
+ action: 'Open Diagnostics',
81
+ target: 'diagnostics',
82
+ },
83
+ unavailable: {
84
+ title: 'The request could not be completed',
85
+ detail: 'Something went wrong before a reply could be produced. Diagnostics has the detail.',
86
+ action: 'Open Diagnostics',
87
+ target: 'diagnostics',
88
+ },
89
+ };
90
+ /**
91
+ * The card for one kind.
92
+ *
93
+ * @param kind - the classified failure.
94
+ * @param hasDiagnostics - whether a redacted record was written for it.
95
+ * @returns a card safe to render in an ordinary conversation.
96
+ */
97
+ export function failureCard(kind, hasDiagnostics = true) {
98
+ const card = CARDS[kind];
99
+ return { kind, ...card, hasDiagnostics };
100
+ }
101
+ /**
102
+ * The card a readiness blocker becomes.
103
+ *
104
+ * The two vocabularies are separate because they answer different questions —
105
+ * a blocker is "what is missing", a card is "what a person sees when they tried
106
+ * anyway" — and this is the one place they are joined, so a blocker can never
107
+ * reach a reader with no card defined for it.
108
+ */
109
+ export function cardForBlocker(blocker) {
110
+ const kind = blocker === 'credential_inaccessible'
111
+ ? 'credential_unavailable'
112
+ : blocker === 'provider_untested'
113
+ ? 'not_tested'
114
+ : blocker === 'credential_rejected'
115
+ ? 'credential_rejected'
116
+ : blocker === 'provider_unreachable'
117
+ ? 'provider_unreachable'
118
+ : blocker === 'provider_rate_limited'
119
+ ? 'rate_limited'
120
+ : blocker === 'model_unavailable' || blocker === 'model_invalid'
121
+ ? 'model_unavailable'
122
+ : blocker === 'policy_forbids' || blocker === 'consent_required'
123
+ ? 'policy_forbids'
124
+ : 'not_configured';
125
+ return failureCard(kind);
126
+ }
127
+ /**
128
+ * Classify a raw failure without letting its words through.
129
+ *
130
+ * The input is read for *signals* — an HTTP status, a taxonomy code the
131
+ * Harness already normalised — and the output is a kind. The raw text is
132
+ * never returned, never embedded, and never partially quoted, because a
133
+ * provider's message is exactly where the internal identifiers come from.
134
+ *
135
+ * @param signal - what the runtime managed to normalise about the failure.
136
+ * @returns the kind a reader is told about.
137
+ */
138
+ export function classifyFailure(signal) {
139
+ const { status, code } = signal;
140
+ if (code === 'MISSING_CREDENTIAL')
141
+ return 'credential_unavailable';
142
+ if (code === 'NO_ADAPTER' || code === 'MODEL_NOT_FOUND')
143
+ return 'model_unavailable';
144
+ if (code === 'AUTH')
145
+ return 'credential_rejected';
146
+ if (code === 'RATE_LIMIT')
147
+ return 'rate_limited';
148
+ if (status === 401 || status === 403)
149
+ return 'credential_rejected';
150
+ if (status === 404)
151
+ return 'model_unavailable';
152
+ if (status === 429)
153
+ return 'rate_limited';
154
+ if (status !== undefined && status >= 500)
155
+ return 'provider_unreachable';
156
+ return 'unavailable';
157
+ }
158
+ /**
159
+ * Implementation identity that may not appear in an ordinary conversation.
160
+ *
161
+ * Each of these was on screen when a person's first prompt failed. They are
162
+ * matched as *shapes* rather than as a list of literals, because the list
163
+ * would go stale on the next baseline bump while the shapes — a scoped package
164
+ * name, an adapter route id, a screaming-snake environment variable — do not.
165
+ */
166
+ const INTERNAL_SHAPES = [
167
+ { what: 'a scoped package name', pattern: /@(?:deepseek-ai|deepwatch|earendil-works)\/[a-z0-9-]+/i },
168
+ { what: 'an adapter route id', pattern: /\bllm-[a-z0-9-]+\b/i },
169
+ { what: 'a provider route key', pattern: /\b[a-z0-9]+-official\b/i },
170
+ { what: 'an environment variable name', pattern: /\b[A-Z][A-Z0-9]*_(?:API_KEY|KEY|TOKEN|SECRET|BASE_URL)\b/ },
171
+ { what: 'a stack frame', pattern: /\bat\s+[\w$.<>]+\s+\([^)]*:\d+:\d+\)/ },
172
+ { what: 'a module specifier', pattern: /\b(?:node|file):[/\\]{2}?[^\s"']+/i },
173
+ { what: 'a sandbox or approval policy identifier', pattern: /\b(?:sandbox|approval)Policy\b|\bpolicy:[a-z-]+/i },
174
+ ];
175
+ /**
176
+ * Throw when text bound for an ordinary conversation carries an internal name.
177
+ *
178
+ * Applied to cards at construction rather than to the screen at review time.
179
+ * The failure it prevents is not hypothetical: every shape above is one that
180
+ * actually reached a reader, and the reason each got there was that some layer
181
+ * passed a provider or runtime string through "just this once".
182
+ *
183
+ * @param where - the surface being guarded, so a failure says what to fix.
184
+ * @param text - the candidate copy.
185
+ */
186
+ export function assertNoInternalDisclosure(where, text) {
187
+ for (const { what, pattern } of INTERNAL_SHAPES) {
188
+ const found = pattern.exec(text);
189
+ if (found === null)
190
+ continue;
191
+ throw new Error(`${where} carries ${what} (${found[0]}). Implementation identity belongs in `
192
+ + 'Diagnostics, not in a conversation.');
193
+ }
194
+ }
195
+ /**
196
+ * Whether a card is safe to render, as a boolean rather than a throw.
197
+ *
198
+ * The same check for callers that are validating a table of copy rather than
199
+ * building one value — a test over every kind, typically.
200
+ */
201
+ export function isDisclosureSafe(text) {
202
+ return !INTERNAL_SHAPES.some(shape => shape.pattern.test(text));
203
+ }
204
+ //# sourceMappingURL=failures.js.map
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Content identity, as Watch Core defines it.
3
+ *
4
+ * This is a mirror, not a second algorithm. `src/watch_skill/identity.py` is
5
+ * the source of truth, and it exists because of a specific defect: a video's id
6
+ * used to be `sha256(source_string)`, so overwriting `demo.mp4` returned
7
+ * yesterday's frames, OCR and cached answers for today's file with nothing in
8
+ * the reply admitting it. Core's answer was to stop treating the string as the
9
+ * identity and to name four things separately — the alias the user typed, the
10
+ * logical asset, the immutable revision keyed by content digest, and the cheap
11
+ * fingerprint that decides whether the digest has to be recomputed.
12
+ *
13
+ * The Workspace needs the same identity for the same reason. A Library record
14
+ * read from a file that carries no id of its own was briefly identified by a
15
+ * digest of its *path*, which fixed a disclosure problem and reintroduced the
16
+ * original one: move the bytes and they became a different record; overwrite
17
+ * them and they stayed the same one.
18
+ *
19
+ * So the functions below reproduce Core's exactly, including the material
20
+ * strings that go into each hash, and `tests/content-identity.test.mjs` runs
21
+ * the Python and asserts the two agree. A change to either side that is not
22
+ * made to both fails that test rather than silently splitting the namespace.
23
+ *
24
+ * Browser-safe: hashing goes through the Web Crypto API where the caller has
25
+ * bytes, and every id here is derived from a digest the caller already holds.
26
+ *
27
+ * @module @deepwatch/dsh-contracts/identity
28
+ */
29
+ /** The one digest algorithm this contract uses. Mirrors `DIGEST_ALGORITHM`. */
30
+ export declare const DIGEST_ALGORITHM = "sha256";
31
+ /** Whether a value is a content digest this contract will carry. */
32
+ export declare function isContentDigest(value: unknown): value is string;
33
+ /**
34
+ * The id for one immutable version of some content.
35
+ *
36
+ * `identity.revision_id_for`. The material is `"<algorithm>:<digest>"`, so the
37
+ * algorithm is inside the hash and a future digest change cannot collide with
38
+ * this one.
39
+ */
40
+ export declare function revisionIdFor(contentDigest: string, digest: (value: string) => string): string;
41
+ /**
42
+ * The canonical id for the content itself.
43
+ *
44
+ * `identity.video_id_for_digest`. Named for content rather than for video
45
+ * here because the Library indexes documents, pages and captures through the
46
+ * same function; the material string is Core's, unchanged, so the two produce
47
+ * the same id for the same bytes.
48
+ *
49
+ * Sixteen hex characters with no prefix, which is both what Core hands out and
50
+ * an identifier `@deepwatch/dsh-contracts/query` will accept — so an id
51
+ * derived here can be sent straight back through a read that validates it.
52
+ */
53
+ export declare function contentIdFor(contentDigest: string, digest: (value: string) => string): string;
54
+ //# sourceMappingURL=identity.d.ts.map
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Content identity, as Watch Core defines it.
3
+ *
4
+ * This is a mirror, not a second algorithm. `src/watch_skill/identity.py` is
5
+ * the source of truth, and it exists because of a specific defect: a video's id
6
+ * used to be `sha256(source_string)`, so overwriting `demo.mp4` returned
7
+ * yesterday's frames, OCR and cached answers for today's file with nothing in
8
+ * the reply admitting it. Core's answer was to stop treating the string as the
9
+ * identity and to name four things separately — the alias the user typed, the
10
+ * logical asset, the immutable revision keyed by content digest, and the cheap
11
+ * fingerprint that decides whether the digest has to be recomputed.
12
+ *
13
+ * The Workspace needs the same identity for the same reason. A Library record
14
+ * read from a file that carries no id of its own was briefly identified by a
15
+ * digest of its *path*, which fixed a disclosure problem and reintroduced the
16
+ * original one: move the bytes and they became a different record; overwrite
17
+ * them and they stayed the same one.
18
+ *
19
+ * So the functions below reproduce Core's exactly, including the material
20
+ * strings that go into each hash, and `tests/content-identity.test.mjs` runs
21
+ * the Python and asserts the two agree. A change to either side that is not
22
+ * made to both fails that test rather than silently splitting the namespace.
23
+ *
24
+ * Browser-safe: hashing goes through the Web Crypto API where the caller has
25
+ * bytes, and every id here is derived from a digest the caller already holds.
26
+ *
27
+ * @module @deepwatch/dsh-contracts/identity
28
+ */
29
+ /** The one digest algorithm this contract uses. Mirrors `DIGEST_ALGORITHM`. */
30
+ export const DIGEST_ALGORITHM = 'sha256';
31
+ /** Hex characters in a short id. Sixteen, as every Core id already is. */
32
+ const SHORT_LENGTH = 16;
33
+ /** Lowercase hex of a sha-256 digest, and nothing else. */
34
+ const DIGEST = /^[0-9a-f]{64}$/;
35
+ /** Whether a value is a content digest this contract will carry. */
36
+ export function isContentDigest(value) {
37
+ return typeof value === 'string' && DIGEST.test(value);
38
+ }
39
+ /**
40
+ * `sha256(material)` truncated to sixteen hex characters, with a prefix.
41
+ *
42
+ * `identity._short`. The truncation is Core's and is kept: every id ever
43
+ * printed, cached, or written into an agent's notes is this width, and
44
+ * widening it here would split the namespace in the other direction.
45
+ */
46
+ function short(prefix, material, digest) {
47
+ return prefix + digest(material).slice(0, SHORT_LENGTH);
48
+ }
49
+ /**
50
+ * The id for one immutable version of some content.
51
+ *
52
+ * `identity.revision_id_for`. The material is `"<algorithm>:<digest>"`, so the
53
+ * algorithm is inside the hash and a future digest change cannot collide with
54
+ * this one.
55
+ */
56
+ export function revisionIdFor(contentDigest, digest) {
57
+ return short('rev_', `${DIGEST_ALGORITHM}:${contentDigest}`, digest);
58
+ }
59
+ /**
60
+ * The canonical id for the content itself.
61
+ *
62
+ * `identity.video_id_for_digest`. Named for content rather than for video
63
+ * here because the Library indexes documents, pages and captures through the
64
+ * same function; the material string is Core's, unchanged, so the two produce
65
+ * the same id for the same bytes.
66
+ *
67
+ * Sixteen hex characters with no prefix, which is both what Core hands out and
68
+ * an identifier `@deepwatch/dsh-contracts/query` will accept — so an id
69
+ * derived here can be sent straight back through a read that validates it.
70
+ */
71
+ export function contentIdFor(contentDigest, digest) {
72
+ return digest(`watch-skill/v2/${contentDigest}`).slice(0, SHORT_LENGTH);
73
+ }
74
+ //# sourceMappingURL=identity.js.map