loopctl-mcp-server 2.2.0 → 2.23.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.
Files changed (3) hide show
  1. package/README.md +46 -12
  2. package/index.js +1227 -81
  3. package/package.json +1 -1
package/index.js CHANGED
@@ -139,9 +139,12 @@ function toContent(result) {
139
139
  /**
140
140
  * Compact variant for list endpoints — strips acceptance_criteria and
141
141
  * description (use get_story for full details). Keeps all other fields.
142
- * Enforces a max page size to prevent MCP response token overflow.
142
+ * A modest DEFAULT page size keeps unprompted responses small, but an explicit
143
+ * `limit` is honored up to the server's max (500) so story enumeration paginates
144
+ * honestly instead of silently capping at 20 and skipping rows on offset advance.
143
145
  */
144
- const MAX_PAGE_SIZE = 20;
146
+ const DEFAULT_STORY_PAGE_SIZE = 20;
147
+ const SERVER_MAX_STORY_PAGE_SIZE = 500;
145
148
 
146
149
  function toContentCompact(result) {
147
150
  if (result && result.error === true) return toContent(result);
@@ -175,8 +178,12 @@ async function getTenant() {
175
178
  return toContent(result);
176
179
  }
177
180
 
178
- async function listProjects() {
179
- const result = await apiCall("GET", "/api/v1/projects");
181
+ async function listProjects({ page, page_size } = {}) {
182
+ const params = new URLSearchParams();
183
+ if (page != null) params.set("page", String(page));
184
+ if (page_size != null) params.set("page_size", String(page_size));
185
+ const query = params.toString() ? `?${params}` : "";
186
+ const result = await apiCall("GET", `/api/v1/projects${query}`);
180
187
  return toContent(result);
181
188
  }
182
189
 
@@ -367,7 +374,10 @@ async function listStories({ project_id, agent_status, verified_status, epic_id,
367
374
  if (agent_status) params.set("agent_status", agent_status);
368
375
  if (verified_status) params.set("verified_status", verified_status);
369
376
  if (epic_id) params.set("epic_id", epic_id);
370
- params.set("limit", String(Math.min(limit ?? MAX_PAGE_SIZE, MAX_PAGE_SIZE)));
377
+ params.set(
378
+ "limit",
379
+ String(Math.min(limit ?? DEFAULT_STORY_PAGE_SIZE, SERVER_MAX_STORY_PAGE_SIZE)),
380
+ );
371
381
  if (offset != null) params.set("offset", String(offset));
372
382
  if (include_token_totals) params.set("include_token_totals", "true");
373
383
 
@@ -375,9 +385,13 @@ async function listStories({ project_id, agent_status, verified_status, epic_id,
375
385
  return toContentCompact(result);
376
386
  }
377
387
 
378
- async function listReadyStories({ project_id, limit }) {
388
+ async function listReadyStories({ project_id, page, page_size }) {
389
+ // /stories/ready paginates by page/page_size — the old `limit` param was
390
+ // silently ignored by the server, capping callers at the first page.
379
391
  const params = new URLSearchParams({ project_id });
380
- params.set("limit", String(Math.min(limit ?? MAX_PAGE_SIZE, MAX_PAGE_SIZE)));
392
+ if (page != null) params.set("page", String(page));
393
+ if (page_size != null)
394
+ params.set("page_size", String(Math.min(page_size, SERVER_MAX_STORY_PAGE_SIZE)));
381
395
 
382
396
  const result = await apiCall("GET", `/api/v1/stories/ready?${params}`);
383
397
  return toContentCompact(result);
@@ -550,14 +564,20 @@ async function getCostSummary({ project_id, breakdown }) {
550
564
  return toContent(result);
551
565
  }
552
566
 
553
- async function getStoryTokenUsage({ story_id }) {
554
- const result = await apiCall("GET", `/api/v1/stories/${story_id}/token-usage`);
567
+ async function getStoryTokenUsage({ story_id, page, page_size }) {
568
+ const params = new URLSearchParams();
569
+ if (page != null) params.set("page", String(page));
570
+ if (page_size != null) params.set("page_size", String(page_size));
571
+ const query = params.toString() ? `?${params}` : "";
572
+ const result = await apiCall("GET", `/api/v1/stories/${story_id}/token-usage${query}`);
555
573
  return toContent(result);
556
574
  }
557
575
 
558
- async function getCostAnomalies({ project_id }) {
576
+ async function getCostAnomalies({ project_id, page, page_size }) {
559
577
  const params = new URLSearchParams();
560
578
  if (project_id) params.set("project_id", project_id);
579
+ if (page != null) params.set("page", String(page));
580
+ if (page_size != null) params.set("page_size", String(page_size));
561
581
 
562
582
  const query = params.toString() ? `?${params}` : "";
563
583
  const result = await apiCall("GET", `/api/v1/cost-anomalies${query}`);
@@ -579,31 +599,214 @@ async function setTokenBudget({ scope_type, scope_id, budget_millicents, alert_t
579
599
 
580
600
  // --- Knowledge Wiki Tools (agent key) ---
581
601
 
582
- async function knowledgeIndex({ project_id, story_id }) {
602
+ async function knowledgeIndex({ project_id, story_id, category, tags, match, offset, limit, fields }) {
603
+ if (project_id && !UUID_RE.test(project_id)) {
604
+ return {
605
+ content: [{ type: "text", text: "Error: project_id must be a canonical UUID (8-4-4-4-12 hex)." }],
606
+ isError: true,
607
+ };
608
+ }
583
609
  const basePath = project_id
584
610
  ? `/api/v1/projects/${project_id}/knowledge/index`
585
611
  : "/api/v1/knowledge/index";
586
612
  const params = new URLSearchParams();
587
613
  if (story_id) params.set("story_id", story_id);
614
+ if (category) params.set("category", category);
615
+ if (tags) params.set("tags", tags);
616
+ if (match) params.set("match", match);
617
+ if (offset != null) params.set("offset", String(offset));
618
+ if (limit != null) params.set("limit", String(limit));
619
+ if (fields) params.set("fields", Array.isArray(fields) ? fields.join(",") : fields);
588
620
  const qs = params.toString();
589
621
  const path = qs ? `${basePath}?${qs}` : basePath;
590
622
  const result = await apiCall("GET", path, null, process.env.LOOPCTL_AGENT_KEY);
591
623
  return toContent(result);
592
624
  }
593
625
 
594
- async function knowledgeSearch({ q, project_id, story_id, category, tags, mode, limit }) {
595
- const params = new URLSearchParams({ q });
626
+ async function knowledgeStats({ project_id }) {
627
+ if (project_id && !UUID_RE.test(project_id)) {
628
+ return {
629
+ content: [{ type: "text", text: "Error: project_id must be a canonical UUID (8-4-4-4-12 hex)." }],
630
+ isError: true,
631
+ };
632
+ }
633
+ const path = project_id
634
+ ? `/api/v1/projects/${project_id}/knowledge/stats`
635
+ : "/api/v1/knowledge/stats";
636
+ const result = await apiCall("GET", path, null, process.env.LOOPCTL_AGENT_KEY);
637
+ return toContent(result);
638
+ }
639
+
640
+ async function knowledgeGraph({ article_id, depth, project_id }) {
641
+ const params = new URLSearchParams();
642
+ params.set("article_id", article_id);
643
+ if (depth != null) params.set("depth", String(depth));
644
+ if (project_id) params.set("project_id", project_id);
645
+ const result = await apiCall(
646
+ "GET",
647
+ `/api/v1/knowledge/graph?${params}`,
648
+ null,
649
+ process.env.LOOPCTL_AGENT_KEY,
650
+ );
651
+ return toContent(result);
652
+ }
653
+
654
+ async function knowledgeSuggestLinks({ article_id, limit, threshold }) {
655
+ const params = new URLSearchParams();
656
+ if (limit != null) params.set("limit", String(limit));
657
+ if (threshold != null) params.set("threshold", String(threshold));
658
+ const qs = params.toString();
659
+ const base = `/api/v1/knowledge/articles/${article_id}/suggested_links`;
660
+ const result = await apiCall(
661
+ "GET",
662
+ qs ? `${base}?${qs}` : base,
663
+ null,
664
+ process.env.LOOPCTL_AGENT_KEY,
665
+ );
666
+ return toContent(result);
667
+ }
668
+
669
+ async function knowledgeDistantPairs({ min_distance, max_distance, bridge_path, limit, offset }) {
670
+ const params = new URLSearchParams();
671
+ if (min_distance != null) params.set("min_distance", String(min_distance));
672
+ if (max_distance != null) params.set("max_distance", String(max_distance));
673
+ if (bridge_path === true) params.set("bridge_path", "true");
674
+ if (limit != null) params.set("limit", String(limit));
675
+ if (offset != null) params.set("offset", String(offset));
676
+ const qs = params.toString();
677
+ const path = qs ? `/api/v1/knowledge/pairs?${qs}` : "/api/v1/knowledge/pairs";
678
+ const result = await apiCall("GET", path, null, process.env.LOOPCTL_AGENT_KEY);
679
+ return toContent(result);
680
+ }
681
+
682
+ async function knowledgeNovelty({ ideas, texts, prior_tag }) {
683
+ // Accept either shape (#169): `ideas` (strings or objects) or `texts` (strings).
684
+ const body = {};
685
+ if (ideas !== undefined) body.ideas = ideas;
686
+ if (texts !== undefined) body.texts = texts;
687
+ if (prior_tag) body.prior_tag = prior_tag;
688
+ const result = await apiCall("POST", "/api/v1/knowledge/novelty", body, process.env.LOOPCTL_AGENT_KEY);
689
+ return toContent(result);
690
+ }
691
+
692
+ async function knowledgeRandomWalk({ start_id, length }) {
693
+ const params = new URLSearchParams();
694
+ params.set("start_id", start_id);
695
+ if (length != null) params.set("length", String(length));
696
+ const result = await apiCall(
697
+ "GET",
698
+ `/api/v1/knowledge/walk?${params}`,
699
+ null,
700
+ process.env.LOOPCTL_AGENT_KEY,
701
+ );
702
+ return toContent(result);
703
+ }
704
+
705
+ async function knowledgeCount({
706
+ project_id,
707
+ category,
708
+ status,
709
+ tags,
710
+ match,
711
+ source_type,
712
+ source_id,
713
+ idempotency_key,
714
+ }) {
715
+ const params = new URLSearchParams();
716
+ if (project_id) params.set("project_id", project_id);
717
+ if (category) params.set("category", category);
718
+ if (status) params.set("status", status);
719
+ if (tags) params.set("tags", tags);
720
+ if (match) params.set("match", match);
721
+ if (source_type) params.set("source_type", source_type);
722
+ if (source_id) params.set("source_id", source_id);
723
+ if (idempotency_key) params.set("idempotency_key", idempotency_key);
724
+ const qs = params.toString();
725
+ const path = qs ? `/api/v1/knowledge/count?${qs}` : "/api/v1/knowledge/count";
726
+ const result = await apiCall("GET", path, null, process.env.LOOPCTL_AGENT_KEY);
727
+ return toContent(result);
728
+ }
729
+
730
+ async function knowledgeFacets({
731
+ project_id,
732
+ category,
733
+ status,
734
+ tags,
735
+ match,
736
+ tag_prefix,
737
+ limit,
738
+ }) {
739
+ const params = new URLSearchParams();
740
+ params.set("group_by", "tag");
741
+ if (project_id) params.set("project_id", project_id);
742
+ if (category) params.set("category", category);
743
+ if (status) params.set("status", status);
744
+ if (tags) params.set("tags", tags);
745
+ if (match) params.set("match", match);
746
+ if (tag_prefix) params.set("tag_prefix", tag_prefix);
747
+ if (limit != null) params.set("limit", String(limit));
748
+ const result = await apiCall(
749
+ "GET",
750
+ `/api/v1/knowledge/facets?${params}`,
751
+ null,
752
+ process.env.LOOPCTL_AGENT_KEY,
753
+ );
754
+ return toContent(result);
755
+ }
756
+
757
+ async function knowledgeSearch({ q, project_id, story_id, category, tags, match, mode, limit, offset }) {
758
+ const params = new URLSearchParams();
759
+ if (q != null && q !== "") params.set("q", q);
596
760
  if (project_id) params.set("project_id", project_id);
597
761
  if (story_id) params.set("story_id", story_id);
598
762
  if (category) params.set("category", category);
599
763
  if (tags) params.set("tags", tags);
764
+ if (match) params.set("match", match);
600
765
  if (mode) params.set("mode", mode);
601
766
  if (limit != null) params.set("limit", String(limit));
767
+ if (offset != null) params.set("offset", String(offset));
602
768
 
603
769
  const result = await apiCall("GET", `/api/v1/knowledge/search?${params}`, null, process.env.LOOPCTL_AGENT_KEY);
604
770
  return toContent(result);
605
771
  }
606
772
 
773
+ async function knowledgeList({
774
+ project_id,
775
+ category,
776
+ status,
777
+ tags,
778
+ match,
779
+ source_type,
780
+ source_id,
781
+ idempotency_key,
782
+ limit,
783
+ offset,
784
+ include_body,
785
+ }) {
786
+ const params = new URLSearchParams();
787
+ if (project_id) params.set("project_id", project_id);
788
+ if (category) params.set("category", category);
789
+ if (status) params.set("status", status);
790
+ if (tags) params.set("tags", tags);
791
+ if (match) params.set("match", match);
792
+ if (source_type) params.set("source_type", source_type);
793
+ if (source_id) params.set("source_id", source_id);
794
+ if (idempotency_key) params.set("idempotency_key", idempotency_key);
795
+ if (limit != null) params.set("limit", String(limit));
796
+ if (offset != null) params.set("offset", String(offset));
797
+ // Body-less summary by default (safe to enumerate large pages); opt into full
798
+ // bodies (byte-budget bounded server-side) with include_body: true.
799
+ if (include_body === true) params.set("include_body", "true");
800
+
801
+ const result = await apiCall(
802
+ "GET",
803
+ `/api/v1/articles?${params}`,
804
+ null,
805
+ process.env.LOOPCTL_AGENT_KEY,
806
+ );
807
+ return toContent(result);
808
+ }
809
+
607
810
  async function knowledgeGet({ article_id, project_id, story_id }) {
608
811
  const params = new URLSearchParams();
609
812
  if (project_id) params.set("project_id", project_id);
@@ -614,24 +817,63 @@ async function knowledgeGet({ article_id, project_id, story_id }) {
614
817
  return toContent(result);
615
818
  }
616
819
 
617
- async function knowledgeContext({ query, project_id, story_id, limit, recency_weight }) {
820
+ async function knowledgeContext({
821
+ query,
822
+ project_id,
823
+ story_id,
824
+ limit,
825
+ recency_weight,
826
+ memory_types,
827
+ agents,
828
+ conversation_id,
829
+ }) {
618
830
  const params = new URLSearchParams({ query });
619
831
  if (project_id) params.set("project_id", project_id);
620
832
  if (story_id) params.set("story_id", story_id);
621
833
  if (limit != null) params.set("limit", String(limit));
622
834
  if (recency_weight != null) params.set("recency_weight", String(recency_weight));
835
+ // Agent-memory scoping (comma-separated lists are OR'd; conversation_id is exact).
836
+ if (memory_types) params.set("memory_types", Array.isArray(memory_types) ? memory_types.join(",") : memory_types);
837
+ if (agents) params.set("agents", Array.isArray(agents) ? agents.join(",") : agents);
838
+ if (conversation_id) params.set("conversation_id", conversation_id);
623
839
 
624
840
  const result = await apiCall("GET", `/api/v1/knowledge/context?${params}`, null, process.env.LOOPCTL_AGENT_KEY);
625
841
  return toContent(result);
626
842
  }
627
843
 
628
- async function knowledgeCreate({ title, body, category, tags, project_id }) {
844
+ async function knowledgeCreate({
845
+ title,
846
+ body,
847
+ category,
848
+ tags,
849
+ project_id,
850
+ draft,
851
+ source_type,
852
+ source_id,
853
+ idempotency_key,
854
+ metadata,
855
+ }) {
629
856
  const payload = { title, body };
630
857
  if (category) payload.category = category;
631
858
  if (tags) payload.tags = tags;
632
859
  if (project_id) payload.project_id = project_id;
860
+ if (source_type) payload.source_type = source_type;
861
+ if (source_id) payload.source_id = source_id;
862
+ if (idempotency_key) payload.idempotency_key = idempotency_key;
863
+ if (metadata) payload.metadata = metadata;
864
+
865
+ // Articles publish on create by default for every role (including agent), so a
866
+ // plain create routes through the agent key and is immediately visible. Pass
867
+ // draft:true to stage it for later review instead — publish it afterwards with
868
+ // knowledge_publish.
869
+ if (draft) payload.draft = true;
633
870
 
634
- const result = await apiCall("POST", "/api/v1/articles", payload, process.env.LOOPCTL_AGENT_KEY);
871
+ const result = await apiCall(
872
+ "POST",
873
+ "/api/v1/articles",
874
+ payload,
875
+ process.env.LOOPCTL_AGENT_KEY,
876
+ );
635
877
  return toContent(result);
636
878
  }
637
879
 
@@ -649,6 +891,46 @@ async function knowledgeBulkPublish({ article_ids }) {
649
891
  { article_ids },
650
892
  process.env.LOOPCTL_USER_KEY
651
893
  );
894
+
895
+ // Partial success returns 200 even when nothing published. Surface a warning
896
+ // so the agent doesn't treat not_found/errored ids as success.
897
+ const counts = result?.meta?.counts;
898
+ if (counts && (counts.not_found > 0 || counts.errored > 0)) {
899
+ const warning =
900
+ `WARNING: bulk-publish was partial — published ${counts.published}, ` +
901
+ `skipped ${counts.skipped}, not_found ${counts.not_found}, errored ${counts.errored} ` +
902
+ `(of ${counts.requested} requested). Inspect meta.results for per-id outcomes; ` +
903
+ `not_found/errored ids were NOT published.`;
904
+ return {
905
+ content: [{ type: "text", text: warning }, ...toContent(result).content],
906
+ };
907
+ }
908
+
909
+ return toContent(result);
910
+ }
911
+
912
+ async function knowledgeBulkUnpublish({ article_ids }) {
913
+ const result = await apiCall(
914
+ "POST",
915
+ "/api/v1/knowledge/bulk-unpublish",
916
+ { article_ids },
917
+ process.env.LOOPCTL_USER_KEY
918
+ );
919
+
920
+ // Partial success returns 200 even when nothing unpublished. Surface a warning
921
+ // so the agent doesn't treat not_found/errored ids as success.
922
+ const counts = result?.meta?.counts;
923
+ if (counts && (counts.not_found > 0 || counts.errored > 0)) {
924
+ const warning =
925
+ `WARNING: bulk-unpublish was partial — unpublished ${counts.unpublished}, ` +
926
+ `skipped ${counts.skipped}, not_found ${counts.not_found}, errored ${counts.errored} ` +
927
+ `(of ${counts.requested} requested). Inspect meta.results for per-id outcomes; ` +
928
+ `not_found/errored ids were NOT unpublished.`;
929
+ return {
930
+ content: [{ type: "text", text: warning }, ...toContent(result).content],
931
+ };
932
+ }
933
+
652
934
  return toContent(result);
653
935
  }
654
936
 
@@ -682,12 +964,68 @@ async function knowledgeDelete({ article_id }) {
682
964
  return toContent(result);
683
965
  }
684
966
 
967
+ async function knowledgeBulkDelete({
968
+ article_ids,
969
+ source_type,
970
+ source_id,
971
+ tag,
972
+ confirm,
973
+ dry_run,
974
+ hard,
975
+ token,
976
+ confirm_hash,
977
+ }) {
978
+ const payload = {};
979
+ if (article_ids) payload.article_ids = article_ids;
980
+ if (source_type) payload.source_type = source_type;
981
+ if (source_id) payload.source_id = source_id;
982
+ if (tag) payload.tag = tag;
983
+ if (confirm) payload.confirm = confirm;
984
+ // US-27.12: dry-run preview + irreversible hard delete via a single-use frozen
985
+ // token. dry_run=true mutates nothing (returns meta.would_affect; for the hard
986
+ // path a single-use meta.token); hard=true + token performs the FK-correct
987
+ // IRREVERSIBLE delete over the frozen id-set. Oversized selectors echo
988
+ // meta.confirm_hash for re-confirm-on-drift instead of a token.
989
+ if (dry_run) payload.dry_run = true;
990
+ if (hard) payload.hard = true;
991
+ if (token) payload.token = token;
992
+ if (confirm_hash) payload.confirm_hash = confirm_hash;
993
+
994
+ const result = await apiCall(
995
+ "POST",
996
+ "/api/v1/knowledge/bulk-delete",
997
+ payload,
998
+ process.env.LOOPCTL_USER_KEY,
999
+ );
1000
+
1001
+ // Partial success returns 200 even when some/none archived — surface a warning
1002
+ // so the agent doesn't treat not_found/errored, or a nothing-archived run, as
1003
+ // success.
1004
+ const counts = result?.meta?.counts;
1005
+ if (
1006
+ counts &&
1007
+ (counts.not_found > 0 ||
1008
+ counts.errored > 0 ||
1009
+ (counts.archived === 0 && counts.requested > 0))
1010
+ ) {
1011
+ const warning =
1012
+ `WARNING: bulk-delete was partial — archived ${counts.archived}, ` +
1013
+ `skipped ${counts.skipped}, not_found ${counts.not_found}, errored ${counts.errored} ` +
1014
+ `(of ${counts.requested}). Inspect meta.results; not_found/errored ids were NOT archived.`;
1015
+ return {
1016
+ content: [{ type: "text", text: warning }, ...toContent(result).content],
1017
+ };
1018
+ }
1019
+
1020
+ return toContent(result);
1021
+ }
1022
+
685
1023
  async function knowledgeDrafts({ limit, offset, project_id }) {
686
1024
  const params = new URLSearchParams();
687
- params.set(
688
- "limit",
689
- String(Math.min(limit ?? MAX_PAGE_SIZE, MAX_PAGE_SIZE))
690
- );
1025
+ // Pass `limit` through verbatim (like knowledge_list/index/search) so the
1026
+ // server honors it up to its max page size and returns 400 above it — rather
1027
+ // than silently clamping client-side, which would truncate draft enumeration.
1028
+ if (limit != null) params.set("limit", String(limit));
691
1029
  if (offset != null) params.set("offset", String(offset));
692
1030
  if (project_id) params.set("project_id", project_id);
693
1031
  const path = `/api/v1/knowledge/drafts?${params.toString()}`;
@@ -709,24 +1047,28 @@ async function knowledgeLint({ project_id, stale_days, min_coverage, max_per_cat
709
1047
  return toContent(result);
710
1048
  }
711
1049
 
712
- async function knowledgeIngest({ url, content, source_type, project_id }) {
1050
+ async function knowledgeIngest({ url, content, source_type, project_id, publish }) {
713
1051
  const body = { source_type };
714
1052
  if (url) body.url = url;
715
1053
  if (content) body.content = content;
716
1054
  if (project_id) body.project_id = project_id;
1055
+ if (publish) body.publish = true;
717
1056
  const result = await apiCall("POST", "/api/v1/knowledge/ingest", body, process.env.LOOPCTL_ORCH_KEY);
718
1057
  return toContent(result);
719
1058
  }
720
1059
 
721
- async function knowledgeIngestBatch({ items, project_id }) {
722
- // If a batch-level project_id is supplied, apply it as a default to every
723
- // item that doesn't already set its own.
1060
+ async function knowledgeIngestBatch({ items, project_id, publish }) {
1061
+ // Apply batch-level project_id / publish as defaults to any item that doesn't
1062
+ // set its own.
724
1063
  const resolvedItems = Array.isArray(items)
725
- ? items.map((item) =>
726
- project_id && item && item.project_id == null
727
- ? { ...item, project_id }
728
- : item
729
- )
1064
+ ? items.map((item) => {
1065
+ if (!item) return item;
1066
+ let resolved = item;
1067
+ if (project_id && resolved.project_id == null)
1068
+ resolved = { ...resolved, project_id };
1069
+ if (publish && resolved.publish == null) resolved = { ...resolved, publish: true };
1070
+ return resolved;
1071
+ })
730
1072
  : items;
731
1073
 
732
1074
  const result = await apiCall(
@@ -738,8 +1080,18 @@ async function knowledgeIngestBatch({ items, project_id }) {
738
1080
  return toContent(result);
739
1081
  }
740
1082
 
741
- async function knowledgeIngestionJobs() {
742
- const result = await apiCall("GET", "/api/v1/knowledge/ingestion-jobs", null, process.env.LOOPCTL_ORCH_KEY);
1083
+ async function knowledgeIngestionJobs({ limit, offset, since_days } = {}) {
1084
+ const params = new URLSearchParams();
1085
+ if (limit != null) params.set("limit", String(limit));
1086
+ if (offset != null) params.set("offset", String(offset));
1087
+ if (since_days != null) params.set("since_days", String(since_days));
1088
+ const query = params.toString() ? `?${params}` : "";
1089
+ const result = await apiCall(
1090
+ "GET",
1091
+ `/api/v1/knowledge/ingestion-jobs${query}`,
1092
+ null,
1093
+ process.env.LOOPCTL_ORCH_KEY,
1094
+ );
743
1095
  return toContent(result);
744
1096
  }
745
1097
 
@@ -852,12 +1204,18 @@ async function knowledgeExport({ project_id }) {
852
1204
  ? `/api/v1/projects/${project_id}/knowledge/export`
853
1205
  : "/api/v1/knowledge/export";
854
1206
  const baseUrl = getBaseUrl();
855
- const downloadCmd = `curl -H "Authorization: Bearer $LOOPCTL_ORCH_KEY" "${baseUrl}${basePath}" -o knowledge-export.zip`;
1207
+ // The export endpoint is role:user use LOOPCTL_USER_KEY. (LOOPCTL_ORCH_KEY is
1208
+ // orchestrator role, which is BELOW user in the hierarchy and would 403.) The
1209
+ // endpoint streams a gzipped tar (`application/gzip`), so save it as `.tar.gz`.
1210
+ const downloadCmd = `curl -H "Authorization: Bearer $LOOPCTL_USER_KEY" "${baseUrl}${basePath}" -o knowledge-export.tar.gz`;
856
1211
  return {
857
1212
  content: [{
858
1213
  type: "text",
859
1214
  text: JSON.stringify({
860
- message: "Knowledge export produces a ZIP file. Use the curl command below to download it directly.",
1215
+ message:
1216
+ "Knowledge export streams a bounded-memory gzipped tar (.tar.gz). Use the curl " +
1217
+ "command below to download it directly. Requires a user-role key " +
1218
+ "(LOOPCTL_USER_KEY); extract with `tar -xzf knowledge-export.tar.gz`.",
861
1219
  command: downloadCmd,
862
1220
  endpoint: `${baseUrl}${basePath}`,
863
1221
  }, null, 2),
@@ -865,6 +1223,136 @@ async function knowledgeExport({ project_id }) {
865
1223
  };
866
1224
  }
867
1225
 
1226
+ // --- OKF (Open Knowledge Format) interchange ---
1227
+
1228
+ // Guard a user-supplied absolute filesystem path against pseudo-filesystems.
1229
+ function assertSafeAbsolutePath(nodePath, p, label) {
1230
+ if (!nodePath.isAbsolute(p)) {
1231
+ return `${label} must be an absolute path (got '${p}').`;
1232
+ }
1233
+ const blocked = ["/proc", "/dev", "/sys"];
1234
+ if (blocked.some((b) => p === b || p.startsWith(b + "/"))) {
1235
+ return `${label} refused: '${p}' targets a pseudo-filesystem path.`;
1236
+ }
1237
+ return null;
1238
+ }
1239
+
1240
+ async function knowledgeOkfExport({ project_id, out_dir }) {
1241
+ const basePath = project_id
1242
+ ? `/api/v1/projects/${project_id}/knowledge/okf/export`
1243
+ : "/api/v1/knowledge/okf/export";
1244
+
1245
+ const result = await apiCall(
1246
+ "GET",
1247
+ `${basePath}?format=json`,
1248
+ null,
1249
+ process.env.LOOPCTL_USER_KEY,
1250
+ );
1251
+
1252
+ if (result.error) return toContent(result);
1253
+
1254
+ const bundle = result.data || result;
1255
+ const files = bundle.files || {};
1256
+ const meta = bundle.meta || {};
1257
+
1258
+ if (!out_dir) {
1259
+ return toContent({ meta, file_count: Object.keys(files).length, files });
1260
+ }
1261
+
1262
+ const nodePath = await import("node:path");
1263
+ const pathErr = assertSafeAbsolutePath(nodePath, out_dir, "out_dir");
1264
+ if (pathErr) {
1265
+ return { content: [{ type: "text", text: `Error: ${pathErr}` }], isError: true };
1266
+ }
1267
+ // Normalize so `..`/trailing-slash can't defeat the per-file fence below.
1268
+ const root = nodePath.resolve(out_dir);
1269
+
1270
+ const fs = await import("node:fs/promises");
1271
+ let written = 0;
1272
+ for (const [rel, content] of Object.entries(files)) {
1273
+ // Keep writes inside root even if a server-supplied path is adversarial.
1274
+ const dest = nodePath.resolve(root, rel);
1275
+ if (dest !== root && !dest.startsWith(root + nodePath.sep)) continue;
1276
+ await fs.mkdir(nodePath.dirname(dest), { recursive: true });
1277
+ await fs.writeFile(dest, content, "utf8");
1278
+ written += 1;
1279
+ }
1280
+
1281
+ return toContent({ meta, out_dir: root, written });
1282
+ }
1283
+
1284
+ async function knowledgeOkfImport({ bundle_dir, project_id, merge, dry_run }) {
1285
+ const nodePath = await import("node:path");
1286
+ const pathErr = assertSafeAbsolutePath(nodePath, bundle_dir, "bundle_dir");
1287
+ if (pathErr) {
1288
+ return { content: [{ type: "text", text: `Error: ${pathErr}` }], isError: true };
1289
+ }
1290
+
1291
+ const fs = await import("node:fs/promises");
1292
+
1293
+ let stat;
1294
+ try {
1295
+ stat = await fs.stat(bundle_dir);
1296
+ } catch {
1297
+ return { content: [{ type: "text", text: `Error: bundle_dir '${bundle_dir}' not found.` }], isError: true };
1298
+ }
1299
+ if (!stat.isDirectory()) {
1300
+ return { content: [{ type: "text", text: `Error: bundle_dir '${bundle_dir}' is not a directory.` }], isError: true };
1301
+ }
1302
+
1303
+ // Kept in step with the server-side caps in okf_controller.ex.
1304
+ const MAX_FILES = 10_000;
1305
+ const MAX_TOTAL_BYTES = 50 * 1024 * 1024;
1306
+ const files = {};
1307
+ let totalBytes = 0;
1308
+
1309
+ async function walk(dir) {
1310
+ const entries = await fs.readdir(dir, { withFileTypes: true });
1311
+ for (const entry of entries) {
1312
+ const abs = nodePath.join(dir, entry.name);
1313
+ // Never traverse or read through symlinks (escape out of bundle_dir).
1314
+ if (entry.isSymbolicLink()) {
1315
+ continue;
1316
+ } else if (entry.isDirectory()) {
1317
+ await walk(abs);
1318
+ } else if (entry.isFile() && entry.name.endsWith(".md")) {
1319
+ if (Object.keys(files).length >= MAX_FILES) {
1320
+ throw new Error(`bundle exceeds ${MAX_FILES} files`);
1321
+ }
1322
+ const content = await fs.readFile(abs, "utf8");
1323
+ totalBytes += Buffer.byteLength(content, "utf8");
1324
+ if (totalBytes > MAX_TOTAL_BYTES) {
1325
+ throw new Error("bundle exceeds 25 MiB total");
1326
+ }
1327
+ files[nodePath.relative(bundle_dir, abs).split(nodePath.sep).join("/")] = content;
1328
+ }
1329
+ }
1330
+ }
1331
+
1332
+ try {
1333
+ await walk(bundle_dir);
1334
+ } catch (e) {
1335
+ return { content: [{ type: "text", text: `Error: ${e.message}` }], isError: true };
1336
+ }
1337
+
1338
+ if (Object.keys(files).length === 0) {
1339
+ return { content: [{ type: "text", text: `Error: no .md files found under '${bundle_dir}'.` }], isError: true };
1340
+ }
1341
+
1342
+ const payload = { files };
1343
+ if (project_id) payload.project_id = project_id;
1344
+ if (merge != null) payload.merge = merge;
1345
+ if (dry_run != null) payload.dry_run = dry_run;
1346
+
1347
+ const result = await apiCall(
1348
+ "POST",
1349
+ "/api/v1/knowledge/okf/import",
1350
+ payload,
1351
+ process.env.LOOPCTL_USER_KEY,
1352
+ );
1353
+ return toContent(result);
1354
+ }
1355
+
868
1356
  // --- Discovery Tools ---
869
1357
 
870
1358
  async function listRoutes() {
@@ -934,10 +1422,15 @@ const TOOLS = [
934
1422
  },
935
1423
  {
936
1424
  name: "list_projects",
937
- description: "List all projects in the current tenant.",
1425
+ description:
1426
+ "List projects in the current tenant. Paginated (page/page_size); advance " +
1427
+ "`page` to enumerate all projects. Response includes pagination meta.",
938
1428
  inputSchema: {
939
1429
  type: "object",
940
- properties: {},
1430
+ properties: {
1431
+ page: { type: "integer", description: "Page number (default 1)." },
1432
+ page_size: { type: "integer", description: "Items per page (default 20)." },
1433
+ },
941
1434
  required: [],
942
1435
  },
943
1436
  },
@@ -1098,8 +1591,8 @@ const TOOLS = [
1098
1591
  description:
1099
1592
  "List stories for a project, optionally filtered by agent_status, verified_status, or epic_id. " +
1100
1593
  "Returns compact results (no acceptance_criteria/description) — use get_story for full details. " +
1101
- "Max 20 per page. Use offset to paginate (response includes total_count). " +
1102
- "Filter by epic_id or agent_status to reduce result size.",
1594
+ "Defaults to 20 per page; pass `limit` up to 500 to page larger. Use offset to paginate " +
1595
+ "(response includes total_count). Filter by epic_id or agent_status to reduce result size.",
1103
1596
  inputSchema: {
1104
1597
  type: "object",
1105
1598
  properties: {
@@ -1122,7 +1615,7 @@ const TOOLS = [
1122
1615
  },
1123
1616
  limit: {
1124
1617
  type: "integer",
1125
- description: "Maximum number of stories to return.",
1618
+ description: "Maximum number of stories to return (default 20, max 500).",
1126
1619
  },
1127
1620
  offset: {
1128
1621
  type: "integer",
@@ -1141,7 +1634,8 @@ const TOOLS = [
1141
1634
  description:
1142
1635
  "List stories that are ready to be worked on (contracted, dependencies met). " +
1143
1636
  "Returns compact results — use get_story for full details. " +
1144
- "Max 20 per page. Response includes total_count for pagination.",
1637
+ "Paginated (page/page_size); advance `page` to enumerate all ready stories. " +
1638
+ "Response includes total_count.",
1145
1639
  inputSchema: {
1146
1640
  type: "object",
1147
1641
  properties: {
@@ -1149,9 +1643,10 @@ const TOOLS = [
1149
1643
  type: "string",
1150
1644
  description: "The UUID of the project.",
1151
1645
  },
1152
- limit: {
1646
+ page: { type: "integer", description: "Page number (default 1)." },
1647
+ page_size: {
1153
1648
  type: "integer",
1154
- description: "Maximum number of stories to return.",
1649
+ description: "Stories per page (default 100, max 500).",
1155
1650
  },
1156
1651
  },
1157
1652
  required: ["project_id"],
@@ -1496,7 +1991,9 @@ const TOOLS = [
1496
1991
  },
1497
1992
  {
1498
1993
  name: "get_story_token_usage",
1499
- description: "Get token usage records for a single story.",
1994
+ description:
1995
+ "Get token usage records for a single story. Paginated (page/page_size); " +
1996
+ "advance `page` to enumerate all records.",
1500
1997
  inputSchema: {
1501
1998
  type: "object",
1502
1999
  properties: {
@@ -1504,6 +2001,8 @@ const TOOLS = [
1504
2001
  type: "string",
1505
2002
  description: "The UUID of the story.",
1506
2003
  },
2004
+ page: { type: "integer", description: "Page number (default 1)." },
2005
+ page_size: { type: "integer", description: "Records per page (default 20)." },
1507
2006
  },
1508
2007
  required: ["story_id"],
1509
2008
  },
@@ -1512,7 +2011,8 @@ const TOOLS = [
1512
2011
  name: "get_cost_anomalies",
1513
2012
  description:
1514
2013
  "Get cost anomaly alerts — stories or agents that exceed expected token budgets. " +
1515
- "Optionally filter by project.",
2014
+ "Optionally filter by project. Paginated (page/page_size); advance `page` to " +
2015
+ "enumerate all anomalies.",
1516
2016
  inputSchema: {
1517
2017
  type: "object",
1518
2018
  properties: {
@@ -1520,6 +2020,8 @@ const TOOLS = [
1520
2020
  type: "string",
1521
2021
  description: "Optional: filter anomalies to a specific project UUID.",
1522
2022
  },
2023
+ page: { type: "integer", description: "Page number (default 1)." },
2024
+ page_size: { type: "integer", description: "Anomalies per page (default 20)." },
1523
2025
  },
1524
2026
  required: [],
1525
2027
  },
@@ -1560,7 +2062,13 @@ const TOOLS = [
1560
2062
  {
1561
2063
  name: "knowledge_index",
1562
2064
  description:
1563
- "Load the knowledge wiki catalog at session start. Returns article metadata grouped by category. " +
2065
+ "Browse/paginate the knowledge wiki catalog. Returns LIGHTWEIGHT article metadata grouped by " +
2066
+ "category — by default only id, title, category per article (NOT full metadata). " +
2067
+ "Honors category/tags filters and offset/limit pagination with deterministic ordering, so " +
2068
+ "every article is reachable (meta.categories reports per-category counts over the whole " +
2069
+ "filtered set; meta.has_more/meta.truncated signal more pages). Use `fields` to control the " +
2070
+ "projection (request tags/status/updated_at explicitly; id and category are always included) " +
2071
+ "to keep the payload small on large catalogs. " +
1564
2072
  "Pass story_id when working on a loopctl story so reads attribute correctly.",
1565
2073
  inputSchema: {
1566
2074
  type: "object",
@@ -1575,51 +2083,395 @@ const TOOLS = [
1575
2083
  format: "uuid",
1576
2084
  description: "Optional: loopctl story UUID for attribution tracking.",
1577
2085
  },
2086
+ category: {
2087
+ type: "string",
2088
+ enum: ["pattern", "convention", "decision", "finding", "reference"],
2089
+ description: "Optional: filter to a single category. Rejected (400) if unknown.",
2090
+ },
2091
+ tags: {
2092
+ type: "string",
2093
+ description: "Optional: comma-separated tags; matches articles carrying ANY of them.",
2094
+ },
2095
+ match: {
2096
+ type: "string",
2097
+ enum: ["any", "all"],
2098
+ description: "Optional: tag match mode — 'any' (default, OR) or 'all' (AND, every tag).",
2099
+ },
2100
+ offset: {
2101
+ type: "integer",
2102
+ description: "Optional: rows to skip for pagination (default 0).",
2103
+ },
2104
+ limit: {
2105
+ type: "integer",
2106
+ description: "Optional: max articles per page (default 1000, max 1000).",
2107
+ },
2108
+ fields: {
2109
+ type: "array",
2110
+ items: {
2111
+ type: "string",
2112
+ enum: ["id", "title", "category", "tags", "status", "updated_at"],
2113
+ },
2114
+ description:
2115
+ "Optional: projection of article fields to return. Default: id, title, category. `id` is always included.",
2116
+ },
1578
2117
  },
1579
2118
  required: [],
1580
2119
  },
1581
2120
  },
1582
2121
  {
1583
- name: "knowledge_search",
2122
+ name: "knowledge_list",
1584
2123
  description:
1585
- "Search the knowledge wiki by topic. Returns snippets. " +
1586
- "Pass story_id when working on a loopctl story so reads attribute correctly.",
2124
+ "List articles (id, title, category, status, tags, source_type, source_id, " +
2125
+ "idempotency_key, timestamps), filtered and paginated. **Body-less summary by default** " +
2126
+ "— the right tool to enumerate, dedup, or repair at scale (safe to page up to limit=1000). " +
2127
+ "Pass `include_body: true` to also return the full `body`, in which case the server bounds " +
2128
+ "the page by a ~5 MB serialized-body budget and returns meta.next_offset/has_more/" +
2129
+ "byte_truncated for continuation (for a single full body use knowledge_get; for the relevant " +
2130
+ "bodies use knowledge_context; for a bulk content dump use knowledge_export). Unlike " +
2131
+ "knowledge_search (ranked, PUBLISHED-only, lags writes while embeddings index) and " +
2132
+ "knowledge_index (id/title/category only), this is the LAG-FREE, ALL-STATUS read of the DB " +
2133
+ "of record (draft, published, archived, superseded visible). Use for idempotency/existence " +
2134
+ "checks: filter by `tags`, `source_type`+`source_id`, or `idempotency_key` and read " +
2135
+ "`meta.total_count` (exact) to answer \"does an article for X already exist?\" reliably " +
2136
+ "right after a write. Paginate via offset/limit.",
1587
2137
  inputSchema: {
1588
2138
  type: "object",
1589
2139
  properties: {
1590
- q: {
1591
- type: "string",
1592
- description: "Search query string.",
1593
- },
1594
2140
  project_id: {
1595
2141
  type: "string",
1596
2142
  format: "uuid",
1597
- description: "Optional: scope search to a specific project UUID.",
2143
+ description: "Optional: scope to a project UUID.",
1598
2144
  },
1599
- story_id: {
2145
+ category: {
1600
2146
  type: "string",
1601
- format: "uuid",
1602
- description: "Optional: loopctl story UUID for attribution tracking.",
2147
+ description: "Optional: filter by category.",
1603
2148
  },
1604
- category: {
2149
+ status: {
1605
2150
  type: "string",
1606
- description: "Optional: filter results by category.",
2151
+ description: "Optional: filter by status (draft, published, archived, superseded).",
1607
2152
  },
1608
2153
  tags: {
1609
2154
  type: "string",
1610
- description: "Optional: comma-separated tags to filter by.",
2155
+ description: "Optional: comma-separated tags; matches articles carrying ANY of them.",
1611
2156
  },
1612
- mode: {
2157
+ match: {
1613
2158
  type: "string",
1614
- enum: ["keyword", "semantic", "combined"],
1615
- description: "Optional: search mode (keyword, semantic, or combined).",
2159
+ enum: ["any", "all"],
2160
+ description: "Optional: tag match mode — 'any' (default, OR) or 'all' (AND, every tag).",
2161
+ },
2162
+ source_type: {
2163
+ type: "string",
2164
+ description: "Optional: filter by source_type.",
2165
+ },
2166
+ source_id: {
2167
+ type: "string",
2168
+ description: "Optional: filter by source_id (a malformed id matches nothing).",
2169
+ },
2170
+ idempotency_key: {
2171
+ type: "string",
2172
+ description:
2173
+ "Optional: filter by exact idempotency_key — the lag-free existence check for a " +
2174
+ "prior capture.",
2175
+ },
2176
+ offset: {
2177
+ type: "integer",
2178
+ description: "Optional: rows to skip for pagination (default 0).",
1616
2179
  },
1617
2180
  limit: {
1618
2181
  type: "integer",
1619
- description: "Optional: maximum number of results to return.",
2182
+ description:
2183
+ "Optional: max articles per page (default 20, max 1000). A limit above " +
2184
+ "the max is rejected with 400 — not silently clamped — so paging by offset " +
2185
+ "over meta.total_count enumerates the complete set without skipping rows.",
1620
2186
  },
2187
+ include_body: {
2188
+ type: "boolean",
2189
+ description:
2190
+ "Optional (default false): when true, include the full article `body`. The page is " +
2191
+ "then bounded by a ~5 MB serialized-body budget (it may return fewer than `limit` " +
2192
+ "rows); continue via meta.next_offset while meta.has_more is true. Leave false to " +
2193
+ "enumerate metadata cheaply at scale.",
2194
+ },
2195
+ },
2196
+ required: [],
2197
+ },
2198
+ },
2199
+ {
2200
+ name: "knowledge_stats",
2201
+ description:
2202
+ "Get aggregate article counts for the wiki without pulling any article metadata. " +
2203
+ "Returns { total, by_category, by_status } via cheap COUNT(*) GROUP BY. This is the " +
2204
+ "right tool to answer \"how many articles are in this project?\" — knowledge_index " +
2205
+ "pages article metadata and knowledge_search's total_count is query-dependent. Counts " +
2206
+ "span all statuses (draft/published/archived/superseded); see by_status for the split. " +
2207
+ "Note: `total` is NOT the same as knowledge_index's meta.total_count (which counts only " +
2208
+ "published) — they differ whenever drafts/archived exist.",
2209
+ inputSchema: {
2210
+ type: "object",
2211
+ properties: {
2212
+ project_id: {
2213
+ type: "string",
2214
+ format: "uuid",
2215
+ description:
2216
+ "Optional: scope counts to a project (counts both tenant-wide and project-specific articles).",
2217
+ },
2218
+ },
2219
+ required: [],
2220
+ },
2221
+ },
2222
+ {
2223
+ name: "knowledge_count",
2224
+ description:
2225
+ "Count articles matching filters WITHOUT returning any rows. Accepts the same filters " +
2226
+ "as knowledge_list (category, status, tags, match, source_type, source_id, " +
2227
+ "idempotency_key, project_id). With tags + match:'all' it counts articles carrying ALL " +
2228
+ "the tags; add status:'published' to answer \"how many PUBLISHED articles tagged both X " +
2229
+ "and Y\". Removes the need to paginate rows just to count. Returns { count }.",
2230
+ inputSchema: {
2231
+ type: "object",
2232
+ properties: {
2233
+ project_id: { type: "string", format: "uuid", description: "Optional: scope to a project UUID." },
2234
+ category: { type: "string", description: "Optional: filter by category." },
2235
+ status: { type: "string", description: "Optional: filter by status." },
2236
+ tags: { type: "string", description: "Optional: comma-separated tags." },
2237
+ match: {
2238
+ type: "string",
2239
+ enum: ["any", "all"],
2240
+ description: "Tag match mode: 'any' (default, OR) or 'all' (AND — carries every tag).",
2241
+ },
2242
+ source_type: { type: "string", description: "Optional: filter by source_type." },
2243
+ source_id: { type: "string", description: "Optional: filter by source_id." },
2244
+ idempotency_key: { type: "string", description: "Optional: filter by idempotency_key." },
2245
+ },
2246
+ required: [],
2247
+ },
2248
+ },
2249
+ {
2250
+ name: "knowledge_facets",
2251
+ description:
2252
+ "Count articles grouped by each distinct tag, over the filtered set, WITHOUT returning " +
2253
+ "rows. Returns { data: { tag: count }, meta: { distinct_count, truncated } }. " +
2254
+ "meta.distinct_count is the TRUE number of distinct tags (independent of limit); " +
2255
+ "meta.truncated flags when limit capped the rows. Each `count` is the number of distinct " +
2256
+ "articles carrying that tag. Pass tag_prefix to restrict to a tag family (e.g. 'book-') " +
2257
+ "so you get the DISTINCT count of that family (how many distinct books) plus per-member " +
2258
+ "totals — without dragging tens of thousands of rows through context. Honors the same " +
2259
+ "filters as knowledge_count (status, tags, match). Cost: unnests tags over the whole " +
2260
+ "filtered set; on large tenants narrow with tag_prefix/category/status/project_id.",
2261
+ inputSchema: {
2262
+ type: "object",
2263
+ properties: {
2264
+ project_id: { type: "string", format: "uuid", description: "Optional: scope to a project UUID." },
2265
+ category: { type: "string", description: "Optional: filter by category." },
2266
+ status: { type: "string", description: "Optional: filter by status." },
2267
+ tags: { type: "string", description: "Optional: comma-separated tags." },
2268
+ match: {
2269
+ type: "string",
2270
+ enum: ["any", "all"],
2271
+ description: "Tag match mode: 'any' (default, OR) or 'all' (AND).",
2272
+ },
2273
+ tag_prefix: {
2274
+ type: "string",
2275
+ description: "Optional: only tags starting with this literal prefix (e.g. 'book-').",
2276
+ },
2277
+ limit: { type: "integer", description: "Optional: max distinct tags in the result (default all, max 1000; values above 1000 are rejected with 400)." },
2278
+ },
2279
+ required: [],
2280
+ },
2281
+ },
2282
+ {
2283
+ name: "knowledge_graph",
2284
+ description:
2285
+ "Traverse the published article-link graph outward from an article, up to `depth` hops " +
2286
+ "(1–3, default 1). BIDIRECTIONAL (follows links regardless of source/target direction) and " +
2287
+ "cycle-safe (no node twice). Returns { nodes: [{id,title,category,depth}], edges: " +
2288
+ "[{source_article_id,target_article_id,relationship_type}], truncated, node_count }. Bounded " +
2289
+ "to 100 nodes / 500 edges (truncated:true when hit). Use to explore how a piece of knowledge " +
2290
+ "connects (relates_to/derived_from/contradicts/supersedes) beyond the 1-hop links in " +
2291
+ "knowledge_context.",
2292
+ inputSchema: {
2293
+ type: "object",
2294
+ properties: {
2295
+ article_id: {
2296
+ type: "string",
2297
+ format: "uuid",
2298
+ description: "Starting article UUID (required).",
2299
+ },
2300
+ depth: {
2301
+ type: "integer",
2302
+ minimum: 1,
2303
+ maximum: 3,
2304
+ description: "Hops to traverse (1–3, default 1). Out of range → 400.",
2305
+ },
2306
+ project_id: { type: "string", format: "uuid", description: "Optional: project UUID." },
2307
+ },
2308
+ required: ["article_id"],
2309
+ },
2310
+ },
2311
+ {
2312
+ name: "knowledge_suggest_links",
2313
+ description:
2314
+ "Suggest ranked typed-link CANDIDATES for an article by embedding similarity — " +
2315
+ "READ-ONLY, creates nothing. Excludes the article itself and any already-linked " +
2316
+ "article (either direction, any relationship type); only embedded published articles. " +
2317
+ "Returns { data: [{id, title, category, similarity_score}] } highest-similarity first. " +
2318
+ "Review them and create the one you want as a TYPED link (relates_to/derived_from/" +
2319
+ "contradicts/supersedes) — unlike the auto-linker which only makes ambient relates_to. " +
2320
+ "Optional: threshold (cosine floor 0–1, default 0.5), limit (default 5).",
2321
+ inputSchema: {
2322
+ type: "object",
2323
+ properties: {
2324
+ article_id: {
2325
+ type: "string",
2326
+ format: "uuid",
2327
+ description: "The article to suggest links for (required).",
2328
+ },
2329
+ limit: { type: "integer", description: "Max candidates (default 5)." },
2330
+ threshold: {
2331
+ type: "number",
2332
+ minimum: 0,
2333
+ maximum: 1,
2334
+ description: "Cosine similarity floor (default 0.5).",
2335
+ },
2336
+ },
2337
+ required: ["article_id"],
2338
+ },
2339
+ },
2340
+ {
2341
+ name: "knowledge_distant_pairs",
2342
+ description:
2343
+ "Find distant-but-bridgeable article pairs in the optimal-novelty embedding band " +
2344
+ "(cosine distance min..max, default 0.3–0.7) — the creative sweet spot (neither banal " +
2345
+ "nor nonsense). Returns { data: [{a, b, distance}], meta:{count} }. With bridge_path:true, " +
2346
+ "only pairs also connected in the link graph (≤2 hops) are returned. Samples up to 1000 " +
2347
+ "embedded published articles; paginate via limit/offset. For computational-creativity " +
2348
+ "ideation (remote-associates generator).",
2349
+ inputSchema: {
2350
+ type: "object",
2351
+ properties: {
2352
+ min_distance: { type: "number", description: "Lower cosine-distance bound (default 0.3)." },
2353
+ max_distance: { type: "number", description: "Upper cosine-distance bound (default 0.7)." },
2354
+ bridge_path: { type: "boolean", description: "Require a ≤2-hop graph path (default false)." },
2355
+ limit: { type: "integer", description: "Max pairs (default 20, max 100)." },
2356
+ offset: { type: "integer", description: "Pairs to skip." },
1621
2357
  },
1622
- required: ["q"],
2358
+ required: [],
2359
+ },
2360
+ },
2361
+ {
2362
+ name: "knowledge_novelty",
2363
+ description:
2364
+ "Score the NOVELTY of ideas: each idea's text is embedded and compared to the nearest " +
2365
+ "prior proposal, returning novelty_score = cosine distance (0 = identical to existing " +
2366
+ "work, higher = more novel, up to 2.0; null when the idea text is blank, no priors " +
2367
+ "exist, or embedding fails). Priors default to published articles tagged 'proposal' " +
2368
+ "(override with prior_tag). Provide the ideas as EITHER `texts` (a list of strings) " +
2369
+ "OR `ideas` (a list of strings or objects {text|title/spark/thesis,...}); all forms " +
2370
+ "are accepted. " +
2371
+ "Returns { data: [{...idea, novelty_score}], meta: { prior_count } }. Use to rerank " +
2372
+ "generated ideas by novelty × value and avoid repeating prior work.",
2373
+ inputSchema: {
2374
+ type: "object",
2375
+ properties: {
2376
+ ideas: {
2377
+ type: "array",
2378
+ description:
2379
+ "Ideas to score (≤50). Each is a string, or an object whose embed text is " +
2380
+ "`text` (else title/spark/thesis are joined).",
2381
+ items: { type: ["string", "object"] },
2382
+ },
2383
+ texts: {
2384
+ type: "array",
2385
+ description: "Alternative to `ideas`: a list of idea strings (the #152 AC shape).",
2386
+ items: { type: "string" },
2387
+ },
2388
+ prior_tag: {
2389
+ type: "string",
2390
+ description: "Tag identifying prior proposals to compare against (default 'proposal').",
2391
+ },
2392
+ },
2393
+ required: [],
2394
+ },
2395
+ },
2396
+ {
2397
+ name: "knowledge_random_walk",
2398
+ description:
2399
+ "Random walk through the link graph from a starting article (up to `length` published " +
2400
+ "nodes, no cycles, stops at a dead end). Surfaces unexpected connections for creative " +
2401
+ "incubation. Returns { data: [{id,title,category}], meta:{count} } in walk order.",
2402
+ inputSchema: {
2403
+ type: "object",
2404
+ properties: {
2405
+ start_id: { type: "string", format: "uuid", description: "Starting article UUID (required)." },
2406
+ length: { type: "integer", description: "Walk steps (default 4, max 25)." },
2407
+ },
2408
+ required: ["start_id"],
2409
+ },
2410
+ },
2411
+ {
2412
+ name: "knowledge_search",
2413
+ description:
2414
+ "Search the knowledge wiki by topic. Returns snippets. Ranked, and returns PUBLISHED " +
2415
+ "articles only; it LAGS writes by minutes while embeddings index. Do NOT use it for " +
2416
+ "existence/idempotency/dedup checks ('already captured?') — a freshly-written article will " +
2417
+ "false-negative. Use knowledge_list (lag-free, all-status, exact meta.total_count) for that. " +
2418
+ "q is optional when tags and/or category are supplied: in that list mode it returns the " +
2419
+ "COMPLETE filtered set (no relevance ranking) paginated via offset/limit over " +
2420
+ "meta.total_count, so you can enumerate every article carrying a tag/category. " +
2421
+ "IMPORTANT: meta.total_count is mode-dependent — read meta.total_count_scope to know what " +
2422
+ "it counts: keyword_matches (stop-word-filtered tsquery matches; 'the' matches ~nothing), " +
2423
+ "ranked_corpus (semantic ranks all EMBEDDED published articles — that embedded set's size, " +
2424
+ "not a match count, and <= the published count), merged_candidates (combined: deduped UNION of " +
2425
+ "a keyword and a semantic sub-search, each capped at 100, so up to ~200), or filtered_set " +
2426
+ "(list mode: the full set). Do NOT use a relevance-mode total_count to size the wiki — use " +
2427
+ "list mode or knowledge_stats. " +
2428
+ "Pass story_id when working on a loopctl story so reads attribute correctly.",
2429
+ inputSchema: {
2430
+ type: "object",
2431
+ properties: {
2432
+ q: {
2433
+ type: "string",
2434
+ description:
2435
+ "Search query string. Optional when tags/category are supplied (enumeration mode).",
2436
+ },
2437
+ project_id: {
2438
+ type: "string",
2439
+ format: "uuid",
2440
+ description: "Optional: scope search to a specific project UUID.",
2441
+ },
2442
+ story_id: {
2443
+ type: "string",
2444
+ format: "uuid",
2445
+ description: "Optional: loopctl story UUID for attribution tracking.",
2446
+ },
2447
+ category: {
2448
+ type: "string",
2449
+ description: "Optional: filter results by category.",
2450
+ },
2451
+ tags: {
2452
+ type: "string",
2453
+ description: "Optional: comma-separated tags to filter by.",
2454
+ },
2455
+ match: {
2456
+ type: "string",
2457
+ enum: ["any", "all"],
2458
+ description: "Optional: tag match mode — 'any' (default, OR) or 'all' (AND, every tag).",
2459
+ },
2460
+ mode: {
2461
+ type: "string",
2462
+ enum: ["keyword", "semantic", "combined"],
2463
+ description: "Optional: search mode (keyword, semantic, or combined).",
2464
+ },
2465
+ limit: {
2466
+ type: "integer",
2467
+ description: "Optional: maximum number of results to return.",
2468
+ },
2469
+ offset: {
2470
+ type: "integer",
2471
+ description: "Optional: results to skip for pagination (default 0).",
2472
+ },
2473
+ },
2474
+ required: [],
1623
2475
  },
1624
2476
  },
1625
2477
  {
@@ -1653,7 +2505,11 @@ const TOOLS = [
1653
2505
  name: "knowledge_context",
1654
2506
  description:
1655
2507
  "Get ranked full articles for a task query. Returns best knowledge with linked references. " +
1656
- "Pass story_id when working on a loopctl story so reads attribute correctly.",
2508
+ "Pass story_id when working on a loopctl story so reads attribute correctly. For agent " +
2509
+ "memory, scope to a memory_type/agent/conversation via the memory_types/agents/" +
2510
+ "conversation_id filters (articles whose metadata carries those keys). NOTE (#163): for " +
2511
+ "an agent key, another agent's private/owner memories are never returned (results AND " +
2512
+ "linked refs) regardless of the agents= filter — visibility is enforced, not advisory.",
1657
2513
  inputSchema: {
1658
2514
  type: "object",
1659
2515
  properties: {
@@ -1681,6 +2537,20 @@ const TOOLS = [
1681
2537
  minimum: 0,
1682
2538
  maximum: 1,
1683
2539
  },
2540
+ memory_types: {
2541
+ type: "string",
2542
+ description:
2543
+ "Optional agent-memory scope: comma-separated memory_types (OR) — " +
2544
+ "observation|finding|summary|decision|question|task.",
2545
+ },
2546
+ agents: {
2547
+ type: "string",
2548
+ description: "Optional agent-memory scope: comma-separated agent_ids (OR).",
2549
+ },
2550
+ conversation_id: {
2551
+ type: "string",
2552
+ description: "Optional agent-memory scope: exact conversation_id.",
2553
+ },
1684
2554
  },
1685
2555
  required: ["query"],
1686
2556
  },
@@ -1689,7 +2559,15 @@ const TOOLS = [
1689
2559
  name: "knowledge_create",
1690
2560
  description:
1691
2561
  "Create a new knowledge article. Use to file findings, document patterns, or record decisions " +
1692
- "discovered during implementation.",
2562
+ "discovered during implementation. Articles are PUBLISHED IMMEDIATELY by default and are visible " +
2563
+ "to agents (search/index/context) right away — no separate publish step is needed. Pass " +
2564
+ "draft: true to stage the article for later review instead; the response `note` says which " +
2565
+ "outcome occurred, and a draft can be published afterwards with knowledge_publish. " +
2566
+ "Concurrency-safe: if a create races/retries against an " +
2567
+ "existing article with the same title AND an identical body (ignoring surrounding whitespace), the " +
2568
+ "server returns that existing article idempotently (HTTP 200) instead of a 422. A same-title create " +
2569
+ "with a DIFFERENT body returns 409 title_conflict — do not retry; choose a different title or PATCH " +
2570
+ "the existing article.",
1693
2571
  inputSchema: {
1694
2572
  type: "object",
1695
2573
  properties: {
@@ -1714,6 +2592,47 @@ const TOOLS = [
1714
2592
  type: "string",
1715
2593
  description: "Optional: associate the article with a project UUID.",
1716
2594
  },
2595
+ draft: {
2596
+ type: "boolean",
2597
+ description:
2598
+ "Optional: stage as a draft instead of publishing on create (default false → " +
2599
+ "published immediately). Publish later with knowledge_publish.",
2600
+ },
2601
+ idempotency_key: {
2602
+ type: "string",
2603
+ description:
2604
+ "Optional: stable per-article key for idempotent capture (max 255). Re-creating " +
2605
+ "with the same key is a no-op that returns a reference to the existing article " +
2606
+ "(deduplicated; id only, not its body) instead of a partial duplicate. Use a " +
2607
+ "HIGH-ENTROPY value (e.g. a content hash) — it is a per-tenant lookup key, not a " +
2608
+ "secret, so a guessable key lets another agent in your tenant probe which keys " +
2609
+ "exist. Distinct from source_type/source_id (which mark a shared source).",
2610
+ },
2611
+ metadata: {
2612
+ type: "object",
2613
+ description:
2614
+ "Optional: extensible JSONB. Set the agent-memory keys to file this article as a " +
2615
+ "scoped agent memory: `memory_type` (observation/finding/summary/decision/question/" +
2616
+ "task) and `visibility` (shared | private | owner). TRUST MODEL (#163): for an " +
2617
+ "agent key, `metadata.agent_id` is stamped server-side from your verified key " +
2618
+ "identity — do NOT set it (any value you pass is overridden); `private`/`owner` " +
2619
+ "memories are then readable only by you (other agents get 404/exclusion across all " +
2620
+ "knowledge reads), while `shared` (the default) is visible tenant-wide. An agent " +
2621
+ "key with no agent identity gets 403 agent_identity_required when writing memory " +
2622
+ "metadata. Higher roles may attribute on behalf of others.",
2623
+ },
2624
+ source_type: {
2625
+ type: "string",
2626
+ description:
2627
+ "Optional: advisory provenance for the originating source (e.g. 'web_article', " +
2628
+ "'newsletter'). Shared across articles from the same source; not an idempotency key.",
2629
+ },
2630
+ source_id: {
2631
+ type: "string",
2632
+ description:
2633
+ "Optional: UUID of the originating source entity (shared across articles from " +
2634
+ "that source). Pair with source_type.",
2635
+ },
1717
2636
  },
1718
2637
  required: ["title", "body"],
1719
2638
  },
@@ -1738,19 +2657,48 @@ const TOOLS = [
1738
2657
  {
1739
2658
  name: "knowledge_bulk_publish",
1740
2659
  description:
1741
- "Atomically publish up to 100 draft articles in a single call. " +
1742
- "REQUIRES LOOPCTL_USER_KEY to be set in the MCP server env (user role " +
1743
- "orchestrator role is NOT sufficient for this destructive operation). " +
1744
- "All articles must be drafts belonging to the tenant; if any fail validation, " +
1745
- "the entire operation rolls back.",
2660
+ "Publish draft articles, partial-success style. REQUIRES LOOPCTL_USER_KEY " +
2661
+ "(user role orchestrator is NOT sufficient). Every valid draft is published; " +
2662
+ "each other id gets a per-id outcome instead of failing the whole call: " +
2663
+ "published, skipped (already published idempotent or archived/superseded), " +
2664
+ "not_found, or errored. Duplicate ids are de-duplicated and there is NO 100-id " +
2665
+ "cap (larger requests are auto-chunked server-side). The response's meta.count " +
2666
+ "is the number actually published; meta.counts has the full breakdown and " +
2667
+ "meta.results is the per-id list in request order. Safe to retry — already " +
2668
+ "published ids are skipped, not errored.",
1746
2669
  inputSchema: {
1747
2670
  type: "object",
1748
2671
  properties: {
1749
2672
  article_ids: {
1750
2673
  type: "array",
1751
2674
  items: { type: "string" },
1752
- description: "List of draft article UUIDs to publish (max 100).",
1753
- maxItems: 100,
2675
+ description:
2676
+ "Article UUIDs to publish. Any length (auto-chunked); duplicates ignored.",
2677
+ },
2678
+ },
2679
+ required: ["article_ids"],
2680
+ },
2681
+ },
2682
+ {
2683
+ name: "knowledge_bulk_unpublish",
2684
+ description:
2685
+ "Unpublish (published → draft) articles in bulk, partial-success style — the mirror " +
2686
+ "of knowledge_bulk_publish, for cleanup passes. REQUIRES LOOPCTL_USER_KEY (user role). " +
2687
+ "Every currently-published id is reverted to draft; each other id gets a per-id outcome: " +
2688
+ "unpublished, skipped (already draft — idempotent — or archived/superseded), not_found, " +
2689
+ "or errored. Duplicate ids de-duplicated; no 100-id cap (auto-chunked server-side, " +
2690
+ "bounded to 5000/call). meta.count = number actually unpublished; meta.counts has the " +
2691
+ "full breakdown; meta.results is the per-id list in request order. Safe to retry — " +
2692
+ "already-draft ids are skipped, not errored. Articles are NOT deleted (re-publish with " +
2693
+ "knowledge_bulk_publish); to archive/soft-delete use knowledge_bulk_delete.",
2694
+ inputSchema: {
2695
+ type: "object",
2696
+ properties: {
2697
+ article_ids: {
2698
+ type: "array",
2699
+ items: { type: "string" },
2700
+ description:
2701
+ "Article UUIDs to unpublish. Any length (auto-chunked); duplicates ignored.",
1754
2702
  },
1755
2703
  },
1756
2704
  required: ["article_ids"],
@@ -1810,20 +2758,92 @@ const TOOLS = [
1810
2758
  required: ["article_id"],
1811
2759
  },
1812
2760
  },
2761
+ {
2762
+ name: "knowledge_bulk_delete",
2763
+ description:
2764
+ "Bulk archive (default, reversible) or IRREVERSIBLE hard-delete of articles by selector. " +
2765
+ "REQUIRES LOOPCTL_USER_KEY (user role — orchestrator is NOT sufficient). Provide EXACTLY ONE " +
2766
+ "selector: article_ids (explicit list), source_type + source_id (every active article from " +
2767
+ "that source), or tag + confirm:true (every active article carrying the tag — high blast " +
2768
+ "radius, so confirm:true is required). " +
2769
+ "DEFAULT (soft archive): rows move to archived, never dropped; set-based + idempotent; " +
2770
+ "meta.count = archived, meta.counts/meta.results give the breakdown. " +
2771
+ "DRY-RUN: dry_run:true mutates NOTHING and returns meta.would_affect (and, for hard, a " +
2772
+ "single-use meta.token / for oversized selectors a meta.confirm_hash). " +
2773
+ "HARD DELETE (irreversible): first dry_run with hard:true to get a token, then call again " +
2774
+ "with hard:true + that token to FK-correctly delete the FROZEN id-set (links removed first, " +
2775
+ "access events cascade). The token is single-use and TTL-bounded. Bounded to 5000 per call.",
2776
+ inputSchema: {
2777
+ type: "object",
2778
+ properties: {
2779
+ article_ids: {
2780
+ type: "array",
2781
+ items: { type: "string" },
2782
+ description: "Explicit article UUIDs (selector 1).",
2783
+ },
2784
+ source_type: {
2785
+ type: "string",
2786
+ description: "With source_id: every active article from this source (selector 2).",
2787
+ },
2788
+ source_id: {
2789
+ type: "string",
2790
+ description: "With source_type: the source entity UUID (selector 2).",
2791
+ },
2792
+ tag: {
2793
+ type: "string",
2794
+ description:
2795
+ "Every active article carrying this tag (selector 3). Requires confirm:true.",
2796
+ },
2797
+ confirm: {
2798
+ type: "boolean",
2799
+ description: "Required (true) when selecting by tag — guards the high blast radius.",
2800
+ },
2801
+ dry_run: {
2802
+ type: "boolean",
2803
+ description:
2804
+ "Preview only — mutate nothing. Returns meta.would_affect; with hard:true also a " +
2805
+ "single-use meta.token (or meta.confirm_hash for oversized selectors).",
2806
+ },
2807
+ hard: {
2808
+ type: "boolean",
2809
+ description:
2810
+ "IRREVERSIBLE hard delete (vs default reversible archive). Run dry_run first to get a " +
2811
+ "token, then pass hard:true + token.",
2812
+ },
2813
+ token: {
2814
+ type: "string",
2815
+ description:
2816
+ "The single-use frozen-set token from a `dry_run:true, hard:true` preview. Required " +
2817
+ "for the hard delete.",
2818
+ },
2819
+ confirm_hash: {
2820
+ type: "string",
2821
+ description:
2822
+ "For an oversized hard-delete selector (no token): the meta.confirm_hash from the " +
2823
+ "dry-run, echoed back to re-confirm the id-set hasn't drifted.",
2824
+ },
2825
+ },
2826
+ required: [],
2827
+ },
2828
+ },
1813
2829
  {
1814
2830
  name: "knowledge_drafts",
1815
2831
  description:
1816
2832
  "List draft (unpublished) knowledge articles. Requires orchestrator role. " +
1817
- "Returns paginated drafts with total_count in meta. Max 20 per page.",
2833
+ "Returns paginated drafts with total_count in meta. Paginate via offset/limit " +
2834
+ "(limit honored up to 1000; a limit above the max is rejected with 400, not " +
2835
+ "silently clamped).",
1818
2836
  inputSchema: {
1819
2837
  type: "object",
1820
2838
  properties: {
1821
2839
  limit: {
1822
2840
  type: "integer",
1823
- description: "Max drafts per page. Default 20, hard max 20.",
2841
+ description:
2842
+ "Max drafts per page (default 20, max 1000). A limit above the max is " +
2843
+ "rejected with 400 — not silently clamped — so offset pagination stays complete.",
1824
2844
  default: 20,
1825
2845
  minimum: 1,
1826
- maximum: 20,
2846
+ maximum: 1000,
1827
2847
  },
1828
2848
  offset: {
1829
2849
  type: "integer",
@@ -1878,8 +2898,9 @@ const TOOLS = [
1878
2898
  {
1879
2899
  name: "knowledge_export",
1880
2900
  description:
1881
- "Export all knowledge articles as a ZIP archive. Because ZIP binary cannot be returned as MCP content, " +
1882
- "this tool returns a curl command you can run directly to download the archive.",
2901
+ "Export all knowledge articles as an OKF v0.1 bundle gzipped tar archive, unbounded, bounded-memory streaming, " +
2902
+ "fail-closed (no partial bundles). Because binary cannot be returned as MCP content, this tool returns a curl command. " +
2903
+ "Optional: ?format=json for buffered in-memory JSON (capped at export_max_buffered_export_articles, 413 over cap).",
1883
2904
  inputSchema: {
1884
2905
  type: "object",
1885
2906
  properties: {
@@ -1887,10 +2908,71 @@ const TOOLS = [
1887
2908
  type: "string",
1888
2909
  description: "Optional: scope export to a specific project UUID.",
1889
2910
  },
2911
+ format: {
2912
+ type: "string",
2913
+ enum: ["tar.gz", "json"],
2914
+ description: "Optional: tar.gz (default, unbounded streaming) or json (buffered, capped).",
2915
+ },
2916
+ },
2917
+ required: [],
2918
+ },
2919
+ },
2920
+ {
2921
+ name: "knowledge_okf_export",
2922
+ description:
2923
+ "Export the knowledge wiki as a portable OKF (Open Knowledge Format) v0.1 bundle — a tree of " +
2924
+ "markdown files with YAML frontmatter. Requires LOOPCTL_USER_KEY. If out_dir is given, the bundle " +
2925
+ "is written there (one .md file per concept, plus index.md/log.md) and a summary is returned; " +
2926
+ "otherwise the bundle is returned inline as {files, meta}.",
2927
+ inputSchema: {
2928
+ type: "object",
2929
+ properties: {
2930
+ project_id: {
2931
+ type: "string",
2932
+ format: "uuid",
2933
+ description: "Optional: scope the export to a project (includes tenant-wide articles too).",
2934
+ },
2935
+ out_dir: {
2936
+ type: "string",
2937
+ description:
2938
+ "Optional: absolute path of a directory to write the bundle into. When omitted, the " +
2939
+ "bundle is returned inline.",
2940
+ },
1890
2941
  },
1891
2942
  required: [],
1892
2943
  },
1893
2944
  },
2945
+ {
2946
+ name: "knowledge_okf_import",
2947
+ description:
2948
+ "Import an OKF (Open Knowledge Format) v0.1 bundle from a local directory into the wiki. " +
2949
+ "Requires LOOPCTL_USER_KEY. Reserved files (index.md/log.md) are skipped; each concept is created, " +
2950
+ "or (with merge=true, the default) updated in place when it matches an existing article. Unknown " +
2951
+ "frontmatter types/keys are tolerated and preserved. Returns a per-file import report.",
2952
+ inputSchema: {
2953
+ type: "object",
2954
+ properties: {
2955
+ bundle_dir: {
2956
+ type: "string",
2957
+ description: "Absolute path of the OKF bundle directory to read .md files from.",
2958
+ },
2959
+ project_id: {
2960
+ type: "string",
2961
+ format: "uuid",
2962
+ description: "Optional: assign imported articles to a project.",
2963
+ },
2964
+ merge: {
2965
+ type: "boolean",
2966
+ description: "Update existing articles instead of skipping them (default true).",
2967
+ },
2968
+ dry_run: {
2969
+ type: "boolean",
2970
+ description: "Validate and plan only; write nothing (default false).",
2971
+ },
2972
+ },
2973
+ required: ["bundle_dir"],
2974
+ },
2975
+ },
1894
2976
 
1895
2977
  // Knowledge Ingestion Tools
1896
2978
  {
@@ -1898,7 +2980,9 @@ const TOOLS = [
1898
2980
  description:
1899
2981
  "Submit a URL or raw content for knowledge extraction. " +
1900
2982
  "Enqueues an Oban job that fetches the content (if URL), extracts knowledge articles via LLM, " +
1901
- "and inserts them as draft articles. Requires orchestrator role.",
2983
+ "and inserts them. Extracted articles are DRAFTS by default (lower-trust LLM output, staged " +
2984
+ "for review) — unlike knowledge_create which publishes by default. Pass publish:true to " +
2985
+ "publish them on extraction. Requires orchestrator role.",
1902
2986
  inputSchema: {
1903
2987
  type: "object",
1904
2988
  properties: {
@@ -1918,6 +3002,11 @@ const TOOLS = [
1918
3002
  type: "string",
1919
3003
  description: "Optional: scope extracted articles to a specific project UUID.",
1920
3004
  },
3005
+ publish: {
3006
+ type: "boolean",
3007
+ description:
3008
+ "Optional: publish extracted articles immediately instead of staging them as drafts (default false).",
3009
+ },
1921
3010
  },
1922
3011
  required: ["source_type"],
1923
3012
  },
@@ -1956,6 +3045,11 @@ const TOOLS = [
1956
3045
  type: "string",
1957
3046
  description: "Optional: scope the item to a specific project UUID.",
1958
3047
  },
3048
+ publish: {
3049
+ type: "boolean",
3050
+ description:
3051
+ "Optional: publish this item's extracted articles immediately (default false → draft).",
3052
+ },
1959
3053
  metadata: {
1960
3054
  type: "object",
1961
3055
  description: "Optional metadata map.",
@@ -1968,6 +3062,10 @@ const TOOLS = [
1968
3062
  type: "string",
1969
3063
  description: "Optional batch-level default project UUID applied to items that don't specify their own.",
1970
3064
  },
3065
+ publish: {
3066
+ type: "boolean",
3067
+ description: "Optional batch-level default publish flag applied to items that don't specify their own.",
3068
+ },
1971
3069
  },
1972
3070
  required: ["items"],
1973
3071
  },
@@ -1975,11 +3073,20 @@ const TOOLS = [
1975
3073
  {
1976
3074
  name: "knowledge_ingestion_jobs",
1977
3075
  description:
1978
- "List recent content ingestion jobs for the current tenant. " +
1979
- "Returns jobs from the last 7 days, max 50 results. Requires orchestrator role.",
3076
+ "List content ingestion jobs for the current tenant, newest first. " +
3077
+ "Paginated (limit/offset over the full history; advance `offset` by `meta.limit` " +
3078
+ "to enumerate to completeness). Optional `since_days` narrows to a recent window. " +
3079
+ "Requires orchestrator role.",
1980
3080
  inputSchema: {
1981
3081
  type: "object",
1982
- properties: {},
3082
+ properties: {
3083
+ limit: { type: "integer", description: "Jobs per page (default 20)." },
3084
+ offset: { type: "integer", description: "Rows to skip (default 0)." },
3085
+ since_days: {
3086
+ type: "integer",
3087
+ description: "Optional: only jobs from the last N days (default: all history).",
3088
+ },
3089
+ },
1983
3090
  required: [],
1984
3091
  },
1985
3092
  },
@@ -2298,9 +3405,36 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2298
3405
  case "knowledge_index":
2299
3406
  return await knowledgeIndex(args);
2300
3407
 
3408
+ case "knowledge_stats":
3409
+ return await knowledgeStats(args);
3410
+
3411
+ case "knowledge_count":
3412
+ return await knowledgeCount(args);
3413
+
3414
+ case "knowledge_facets":
3415
+ return await knowledgeFacets(args);
3416
+
3417
+ case "knowledge_graph":
3418
+ return await knowledgeGraph(args);
3419
+
3420
+ case "knowledge_suggest_links":
3421
+ return await knowledgeSuggestLinks(args);
3422
+
3423
+ case "knowledge_distant_pairs":
3424
+ return await knowledgeDistantPairs(args);
3425
+
3426
+ case "knowledge_novelty":
3427
+ return await knowledgeNovelty(args);
3428
+
3429
+ case "knowledge_random_walk":
3430
+ return await knowledgeRandomWalk(args);
3431
+
2301
3432
  case "knowledge_search":
2302
3433
  return await knowledgeSearch(args);
2303
3434
 
3435
+ case "knowledge_list":
3436
+ return await knowledgeList(args);
3437
+
2304
3438
  case "knowledge_get":
2305
3439
  return await knowledgeGet(args);
2306
3440
 
@@ -2317,6 +3451,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2317
3451
  case "knowledge_bulk_publish":
2318
3452
  return await knowledgeBulkPublish(args);
2319
3453
 
3454
+ case "knowledge_bulk_unpublish":
3455
+ return await knowledgeBulkUnpublish(args);
3456
+
2320
3457
  case "knowledge_unpublish":
2321
3458
  return await knowledgeUnpublish(args);
2322
3459
 
@@ -2326,6 +3463,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2326
3463
  case "knowledge_delete":
2327
3464
  return await knowledgeDelete(args);
2328
3465
 
3466
+ case "knowledge_bulk_delete":
3467
+ return await knowledgeBulkDelete(args);
3468
+
2329
3469
  case "knowledge_drafts":
2330
3470
  return await knowledgeDrafts(args);
2331
3471
 
@@ -2335,6 +3475,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2335
3475
  case "knowledge_export":
2336
3476
  return await knowledgeExport(args);
2337
3477
 
3478
+ case "knowledge_okf_export":
3479
+ return await knowledgeOkfExport(args);
3480
+
3481
+ case "knowledge_okf_import":
3482
+ return await knowledgeOkfImport(args);
3483
+
2338
3484
  // Knowledge Ingestion Tools
2339
3485
  case "knowledge_ingest":
2340
3486
  return await knowledgeIngest(args);