@sjawhar/opencode-legion-envoy 0.35.0 → 0.36.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 CHANGED
@@ -22,14 +22,16 @@ This package exposes:
22
22
  - `dispatch_doc_read`
23
23
  - `dispatch_artifact`
24
24
  - `dispatch_read`
25
-
26
- The native `dispatch_*` tools create and read Dispatch issues, asks, comments, documents, and
27
- artifacts. They are present when `dispatch.enabled` resolves a server URL and bearer token from
28
- envoy.json (`~/.config/opencode/envoy.json`, merged with `<repo>/.opencode/envoy.json`) or the
29
- `DISPATCH_URL` and `DISPATCH_TOKEN` environment variables; `dispatch.enabled: true` without
30
- `dispatch.serverUrl` targets `http://localhost:8766`. Each call fills the target issue from
31
- the session working directory, stamps it with the OpenCode session id and title, and stores
32
- `details.topic` as tool metadata so a successful mutation subscribes to that exact Dispatch topic.
25
+ - `dispatch_search`
26
+
27
+ The eleven native `dispatch_*` tools create and read Dispatch issues, asks, comments,
28
+ documents, and artifacts, or search all of them. They are present when `dispatch.enabled`
29
+ resolves a server URL and bearer token from envoy.json (`~/.config/opencode/envoy.json`, merged
30
+ with `<repo>/.opencode/envoy.json`) or the `DISPATCH_URL` and `DISPATCH_TOKEN` environment
31
+ variables; `dispatch.enabled: true` without `dispatch.serverUrl` targets `http://localhost:8766`.
32
+ Each issue-scoped call fills the target issue from the session working directory, stamps it with
33
+ the OpenCode session id and title, and stores a successful mutation's `details.topic` as tool
34
+ metadata so the host subscribes to that exact Dispatch topic.
33
35
 
34
36
  `dispatch_artifact` accepts exactly one upload source: a local `path`, or inline `content`.
35
37
  An architect can post a specification directly with
@@ -13597,6 +13597,56 @@ var ChildStatusEventPayloadSchema = object({
13597
13597
  from: string2().optional(),
13598
13598
  to: string2().optional()
13599
13599
  });
13600
+ // ../contracts/src/dispatch-snippet.ts
13601
+ var HTML_ENTITIES = [
13602
+ ["&lt;", "<"],
13603
+ ["&gt;", ">"],
13604
+ ["&#39;", "'"],
13605
+ ["&#34;", '"'],
13606
+ ["&amp;", "&"]
13607
+ ];
13608
+ function decodeEntities(text) {
13609
+ let decoded = text;
13610
+ for (const [entity, character] of HTML_ENTITIES) {
13611
+ decoded = decoded.replaceAll(entity, character);
13612
+ }
13613
+ return decoded;
13614
+ }
13615
+ function hasOnlyBalancedMarkers(snippet) {
13616
+ let marked = false;
13617
+ for (const marker of snippet.matchAll(/<mark>|<\/mark>/gu)) {
13618
+ if (marker[0] === "<mark>") {
13619
+ if (marked)
13620
+ return false;
13621
+ marked = true;
13622
+ } else {
13623
+ if (!marked)
13624
+ return false;
13625
+ marked = false;
13626
+ }
13627
+ }
13628
+ return !marked;
13629
+ }
13630
+ function snippetSegments(snippet) {
13631
+ if (!hasOnlyBalancedMarkers(snippet)) {
13632
+ return snippet === "" ? [] : [{ text: decodeEntities(snippet), mark: false }];
13633
+ }
13634
+ let mark = false;
13635
+ const segments = [];
13636
+ for (const part of snippet.split(/(<mark>|<\/mark>)/u)) {
13637
+ if (part === "<mark>") {
13638
+ mark = true;
13639
+ } else if (part === "</mark>") {
13640
+ mark = false;
13641
+ } else if (part !== "") {
13642
+ segments.push({ text: decodeEntities(part), mark });
13643
+ }
13644
+ }
13645
+ return segments;
13646
+ }
13647
+ function snippetText(snippet) {
13648
+ return snippetSegments(snippet).map(({ text, mark }) => mark ? `**${text}**` : text).join("");
13649
+ }
13600
13650
  // ../contracts/src/dispatch-tools.ts
13601
13651
  function dispatchToolSchema(spec, z, opts) {
13602
13652
  const shape = spec.arguments(z);
@@ -13618,12 +13668,13 @@ var DOC_EDIT_OPS = ["replace", "delete", "insert"];
13618
13668
  var dispatchToolSpecs = [
13619
13669
  {
13620
13670
  name: "dispatch_issue",
13621
- description: "Create a native Dispatch issue for newly tracked work. Do not use it when an existing issue " + `already covers the work; read or update that issue instead. ${ISSUE_REFERENCE}`,
13671
+ description: "Create a native Dispatch issue for newly tracked work. Search first with dispatch_search; if potentially duplicate issues exist, this returns 409 POSSIBLE_DUPLICATE unless force is true after reading them. " + `Do not use it when an existing issue already covers the work; read or update that issue instead. ${ISSUE_REFERENCE}`,
13622
13672
  arguments: (z) => ({
13623
13673
  project: z.string().describe("Project key for the new issue."),
13624
13674
  title: z.string().describe("Concise issue title."),
13625
13675
  parent: z.string().describe("Optional parent issue.").optional(),
13626
13676
  external: z.string().describe("Optional external issue reference.").optional(),
13677
+ force: z.boolean().describe("Create even though POSSIBLE_DUPLICATE listed similar issues; pass it only after reading them.").optional(),
13627
13678
  spec: z.string().describe(`Optional initial primary-document markdown. ${SPEC_WRITING_GUIDANCE}`).optional()
13628
13679
  })
13629
13680
  },
@@ -13741,6 +13792,15 @@ var dispatchToolSpecs = [
13741
13792
  issue: z.string().describe(ISSUE_REFERENCE).optional(),
13742
13793
  ref: z.string().describe("Optional dispatch:// issue reference.").optional()
13743
13794
  })
13795
+ },
13796
+ {
13797
+ name: "dispatch_search",
13798
+ description: "Search every issue, document, comment, ask, and message for a keyword or phrase and get deep links. " + "Use it before creating an issue or a design document, and to find where a word was written. " + 'Websearch syntax: "quoted phrase", -excluded, OR.',
13799
+ arguments: (z) => ({
13800
+ query: z.string({ min: 2 }).describe("Keyword, phrase, or websearch expression; at least 2 characters."),
13801
+ project: z.string().describe("Optional project key to search within.").optional(),
13802
+ limit: z.number({ int: true, min: 1, max: 50 }).describe("Maximum results, 1-50; default 20.").optional()
13803
+ })
13744
13804
  }
13745
13805
  ];
13746
13806
  // ../contracts/src/envelope.ts
@@ -14331,6 +14391,9 @@ class DispatchClient {
14331
14391
  async listIssues(options = {}) {
14332
14392
  return this.#json("GET", ["api", "v1", "issues"], undefined, options);
14333
14393
  }
14394
+ async search(query, options = {}) {
14395
+ return this.#json("GET", ["api", "v1", "search"], undefined, { q: query, ...options });
14396
+ }
14334
14397
  async getIssue(issue) {
14335
14398
  return this.#json("GET", ["api", "v1", "issues", await this.#resolveIssue(issue)]);
14336
14399
  }
@@ -14505,6 +14568,7 @@ class DispatchClient {
14505
14568
  var nativeIssueKeyPattern = /^[A-Z][A-Z0-9]{1,9}-[0-9]+$/;
14506
14569
  var externalIssueRefPattern = /^([^/\s]+)\/([^/\s#]+)#([1-9][0-9]*)$/;
14507
14570
  var bareIssueNumberPattern = /^[1-9][0-9]*$/;
14571
+ var issueFreeTools = new Set(["dispatch_issue", "dispatch_resolve_ask", "dispatch_search"]);
14508
14572
  function canonicalExternalIssueRef(value) {
14509
14573
  const match = value.trim().match(externalIssueRefPattern);
14510
14574
  return match ? `${canonicalRepo(match[1] ?? "", match[2] ?? "")}#${match[3]}` : value;
@@ -14527,6 +14591,23 @@ function optionalNumber(args, name) {
14527
14591
  const value = args[name];
14528
14592
  return typeof value === "number" ? value : undefined;
14529
14593
  }
14594
+ function isDuplicateCandidate(value) {
14595
+ if (typeof value !== "object" || value === null)
14596
+ return false;
14597
+ const candidate = value;
14598
+ return typeof candidate.key === "string" && typeof candidate.title === "string" && typeof candidate.status === "string" && typeof candidate.snippet === "string" && typeof candidate.shared_terms === "number" && typeof candidate.href === "string";
14599
+ }
14600
+ function duplicateCandidates(error) {
14601
+ if (error.candidates === undefined || !error.candidates.every(isDuplicateCandidate))
14602
+ throw error;
14603
+ return error.candidates;
14604
+ }
14605
+ function searchResultLine(result, baseUrl) {
14606
+ const artifactName = result.artifact ? ` ${result.artifact.name}` : "";
14607
+ const label = `${result.issue.key} [${result.issue.status}] ${result.issue.title} - ${result.kind}${artifactName}`;
14608
+ const href = new URL(result.href, baseUrl).toString();
14609
+ return `${label}: ${snippetText(result.snippet)} -> ${href}`;
14610
+ }
14530
14611
  function askUrgency(args) {
14531
14612
  const value = args.urgency;
14532
14613
  return ASK_URGENCIES.find((urgency) => urgency === value);
@@ -14565,7 +14646,7 @@ function toolSchema(tool) {
14565
14646
  return dispatchToolSchema(spec, zodSchemaApi(exports_external), { strict: true });
14566
14647
  }
14567
14648
  async function resolveIssueArguments(tool, args, cwd, env, exec) {
14568
- if (tool === "dispatch_issue" || tool === "dispatch_resolve_ask")
14649
+ if (issueFreeTools.has(tool))
14569
14650
  return { args, ref: null };
14570
14651
  const refArgument = args.ref;
14571
14652
  const ref = typeof refArgument === "string" ? parseDispatchRef(refArgument) ?? (() => {
@@ -14713,7 +14794,9 @@ async function openArtifactMarks(client, resolved) {
14713
14794
  ];
14714
14795
  }
14715
14796
  async function executeDispatchTool(input) {
14716
- if (!input.config.enabled || !input.config.url || !input.config.token) {
14797
+ const configUrl = input.config.url;
14798
+ const configToken = input.config.token;
14799
+ if (!input.config.enabled || !configUrl || !configToken) {
14717
14800
  throw new Error("Dispatch is disabled; resolve both DISPATCH_URL and DISPATCH_TOKEN");
14718
14801
  }
14719
14802
  const env = input.env ?? process.env;
@@ -14721,8 +14804,8 @@ async function executeDispatchTool(input) {
14721
14804
  const issueArguments = await resolveIssueArguments(input.tool, input.args, input.cwd, env, exec);
14722
14805
  const args = toolSchema(input.tool).parse(issueArguments.args);
14723
14806
  const actor = toolActor(await resolveOrigin(env, exec, input.cwd), input);
14724
- const client = new DispatchClient(input.config.url, input.config.token, input.fetchImpl);
14725
- const issueKey = input.tool === "dispatch_issue" || input.tool === "dispatch_resolve_ask" ? null : await ensureIssue(client, stringArg(args, "issue"), actor);
14807
+ const client = new DispatchClient(configUrl, configToken, input.fetchImpl);
14808
+ const issueKey = issueFreeTools.has(input.tool) ? null : await ensureIssue(client, stringArg(args, "issue"), actor);
14726
14809
  const issue = () => {
14727
14810
  if (issueKey === null)
14728
14811
  throw new Error("issue is required");
@@ -14730,20 +14813,62 @@ async function executeDispatchTool(input) {
14730
14813
  };
14731
14814
  switch (input.tool) {
14732
14815
  case "dispatch_issue": {
14816
+ const project = stringArg(args, "project");
14817
+ const title = stringArg(args, "title");
14733
14818
  const parent = optionalString(args, "parent");
14734
14819
  const external = optionalString(args, "external");
14820
+ const force = optionalBoolean(args, "force");
14735
14821
  const spec = optionalString(args, "spec");
14736
- const created = await client.issue({
14737
- project: stringArg(args, "project"),
14738
- title: stringArg(args, "title"),
14739
- ...parent === undefined ? {} : { parent },
14740
- ...external === undefined ? {} : { external },
14741
- ...spec === undefined ? {} : { spec },
14742
- actor
14822
+ try {
14823
+ const created = await client.issue({
14824
+ project,
14825
+ title,
14826
+ ...parent === undefined ? {} : { parent },
14827
+ ...external === undefined ? {} : { external },
14828
+ ...force === undefined ? {} : { force },
14829
+ ...spec === undefined ? {} : { spec },
14830
+ actor
14831
+ });
14832
+ return {
14833
+ text: `Created ${created.key}: ${created.title}`,
14834
+ details: { issue: created.key, topic: dispatchIssueSubject(created.key, ">") }
14835
+ };
14836
+ } catch (error) {
14837
+ if (!(error instanceof DispatchServiceError) || error.code !== "POSSIBLE_DUPLICATE") {
14838
+ throw error;
14839
+ }
14840
+ const candidates = duplicateCandidates(error);
14841
+ return {
14842
+ text: [
14843
+ `Not created: "${title}" looks like a duplicate.`,
14844
+ ...candidates.map((candidate) => {
14845
+ const href = new URL(candidate.href, configUrl).toString();
14846
+ return `${candidate.key} [${candidate.status}] ${candidate.title} \u2192 ${href}`;
14847
+ }),
14848
+ "Reference the existing issue, or call dispatch_issue again with force: true after reading it."
14849
+ ].join(`
14850
+ `),
14851
+ details: { duplicates: candidates }
14852
+ };
14853
+ }
14854
+ }
14855
+ case "dispatch_search": {
14856
+ const query = stringArg(args, "query");
14857
+ const project = optionalString(args, "project");
14858
+ const limit = optionalNumber(args, "limit");
14859
+ const search = await client.search(query, {
14860
+ ...project === undefined ? {} : { project },
14861
+ ...limit === undefined ? {} : { limit }
14743
14862
  });
14863
+ const results = search.results;
14864
+ const count = results.length;
14744
14865
  return {
14745
- text: `Created ${created.key}: ${created.title}`,
14746
- details: { issue: created.key, topic: dispatchIssueSubject(created.key, ">") }
14866
+ text: count === 0 ? `No results for "${query}".` : [
14867
+ `${count} ${count === 1 ? "result" : "results"} for "${query}" (${search.took_ms} ms)`,
14868
+ ...results.map((result) => searchResultLine(result, configUrl))
14869
+ ].join(`
14870
+ `),
14871
+ details: { query, results }
14747
14872
  };
14748
14873
  }
14749
14874
  case "dispatch_resolve_ask": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sjawhar/opencode-legion-envoy",
3
- "version": "0.35.0",
3
+ "version": "0.36.0",
4
4
  "type": "module",
5
5
  "main": "dist/src/server.js",
6
6
  "exports": {
@@ -54,11 +54,24 @@ project configured for that repository in Dispatch Settings, then falls back to
54
54
 
55
55
  Architects create newly tracked child work with:
56
56
  ```ts
57
- dispatch_issue({ project, title, parent?, external?, spec? })
57
+ dispatch_issue({ project, title, parent?, external?, spec?, force? })
58
58
  ```
59
59
  It returns `details` `{ issue, topic }`. Use `dispatch_issue` only to create an issue; never use
60
- it to park a question.
61
- When `spec` is supplied, follow [Writing a spec](#writing-a-spec).
60
+ it to park a question. When `spec` is supplied, follow [Writing a spec](#writing-a-spec).
61
+
62
+ ## Search first
63
+
64
+ Before you create an issue or start a design document, search:
65
+ ```ts
66
+ dispatch_search({ query, project?, limit? })
67
+ ```
68
+ It returns every issue, document, comment, ask, and message that contains the words, with the
69
+ issue key and a link. Cite the hit you build on (`dispatch://KEY` or the document reference), or
70
+ state "no prior issue" in the spec. Websearch syntax applies: `"merge queue"`, `-daemon`, `OR`.
71
+
72
+ `dispatch_issue` refuses a title that near-duplicates an issue in the same project and returns
73
+ the candidates (`POSSIBLE_DUPLICATE`). Read them; reference the existing issue, or repeat the
74
+ call with `force: true` when it is genuinely new work.
62
75
 
63
76
  ## Asking
64
77