@try-works/dsh-righthand 0.1.4 → 0.1.6

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 CHANGED
@@ -26,6 +26,8 @@ dsh --profile <profile> --patch ./cordis.patch.yml
26
26
  |---|---|---|
27
27
  | `rh_store_put` / `rh_store_get` / `rh_store_delete` / `rh_store_list` | store | `ctx.storageDomain` (domain KV) |
28
28
  | `rh_task_create` / `rh_task_list` / `rh_task_next` / `rh_task_update` / `rh_task_delete` | tasks | `ctx.storageDomain` (typed `righthand_tasks` domain; state machine open → done/failed) |
29
+ | `rh_text_summarise` / `rh_text_extract` / `rh_text_classify` / `rh_text_translate` | text | `ctx.llm` (one model call per verb) |
30
+ | `rh_weather_forecast` / `rh_weather_air` | weather | Open-Meteo (keyless), every request through the SSRF-guarded fetcher |
29
31
  | `rh_credential_describe` / `rh_credential_set` / `rh_credential_unset` | secrets | `ctx.credentials` (values never echoed) |
30
32
  | `rh_settings_get` / `rh_settings_set` | secrets | `ctx.settings` (namespace `righthand`) |
31
33
  | `rh_run` | exec | `ctx.subprocess` (collect mode, bounded output) |
@@ -81,6 +83,7 @@ canonical example of the current DSH-native form (tool-to-tool via
81
83
  | Blueprint | What it builds |
82
84
  |---|---|
83
85
  | `blueprint/daily-digest` | Collect sources, summarize, send on a schedule — extractive locally, LLM synthesis as escalation |
86
+ | `blueprint/data-adapter` | Wrap one keyless public API as a tool family: guarded fetch, normalize, one tool per query. Worked example ships in the plugin: `rh_weather_*` |
84
87
  | `blueprint/research-radar` | Multi-source research over a rolling window (Reddit, X, YouTube, HN, Polymarket, web): score, dedupe, synthesize with citations |
85
88
  | `blueprint/web-scraper` | Fetch a page, extract title/headings/links/text, make it searchable — static HTML first, render/vision as escalation |
86
89
  | `blueprint/vision-worker` | Cloudflare Worker exposing kimi-k2.6 vision: route image requests for text-only models, return a versioned `VisionEnvelope` |
package/lib/index.d.ts CHANGED
@@ -15,6 +15,8 @@
15
15
  * - store-tools: rh_store_put/get/delete/list over ctx.storageDomain
16
16
  * - secrets-tools: rh_credential_describe/set/unset + rh_settings_get/set
17
17
  * - task-tools: rh_task_create/list/next/update/delete over ctx.storageDomain
18
+ * - text-tools: rh_text_summarise/extract/classify/translate over ctx.llm
19
+ * - weather-tools: rh_weather_forecast/air over Open-Meteo (keyless, SSRF-guarded)
18
20
  * - exec-tools: rh_run / rh_run_bg over ctx.subprocess + ctx.jobs
19
21
  * - guard-tools: tools/pre-execute policy (config.rules)
20
22
  * - skills: the packaged dsh-righthand skill (ctx.skills)
@@ -27,6 +29,8 @@ import * as secretsTools from './secrets-tools.ts';
27
29
  import * as execTools from './exec-tools.ts';
28
30
  import * as guardTools from './guard-tools.ts';
29
31
  import * as taskTools from './task-tools.ts';
32
+ import * as textTools from './text-tools.ts';
33
+ import * as weatherTools from './weather-tools.ts';
30
34
  import * as righthandSkills from './skills.ts';
31
35
  import type { GuardRule } from './guard-tools.ts';
32
36
  export declare const name = "dsh-righthand";
@@ -44,9 +48,11 @@ export interface RighthandConfig {
44
48
  * instead of failing the whole plugin.
45
49
  */
46
50
  export declare function apply(ctx: Context, config?: RighthandConfig): void;
47
- export { storeTools, secretsTools, execTools, taskTools, guardTools, righthandSkills };
51
+ export { storeTools, secretsTools, execTools, taskTools, textTools, weatherTools, guardTools, righthandSkills };
48
52
  export { SKILL_NAME, SKILL_DESCRIPTION, skillDirectory, skillBody } from './skills.ts';
49
53
  export type { GuardRule } from './guard-tools.ts';
50
54
  export { guardFactsFor } from './guard-tools.ts';
51
55
  export type { GuardFacts } from './guard-tools.ts';
56
+ export { isBlockedIP, guardedFetch, normalizeForecast, normalizeAir } from './weather-tools.ts';
57
+ export type { WeatherConfig, ForecastOut, AirOut } from './weather-tools.ts';
52
58
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAA;AAClD,OAAO,KAAK,UAAU,MAAM,kBAAkB,CAAA;AAC9C,OAAO,KAAK,YAAY,MAAM,oBAAoB,CAAA;AAClD,OAAO,KAAK,SAAS,MAAM,iBAAiB,CAAA;AAC5C,OAAO,KAAK,UAAU,MAAM,kBAAkB,CAAA;AAC9C,OAAO,KAAK,SAAS,MAAM,iBAAiB,CAAA;AAC5C,OAAO,KAAK,eAAe,MAAM,aAAa,CAAA;AAC9C,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAA;AAEjD,eAAO,MAAM,IAAI,kBAAkB,CAAA;AAEnC,iFAAiF;AACjF,eAAO,MAAM,MAAM,EAAE,MAAM,EAAO,CAAA;AAElC,qCAAqC;AACrC,MAAM,WAAW,eAAe;IAC9B,qEAAqE;IACrE,KAAK,CAAC,EAAE,SAAS,EAAE,CAAA;CACpB;AAED;;;;;GAKG;AACH,wBAAgB,KAAK,CAAC,GAAG,EAAE,OAAO,EAAE,MAAM,GAAE,eAAoB,GAAG,IAAI,CAOtE;AAGD,OAAO,EAAE,UAAU,EAAE,YAAY,EAAE,SAAS,EAAE,SAAS,EAAE,UAAU,EAAE,eAAe,EAAE,CAAA;AACtF,OAAO,EAAE,UAAU,EAAE,iBAAiB,EAAE,cAAc,EAAE,SAAS,EAAE,MAAM,aAAa,CAAA;AACtF,YAAY,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAA;AACjD,OAAO,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAA;AAChD,YAAY,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAEH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAA;AAClD,OAAO,KAAK,UAAU,MAAM,kBAAkB,CAAA;AAC9C,OAAO,KAAK,YAAY,MAAM,oBAAoB,CAAA;AAClD,OAAO,KAAK,SAAS,MAAM,iBAAiB,CAAA;AAC5C,OAAO,KAAK,UAAU,MAAM,kBAAkB,CAAA;AAC9C,OAAO,KAAK,SAAS,MAAM,iBAAiB,CAAA;AAC5C,OAAO,KAAK,SAAS,MAAM,iBAAiB,CAAA;AAC5C,OAAO,KAAK,YAAY,MAAM,oBAAoB,CAAA;AAClD,OAAO,KAAK,eAAe,MAAM,aAAa,CAAA;AAC9C,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAA;AAEjD,eAAO,MAAM,IAAI,kBAAkB,CAAA;AAEnC,iFAAiF;AACjF,eAAO,MAAM,MAAM,EAAE,MAAM,EAAO,CAAA;AAElC,qCAAqC;AACrC,MAAM,WAAW,eAAe;IAC9B,qEAAqE;IACrE,KAAK,CAAC,EAAE,SAAS,EAAE,CAAA;CACpB;AAED;;;;;GAKG;AACH,wBAAgB,KAAK,CAAC,GAAG,EAAE,OAAO,EAAE,MAAM,GAAE,eAAoB,GAAG,IAAI,CAStE;AAGD,OAAO,EAAE,UAAU,EAAE,YAAY,EAAE,SAAS,EAAE,SAAS,EAAE,SAAS,EAAE,YAAY,EAAE,UAAU,EAAE,eAAe,EAAE,CAAA;AAC/G,OAAO,EAAE,UAAU,EAAE,iBAAiB,EAAE,cAAc,EAAE,SAAS,EAAE,MAAM,aAAa,CAAA;AACtF,YAAY,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAA;AACjD,OAAO,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAA;AAChD,YAAY,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAA;AAClD,OAAO,EAAE,WAAW,EAAE,YAAY,EAAE,iBAAiB,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAA;AAC/F,YAAY,EAAE,aAAa,EAAE,WAAW,EAAE,MAAM,EAAE,MAAM,oBAAoB,CAAA"}
package/lib/index.js CHANGED
@@ -15,6 +15,8 @@
15
15
  * - store-tools: rh_store_put/get/delete/list over ctx.storageDomain
16
16
  * - secrets-tools: rh_credential_describe/set/unset + rh_settings_get/set
17
17
  * - task-tools: rh_task_create/list/next/update/delete over ctx.storageDomain
18
+ * - text-tools: rh_text_summarise/extract/classify/translate over ctx.llm
19
+ * - weather-tools: rh_weather_forecast/air over Open-Meteo (keyless, SSRF-guarded)
18
20
  * - exec-tools: rh_run / rh_run_bg over ctx.subprocess + ctx.jobs
19
21
  * - guard-tools: tools/pre-execute policy (config.rules)
20
22
  * - skills: the packaged dsh-righthand skill (ctx.skills)
@@ -26,6 +28,8 @@ import * as secretsTools from "./secrets-tools.js";
26
28
  import * as execTools from "./exec-tools.js";
27
29
  import * as guardTools from "./guard-tools.js";
28
30
  import * as taskTools from "./task-tools.js";
31
+ import * as textTools from "./text-tools.js";
32
+ import * as weatherTools from "./weather-tools.js";
29
33
  import * as righthandSkills from "./skills.js";
30
34
  export const name = 'dsh-righthand';
31
35
  /** The combined plugin needs no services itself; each child waits on its own. */
@@ -41,11 +45,14 @@ export function apply(ctx, config = {}) {
41
45
  ctx.plugin(secretsTools);
42
46
  ctx.plugin(execTools);
43
47
  ctx.plugin(taskTools);
48
+ ctx.plugin(textTools);
49
+ ctx.plugin(weatherTools);
44
50
  ctx.plugin(guardTools, { rules: config.rules ?? [] });
45
51
  ctx.plugin(righthandSkills);
46
52
  }
47
53
  // Individual modules stay importable for selective mounting.
48
- export { storeTools, secretsTools, execTools, taskTools, guardTools, righthandSkills };
54
+ export { storeTools, secretsTools, execTools, taskTools, textTools, weatherTools, guardTools, righthandSkills };
49
55
  export { SKILL_NAME, SKILL_DESCRIPTION, skillDirectory, skillBody } from "./skills.js";
50
56
  export { guardFactsFor } from "./guard-tools.js";
57
+ export { isBlockedIP, guardedFetch, normalizeForecast, normalizeAir } from "./weather-tools.js";
51
58
  //# sourceMappingURL=index.js.map
package/lib/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAGH,OAAO,KAAK,UAAU,MAAM,kBAAkB,CAAA;AAC9C,OAAO,KAAK,YAAY,MAAM,oBAAoB,CAAA;AAClD,OAAO,KAAK,SAAS,MAAM,iBAAiB,CAAA;AAC5C,OAAO,KAAK,UAAU,MAAM,kBAAkB,CAAA;AAC9C,OAAO,KAAK,SAAS,MAAM,iBAAiB,CAAA;AAC5C,OAAO,KAAK,eAAe,MAAM,aAAa,CAAA;AAG9C,MAAM,CAAC,MAAM,IAAI,GAAG,eAAe,CAAA;AAEnC,iFAAiF;AACjF,MAAM,CAAC,MAAM,MAAM,GAAa,EAAE,CAAA;AAQlC;;;;;GAKG;AACH,MAAM,UAAU,KAAK,CAAC,GAAY,EAAE,SAA0B,EAAE;IAC9D,GAAG,CAAC,MAAM,CAAC,UAAU,CAAC,CAAA;IACtB,GAAG,CAAC,MAAM,CAAC,YAAY,CAAC,CAAA;IACxB,GAAG,CAAC,MAAM,CAAC,SAAS,CAAC,CAAA;IACrB,GAAG,CAAC,MAAM,CAAC,SAAS,CAAC,CAAA;IACrB,GAAG,CAAC,MAAM,CAAC,UAAU,EAAE,EAAE,KAAK,EAAE,MAAM,CAAC,KAAK,IAAI,EAAE,EAAE,CAAC,CAAA;IACrD,GAAG,CAAC,MAAM,CAAC,eAAe,CAAC,CAAA;AAC7B,CAAC;AAED,6DAA6D;AAC7D,OAAO,EAAE,UAAU,EAAE,YAAY,EAAE,SAAS,EAAE,SAAS,EAAE,UAAU,EAAE,eAAe,EAAE,CAAA;AACtF,OAAO,EAAE,UAAU,EAAE,iBAAiB,EAAE,cAAc,EAAE,SAAS,EAAE,MAAM,aAAa,CAAA;AAEtF,OAAO,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAA"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAGH,OAAO,KAAK,UAAU,MAAM,kBAAkB,CAAA;AAC9C,OAAO,KAAK,YAAY,MAAM,oBAAoB,CAAA;AAClD,OAAO,KAAK,SAAS,MAAM,iBAAiB,CAAA;AAC5C,OAAO,KAAK,UAAU,MAAM,kBAAkB,CAAA;AAC9C,OAAO,KAAK,SAAS,MAAM,iBAAiB,CAAA;AAC5C,OAAO,KAAK,SAAS,MAAM,iBAAiB,CAAA;AAC5C,OAAO,KAAK,YAAY,MAAM,oBAAoB,CAAA;AAClD,OAAO,KAAK,eAAe,MAAM,aAAa,CAAA;AAG9C,MAAM,CAAC,MAAM,IAAI,GAAG,eAAe,CAAA;AAEnC,iFAAiF;AACjF,MAAM,CAAC,MAAM,MAAM,GAAa,EAAE,CAAA;AAQlC;;;;;GAKG;AACH,MAAM,UAAU,KAAK,CAAC,GAAY,EAAE,SAA0B,EAAE;IAC9D,GAAG,CAAC,MAAM,CAAC,UAAU,CAAC,CAAA;IACtB,GAAG,CAAC,MAAM,CAAC,YAAY,CAAC,CAAA;IACxB,GAAG,CAAC,MAAM,CAAC,SAAS,CAAC,CAAA;IACrB,GAAG,CAAC,MAAM,CAAC,SAAS,CAAC,CAAA;IACrB,GAAG,CAAC,MAAM,CAAC,SAAS,CAAC,CAAA;IACrB,GAAG,CAAC,MAAM,CAAC,YAAY,CAAC,CAAA;IACxB,GAAG,CAAC,MAAM,CAAC,UAAU,EAAE,EAAE,KAAK,EAAE,MAAM,CAAC,KAAK,IAAI,EAAE,EAAE,CAAC,CAAA;IACrD,GAAG,CAAC,MAAM,CAAC,eAAe,CAAC,CAAA;AAC7B,CAAC;AAED,6DAA6D;AAC7D,OAAO,EAAE,UAAU,EAAE,YAAY,EAAE,SAAS,EAAE,SAAS,EAAE,SAAS,EAAE,YAAY,EAAE,UAAU,EAAE,eAAe,EAAE,CAAA;AAC/G,OAAO,EAAE,UAAU,EAAE,iBAAiB,EAAE,cAAc,EAAE,SAAS,EAAE,MAAM,aAAa,CAAA;AAEtF,OAAO,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAA;AAEhD,OAAO,EAAE,WAAW,EAAE,YAAY,EAAE,iBAAiB,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAA"}
@@ -0,0 +1,18 @@
1
+ /**
2
+ * text-tools — rh_text_* over ctx.llm.
3
+ * Language work at one call per verb: summarise, extract-to-JSON,
4
+ * classify, translate. Same prompt discipline as the digest summarizer:
5
+ * roles stay roles, caps are deliberate, each call's failure is contained
6
+ * in that call (the other tools are unaffected).
7
+ * @module dsh-righthand/text-tools
8
+ */
9
+ import type { Context } from '@deepseek-ai/cordis';
10
+ export declare const name = "righthand-text";
11
+ export declare const inject: string[];
12
+ export interface TextConfig {
13
+ /** LLM route; defaults match the harness default model. */
14
+ provider?: string;
15
+ model?: string;
16
+ }
17
+ export declare function apply(ctx: Context, config?: TextConfig): void;
18
+ //# sourceMappingURL=text-tools.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"text-tools.d.ts","sourceRoot":"","sources":["../src/text-tools.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAA;AAKlD,eAAO,MAAM,IAAI,mBAAmB,CAAA;AACpC,eAAO,MAAM,MAAM,UAAmB,CAAA;AAEtC,MAAM,WAAW,UAAU;IACzB,2DAA2D;IAC3D,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,KAAK,CAAC,EAAE,MAAM,CAAA;CACf;AA8CD,wBAAgB,KAAK,CAAC,GAAG,EAAE,OAAO,EAAE,MAAM,GAAE,UAAe,GAAG,IAAI,CAoGjE"}
@@ -0,0 +1,158 @@
1
+ /**
2
+ * text-tools — rh_text_* over ctx.llm.
3
+ * Language work at one call per verb: summarise, extract-to-JSON,
4
+ * classify, translate. Same prompt discipline as the digest summarizer:
5
+ * roles stay roles, caps are deliberate, each call's failure is contained
6
+ * in that call (the other tools are unaffected).
7
+ * @module dsh-righthand/text-tools
8
+ */
9
+ import { defineTool } from '@deepseek-ai/dsh-tools';
10
+ import { BlockAssembler, createUserMessage, deepFreeze } from '@deepseek-ai/dsh-llm';
11
+ export const name = 'righthand-text';
12
+ export const inject = ['tools', 'llm'];
13
+ const DEFAULTS = { provider: 'deepseek-official', model: 'deepseek-v4-flash' };
14
+ /** One LLM call, text in, text out. Returns the assembled text or throws. */
15
+ async function ask(ctx, cfg, system, user, maxTokens, signal) {
16
+ const options = deepFreeze({
17
+ provider: cfg.provider,
18
+ model: cfg.model,
19
+ messages: [createUserMessage({
20
+ content: [{ type: 'text', text: user }],
21
+ source: { kind: 'plugin', plugin: 'righthand-text' },
22
+ })],
23
+ system,
24
+ maxTokens,
25
+ purpose: 'compaction',
26
+ signal,
27
+ });
28
+ const assembler = new BlockAssembler();
29
+ for await (const chunk of ctx.llm.stream(options))
30
+ assembler.push(chunk);
31
+ if (assembler.finish.kind !== 'stop')
32
+ throw new Error('text model call did not stop cleanly: ' + JSON.stringify(assembler.finish.kind));
33
+ const text = assembler.blocks().filter((b) => b.type === 'text').map((b) => b.text).join(' ').trim();
34
+ if (text === '')
35
+ throw new Error('text model call produced no text');
36
+ return text;
37
+ }
38
+ /** Extract the outermost {...} JSON object from a model reply. */
39
+ function parseJsonWindow(text) {
40
+ const start = text.indexOf('{');
41
+ const end = text.lastIndexOf('}');
42
+ if (start < 0 || end <= start)
43
+ throw new Error('model output was not JSON');
44
+ return JSON.parse(text.slice(start, end + 1));
45
+ }
46
+ /** Summarise: plain prose, no JSON. */
47
+ const SUMMARISE_SYSTEM = 'You summarise text. Return ONLY the summary itself - a few sentences, plain prose, no JSON, no Markdown headers, no quotes around it. Preserve the language of the input.';
48
+ /** Extract: JSON matching a caller schema. */
49
+ const EXTRACT_SYSTEM = 'You extract structured data from text. Return ONLY one JSON object matching the caller schema exactly - no prose, no Markdown, no code fences.';
50
+ /** Classify: one label plus confidence, as JSON. */
51
+ const CLASSIFY_SYSTEM = 'You classify text. Return ONLY one JSON object with exactly two keys: label (one of the given labels, verbatim) and confidence (a number from 0 to 1). No prose, no Markdown.';
52
+ /** Translate: preserve formatting, plain prose. */
53
+ const TRANSLATE_SYSTEM = 'You translate text. Return ONLY the translation - plain prose, no JSON, no quotes around it, preserving paragraph breaks and any Markdown or code the input contains.';
54
+ export function apply(ctx, config = {}) {
55
+ const cfg = { ...DEFAULTS, ...config };
56
+ ctx.tools.register(defineTool({
57
+ name: 'rh_text_summarise',
58
+ description: 'Summarise text into a few sentences. One model call per invocation; the result is new prose, not extracted sentences.',
59
+ parameters: {
60
+ text: { type: 'string', required: true, description: 'The text to summarise (max ~30000 characters).' },
61
+ sentences: { type: 'integer', description: 'Target length in sentences (default 3).' },
62
+ },
63
+ output: {
64
+ schema: {
65
+ type: 'object',
66
+ additionalProperties: false,
67
+ properties: {
68
+ summary: { type: 'string', required: true },
69
+ },
70
+ },
71
+ render: (_args, value) => [{ type: 'text', text: value.summary }],
72
+ },
73
+ async execute(args, exec) {
74
+ const n = args.sentences ?? 3;
75
+ const summary = await ask(ctx, cfg, SUMMARISE_SYSTEM, 'Summarise this in about ' + n + ' sentences:\n\n' + args.text, 600, exec.signal);
76
+ return { summary };
77
+ },
78
+ }));
79
+ ctx.tools.register(defineTool({
80
+ name: 'rh_text_extract',
81
+ description: 'Turn text into JSON matching a schema you give. The schema is a JSON Schema object; the model returns one object conforming to it, or the call fails cleanly.',
82
+ parameters: {
83
+ text: { type: 'string', required: true, description: 'The text to extract from.' },
84
+ schema: { type: 'json', required: true, description: 'JSON Schema the result must match, e.g. { "type": "object", "properties": { "name": { "type": "string" } }, "required": ["name"] }.' },
85
+ },
86
+ output: {
87
+ schema: {
88
+ type: 'object',
89
+ additionalProperties: false,
90
+ properties: {
91
+ extracted: { type: 'json', required: true },
92
+ },
93
+ },
94
+ render: (_args, value) => [{ type: 'text', text: JSON.stringify(value.extracted) }],
95
+ },
96
+ async execute(args, exec) {
97
+ const framed = 'Schema:\n' + JSON.stringify(args.schema) + '\n\nText:\n' + args.text;
98
+ const raw = await ask(ctx, cfg, EXTRACT_SYSTEM, framed, 800, exec.signal);
99
+ return { extracted: parseJsonWindow(raw) };
100
+ },
101
+ }));
102
+ ctx.tools.register(defineTool({
103
+ name: 'rh_text_classify',
104
+ description: 'Sort text into one of the labels you give, with a confidence score from 0 to 1. The label returned is one of yours, verbatim.',
105
+ parameters: {
106
+ text: { type: 'string', required: true, description: 'The text to classify.' },
107
+ labels: { type: 'array', required: true, items: { type: 'string' }, description: 'The candidate labels, e.g. ["bug", "feature request", "question"].' },
108
+ },
109
+ output: {
110
+ schema: {
111
+ type: 'object',
112
+ additionalProperties: false,
113
+ properties: {
114
+ label: { type: 'string', required: true },
115
+ confidence: { type: 'number', required: true },
116
+ },
117
+ },
118
+ render: (_args, value) => [{ type: 'text', text: value.label + ' (' + value.confidence + ')' }],
119
+ },
120
+ async execute(args, exec) {
121
+ if (args.labels.length === 0)
122
+ throw new Error('classify needs at least one label');
123
+ const framed = 'Labels: ' + JSON.stringify(args.labels) + '\n\nText:\n' + args.text;
124
+ const raw = await ask(ctx, cfg, CLASSIFY_SYSTEM, framed, 100, exec.signal);
125
+ const parsed = parseJsonWindow(raw);
126
+ if (typeof parsed.label !== 'string' || !args.labels.includes(parsed.label))
127
+ throw new Error('model returned a label outside the given set');
128
+ const confidence = Number(parsed.confidence);
129
+ if (!Number.isFinite(confidence) || confidence < 0 || confidence > 1)
130
+ throw new Error('model returned an invalid confidence');
131
+ return { label: parsed.label, confidence };
132
+ },
133
+ }));
134
+ ctx.tools.register(defineTool({
135
+ name: 'rh_text_translate',
136
+ description: 'Translate text into another language, preserving formatting (paragraph breaks, Markdown, code).',
137
+ parameters: {
138
+ text: { type: 'string', required: true, description: 'The text to translate.' },
139
+ language: { type: 'string', required: true, description: 'The target language, e.g. "Spanish", "zh-CN", "Japanese".' },
140
+ },
141
+ output: {
142
+ schema: {
143
+ type: 'object',
144
+ additionalProperties: false,
145
+ properties: {
146
+ translation: { type: 'string', required: true },
147
+ },
148
+ },
149
+ render: (_args, value) => [{ type: 'text', text: value.translation }],
150
+ },
151
+ async execute(args, exec) {
152
+ const framed = 'Translate the following into ' + args.language + ':\n\n' + args.text;
153
+ const translation = await ask(ctx, cfg, TRANSLATE_SYSTEM, framed, 800, exec.signal);
154
+ return { translation };
155
+ },
156
+ }));
157
+ }
158
+ //# sourceMappingURL=text-tools.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"text-tools.js","sourceRoot":"","sources":["../src/text-tools.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,68 @@
1
+ /**
2
+ * weather-tools — the worked example of the keyless data-adapter recipe
3
+ * (blueprint/data-adapter). Two tools over Open-Meteo, a keyless API:
4
+ * rh_weather_forecast — current conditions + 3-day min/max
5
+ * rh_weather_air — PM2.5 / PM10 / European AQI
6
+ *
7
+ * Every request goes through guardedFetch, the SSRF checklist from Mu's
8
+ * safefetch: public destinations only (loopback, private, link-local incl.
9
+ * the metadata address, multicast and unspecified are refused), every
10
+ * redirect hop revalidated, response size and time capped.
11
+ * @module dsh-righthand/weather-tools
12
+ */
13
+ import type { Context } from '@deepseek-ai/cordis';
14
+ export declare const name = "righthand-weather";
15
+ export declare const inject: string[];
16
+ export interface WeatherConfig {
17
+ /** Endpoint URLs; defaults are Open-Meteo (keyless). Tests override these. */
18
+ forecastUrl?: string;
19
+ airUrl?: string;
20
+ }
21
+ /**
22
+ * Whether an IP must not be reached: the Mu safefetch set — loopback,
23
+ * RFC1918 private, ULA, link-local (169.254/16, fe80::/10 — which includes
24
+ * the 169.254.169.254 metadata address), multicast, and unspecified.
25
+ */
26
+ export declare function isBlockedIP(ip: string): boolean;
27
+ /**
28
+ * Fetch one URL with the SSRF checklist. `fetchImpl` and `resolve` are
29
+ * injectable for tests; production uses the globals.
30
+ */
31
+ export declare function guardedFetch(url: string, deps?: {
32
+ fetchImpl?: typeof fetch;
33
+ resolve?: (host: string) => Promise<string[]>;
34
+ }): Promise<string>;
35
+ /**
36
+ * Normalize the Open-Meteo forecast response into a stable digest shape:
37
+ * what changed is the wire JSON; what callers see is this.
38
+ */
39
+ export interface ForecastOut {
40
+ latitude: number;
41
+ longitude: number;
42
+ timezone: string;
43
+ current: {
44
+ time: string;
45
+ temperature: number;
46
+ windspeed: number;
47
+ winddirection: number;
48
+ weathercode: number;
49
+ } | null;
50
+ daily: {
51
+ time: string[];
52
+ temperatureMax: number[];
53
+ temperatureMin: number[];
54
+ };
55
+ }
56
+ export declare function normalizeForecast(json: any): ForecastOut;
57
+ /** Normalize the Open-Meteo air-quality response. */
58
+ export interface AirOut {
59
+ latitude: number;
60
+ longitude: number;
61
+ time: string;
62
+ pm2_5: number | null;
63
+ pm10: number | null;
64
+ aqi: number | null;
65
+ }
66
+ export declare function normalizeAir(json: any): AirOut;
67
+ export declare function apply(ctx: Context, config?: WeatherConfig): void;
68
+ //# sourceMappingURL=weather-tools.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"weather-tools.d.ts","sourceRoot":"","sources":["../src/weather-tools.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAA;AAKlD,eAAO,MAAM,IAAI,sBAAsB,CAAA;AACvC,eAAO,MAAM,MAAM,UAAY,CAAA;AAE/B,MAAM,WAAW,aAAa;IAC5B,8EAA8E;IAC9E,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,MAAM,CAAC,EAAE,MAAM,CAAA;CAChB;AAWD;;;;GAIG;AACH,wBAAgB,WAAW,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAoB/C;AAED;;;GAGG;AACH,wBAAsB,YAAY,CAChC,GAAG,EAAE,MAAM,EACX,IAAI,GAAE;IAAE,SAAS,CAAC,EAAE,OAAO,KAAK,CAAC;IAAC,OAAO,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,EAAE,CAAC,CAAA;CAAO,GACrF,OAAO,CAAC,MAAM,CAAC,CAgCjB;AAED;;;GAGG;AACH,MAAM,WAAW,WAAW;IAC1B,QAAQ,EAAE,MAAM,CAAA;IAChB,SAAS,EAAE,MAAM,CAAA;IACjB,QAAQ,EAAE,MAAM,CAAA;IAChB,OAAO,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,WAAW,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,MAAM,CAAC;QAAC,aAAa,EAAE,MAAM,CAAC;QAAC,WAAW,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAA;IACpH,KAAK,EAAE;QAAE,IAAI,EAAE,MAAM,EAAE,CAAC;QAAC,cAAc,EAAE,MAAM,EAAE,CAAC;QAAC,cAAc,EAAE,MAAM,EAAE,CAAA;KAAE,CAAA;CAC9E;AAED,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,GAAG,GAAG,WAAW,CAmBxD;AAED,qDAAqD;AACrD,MAAM,WAAW,MAAM;IACrB,QAAQ,EAAE,MAAM,CAAA;IAChB,SAAS,EAAE,MAAM,CAAA;IACjB,IAAI,EAAE,MAAM,CAAA;IACZ,KAAK,EAAE,MAAM,GAAG,IAAI,CAAA;IACpB,IAAI,EAAE,MAAM,GAAG,IAAI,CAAA;IACnB,GAAG,EAAE,MAAM,GAAG,IAAI,CAAA;CACnB;AAED,wBAAgB,YAAY,CAAC,IAAI,EAAE,GAAG,GAAG,MAAM,CAW9C;AAED,wBAAgB,KAAK,CAAC,GAAG,EAAE,OAAO,EAAE,MAAM,GAAE,aAAkB,GAAG,IAAI,CAyEpE"}
@@ -0,0 +1,208 @@
1
+ /**
2
+ * weather-tools — the worked example of the keyless data-adapter recipe
3
+ * (blueprint/data-adapter). Two tools over Open-Meteo, a keyless API:
4
+ * rh_weather_forecast — current conditions + 3-day min/max
5
+ * rh_weather_air — PM2.5 / PM10 / European AQI
6
+ *
7
+ * Every request goes through guardedFetch, the SSRF checklist from Mu's
8
+ * safefetch: public destinations only (loopback, private, link-local incl.
9
+ * the metadata address, multicast and unspecified are refused), every
10
+ * redirect hop revalidated, response size and time capped.
11
+ * @module dsh-righthand/weather-tools
12
+ */
13
+ import { defineTool } from '@deepseek-ai/dsh-tools';
14
+ import { lookup } from 'node:dns/promises';
15
+ import { isIP } from 'node:net';
16
+ export const name = 'righthand-weather';
17
+ export const inject = ['tools'];
18
+ const DEFAULTS = {
19
+ forecastUrl: 'https://api.open-meteo.com/v1/forecast',
20
+ airUrl: 'https://air-quality-api.open-meteo.com/v1/air-quality',
21
+ };
22
+ const MAX_BODY_BYTES = 2 * 1024 * 1024;
23
+ const MAX_REDIRECTS = 5;
24
+ const FETCH_TIMEOUT_MS = 10000;
25
+ /**
26
+ * Whether an IP must not be reached: the Mu safefetch set — loopback,
27
+ * RFC1918 private, ULA, link-local (169.254/16, fe80::/10 — which includes
28
+ * the 169.254.169.254 metadata address), multicast, and unspecified.
29
+ */
30
+ export function isBlockedIP(ip) {
31
+ if (ip === '')
32
+ return true;
33
+ if (isIP(ip) === 4) {
34
+ const [a, b] = ip.split('.').map(Number);
35
+ if (a === 0 || a === 10 || a === 127)
36
+ return true;
37
+ if (a === 169 && b === 254)
38
+ return true;
39
+ if (a === 172 && b >= 16 && b <= 31)
40
+ return true;
41
+ if (a === 192 && b === 168)
42
+ return true;
43
+ if (a >= 224 && a <= 239)
44
+ return true;
45
+ return false;
46
+ }
47
+ if (isIP(ip) === 6) {
48
+ const lower = ip.toLowerCase();
49
+ if (lower === '::' || lower === '::1')
50
+ return true;
51
+ if (lower.startsWith('fc') || lower.startsWith('fd'))
52
+ return true;
53
+ if (lower.startsWith('fe8') || lower.startsWith('fe9') || lower.startsWith('fea') || lower.startsWith('feb'))
54
+ return true;
55
+ if (lower.startsWith('ff'))
56
+ return true;
57
+ return false;
58
+ }
59
+ return true;
60
+ }
61
+ /**
62
+ * Fetch one URL with the SSRF checklist. `fetchImpl` and `resolve` are
63
+ * injectable for tests; production uses the globals.
64
+ */
65
+ export async function guardedFetch(url, deps = {}) {
66
+ const fetchImpl = deps.fetchImpl ?? fetch;
67
+ const resolve = deps.resolve ?? (async (host) => (await lookup(host, { all: true })).map(a => a.address));
68
+ let current = url;
69
+ for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
70
+ const parsed = new URL(current);
71
+ if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:')
72
+ throw new Error('blocked: non-http(s) URL');
73
+ const ips = await resolve(parsed.hostname);
74
+ if (ips.length === 0 || ips.some(isBlockedIP))
75
+ throw new Error('blocked: destination is not a public host');
76
+ const res = await fetchImpl(current, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), redirect: 'manual' });
77
+ if (res.status >= 300 && res.status < 400) {
78
+ const next = res.headers.get('location');
79
+ if (next === null)
80
+ throw new Error('blocked: redirect without location');
81
+ current = new URL(next, current).toString();
82
+ continue;
83
+ }
84
+ if (!res.ok)
85
+ throw new Error('HTTP ' + res.status + ' for ' + current);
86
+ const reader = res.body?.getReader();
87
+ if (reader === undefined)
88
+ return '';
89
+ const chunks = [];
90
+ let bytes = 0;
91
+ for (;;) {
92
+ const { done, value } = await reader.read();
93
+ if (done)
94
+ break;
95
+ bytes += value.length;
96
+ if (bytes > MAX_BODY_BYTES)
97
+ throw new Error('response exceeds ' + MAX_BODY_BYTES + ' bytes');
98
+ chunks.push(value);
99
+ }
100
+ return Buffer.concat(chunks).toString('utf8');
101
+ }
102
+ throw new Error('too many redirects');
103
+ }
104
+ export function normalizeForecast(json) {
105
+ const cw = json.current_weather;
106
+ return {
107
+ latitude: Number(json.latitude ?? 0),
108
+ longitude: Number(json.longitude ?? 0),
109
+ timezone: json.timezone ?? '',
110
+ current: cw ? {
111
+ time: String(cw.time ?? ''),
112
+ temperature: Number(cw.temperature ?? 0),
113
+ windspeed: Number(cw.windspeed ?? 0),
114
+ winddirection: Number(cw.winddirection ?? 0),
115
+ weathercode: Number(cw.weathercode ?? 0),
116
+ } : null,
117
+ daily: {
118
+ time: (json.daily?.time ?? []).map(String),
119
+ temperatureMax: (json.daily?.temperature_2m_max ?? []).map(Number),
120
+ temperatureMin: (json.daily?.temperature_2m_min ?? []).map(Number),
121
+ },
122
+ };
123
+ }
124
+ export function normalizeAir(json) {
125
+ const c = json.current ?? {};
126
+ const num = (v) => (v === undefined || v === null ? null : Number(v));
127
+ return {
128
+ latitude: Number(json.latitude ?? 0),
129
+ longitude: Number(json.longitude ?? 0),
130
+ time: String(c.time ?? ''),
131
+ pm2_5: num(c.pm2_5),
132
+ pm10: num(c.pm10),
133
+ aqi: num(c.european_aqi),
134
+ };
135
+ }
136
+ export function apply(ctx, config = {}) {
137
+ const cfg = { ...DEFAULTS, ...config };
138
+ ctx.tools.register(defineTool({
139
+ name: 'rh_weather_forecast',
140
+ description: 'Weather forecast for a location from Open-Meteo (keyless): current conditions plus 3 days of min/max temperatures.',
141
+ parameters: {
142
+ latitude: { type: 'number', required: true, description: 'Latitude, e.g. 51.5074 for London.' },
143
+ longitude: { type: 'number', required: true, description: 'Longitude, e.g. -0.1278 for London.' },
144
+ },
145
+ output: {
146
+ schema: {
147
+ type: 'object',
148
+ additionalProperties: false,
149
+ properties: {
150
+ latitude: { type: 'number', required: true },
151
+ longitude: { type: 'number', required: true },
152
+ timezone: { type: 'string', required: true },
153
+ current: { type: 'json' },
154
+ daily: { type: 'json', required: true },
155
+ },
156
+ },
157
+ render: (_args, value) => { const c = value.current; return [{ type: 'text', text: c ? (value.timezone + ': ' + c.temperature + ' C, wind ' + c.windspeed + ' km/h') : value.timezone + ': current conditions unavailable' }]; },
158
+ },
159
+ async execute(args) {
160
+ const url = cfg.forecastUrl
161
+ + '?latitude=' + args.latitude
162
+ + '&longitude=' + args.longitude
163
+ + '&current_weather=true&daily=temperature_2m_max,temperature_2m_min&forecast_days=3';
164
+ const body = await guardedFetch(url);
165
+ return normalizeForecast(JSON.parse(body));
166
+ },
167
+ }));
168
+ ctx.tools.register(defineTool({
169
+ name: 'rh_weather_air',
170
+ description: 'Air quality at a location from Open-Meteo (keyless): PM2.5, PM10 and the European AQI.',
171
+ parameters: {
172
+ latitude: { type: 'number', required: true, description: 'Latitude.' },
173
+ longitude: { type: 'number', required: true, description: 'Longitude.' },
174
+ },
175
+ output: {
176
+ schema: {
177
+ type: 'object',
178
+ additionalProperties: false,
179
+ properties: {
180
+ latitude: { type: 'number', required: true },
181
+ longitude: { type: 'number', required: true },
182
+ time: { type: 'string', required: true },
183
+ pm2_5: { type: 'number' },
184
+ pm10: { type: 'number' },
185
+ aqi: { type: 'number' },
186
+ },
187
+ },
188
+ render: (_args, value) => [{ type: 'text', text: 'PM2.5 ' + value.pm2_5 + ', PM10 ' + value.pm10 + ', AQI ' + value.aqi }],
189
+ },
190
+ async execute(args) {
191
+ const url = cfg.airUrl
192
+ + '?latitude=' + args.latitude
193
+ + '&longitude=' + args.longitude
194
+ + '&current=pm2_5,pm10,european_aqi';
195
+ const body = await guardedFetch(url);
196
+ const out = normalizeAir(JSON.parse(body));
197
+ return {
198
+ latitude: out.latitude,
199
+ longitude: out.longitude,
200
+ time: out.time,
201
+ ...out.pm2_5 !== null ? { pm2_5: out.pm2_5 } : {},
202
+ ...out.pm10 !== null ? { pm10: out.pm10 } : {},
203
+ ...out.aqi !== null ? { aqi: out.aqi } : {},
204
+ };
205
+ },
206
+ }));
207
+ }
208
+ //# sourceMappingURL=weather-tools.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"weather-tools.js","sourceRoot":"","sources":["../src/weather-tools.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@try-works/dsh-righthand",
3
3
  "description": "DeepSeek Harness plugin: DSH-native righthand tools — durable KV store, credential/settings management, governed exec, and a tool guard",
4
- "version": "0.1.4",
4
+ "version": "0.1.6",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "types": "lib/index.d.ts",
@@ -16,6 +16,8 @@ The righthand toolkit: DSH-native tools over the harness's own services. Use thi
16
16
  | secrets | `rh_credential_describe` / `rh_credential_set` / `rh_credential_unset` | `ctx.credentials` | The task must check, store, or remove a credential reference (e.g. `CLOUDFLARE_API_TOKEN`). Values are written durably and **never echoed back**. |
17
17
  | settings | `rh_settings_get` / `rh_settings_set` | `ctx.settings` (namespace `righthand`) | Reading or patching righthand settings (`accountId`, `defaultZone`, `defaultScriptPrefix`). |
18
18
  | exec | `rh_run` / `rh_run_bg` | `ctx.subprocess` + `ctx.jobs` | Running one command (argv array, no shell interpretation). `rh_run` collects bounded output; `rh_run_bg` starts an owner-scoped background job and returns its id. |
19
+ | text | `rh_text_summarise` / `rh_text_extract` / `rh_text_classify` / `rh_text_translate` | `ctx.llm` (one model call per verb) | Language work: shorten, turn into JSON matching a schema, sort into your labels with confidence, or translate preserving formatting. |
20
+ | weather | `rh_weather_forecast` / `rh_weather_air` | Open-Meteo (keyless) via the SSRF-guarded fetcher | Current conditions + 3-day min/max, or PM2.5/PM10/AQI, for a lat/lon. Keyless only — anything needing an API token is out of scope. |
19
21
  | guard | (policy, not a tool) | `ctx.tools` `tools/pre-execute` | Configured via plugin config `rules`; gates tools by name prefix with `allow` / `deny` / `ask` modes. |
20
22
 
21
23
  ## Store semantics
@@ -24,6 +26,19 @@ The righthand toolkit: DSH-native tools over the harness's own services. Use thi
24
26
  - `rh_store_get` returns `{ found, key, value?, updatedAt? }` — `found: false` means the key is absent, not an error.
25
27
  - Writes are durable (backend flush before commit) and serialized on one write chain; values must be JSON-serializable.
26
28
 
29
+ ## Weather semantics
30
+
31
+ - Keyless only: Open-Meteo, no token. Every request goes through `guardedFetch` — public destinations only (loopback/private/link-local/multicast refused), every redirect hop revalidated, size and time capped.
32
+ - Results are normalized to a stable shape (the wire JSON is the provider's; callers see ours).
33
+ - Output schemas reject `null` — absent pollutant values are omitted, not null.
34
+
35
+ ## Text semantics
36
+
37
+ - One model call per verb; each call's failure is contained (the other tools are unaffected).
38
+ - `rh_text_extract` takes a JSON Schema and the model returns one object conforming to it — parse failures surface as a clean error, not raw prose.
39
+ - `rh_text_classify` returns a label verbatim from the given set plus a 0–1 confidence; a label outside the set is an error.
40
+ - Provider/model come from plugin config (`provider`/`model`, default the harness default model).
41
+
27
42
  ## Task semantics
28
43
 
29
44
  - One domain `righthand_tasks`: a `tasks` table keyed by generated task id (`t-...`).
@@ -53,5 +68,5 @@ Rules come from the plugin config (`rules: [{ toolPrefix, mode, ask?, destructiv
53
68
 
54
69
  ## Service availability
55
70
 
56
- - **web profile**: all sixteen tools register (`storageDomain` is provided by the web app rows).
71
+ - **web profile**: all twenty-two tools register (`storageDomain` is provided by the web app rows).
57
72
  - **other profiles** (e.g. headless): the store and task families stay dormant; the other seven tools still register. The plugin never fails the boot.
package/src/index.ts CHANGED
@@ -15,6 +15,8 @@
15
15
  * - store-tools: rh_store_put/get/delete/list over ctx.storageDomain
16
16
  * - secrets-tools: rh_credential_describe/set/unset + rh_settings_get/set
17
17
  * - task-tools: rh_task_create/list/next/update/delete over ctx.storageDomain
18
+ * - text-tools: rh_text_summarise/extract/classify/translate over ctx.llm
19
+ * - weather-tools: rh_weather_forecast/air over Open-Meteo (keyless, SSRF-guarded)
18
20
  * - exec-tools: rh_run / rh_run_bg over ctx.subprocess + ctx.jobs
19
21
  * - guard-tools: tools/pre-execute policy (config.rules)
20
22
  * - skills: the packaged dsh-righthand skill (ctx.skills)
@@ -28,6 +30,8 @@ import * as secretsTools from './secrets-tools.ts'
28
30
  import * as execTools from './exec-tools.ts'
29
31
  import * as guardTools from './guard-tools.ts'
30
32
  import * as taskTools from './task-tools.ts'
33
+ import * as textTools from './text-tools.ts'
34
+ import * as weatherTools from './weather-tools.ts'
31
35
  import * as righthandSkills from './skills.ts'
32
36
  import type { GuardRule } from './guard-tools.ts'
33
37
 
@@ -53,14 +57,18 @@ export function apply(ctx: Context, config: RighthandConfig = {}): void {
53
57
  ctx.plugin(secretsTools)
54
58
  ctx.plugin(execTools)
55
59
  ctx.plugin(taskTools)
60
+ ctx.plugin(textTools)
61
+ ctx.plugin(weatherTools)
56
62
  ctx.plugin(guardTools, { rules: config.rules ?? [] })
57
63
  ctx.plugin(righthandSkills)
58
64
  }
59
65
 
60
66
  // Individual modules stay importable for selective mounting.
61
- export { storeTools, secretsTools, execTools, taskTools, guardTools, righthandSkills }
67
+ export { storeTools, secretsTools, execTools, taskTools, textTools, weatherTools, guardTools, righthandSkills }
62
68
  export { SKILL_NAME, SKILL_DESCRIPTION, skillDirectory, skillBody } from './skills.ts'
63
69
  export type { GuardRule } from './guard-tools.ts'
64
70
  export { guardFactsFor } from './guard-tools.ts'
65
71
  export type { GuardFacts } from './guard-tools.ts'
72
+ export { isBlockedIP, guardedFetch, normalizeForecast, normalizeAir } from './weather-tools.ts'
73
+ export type { WeatherConfig, ForecastOut, AirOut } from './weather-tools.ts'
66
74
 
@@ -0,0 +1,173 @@
1
+ /**
2
+ * text-tools — rh_text_* over ctx.llm.
3
+ * Language work at one call per verb: summarise, extract-to-JSON,
4
+ * classify, translate. Same prompt discipline as the digest summarizer:
5
+ * roles stay roles, caps are deliberate, each call's failure is contained
6
+ * in that call (the other tools are unaffected).
7
+ * @module dsh-righthand/text-tools
8
+ */
9
+
10
+ import type { Context } from '@deepseek-ai/cordis'
11
+ import { defineTool } from '@deepseek-ai/dsh-tools'
12
+ import { BlockAssembler, createUserMessage, deepFreeze } from '@deepseek-ai/dsh-llm'
13
+ import type { GenerateOptions } from '@deepseek-ai/dsh-llm'
14
+
15
+ export const name = 'righthand-text'
16
+ export const inject = ['tools', 'llm']
17
+
18
+ export interface TextConfig {
19
+ /** LLM route; defaults match the harness default model. */
20
+ provider?: string
21
+ model?: string
22
+ }
23
+
24
+ const DEFAULTS: Required<TextConfig> = { provider: 'deepseek-official', model: 'deepseek-v4-flash' }
25
+
26
+ /** One LLM call, text in, text out. Returns the assembled text or throws. */
27
+ async function ask(ctx: Context, cfg: Required<TextConfig>, system: string, user: string, maxTokens: number, signal: AbortSignal): Promise<string> {
28
+ const options: GenerateOptions = deepFreeze({
29
+ provider: cfg.provider,
30
+ model: cfg.model,
31
+ messages: [createUserMessage({
32
+ content: [{ type: 'text', text: user }],
33
+ source: { kind: 'plugin', plugin: 'righthand-text' },
34
+ })],
35
+ system,
36
+ maxTokens,
37
+ purpose: 'compaction',
38
+ signal,
39
+ })
40
+ const assembler = new BlockAssembler()
41
+ for await (const chunk of ctx.llm.stream(options)) assembler.push(chunk)
42
+ if (assembler.finish.kind !== 'stop') throw new Error('text model call did not stop cleanly: ' + JSON.stringify(assembler.finish.kind))
43
+ const text = assembler.blocks().filter((b: any) => b.type === 'text').map((b: any) => b.text).join(' ').trim()
44
+ if (text === '') throw new Error('text model call produced no text')
45
+ return text
46
+ }
47
+
48
+ /** Extract the outermost {...} JSON object from a model reply. */
49
+ function parseJsonWindow(text: string): any {
50
+ const start = text.indexOf('{')
51
+ const end = text.lastIndexOf('}')
52
+ if (start < 0 || end <= start) throw new Error('model output was not JSON')
53
+ return JSON.parse(text.slice(start, end + 1))
54
+ }
55
+
56
+ /** Summarise: plain prose, no JSON. */
57
+ const SUMMARISE_SYSTEM = 'You summarise text. Return ONLY the summary itself - a few sentences, plain prose, no JSON, no Markdown headers, no quotes around it. Preserve the language of the input.'
58
+
59
+ /** Extract: JSON matching a caller schema. */
60
+ const EXTRACT_SYSTEM = 'You extract structured data from text. Return ONLY one JSON object matching the caller schema exactly - no prose, no Markdown, no code fences.'
61
+
62
+ /** Classify: one label plus confidence, as JSON. */
63
+ const CLASSIFY_SYSTEM = 'You classify text. Return ONLY one JSON object with exactly two keys: label (one of the given labels, verbatim) and confidence (a number from 0 to 1). No prose, no Markdown.'
64
+
65
+ /** Translate: preserve formatting, plain prose. */
66
+ const TRANSLATE_SYSTEM = 'You translate text. Return ONLY the translation - plain prose, no JSON, no quotes around it, preserving paragraph breaks and any Markdown or code the input contains.'
67
+
68
+ export function apply(ctx: Context, config: TextConfig = {}): void {
69
+ const cfg: Required<TextConfig> = { ...DEFAULTS, ...config }
70
+ ctx.tools.register(defineTool({
71
+ name: 'rh_text_summarise',
72
+ description: 'Summarise text into a few sentences. One model call per invocation; the result is new prose, not extracted sentences.',
73
+ parameters: {
74
+ text: { type: 'string', required: true, description: 'The text to summarise (max ~30000 characters).' },
75
+ sentences: { type: 'integer', description: 'Target length in sentences (default 3).' },
76
+ },
77
+ output: {
78
+ schema: {
79
+ type: 'object',
80
+ additionalProperties: false,
81
+ properties: {
82
+ summary: { type: 'string', required: true },
83
+ },
84
+ },
85
+ render: (_args, value) => [{ type: 'text', text: value.summary }],
86
+ },
87
+ async execute(args, exec) {
88
+ const n = args.sentences ?? 3
89
+ const summary = await ask(ctx, cfg, SUMMARISE_SYSTEM, 'Summarise this in about ' + n + ' sentences:\n\n' + args.text, 600, exec.signal)
90
+ return { summary }
91
+ },
92
+ }))
93
+ ctx.tools.register(defineTool({
94
+ name: 'rh_text_extract',
95
+ description: 'Turn text into JSON matching a schema you give. The schema is a JSON Schema object; the model returns one object conforming to it, or the call fails cleanly.',
96
+ parameters: {
97
+ text: { type: 'string', required: true, description: 'The text to extract from.' },
98
+ schema: { type: 'json', required: true, description: 'JSON Schema the result must match, e.g. { "type": "object", "properties": { "name": { "type": "string" } }, "required": ["name"] }.' },
99
+ },
100
+ output: {
101
+ schema: {
102
+ type: 'object',
103
+ additionalProperties: false,
104
+ properties: {
105
+ extracted: { type: 'json', required: true },
106
+ },
107
+ },
108
+ render: (_args, value) => [{ type: 'text', text: JSON.stringify(value.extracted) }],
109
+ },
110
+ async execute(args, exec) {
111
+ const framed = 'Schema:\n' + JSON.stringify(args.schema) + '\n\nText:\n' + args.text
112
+ const raw = await ask(ctx, cfg, EXTRACT_SYSTEM, framed, 800, exec.signal)
113
+ return { extracted: parseJsonWindow(raw) }
114
+ },
115
+ }))
116
+ ctx.tools.register(defineTool({
117
+ name: 'rh_text_classify',
118
+ description: 'Sort text into one of the labels you give, with a confidence score from 0 to 1. The label returned is one of yours, verbatim.',
119
+ parameters: {
120
+ text: { type: 'string', required: true, description: 'The text to classify.' },
121
+ labels: { type: 'array', required: true, items: { type: 'string' }, description: 'The candidate labels, e.g. ["bug", "feature request", "question"].' },
122
+ },
123
+ output: {
124
+ schema: {
125
+ type: 'object',
126
+ additionalProperties: false,
127
+ properties: {
128
+ label: { type: 'string', required: true },
129
+ confidence: { type: 'number', required: true },
130
+ },
131
+ },
132
+ render: (_args, value) => [{ type: 'text', text: value.label + ' (' + value.confidence + ')' }],
133
+ },
134
+ async execute(args, exec) {
135
+ if (args.labels.length === 0) throw new Error('classify needs at least one label')
136
+ const framed = 'Labels: ' + JSON.stringify(args.labels) + '\n\nText:\n' + args.text
137
+ const raw = await ask(ctx, cfg, CLASSIFY_SYSTEM, framed, 100, exec.signal)
138
+ const parsed = parseJsonWindow(raw)
139
+ if (typeof parsed.label !== 'string' || !args.labels.includes(parsed.label)) throw new Error('model returned a label outside the given set')
140
+ const confidence = Number(parsed.confidence)
141
+ if (!Number.isFinite(confidence) || confidence < 0 || confidence > 1) throw new Error('model returned an invalid confidence')
142
+ return { label: parsed.label, confidence }
143
+ },
144
+ }))
145
+ ctx.tools.register(defineTool({
146
+ name: 'rh_text_translate',
147
+ description: 'Translate text into another language, preserving formatting (paragraph breaks, Markdown, code).',
148
+ parameters: {
149
+ text: { type: 'string', required: true, description: 'The text to translate.' },
150
+ language: { type: 'string', required: true, description: 'The target language, e.g. "Spanish", "zh-CN", "Japanese".' },
151
+ },
152
+ output: {
153
+ schema: {
154
+ type: 'object',
155
+ additionalProperties: false,
156
+ properties: {
157
+ translation: { type: 'string', required: true },
158
+ },
159
+ },
160
+ render: (_args, value) => [{ type: 'text', text: value.translation }],
161
+ },
162
+ async execute(args, exec) {
163
+ const framed = 'Translate the following into ' + args.language + ':\n\n' + args.text
164
+ const translation = await ask(ctx, cfg, TRANSLATE_SYSTEM, framed, 800, exec.signal)
165
+ return { translation }
166
+ },
167
+ }))
168
+ }
169
+
170
+
171
+
172
+
173
+
@@ -0,0 +1,236 @@
1
+ /**
2
+ * weather-tools — the worked example of the keyless data-adapter recipe
3
+ * (blueprint/data-adapter). Two tools over Open-Meteo, a keyless API:
4
+ * rh_weather_forecast — current conditions + 3-day min/max
5
+ * rh_weather_air — PM2.5 / PM10 / European AQI
6
+ *
7
+ * Every request goes through guardedFetch, the SSRF checklist from Mu's
8
+ * safefetch: public destinations only (loopback, private, link-local incl.
9
+ * the metadata address, multicast and unspecified are refused), every
10
+ * redirect hop revalidated, response size and time capped.
11
+ * @module dsh-righthand/weather-tools
12
+ */
13
+
14
+ import type { Context } from '@deepseek-ai/cordis'
15
+ import { defineTool } from '@deepseek-ai/dsh-tools'
16
+ import { lookup } from 'node:dns/promises'
17
+ import { isIP } from 'node:net'
18
+
19
+ export const name = 'righthand-weather'
20
+ export const inject = ['tools']
21
+
22
+ export interface WeatherConfig {
23
+ /** Endpoint URLs; defaults are Open-Meteo (keyless). Tests override these. */
24
+ forecastUrl?: string
25
+ airUrl?: string
26
+ }
27
+
28
+ const DEFAULTS: Required<WeatherConfig> = {
29
+ forecastUrl: 'https://api.open-meteo.com/v1/forecast',
30
+ airUrl: 'https://air-quality-api.open-meteo.com/v1/air-quality',
31
+ }
32
+
33
+ const MAX_BODY_BYTES = 2 * 1024 * 1024
34
+ const MAX_REDIRECTS = 5
35
+ const FETCH_TIMEOUT_MS = 10000
36
+
37
+ /**
38
+ * Whether an IP must not be reached: the Mu safefetch set — loopback,
39
+ * RFC1918 private, ULA, link-local (169.254/16, fe80::/10 — which includes
40
+ * the 169.254.169.254 metadata address), multicast, and unspecified.
41
+ */
42
+ export function isBlockedIP(ip: string): boolean {
43
+ if (ip === '') return true
44
+ if (isIP(ip) === 4) {
45
+ const [a, b] = ip.split('.').map(Number)
46
+ if (a === 0 || a === 10 || a === 127) return true
47
+ if (a === 169 && b === 254) return true
48
+ if (a === 172 && b >= 16 && b <= 31) return true
49
+ if (a === 192 && b === 168) return true
50
+ if (a >= 224 && a <= 239) return true
51
+ return false
52
+ }
53
+ if (isIP(ip) === 6) {
54
+ const lower = ip.toLowerCase()
55
+ if (lower === '::' || lower === '::1') return true
56
+ if (lower.startsWith('fc') || lower.startsWith('fd')) return true
57
+ if (lower.startsWith('fe8') || lower.startsWith('fe9') || lower.startsWith('fea') || lower.startsWith('feb')) return true
58
+ if (lower.startsWith('ff')) return true
59
+ return false
60
+ }
61
+ return true
62
+ }
63
+
64
+ /**
65
+ * Fetch one URL with the SSRF checklist. `fetchImpl` and `resolve` are
66
+ * injectable for tests; production uses the globals.
67
+ */
68
+ export async function guardedFetch(
69
+ url: string,
70
+ deps: { fetchImpl?: typeof fetch; resolve?: (host: string) => Promise<string[]> } = {},
71
+ ): Promise<string> {
72
+ const fetchImpl = deps.fetchImpl ?? fetch
73
+ const resolve = deps.resolve ?? (async (host: string) => (await lookup(host, { all: true })).map(a => a.address))
74
+
75
+ let current = url
76
+ for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
77
+ const parsed = new URL(current)
78
+ if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') throw new Error('blocked: non-http(s) URL')
79
+ const ips = await resolve(parsed.hostname)
80
+ if (ips.length === 0 || ips.some(isBlockedIP)) throw new Error('blocked: destination is not a public host')
81
+ const res = await fetchImpl(current, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), redirect: 'manual' })
82
+ if (res.status >= 300 && res.status < 400) {
83
+ const next = res.headers.get('location')
84
+ if (next === null) throw new Error('blocked: redirect without location')
85
+ current = new URL(next, current).toString()
86
+ continue
87
+ }
88
+ if (!res.ok) throw new Error('HTTP ' + res.status + ' for ' + current)
89
+ const reader = res.body?.getReader()
90
+ if (reader === undefined) return ''
91
+ const chunks: Uint8Array[] = []
92
+ let bytes = 0
93
+ for (;;) {
94
+ const { done, value } = await reader.read()
95
+ if (done) break
96
+ bytes += value.length
97
+ if (bytes > MAX_BODY_BYTES) throw new Error('response exceeds ' + MAX_BODY_BYTES + ' bytes')
98
+ chunks.push(value)
99
+ }
100
+ return Buffer.concat(chunks).toString('utf8')
101
+ }
102
+ throw new Error('too many redirects')
103
+ }
104
+
105
+ /**
106
+ * Normalize the Open-Meteo forecast response into a stable digest shape:
107
+ * what changed is the wire JSON; what callers see is this.
108
+ */
109
+ export interface ForecastOut {
110
+ latitude: number
111
+ longitude: number
112
+ timezone: string
113
+ current: { time: string; temperature: number; windspeed: number; winddirection: number; weathercode: number } | null
114
+ daily: { time: string[]; temperatureMax: number[]; temperatureMin: number[] }
115
+ }
116
+
117
+ export function normalizeForecast(json: any): ForecastOut {
118
+ const cw = json.current_weather
119
+ return {
120
+ latitude: Number(json.latitude ?? 0),
121
+ longitude: Number(json.longitude ?? 0),
122
+ timezone: json.timezone ?? '',
123
+ current: cw ? {
124
+ time: String(cw.time ?? ''),
125
+ temperature: Number(cw.temperature ?? 0),
126
+ windspeed: Number(cw.windspeed ?? 0),
127
+ winddirection: Number(cw.winddirection ?? 0),
128
+ weathercode: Number(cw.weathercode ?? 0),
129
+ } : null,
130
+ daily: {
131
+ time: (json.daily?.time ?? []).map(String),
132
+ temperatureMax: (json.daily?.temperature_2m_max ?? []).map(Number),
133
+ temperatureMin: (json.daily?.temperature_2m_min ?? []).map(Number),
134
+ },
135
+ }
136
+ }
137
+
138
+ /** Normalize the Open-Meteo air-quality response. */
139
+ export interface AirOut {
140
+ latitude: number
141
+ longitude: number
142
+ time: string
143
+ pm2_5: number | null
144
+ pm10: number | null
145
+ aqi: number | null
146
+ }
147
+
148
+ export function normalizeAir(json: any): AirOut {
149
+ const c = json.current ?? {}
150
+ const num = (v: unknown): number | null => (v === undefined || v === null ? null : Number(v))
151
+ return {
152
+ latitude: Number(json.latitude ?? 0),
153
+ longitude: Number(json.longitude ?? 0),
154
+ time: String(c.time ?? ''),
155
+ pm2_5: num(c.pm2_5),
156
+ pm10: num(c.pm10),
157
+ aqi: num(c.european_aqi),
158
+ }
159
+ }
160
+
161
+ export function apply(ctx: Context, config: WeatherConfig = {}): void {
162
+ const cfg: Required<WeatherConfig> = { ...DEFAULTS, ...config }
163
+
164
+ ctx.tools.register(defineTool({
165
+ name: 'rh_weather_forecast',
166
+ description: 'Weather forecast for a location from Open-Meteo (keyless): current conditions plus 3 days of min/max temperatures.',
167
+ parameters: {
168
+ latitude: { type: 'number', required: true, description: 'Latitude, e.g. 51.5074 for London.' },
169
+ longitude: { type: 'number', required: true, description: 'Longitude, e.g. -0.1278 for London.' },
170
+ },
171
+ output: {
172
+ schema: {
173
+ type: 'object',
174
+ additionalProperties: false,
175
+ properties: {
176
+ latitude: { type: 'number', required: true },
177
+ longitude: { type: 'number', required: true },
178
+ timezone: { type: 'string', required: true },
179
+ current: { type: 'json' },
180
+ daily: { type: 'json', required: true },
181
+ },
182
+ },
183
+ render: (_args, value) => { const c = value.current as any; return [{ type: 'text', text: c ? (value.timezone + ': ' + c.temperature + ' C, wind ' + c.windspeed + ' km/h') : value.timezone + ': current conditions unavailable' }] },
184
+ },
185
+ async execute(args) {
186
+ const url = cfg.forecastUrl
187
+ + '?latitude=' + args.latitude
188
+ + '&longitude=' + args.longitude
189
+ + '&current_weather=true&daily=temperature_2m_max,temperature_2m_min&forecast_days=3'
190
+ const body = await guardedFetch(url)
191
+ return normalizeForecast(JSON.parse(body))
192
+ },
193
+ }))
194
+
195
+ ctx.tools.register(defineTool({
196
+ name: 'rh_weather_air',
197
+ description: 'Air quality at a location from Open-Meteo (keyless): PM2.5, PM10 and the European AQI.',
198
+ parameters: {
199
+ latitude: { type: 'number', required: true, description: 'Latitude.' },
200
+ longitude: { type: 'number', required: true, description: 'Longitude.' },
201
+ },
202
+ output: {
203
+ schema: {
204
+ type: 'object',
205
+ additionalProperties: false,
206
+ properties: {
207
+ latitude: { type: 'number', required: true },
208
+ longitude: { type: 'number', required: true },
209
+ time: { type: 'string', required: true },
210
+ pm2_5: { type: 'number' },
211
+ pm10: { type: 'number' },
212
+ aqi: { type: 'number' },
213
+ },
214
+ },
215
+ render: (_args, value) => [{ type: 'text', text: 'PM2.5 ' + value.pm2_5 + ', PM10 ' + value.pm10 + ', AQI ' + value.aqi }],
216
+ },
217
+ async execute(args) {
218
+ const url = cfg.airUrl
219
+ + '?latitude=' + args.latitude
220
+ + '&longitude=' + args.longitude
221
+ + '&current=pm2_5,pm10,european_aqi'
222
+ const body = await guardedFetch(url)
223
+ const out = normalizeAir(JSON.parse(body))
224
+ return {
225
+ latitude: out.latitude,
226
+ longitude: out.longitude,
227
+ time: out.time,
228
+ ...out.pm2_5 !== null ? { pm2_5: out.pm2_5 } : {},
229
+ ...out.pm10 !== null ? { pm10: out.pm10 } : {},
230
+ ...out.aqi !== null ? { aqi: out.aqi } : {},
231
+ }
232
+ },
233
+ }))
234
+ }
235
+
236
+