loopctl-mcp-server 2.2.0 → 2.22.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 +46 -12
- package/index.js +1151 -49
- 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
|
-
*
|
|
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
|
|
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);
|
|
@@ -367,7 +370,10 @@ async function listStories({ project_id, agent_status, verified_status, epic_id,
|
|
|
367
370
|
if (agent_status) params.set("agent_status", agent_status);
|
|
368
371
|
if (verified_status) params.set("verified_status", verified_status);
|
|
369
372
|
if (epic_id) params.set("epic_id", epic_id);
|
|
370
|
-
params.set(
|
|
373
|
+
params.set(
|
|
374
|
+
"limit",
|
|
375
|
+
String(Math.min(limit ?? DEFAULT_STORY_PAGE_SIZE, SERVER_MAX_STORY_PAGE_SIZE)),
|
|
376
|
+
);
|
|
371
377
|
if (offset != null) params.set("offset", String(offset));
|
|
372
378
|
if (include_token_totals) params.set("include_token_totals", "true");
|
|
373
379
|
|
|
@@ -377,7 +383,10 @@ async function listStories({ project_id, agent_status, verified_status, epic_id,
|
|
|
377
383
|
|
|
378
384
|
async function listReadyStories({ project_id, limit }) {
|
|
379
385
|
const params = new URLSearchParams({ project_id });
|
|
380
|
-
params.set(
|
|
386
|
+
params.set(
|
|
387
|
+
"limit",
|
|
388
|
+
String(Math.min(limit ?? DEFAULT_STORY_PAGE_SIZE, SERVER_MAX_STORY_PAGE_SIZE)),
|
|
389
|
+
);
|
|
381
390
|
|
|
382
391
|
const result = await apiCall("GET", `/api/v1/stories/ready?${params}`);
|
|
383
392
|
return toContentCompact(result);
|
|
@@ -579,31 +588,214 @@ async function setTokenBudget({ scope_type, scope_id, budget_millicents, alert_t
|
|
|
579
588
|
|
|
580
589
|
// --- Knowledge Wiki Tools (agent key) ---
|
|
581
590
|
|
|
582
|
-
async function knowledgeIndex({ project_id, story_id }) {
|
|
591
|
+
async function knowledgeIndex({ project_id, story_id, category, tags, match, offset, limit, fields }) {
|
|
592
|
+
if (project_id && !UUID_RE.test(project_id)) {
|
|
593
|
+
return {
|
|
594
|
+
content: [{ type: "text", text: "Error: project_id must be a canonical UUID (8-4-4-4-12 hex)." }],
|
|
595
|
+
isError: true,
|
|
596
|
+
};
|
|
597
|
+
}
|
|
583
598
|
const basePath = project_id
|
|
584
599
|
? `/api/v1/projects/${project_id}/knowledge/index`
|
|
585
600
|
: "/api/v1/knowledge/index";
|
|
586
601
|
const params = new URLSearchParams();
|
|
587
602
|
if (story_id) params.set("story_id", story_id);
|
|
603
|
+
if (category) params.set("category", category);
|
|
604
|
+
if (tags) params.set("tags", tags);
|
|
605
|
+
if (match) params.set("match", match);
|
|
606
|
+
if (offset != null) params.set("offset", String(offset));
|
|
607
|
+
if (limit != null) params.set("limit", String(limit));
|
|
608
|
+
if (fields) params.set("fields", Array.isArray(fields) ? fields.join(",") : fields);
|
|
588
609
|
const qs = params.toString();
|
|
589
610
|
const path = qs ? `${basePath}?${qs}` : basePath;
|
|
590
611
|
const result = await apiCall("GET", path, null, process.env.LOOPCTL_AGENT_KEY);
|
|
591
612
|
return toContent(result);
|
|
592
613
|
}
|
|
593
614
|
|
|
594
|
-
async function
|
|
595
|
-
|
|
615
|
+
async function knowledgeStats({ project_id }) {
|
|
616
|
+
if (project_id && !UUID_RE.test(project_id)) {
|
|
617
|
+
return {
|
|
618
|
+
content: [{ type: "text", text: "Error: project_id must be a canonical UUID (8-4-4-4-12 hex)." }],
|
|
619
|
+
isError: true,
|
|
620
|
+
};
|
|
621
|
+
}
|
|
622
|
+
const path = project_id
|
|
623
|
+
? `/api/v1/projects/${project_id}/knowledge/stats`
|
|
624
|
+
: "/api/v1/knowledge/stats";
|
|
625
|
+
const result = await apiCall("GET", path, null, process.env.LOOPCTL_AGENT_KEY);
|
|
626
|
+
return toContent(result);
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
async function knowledgeGraph({ article_id, depth, project_id }) {
|
|
630
|
+
const params = new URLSearchParams();
|
|
631
|
+
params.set("article_id", article_id);
|
|
632
|
+
if (depth != null) params.set("depth", String(depth));
|
|
633
|
+
if (project_id) params.set("project_id", project_id);
|
|
634
|
+
const result = await apiCall(
|
|
635
|
+
"GET",
|
|
636
|
+
`/api/v1/knowledge/graph?${params}`,
|
|
637
|
+
null,
|
|
638
|
+
process.env.LOOPCTL_AGENT_KEY,
|
|
639
|
+
);
|
|
640
|
+
return toContent(result);
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
async function knowledgeSuggestLinks({ article_id, limit, threshold }) {
|
|
644
|
+
const params = new URLSearchParams();
|
|
645
|
+
if (limit != null) params.set("limit", String(limit));
|
|
646
|
+
if (threshold != null) params.set("threshold", String(threshold));
|
|
647
|
+
const qs = params.toString();
|
|
648
|
+
const base = `/api/v1/knowledge/articles/${article_id}/suggested_links`;
|
|
649
|
+
const result = await apiCall(
|
|
650
|
+
"GET",
|
|
651
|
+
qs ? `${base}?${qs}` : base,
|
|
652
|
+
null,
|
|
653
|
+
process.env.LOOPCTL_AGENT_KEY,
|
|
654
|
+
);
|
|
655
|
+
return toContent(result);
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
async function knowledgeDistantPairs({ min_distance, max_distance, bridge_path, limit, offset }) {
|
|
659
|
+
const params = new URLSearchParams();
|
|
660
|
+
if (min_distance != null) params.set("min_distance", String(min_distance));
|
|
661
|
+
if (max_distance != null) params.set("max_distance", String(max_distance));
|
|
662
|
+
if (bridge_path === true) params.set("bridge_path", "true");
|
|
663
|
+
if (limit != null) params.set("limit", String(limit));
|
|
664
|
+
if (offset != null) params.set("offset", String(offset));
|
|
665
|
+
const qs = params.toString();
|
|
666
|
+
const path = qs ? `/api/v1/knowledge/pairs?${qs}` : "/api/v1/knowledge/pairs";
|
|
667
|
+
const result = await apiCall("GET", path, null, process.env.LOOPCTL_AGENT_KEY);
|
|
668
|
+
return toContent(result);
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
async function knowledgeNovelty({ ideas, texts, prior_tag }) {
|
|
672
|
+
// Accept either shape (#169): `ideas` (strings or objects) or `texts` (strings).
|
|
673
|
+
const body = {};
|
|
674
|
+
if (ideas !== undefined) body.ideas = ideas;
|
|
675
|
+
if (texts !== undefined) body.texts = texts;
|
|
676
|
+
if (prior_tag) body.prior_tag = prior_tag;
|
|
677
|
+
const result = await apiCall("POST", "/api/v1/knowledge/novelty", body, process.env.LOOPCTL_AGENT_KEY);
|
|
678
|
+
return toContent(result);
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
async function knowledgeRandomWalk({ start_id, length }) {
|
|
682
|
+
const params = new URLSearchParams();
|
|
683
|
+
params.set("start_id", start_id);
|
|
684
|
+
if (length != null) params.set("length", String(length));
|
|
685
|
+
const result = await apiCall(
|
|
686
|
+
"GET",
|
|
687
|
+
`/api/v1/knowledge/walk?${params}`,
|
|
688
|
+
null,
|
|
689
|
+
process.env.LOOPCTL_AGENT_KEY,
|
|
690
|
+
);
|
|
691
|
+
return toContent(result);
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
async function knowledgeCount({
|
|
695
|
+
project_id,
|
|
696
|
+
category,
|
|
697
|
+
status,
|
|
698
|
+
tags,
|
|
699
|
+
match,
|
|
700
|
+
source_type,
|
|
701
|
+
source_id,
|
|
702
|
+
idempotency_key,
|
|
703
|
+
}) {
|
|
704
|
+
const params = new URLSearchParams();
|
|
705
|
+
if (project_id) params.set("project_id", project_id);
|
|
706
|
+
if (category) params.set("category", category);
|
|
707
|
+
if (status) params.set("status", status);
|
|
708
|
+
if (tags) params.set("tags", tags);
|
|
709
|
+
if (match) params.set("match", match);
|
|
710
|
+
if (source_type) params.set("source_type", source_type);
|
|
711
|
+
if (source_id) params.set("source_id", source_id);
|
|
712
|
+
if (idempotency_key) params.set("idempotency_key", idempotency_key);
|
|
713
|
+
const qs = params.toString();
|
|
714
|
+
const path = qs ? `/api/v1/knowledge/count?${qs}` : "/api/v1/knowledge/count";
|
|
715
|
+
const result = await apiCall("GET", path, null, process.env.LOOPCTL_AGENT_KEY);
|
|
716
|
+
return toContent(result);
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
async function knowledgeFacets({
|
|
720
|
+
project_id,
|
|
721
|
+
category,
|
|
722
|
+
status,
|
|
723
|
+
tags,
|
|
724
|
+
match,
|
|
725
|
+
tag_prefix,
|
|
726
|
+
limit,
|
|
727
|
+
}) {
|
|
728
|
+
const params = new URLSearchParams();
|
|
729
|
+
params.set("group_by", "tag");
|
|
730
|
+
if (project_id) params.set("project_id", project_id);
|
|
731
|
+
if (category) params.set("category", category);
|
|
732
|
+
if (status) params.set("status", status);
|
|
733
|
+
if (tags) params.set("tags", tags);
|
|
734
|
+
if (match) params.set("match", match);
|
|
735
|
+
if (tag_prefix) params.set("tag_prefix", tag_prefix);
|
|
736
|
+
if (limit != null) params.set("limit", String(limit));
|
|
737
|
+
const result = await apiCall(
|
|
738
|
+
"GET",
|
|
739
|
+
`/api/v1/knowledge/facets?${params}`,
|
|
740
|
+
null,
|
|
741
|
+
process.env.LOOPCTL_AGENT_KEY,
|
|
742
|
+
);
|
|
743
|
+
return toContent(result);
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
async function knowledgeSearch({ q, project_id, story_id, category, tags, match, mode, limit, offset }) {
|
|
747
|
+
const params = new URLSearchParams();
|
|
748
|
+
if (q != null && q !== "") params.set("q", q);
|
|
596
749
|
if (project_id) params.set("project_id", project_id);
|
|
597
750
|
if (story_id) params.set("story_id", story_id);
|
|
598
751
|
if (category) params.set("category", category);
|
|
599
752
|
if (tags) params.set("tags", tags);
|
|
753
|
+
if (match) params.set("match", match);
|
|
600
754
|
if (mode) params.set("mode", mode);
|
|
601
755
|
if (limit != null) params.set("limit", String(limit));
|
|
756
|
+
if (offset != null) params.set("offset", String(offset));
|
|
602
757
|
|
|
603
758
|
const result = await apiCall("GET", `/api/v1/knowledge/search?${params}`, null, process.env.LOOPCTL_AGENT_KEY);
|
|
604
759
|
return toContent(result);
|
|
605
760
|
}
|
|
606
761
|
|
|
762
|
+
async function knowledgeList({
|
|
763
|
+
project_id,
|
|
764
|
+
category,
|
|
765
|
+
status,
|
|
766
|
+
tags,
|
|
767
|
+
match,
|
|
768
|
+
source_type,
|
|
769
|
+
source_id,
|
|
770
|
+
idempotency_key,
|
|
771
|
+
limit,
|
|
772
|
+
offset,
|
|
773
|
+
include_body,
|
|
774
|
+
}) {
|
|
775
|
+
const params = new URLSearchParams();
|
|
776
|
+
if (project_id) params.set("project_id", project_id);
|
|
777
|
+
if (category) params.set("category", category);
|
|
778
|
+
if (status) params.set("status", status);
|
|
779
|
+
if (tags) params.set("tags", tags);
|
|
780
|
+
if (match) params.set("match", match);
|
|
781
|
+
if (source_type) params.set("source_type", source_type);
|
|
782
|
+
if (source_id) params.set("source_id", source_id);
|
|
783
|
+
if (idempotency_key) params.set("idempotency_key", idempotency_key);
|
|
784
|
+
if (limit != null) params.set("limit", String(limit));
|
|
785
|
+
if (offset != null) params.set("offset", String(offset));
|
|
786
|
+
// Body-less summary by default (safe to enumerate large pages); opt into full
|
|
787
|
+
// bodies (byte-budget bounded server-side) with include_body: true.
|
|
788
|
+
if (include_body === true) params.set("include_body", "true");
|
|
789
|
+
|
|
790
|
+
const result = await apiCall(
|
|
791
|
+
"GET",
|
|
792
|
+
`/api/v1/articles?${params}`,
|
|
793
|
+
null,
|
|
794
|
+
process.env.LOOPCTL_AGENT_KEY,
|
|
795
|
+
);
|
|
796
|
+
return toContent(result);
|
|
797
|
+
}
|
|
798
|
+
|
|
607
799
|
async function knowledgeGet({ article_id, project_id, story_id }) {
|
|
608
800
|
const params = new URLSearchParams();
|
|
609
801
|
if (project_id) params.set("project_id", project_id);
|
|
@@ -614,24 +806,63 @@ async function knowledgeGet({ article_id, project_id, story_id }) {
|
|
|
614
806
|
return toContent(result);
|
|
615
807
|
}
|
|
616
808
|
|
|
617
|
-
async function knowledgeContext({
|
|
809
|
+
async function knowledgeContext({
|
|
810
|
+
query,
|
|
811
|
+
project_id,
|
|
812
|
+
story_id,
|
|
813
|
+
limit,
|
|
814
|
+
recency_weight,
|
|
815
|
+
memory_types,
|
|
816
|
+
agents,
|
|
817
|
+
conversation_id,
|
|
818
|
+
}) {
|
|
618
819
|
const params = new URLSearchParams({ query });
|
|
619
820
|
if (project_id) params.set("project_id", project_id);
|
|
620
821
|
if (story_id) params.set("story_id", story_id);
|
|
621
822
|
if (limit != null) params.set("limit", String(limit));
|
|
622
823
|
if (recency_weight != null) params.set("recency_weight", String(recency_weight));
|
|
824
|
+
// Agent-memory scoping (comma-separated lists are OR'd; conversation_id is exact).
|
|
825
|
+
if (memory_types) params.set("memory_types", Array.isArray(memory_types) ? memory_types.join(",") : memory_types);
|
|
826
|
+
if (agents) params.set("agents", Array.isArray(agents) ? agents.join(",") : agents);
|
|
827
|
+
if (conversation_id) params.set("conversation_id", conversation_id);
|
|
623
828
|
|
|
624
829
|
const result = await apiCall("GET", `/api/v1/knowledge/context?${params}`, null, process.env.LOOPCTL_AGENT_KEY);
|
|
625
830
|
return toContent(result);
|
|
626
831
|
}
|
|
627
832
|
|
|
628
|
-
async function knowledgeCreate({
|
|
833
|
+
async function knowledgeCreate({
|
|
834
|
+
title,
|
|
835
|
+
body,
|
|
836
|
+
category,
|
|
837
|
+
tags,
|
|
838
|
+
project_id,
|
|
839
|
+
draft,
|
|
840
|
+
source_type,
|
|
841
|
+
source_id,
|
|
842
|
+
idempotency_key,
|
|
843
|
+
metadata,
|
|
844
|
+
}) {
|
|
629
845
|
const payload = { title, body };
|
|
630
846
|
if (category) payload.category = category;
|
|
631
847
|
if (tags) payload.tags = tags;
|
|
632
848
|
if (project_id) payload.project_id = project_id;
|
|
849
|
+
if (source_type) payload.source_type = source_type;
|
|
850
|
+
if (source_id) payload.source_id = source_id;
|
|
851
|
+
if (idempotency_key) payload.idempotency_key = idempotency_key;
|
|
852
|
+
if (metadata) payload.metadata = metadata;
|
|
853
|
+
|
|
854
|
+
// Articles publish on create by default for every role (including agent), so a
|
|
855
|
+
// plain create routes through the agent key and is immediately visible. Pass
|
|
856
|
+
// draft:true to stage it for later review instead — publish it afterwards with
|
|
857
|
+
// knowledge_publish.
|
|
858
|
+
if (draft) payload.draft = true;
|
|
633
859
|
|
|
634
|
-
const result = await apiCall(
|
|
860
|
+
const result = await apiCall(
|
|
861
|
+
"POST",
|
|
862
|
+
"/api/v1/articles",
|
|
863
|
+
payload,
|
|
864
|
+
process.env.LOOPCTL_AGENT_KEY,
|
|
865
|
+
);
|
|
635
866
|
return toContent(result);
|
|
636
867
|
}
|
|
637
868
|
|
|
@@ -649,6 +880,46 @@ async function knowledgeBulkPublish({ article_ids }) {
|
|
|
649
880
|
{ article_ids },
|
|
650
881
|
process.env.LOOPCTL_USER_KEY
|
|
651
882
|
);
|
|
883
|
+
|
|
884
|
+
// Partial success returns 200 even when nothing published. Surface a warning
|
|
885
|
+
// so the agent doesn't treat not_found/errored ids as success.
|
|
886
|
+
const counts = result?.meta?.counts;
|
|
887
|
+
if (counts && (counts.not_found > 0 || counts.errored > 0)) {
|
|
888
|
+
const warning =
|
|
889
|
+
`WARNING: bulk-publish was partial — published ${counts.published}, ` +
|
|
890
|
+
`skipped ${counts.skipped}, not_found ${counts.not_found}, errored ${counts.errored} ` +
|
|
891
|
+
`(of ${counts.requested} requested). Inspect meta.results for per-id outcomes; ` +
|
|
892
|
+
`not_found/errored ids were NOT published.`;
|
|
893
|
+
return {
|
|
894
|
+
content: [{ type: "text", text: warning }, ...toContent(result).content],
|
|
895
|
+
};
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
return toContent(result);
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
async function knowledgeBulkUnpublish({ article_ids }) {
|
|
902
|
+
const result = await apiCall(
|
|
903
|
+
"POST",
|
|
904
|
+
"/api/v1/knowledge/bulk-unpublish",
|
|
905
|
+
{ article_ids },
|
|
906
|
+
process.env.LOOPCTL_USER_KEY
|
|
907
|
+
);
|
|
908
|
+
|
|
909
|
+
// Partial success returns 200 even when nothing unpublished. Surface a warning
|
|
910
|
+
// so the agent doesn't treat not_found/errored ids as success.
|
|
911
|
+
const counts = result?.meta?.counts;
|
|
912
|
+
if (counts && (counts.not_found > 0 || counts.errored > 0)) {
|
|
913
|
+
const warning =
|
|
914
|
+
`WARNING: bulk-unpublish was partial — unpublished ${counts.unpublished}, ` +
|
|
915
|
+
`skipped ${counts.skipped}, not_found ${counts.not_found}, errored ${counts.errored} ` +
|
|
916
|
+
`(of ${counts.requested} requested). Inspect meta.results for per-id outcomes; ` +
|
|
917
|
+
`not_found/errored ids were NOT unpublished.`;
|
|
918
|
+
return {
|
|
919
|
+
content: [{ type: "text", text: warning }, ...toContent(result).content],
|
|
920
|
+
};
|
|
921
|
+
}
|
|
922
|
+
|
|
652
923
|
return toContent(result);
|
|
653
924
|
}
|
|
654
925
|
|
|
@@ -682,12 +953,68 @@ async function knowledgeDelete({ article_id }) {
|
|
|
682
953
|
return toContent(result);
|
|
683
954
|
}
|
|
684
955
|
|
|
956
|
+
async function knowledgeBulkDelete({
|
|
957
|
+
article_ids,
|
|
958
|
+
source_type,
|
|
959
|
+
source_id,
|
|
960
|
+
tag,
|
|
961
|
+
confirm,
|
|
962
|
+
dry_run,
|
|
963
|
+
hard,
|
|
964
|
+
token,
|
|
965
|
+
confirm_hash,
|
|
966
|
+
}) {
|
|
967
|
+
const payload = {};
|
|
968
|
+
if (article_ids) payload.article_ids = article_ids;
|
|
969
|
+
if (source_type) payload.source_type = source_type;
|
|
970
|
+
if (source_id) payload.source_id = source_id;
|
|
971
|
+
if (tag) payload.tag = tag;
|
|
972
|
+
if (confirm) payload.confirm = confirm;
|
|
973
|
+
// US-27.12: dry-run preview + irreversible hard delete via a single-use frozen
|
|
974
|
+
// token. dry_run=true mutates nothing (returns meta.would_affect; for the hard
|
|
975
|
+
// path a single-use meta.token); hard=true + token performs the FK-correct
|
|
976
|
+
// IRREVERSIBLE delete over the frozen id-set. Oversized selectors echo
|
|
977
|
+
// meta.confirm_hash for re-confirm-on-drift instead of a token.
|
|
978
|
+
if (dry_run) payload.dry_run = true;
|
|
979
|
+
if (hard) payload.hard = true;
|
|
980
|
+
if (token) payload.token = token;
|
|
981
|
+
if (confirm_hash) payload.confirm_hash = confirm_hash;
|
|
982
|
+
|
|
983
|
+
const result = await apiCall(
|
|
984
|
+
"POST",
|
|
985
|
+
"/api/v1/knowledge/bulk-delete",
|
|
986
|
+
payload,
|
|
987
|
+
process.env.LOOPCTL_USER_KEY,
|
|
988
|
+
);
|
|
989
|
+
|
|
990
|
+
// Partial success returns 200 even when some/none archived — surface a warning
|
|
991
|
+
// so the agent doesn't treat not_found/errored, or a nothing-archived run, as
|
|
992
|
+
// success.
|
|
993
|
+
const counts = result?.meta?.counts;
|
|
994
|
+
if (
|
|
995
|
+
counts &&
|
|
996
|
+
(counts.not_found > 0 ||
|
|
997
|
+
counts.errored > 0 ||
|
|
998
|
+
(counts.archived === 0 && counts.requested > 0))
|
|
999
|
+
) {
|
|
1000
|
+
const warning =
|
|
1001
|
+
`WARNING: bulk-delete was partial — archived ${counts.archived}, ` +
|
|
1002
|
+
`skipped ${counts.skipped}, not_found ${counts.not_found}, errored ${counts.errored} ` +
|
|
1003
|
+
`(of ${counts.requested}). Inspect meta.results; not_found/errored ids were NOT archived.`;
|
|
1004
|
+
return {
|
|
1005
|
+
content: [{ type: "text", text: warning }, ...toContent(result).content],
|
|
1006
|
+
};
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
return toContent(result);
|
|
1010
|
+
}
|
|
1011
|
+
|
|
685
1012
|
async function knowledgeDrafts({ limit, offset, project_id }) {
|
|
686
1013
|
const params = new URLSearchParams();
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
);
|
|
1014
|
+
// Pass `limit` through verbatim (like knowledge_list/index/search) so the
|
|
1015
|
+
// server honors it up to its max page size and returns 400 above it — rather
|
|
1016
|
+
// than silently clamping client-side, which would truncate draft enumeration.
|
|
1017
|
+
if (limit != null) params.set("limit", String(limit));
|
|
691
1018
|
if (offset != null) params.set("offset", String(offset));
|
|
692
1019
|
if (project_id) params.set("project_id", project_id);
|
|
693
1020
|
const path = `/api/v1/knowledge/drafts?${params.toString()}`;
|
|
@@ -709,24 +1036,28 @@ async function knowledgeLint({ project_id, stale_days, min_coverage, max_per_cat
|
|
|
709
1036
|
return toContent(result);
|
|
710
1037
|
}
|
|
711
1038
|
|
|
712
|
-
async function knowledgeIngest({ url, content, source_type, project_id }) {
|
|
1039
|
+
async function knowledgeIngest({ url, content, source_type, project_id, publish }) {
|
|
713
1040
|
const body = { source_type };
|
|
714
1041
|
if (url) body.url = url;
|
|
715
1042
|
if (content) body.content = content;
|
|
716
1043
|
if (project_id) body.project_id = project_id;
|
|
1044
|
+
if (publish) body.publish = true;
|
|
717
1045
|
const result = await apiCall("POST", "/api/v1/knowledge/ingest", body, process.env.LOOPCTL_ORCH_KEY);
|
|
718
1046
|
return toContent(result);
|
|
719
1047
|
}
|
|
720
1048
|
|
|
721
|
-
async function knowledgeIngestBatch({ items, project_id }) {
|
|
722
|
-
//
|
|
723
|
-
//
|
|
1049
|
+
async function knowledgeIngestBatch({ items, project_id, publish }) {
|
|
1050
|
+
// Apply batch-level project_id / publish as defaults to any item that doesn't
|
|
1051
|
+
// set its own.
|
|
724
1052
|
const resolvedItems = Array.isArray(items)
|
|
725
|
-
? items.map((item) =>
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
1053
|
+
? items.map((item) => {
|
|
1054
|
+
if (!item) return item;
|
|
1055
|
+
let resolved = item;
|
|
1056
|
+
if (project_id && resolved.project_id == null)
|
|
1057
|
+
resolved = { ...resolved, project_id };
|
|
1058
|
+
if (publish && resolved.publish == null) resolved = { ...resolved, publish: true };
|
|
1059
|
+
return resolved;
|
|
1060
|
+
})
|
|
730
1061
|
: items;
|
|
731
1062
|
|
|
732
1063
|
const result = await apiCall(
|
|
@@ -852,12 +1183,18 @@ async function knowledgeExport({ project_id }) {
|
|
|
852
1183
|
? `/api/v1/projects/${project_id}/knowledge/export`
|
|
853
1184
|
: "/api/v1/knowledge/export";
|
|
854
1185
|
const baseUrl = getBaseUrl();
|
|
855
|
-
|
|
1186
|
+
// The export endpoint is role:user — use LOOPCTL_USER_KEY. (LOOPCTL_ORCH_KEY is
|
|
1187
|
+
// orchestrator role, which is BELOW user in the hierarchy and would 403.) The
|
|
1188
|
+
// endpoint streams a gzipped tar (`application/gzip`), so save it as `.tar.gz`.
|
|
1189
|
+
const downloadCmd = `curl -H "Authorization: Bearer $LOOPCTL_USER_KEY" "${baseUrl}${basePath}" -o knowledge-export.tar.gz`;
|
|
856
1190
|
return {
|
|
857
1191
|
content: [{
|
|
858
1192
|
type: "text",
|
|
859
1193
|
text: JSON.stringify({
|
|
860
|
-
message:
|
|
1194
|
+
message:
|
|
1195
|
+
"Knowledge export streams a bounded-memory gzipped tar (.tar.gz). Use the curl " +
|
|
1196
|
+
"command below to download it directly. Requires a user-role key " +
|
|
1197
|
+
"(LOOPCTL_USER_KEY); extract with `tar -xzf knowledge-export.tar.gz`.",
|
|
861
1198
|
command: downloadCmd,
|
|
862
1199
|
endpoint: `${baseUrl}${basePath}`,
|
|
863
1200
|
}, null, 2),
|
|
@@ -865,6 +1202,136 @@ async function knowledgeExport({ project_id }) {
|
|
|
865
1202
|
};
|
|
866
1203
|
}
|
|
867
1204
|
|
|
1205
|
+
// --- OKF (Open Knowledge Format) interchange ---
|
|
1206
|
+
|
|
1207
|
+
// Guard a user-supplied absolute filesystem path against pseudo-filesystems.
|
|
1208
|
+
function assertSafeAbsolutePath(nodePath, p, label) {
|
|
1209
|
+
if (!nodePath.isAbsolute(p)) {
|
|
1210
|
+
return `${label} must be an absolute path (got '${p}').`;
|
|
1211
|
+
}
|
|
1212
|
+
const blocked = ["/proc", "/dev", "/sys"];
|
|
1213
|
+
if (blocked.some((b) => p === b || p.startsWith(b + "/"))) {
|
|
1214
|
+
return `${label} refused: '${p}' targets a pseudo-filesystem path.`;
|
|
1215
|
+
}
|
|
1216
|
+
return null;
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
async function knowledgeOkfExport({ project_id, out_dir }) {
|
|
1220
|
+
const basePath = project_id
|
|
1221
|
+
? `/api/v1/projects/${project_id}/knowledge/okf/export`
|
|
1222
|
+
: "/api/v1/knowledge/okf/export";
|
|
1223
|
+
|
|
1224
|
+
const result = await apiCall(
|
|
1225
|
+
"GET",
|
|
1226
|
+
`${basePath}?format=json`,
|
|
1227
|
+
null,
|
|
1228
|
+
process.env.LOOPCTL_USER_KEY,
|
|
1229
|
+
);
|
|
1230
|
+
|
|
1231
|
+
if (result.error) return toContent(result);
|
|
1232
|
+
|
|
1233
|
+
const bundle = result.data || result;
|
|
1234
|
+
const files = bundle.files || {};
|
|
1235
|
+
const meta = bundle.meta || {};
|
|
1236
|
+
|
|
1237
|
+
if (!out_dir) {
|
|
1238
|
+
return toContent({ meta, file_count: Object.keys(files).length, files });
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
const nodePath = await import("node:path");
|
|
1242
|
+
const pathErr = assertSafeAbsolutePath(nodePath, out_dir, "out_dir");
|
|
1243
|
+
if (pathErr) {
|
|
1244
|
+
return { content: [{ type: "text", text: `Error: ${pathErr}` }], isError: true };
|
|
1245
|
+
}
|
|
1246
|
+
// Normalize so `..`/trailing-slash can't defeat the per-file fence below.
|
|
1247
|
+
const root = nodePath.resolve(out_dir);
|
|
1248
|
+
|
|
1249
|
+
const fs = await import("node:fs/promises");
|
|
1250
|
+
let written = 0;
|
|
1251
|
+
for (const [rel, content] of Object.entries(files)) {
|
|
1252
|
+
// Keep writes inside root even if a server-supplied path is adversarial.
|
|
1253
|
+
const dest = nodePath.resolve(root, rel);
|
|
1254
|
+
if (dest !== root && !dest.startsWith(root + nodePath.sep)) continue;
|
|
1255
|
+
await fs.mkdir(nodePath.dirname(dest), { recursive: true });
|
|
1256
|
+
await fs.writeFile(dest, content, "utf8");
|
|
1257
|
+
written += 1;
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
return toContent({ meta, out_dir: root, written });
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
async function knowledgeOkfImport({ bundle_dir, project_id, merge, dry_run }) {
|
|
1264
|
+
const nodePath = await import("node:path");
|
|
1265
|
+
const pathErr = assertSafeAbsolutePath(nodePath, bundle_dir, "bundle_dir");
|
|
1266
|
+
if (pathErr) {
|
|
1267
|
+
return { content: [{ type: "text", text: `Error: ${pathErr}` }], isError: true };
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
const fs = await import("node:fs/promises");
|
|
1271
|
+
|
|
1272
|
+
let stat;
|
|
1273
|
+
try {
|
|
1274
|
+
stat = await fs.stat(bundle_dir);
|
|
1275
|
+
} catch {
|
|
1276
|
+
return { content: [{ type: "text", text: `Error: bundle_dir '${bundle_dir}' not found.` }], isError: true };
|
|
1277
|
+
}
|
|
1278
|
+
if (!stat.isDirectory()) {
|
|
1279
|
+
return { content: [{ type: "text", text: `Error: bundle_dir '${bundle_dir}' is not a directory.` }], isError: true };
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
// Kept in step with the server-side caps in okf_controller.ex.
|
|
1283
|
+
const MAX_FILES = 10_000;
|
|
1284
|
+
const MAX_TOTAL_BYTES = 50 * 1024 * 1024;
|
|
1285
|
+
const files = {};
|
|
1286
|
+
let totalBytes = 0;
|
|
1287
|
+
|
|
1288
|
+
async function walk(dir) {
|
|
1289
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
1290
|
+
for (const entry of entries) {
|
|
1291
|
+
const abs = nodePath.join(dir, entry.name);
|
|
1292
|
+
// Never traverse or read through symlinks (escape out of bundle_dir).
|
|
1293
|
+
if (entry.isSymbolicLink()) {
|
|
1294
|
+
continue;
|
|
1295
|
+
} else if (entry.isDirectory()) {
|
|
1296
|
+
await walk(abs);
|
|
1297
|
+
} else if (entry.isFile() && entry.name.endsWith(".md")) {
|
|
1298
|
+
if (Object.keys(files).length >= MAX_FILES) {
|
|
1299
|
+
throw new Error(`bundle exceeds ${MAX_FILES} files`);
|
|
1300
|
+
}
|
|
1301
|
+
const content = await fs.readFile(abs, "utf8");
|
|
1302
|
+
totalBytes += Buffer.byteLength(content, "utf8");
|
|
1303
|
+
if (totalBytes > MAX_TOTAL_BYTES) {
|
|
1304
|
+
throw new Error("bundle exceeds 25 MiB total");
|
|
1305
|
+
}
|
|
1306
|
+
files[nodePath.relative(bundle_dir, abs).split(nodePath.sep).join("/")] = content;
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
try {
|
|
1312
|
+
await walk(bundle_dir);
|
|
1313
|
+
} catch (e) {
|
|
1314
|
+
return { content: [{ type: "text", text: `Error: ${e.message}` }], isError: true };
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
if (Object.keys(files).length === 0) {
|
|
1318
|
+
return { content: [{ type: "text", text: `Error: no .md files found under '${bundle_dir}'.` }], isError: true };
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
const payload = { files };
|
|
1322
|
+
if (project_id) payload.project_id = project_id;
|
|
1323
|
+
if (merge != null) payload.merge = merge;
|
|
1324
|
+
if (dry_run != null) payload.dry_run = dry_run;
|
|
1325
|
+
|
|
1326
|
+
const result = await apiCall(
|
|
1327
|
+
"POST",
|
|
1328
|
+
"/api/v1/knowledge/okf/import",
|
|
1329
|
+
payload,
|
|
1330
|
+
process.env.LOOPCTL_USER_KEY,
|
|
1331
|
+
);
|
|
1332
|
+
return toContent(result);
|
|
1333
|
+
}
|
|
1334
|
+
|
|
868
1335
|
// --- Discovery Tools ---
|
|
869
1336
|
|
|
870
1337
|
async function listRoutes() {
|
|
@@ -1098,8 +1565,8 @@ const TOOLS = [
|
|
|
1098
1565
|
description:
|
|
1099
1566
|
"List stories for a project, optionally filtered by agent_status, verified_status, or epic_id. " +
|
|
1100
1567
|
"Returns compact results (no acceptance_criteria/description) — use get_story for full details. " +
|
|
1101
|
-
"
|
|
1102
|
-
"Filter by epic_id or agent_status to reduce result size.",
|
|
1568
|
+
"Defaults to 20 per page; pass `limit` up to 500 to page larger. Use offset to paginate " +
|
|
1569
|
+
"(response includes total_count). Filter by epic_id or agent_status to reduce result size.",
|
|
1103
1570
|
inputSchema: {
|
|
1104
1571
|
type: "object",
|
|
1105
1572
|
properties: {
|
|
@@ -1122,7 +1589,7 @@ const TOOLS = [
|
|
|
1122
1589
|
},
|
|
1123
1590
|
limit: {
|
|
1124
1591
|
type: "integer",
|
|
1125
|
-
description: "Maximum number of stories to return.",
|
|
1592
|
+
description: "Maximum number of stories to return (default 20, max 500).",
|
|
1126
1593
|
},
|
|
1127
1594
|
offset: {
|
|
1128
1595
|
type: "integer",
|
|
@@ -1141,7 +1608,7 @@ const TOOLS = [
|
|
|
1141
1608
|
description:
|
|
1142
1609
|
"List stories that are ready to be worked on (contracted, dependencies met). " +
|
|
1143
1610
|
"Returns compact results — use get_story for full details. " +
|
|
1144
|
-
"
|
|
1611
|
+
"Defaults to 20 per page; pass `limit` up to 500 to page larger. Response includes total_count.",
|
|
1145
1612
|
inputSchema: {
|
|
1146
1613
|
type: "object",
|
|
1147
1614
|
properties: {
|
|
@@ -1151,7 +1618,7 @@ const TOOLS = [
|
|
|
1151
1618
|
},
|
|
1152
1619
|
limit: {
|
|
1153
1620
|
type: "integer",
|
|
1154
|
-
description: "Maximum number of stories to return.",
|
|
1621
|
+
description: "Maximum number of stories to return (default 20, max 500).",
|
|
1155
1622
|
},
|
|
1156
1623
|
},
|
|
1157
1624
|
required: ["project_id"],
|
|
@@ -1560,7 +2027,13 @@ const TOOLS = [
|
|
|
1560
2027
|
{
|
|
1561
2028
|
name: "knowledge_index",
|
|
1562
2029
|
description:
|
|
1563
|
-
"
|
|
2030
|
+
"Browse/paginate the knowledge wiki catalog. Returns LIGHTWEIGHT article metadata grouped by " +
|
|
2031
|
+
"category — by default only id, title, category per article (NOT full metadata). " +
|
|
2032
|
+
"Honors category/tags filters and offset/limit pagination with deterministic ordering, so " +
|
|
2033
|
+
"every article is reachable (meta.categories reports per-category counts over the whole " +
|
|
2034
|
+
"filtered set; meta.has_more/meta.truncated signal more pages). Use `fields` to control the " +
|
|
2035
|
+
"projection (request tags/status/updated_at explicitly; id and category are always included) " +
|
|
2036
|
+
"to keep the payload small on large catalogs. " +
|
|
1564
2037
|
"Pass story_id when working on a loopctl story so reads attribute correctly.",
|
|
1565
2038
|
inputSchema: {
|
|
1566
2039
|
type: "object",
|
|
@@ -1575,21 +2048,356 @@ const TOOLS = [
|
|
|
1575
2048
|
format: "uuid",
|
|
1576
2049
|
description: "Optional: loopctl story UUID for attribution tracking.",
|
|
1577
2050
|
},
|
|
2051
|
+
category: {
|
|
2052
|
+
type: "string",
|
|
2053
|
+
enum: ["pattern", "convention", "decision", "finding", "reference"],
|
|
2054
|
+
description: "Optional: filter to a single category. Rejected (400) if unknown.",
|
|
2055
|
+
},
|
|
2056
|
+
tags: {
|
|
2057
|
+
type: "string",
|
|
2058
|
+
description: "Optional: comma-separated tags; matches articles carrying ANY of them.",
|
|
2059
|
+
},
|
|
2060
|
+
match: {
|
|
2061
|
+
type: "string",
|
|
2062
|
+
enum: ["any", "all"],
|
|
2063
|
+
description: "Optional: tag match mode — 'any' (default, OR) or 'all' (AND, every tag).",
|
|
2064
|
+
},
|
|
2065
|
+
offset: {
|
|
2066
|
+
type: "integer",
|
|
2067
|
+
description: "Optional: rows to skip for pagination (default 0).",
|
|
2068
|
+
},
|
|
2069
|
+
limit: {
|
|
2070
|
+
type: "integer",
|
|
2071
|
+
description: "Optional: max articles per page (default 1000, max 1000).",
|
|
2072
|
+
},
|
|
2073
|
+
fields: {
|
|
2074
|
+
type: "array",
|
|
2075
|
+
items: {
|
|
2076
|
+
type: "string",
|
|
2077
|
+
enum: ["id", "title", "category", "tags", "status", "updated_at"],
|
|
2078
|
+
},
|
|
2079
|
+
description:
|
|
2080
|
+
"Optional: projection of article fields to return. Default: id, title, category. `id` is always included.",
|
|
2081
|
+
},
|
|
2082
|
+
},
|
|
2083
|
+
required: [],
|
|
2084
|
+
},
|
|
2085
|
+
},
|
|
2086
|
+
{
|
|
2087
|
+
name: "knowledge_list",
|
|
2088
|
+
description:
|
|
2089
|
+
"List articles (id, title, category, status, tags, source_type, source_id, " +
|
|
2090
|
+
"idempotency_key, timestamps), filtered and paginated. **Body-less summary by default** " +
|
|
2091
|
+
"— the right tool to enumerate, dedup, or repair at scale (safe to page up to limit=1000). " +
|
|
2092
|
+
"Pass `include_body: true` to also return the full `body`, in which case the server bounds " +
|
|
2093
|
+
"the page by a ~5 MB serialized-body budget and returns meta.next_offset/has_more/" +
|
|
2094
|
+
"byte_truncated for continuation (for a single full body use knowledge_get; for the relevant " +
|
|
2095
|
+
"bodies use knowledge_context; for a bulk content dump use knowledge_export). Unlike " +
|
|
2096
|
+
"knowledge_search (ranked, PUBLISHED-only, lags writes while embeddings index) and " +
|
|
2097
|
+
"knowledge_index (id/title/category only), this is the LAG-FREE, ALL-STATUS read of the DB " +
|
|
2098
|
+
"of record (draft, published, archived, superseded visible). Use for idempotency/existence " +
|
|
2099
|
+
"checks: filter by `tags`, `source_type`+`source_id`, or `idempotency_key` and read " +
|
|
2100
|
+
"`meta.total_count` (exact) to answer \"does an article for X already exist?\" reliably " +
|
|
2101
|
+
"right after a write. Paginate via offset/limit.",
|
|
2102
|
+
inputSchema: {
|
|
2103
|
+
type: "object",
|
|
2104
|
+
properties: {
|
|
2105
|
+
project_id: {
|
|
2106
|
+
type: "string",
|
|
2107
|
+
format: "uuid",
|
|
2108
|
+
description: "Optional: scope to a project UUID.",
|
|
2109
|
+
},
|
|
2110
|
+
category: {
|
|
2111
|
+
type: "string",
|
|
2112
|
+
description: "Optional: filter by category.",
|
|
2113
|
+
},
|
|
2114
|
+
status: {
|
|
2115
|
+
type: "string",
|
|
2116
|
+
description: "Optional: filter by status (draft, published, archived, superseded).",
|
|
2117
|
+
},
|
|
2118
|
+
tags: {
|
|
2119
|
+
type: "string",
|
|
2120
|
+
description: "Optional: comma-separated tags; matches articles carrying ANY of them.",
|
|
2121
|
+
},
|
|
2122
|
+
match: {
|
|
2123
|
+
type: "string",
|
|
2124
|
+
enum: ["any", "all"],
|
|
2125
|
+
description: "Optional: tag match mode — 'any' (default, OR) or 'all' (AND, every tag).",
|
|
2126
|
+
},
|
|
2127
|
+
source_type: {
|
|
2128
|
+
type: "string",
|
|
2129
|
+
description: "Optional: filter by source_type.",
|
|
2130
|
+
},
|
|
2131
|
+
source_id: {
|
|
2132
|
+
type: "string",
|
|
2133
|
+
description: "Optional: filter by source_id (a malformed id matches nothing).",
|
|
2134
|
+
},
|
|
2135
|
+
idempotency_key: {
|
|
2136
|
+
type: "string",
|
|
2137
|
+
description:
|
|
2138
|
+
"Optional: filter by exact idempotency_key — the lag-free existence check for a " +
|
|
2139
|
+
"prior capture.",
|
|
2140
|
+
},
|
|
2141
|
+
offset: {
|
|
2142
|
+
type: "integer",
|
|
2143
|
+
description: "Optional: rows to skip for pagination (default 0).",
|
|
2144
|
+
},
|
|
2145
|
+
limit: {
|
|
2146
|
+
type: "integer",
|
|
2147
|
+
description:
|
|
2148
|
+
"Optional: max articles per page (default 20, max 1000). A limit above " +
|
|
2149
|
+
"the max is rejected with 400 — not silently clamped — so paging by offset " +
|
|
2150
|
+
"over meta.total_count enumerates the complete set without skipping rows.",
|
|
2151
|
+
},
|
|
2152
|
+
include_body: {
|
|
2153
|
+
type: "boolean",
|
|
2154
|
+
description:
|
|
2155
|
+
"Optional (default false): when true, include the full article `body`. The page is " +
|
|
2156
|
+
"then bounded by a ~5 MB serialized-body budget (it may return fewer than `limit` " +
|
|
2157
|
+
"rows); continue via meta.next_offset while meta.has_more is true. Leave false to " +
|
|
2158
|
+
"enumerate metadata cheaply at scale.",
|
|
2159
|
+
},
|
|
2160
|
+
},
|
|
2161
|
+
required: [],
|
|
2162
|
+
},
|
|
2163
|
+
},
|
|
2164
|
+
{
|
|
2165
|
+
name: "knowledge_stats",
|
|
2166
|
+
description:
|
|
2167
|
+
"Get aggregate article counts for the wiki without pulling any article metadata. " +
|
|
2168
|
+
"Returns { total, by_category, by_status } via cheap COUNT(*) GROUP BY. This is the " +
|
|
2169
|
+
"right tool to answer \"how many articles are in this project?\" — knowledge_index " +
|
|
2170
|
+
"pages article metadata and knowledge_search's total_count is query-dependent. Counts " +
|
|
2171
|
+
"span all statuses (draft/published/archived/superseded); see by_status for the split. " +
|
|
2172
|
+
"Note: `total` is NOT the same as knowledge_index's meta.total_count (which counts only " +
|
|
2173
|
+
"published) — they differ whenever drafts/archived exist.",
|
|
2174
|
+
inputSchema: {
|
|
2175
|
+
type: "object",
|
|
2176
|
+
properties: {
|
|
2177
|
+
project_id: {
|
|
2178
|
+
type: "string",
|
|
2179
|
+
format: "uuid",
|
|
2180
|
+
description:
|
|
2181
|
+
"Optional: scope counts to a project (counts both tenant-wide and project-specific articles).",
|
|
2182
|
+
},
|
|
2183
|
+
},
|
|
2184
|
+
required: [],
|
|
2185
|
+
},
|
|
2186
|
+
},
|
|
2187
|
+
{
|
|
2188
|
+
name: "knowledge_count",
|
|
2189
|
+
description:
|
|
2190
|
+
"Count articles matching filters WITHOUT returning any rows. Accepts the same filters " +
|
|
2191
|
+
"as knowledge_list (category, status, tags, match, source_type, source_id, " +
|
|
2192
|
+
"idempotency_key, project_id). With tags + match:'all' it counts articles carrying ALL " +
|
|
2193
|
+
"the tags; add status:'published' to answer \"how many PUBLISHED articles tagged both X " +
|
|
2194
|
+
"and Y\". Removes the need to paginate rows just to count. Returns { count }.",
|
|
2195
|
+
inputSchema: {
|
|
2196
|
+
type: "object",
|
|
2197
|
+
properties: {
|
|
2198
|
+
project_id: { type: "string", format: "uuid", description: "Optional: scope to a project UUID." },
|
|
2199
|
+
category: { type: "string", description: "Optional: filter by category." },
|
|
2200
|
+
status: { type: "string", description: "Optional: filter by status." },
|
|
2201
|
+
tags: { type: "string", description: "Optional: comma-separated tags." },
|
|
2202
|
+
match: {
|
|
2203
|
+
type: "string",
|
|
2204
|
+
enum: ["any", "all"],
|
|
2205
|
+
description: "Tag match mode: 'any' (default, OR) or 'all' (AND — carries every tag).",
|
|
2206
|
+
},
|
|
2207
|
+
source_type: { type: "string", description: "Optional: filter by source_type." },
|
|
2208
|
+
source_id: { type: "string", description: "Optional: filter by source_id." },
|
|
2209
|
+
idempotency_key: { type: "string", description: "Optional: filter by idempotency_key." },
|
|
2210
|
+
},
|
|
2211
|
+
required: [],
|
|
2212
|
+
},
|
|
2213
|
+
},
|
|
2214
|
+
{
|
|
2215
|
+
name: "knowledge_facets",
|
|
2216
|
+
description:
|
|
2217
|
+
"Count articles grouped by each distinct tag, over the filtered set, WITHOUT returning " +
|
|
2218
|
+
"rows. Returns { data: { tag: count }, meta: { distinct_count, truncated } }. " +
|
|
2219
|
+
"meta.distinct_count is the TRUE number of distinct tags (independent of limit); " +
|
|
2220
|
+
"meta.truncated flags when limit capped the rows. Each `count` is the number of distinct " +
|
|
2221
|
+
"articles carrying that tag. Pass tag_prefix to restrict to a tag family (e.g. 'book-') " +
|
|
2222
|
+
"so you get the DISTINCT count of that family (how many distinct books) plus per-member " +
|
|
2223
|
+
"totals — without dragging tens of thousands of rows through context. Honors the same " +
|
|
2224
|
+
"filters as knowledge_count (status, tags, match). Cost: unnests tags over the whole " +
|
|
2225
|
+
"filtered set; on large tenants narrow with tag_prefix/category/status/project_id.",
|
|
2226
|
+
inputSchema: {
|
|
2227
|
+
type: "object",
|
|
2228
|
+
properties: {
|
|
2229
|
+
project_id: { type: "string", format: "uuid", description: "Optional: scope to a project UUID." },
|
|
2230
|
+
category: { type: "string", description: "Optional: filter by category." },
|
|
2231
|
+
status: { type: "string", description: "Optional: filter by status." },
|
|
2232
|
+
tags: { type: "string", description: "Optional: comma-separated tags." },
|
|
2233
|
+
match: {
|
|
2234
|
+
type: "string",
|
|
2235
|
+
enum: ["any", "all"],
|
|
2236
|
+
description: "Tag match mode: 'any' (default, OR) or 'all' (AND).",
|
|
2237
|
+
},
|
|
2238
|
+
tag_prefix: {
|
|
2239
|
+
type: "string",
|
|
2240
|
+
description: "Optional: only tags starting with this literal prefix (e.g. 'book-').",
|
|
2241
|
+
},
|
|
2242
|
+
limit: { type: "integer", description: "Optional: max distinct tags in the result (default all, max 1000; values above 1000 are rejected with 400)." },
|
|
1578
2243
|
},
|
|
1579
2244
|
required: [],
|
|
1580
2245
|
},
|
|
1581
2246
|
},
|
|
2247
|
+
{
|
|
2248
|
+
name: "knowledge_graph",
|
|
2249
|
+
description:
|
|
2250
|
+
"Traverse the published article-link graph outward from an article, up to `depth` hops " +
|
|
2251
|
+
"(1–3, default 1). BIDIRECTIONAL (follows links regardless of source/target direction) and " +
|
|
2252
|
+
"cycle-safe (no node twice). Returns { nodes: [{id,title,category,depth}], edges: " +
|
|
2253
|
+
"[{source_article_id,target_article_id,relationship_type}], truncated, node_count }. Bounded " +
|
|
2254
|
+
"to 100 nodes / 500 edges (truncated:true when hit). Use to explore how a piece of knowledge " +
|
|
2255
|
+
"connects (relates_to/derived_from/contradicts/supersedes) beyond the 1-hop links in " +
|
|
2256
|
+
"knowledge_context.",
|
|
2257
|
+
inputSchema: {
|
|
2258
|
+
type: "object",
|
|
2259
|
+
properties: {
|
|
2260
|
+
article_id: {
|
|
2261
|
+
type: "string",
|
|
2262
|
+
format: "uuid",
|
|
2263
|
+
description: "Starting article UUID (required).",
|
|
2264
|
+
},
|
|
2265
|
+
depth: {
|
|
2266
|
+
type: "integer",
|
|
2267
|
+
minimum: 1,
|
|
2268
|
+
maximum: 3,
|
|
2269
|
+
description: "Hops to traverse (1–3, default 1). Out of range → 400.",
|
|
2270
|
+
},
|
|
2271
|
+
project_id: { type: "string", format: "uuid", description: "Optional: project UUID." },
|
|
2272
|
+
},
|
|
2273
|
+
required: ["article_id"],
|
|
2274
|
+
},
|
|
2275
|
+
},
|
|
2276
|
+
{
|
|
2277
|
+
name: "knowledge_suggest_links",
|
|
2278
|
+
description:
|
|
2279
|
+
"Suggest ranked typed-link CANDIDATES for an article by embedding similarity — " +
|
|
2280
|
+
"READ-ONLY, creates nothing. Excludes the article itself and any already-linked " +
|
|
2281
|
+
"article (either direction, any relationship type); only embedded published articles. " +
|
|
2282
|
+
"Returns { data: [{id, title, category, similarity_score}] } highest-similarity first. " +
|
|
2283
|
+
"Review them and create the one you want as a TYPED link (relates_to/derived_from/" +
|
|
2284
|
+
"contradicts/supersedes) — unlike the auto-linker which only makes ambient relates_to. " +
|
|
2285
|
+
"Optional: threshold (cosine floor 0–1, default 0.5), limit (default 5).",
|
|
2286
|
+
inputSchema: {
|
|
2287
|
+
type: "object",
|
|
2288
|
+
properties: {
|
|
2289
|
+
article_id: {
|
|
2290
|
+
type: "string",
|
|
2291
|
+
format: "uuid",
|
|
2292
|
+
description: "The article to suggest links for (required).",
|
|
2293
|
+
},
|
|
2294
|
+
limit: { type: "integer", description: "Max candidates (default 5)." },
|
|
2295
|
+
threshold: {
|
|
2296
|
+
type: "number",
|
|
2297
|
+
minimum: 0,
|
|
2298
|
+
maximum: 1,
|
|
2299
|
+
description: "Cosine similarity floor (default 0.5).",
|
|
2300
|
+
},
|
|
2301
|
+
},
|
|
2302
|
+
required: ["article_id"],
|
|
2303
|
+
},
|
|
2304
|
+
},
|
|
2305
|
+
{
|
|
2306
|
+
name: "knowledge_distant_pairs",
|
|
2307
|
+
description:
|
|
2308
|
+
"Find distant-but-bridgeable article pairs in the optimal-novelty embedding band " +
|
|
2309
|
+
"(cosine distance min..max, default 0.3–0.7) — the creative sweet spot (neither banal " +
|
|
2310
|
+
"nor nonsense). Returns { data: [{a, b, distance}], meta:{count} }. With bridge_path:true, " +
|
|
2311
|
+
"only pairs also connected in the link graph (≤2 hops) are returned. Samples up to 1000 " +
|
|
2312
|
+
"embedded published articles; paginate via limit/offset. For computational-creativity " +
|
|
2313
|
+
"ideation (remote-associates generator).",
|
|
2314
|
+
inputSchema: {
|
|
2315
|
+
type: "object",
|
|
2316
|
+
properties: {
|
|
2317
|
+
min_distance: { type: "number", description: "Lower cosine-distance bound (default 0.3)." },
|
|
2318
|
+
max_distance: { type: "number", description: "Upper cosine-distance bound (default 0.7)." },
|
|
2319
|
+
bridge_path: { type: "boolean", description: "Require a ≤2-hop graph path (default false)." },
|
|
2320
|
+
limit: { type: "integer", description: "Max pairs (default 20, max 100)." },
|
|
2321
|
+
offset: { type: "integer", description: "Pairs to skip." },
|
|
2322
|
+
},
|
|
2323
|
+
required: [],
|
|
2324
|
+
},
|
|
2325
|
+
},
|
|
2326
|
+
{
|
|
2327
|
+
name: "knowledge_novelty",
|
|
2328
|
+
description:
|
|
2329
|
+
"Score the NOVELTY of ideas: each idea's text is embedded and compared to the nearest " +
|
|
2330
|
+
"prior proposal, returning novelty_score = cosine distance (0 = identical to existing " +
|
|
2331
|
+
"work, higher = more novel, up to 2.0; null when the idea text is blank, no priors " +
|
|
2332
|
+
"exist, or embedding fails). Priors default to published articles tagged 'proposal' " +
|
|
2333
|
+
"(override with prior_tag). Provide the ideas as EITHER `texts` (a list of strings) " +
|
|
2334
|
+
"OR `ideas` (a list of strings or objects {text|title/spark/thesis,...}); all forms " +
|
|
2335
|
+
"are accepted. " +
|
|
2336
|
+
"Returns { data: [{...idea, novelty_score}], meta: { prior_count } }. Use to rerank " +
|
|
2337
|
+
"generated ideas by novelty × value and avoid repeating prior work.",
|
|
2338
|
+
inputSchema: {
|
|
2339
|
+
type: "object",
|
|
2340
|
+
properties: {
|
|
2341
|
+
ideas: {
|
|
2342
|
+
type: "array",
|
|
2343
|
+
description:
|
|
2344
|
+
"Ideas to score (≤50). Each is a string, or an object whose embed text is " +
|
|
2345
|
+
"`text` (else title/spark/thesis are joined).",
|
|
2346
|
+
items: { type: ["string", "object"] },
|
|
2347
|
+
},
|
|
2348
|
+
texts: {
|
|
2349
|
+
type: "array",
|
|
2350
|
+
description: "Alternative to `ideas`: a list of idea strings (the #152 AC shape).",
|
|
2351
|
+
items: { type: "string" },
|
|
2352
|
+
},
|
|
2353
|
+
prior_tag: {
|
|
2354
|
+
type: "string",
|
|
2355
|
+
description: "Tag identifying prior proposals to compare against (default 'proposal').",
|
|
2356
|
+
},
|
|
2357
|
+
},
|
|
2358
|
+
required: [],
|
|
2359
|
+
},
|
|
2360
|
+
},
|
|
2361
|
+
{
|
|
2362
|
+
name: "knowledge_random_walk",
|
|
2363
|
+
description:
|
|
2364
|
+
"Random walk through the link graph from a starting article (up to `length` published " +
|
|
2365
|
+
"nodes, no cycles, stops at a dead end). Surfaces unexpected connections for creative " +
|
|
2366
|
+
"incubation. Returns { data: [{id,title,category}], meta:{count} } in walk order.",
|
|
2367
|
+
inputSchema: {
|
|
2368
|
+
type: "object",
|
|
2369
|
+
properties: {
|
|
2370
|
+
start_id: { type: "string", format: "uuid", description: "Starting article UUID (required)." },
|
|
2371
|
+
length: { type: "integer", description: "Walk steps (default 4, max 25)." },
|
|
2372
|
+
},
|
|
2373
|
+
required: ["start_id"],
|
|
2374
|
+
},
|
|
2375
|
+
},
|
|
1582
2376
|
{
|
|
1583
2377
|
name: "knowledge_search",
|
|
1584
2378
|
description:
|
|
1585
|
-
"Search the knowledge wiki by topic. Returns snippets. " +
|
|
2379
|
+
"Search the knowledge wiki by topic. Returns snippets. Ranked, and returns PUBLISHED " +
|
|
2380
|
+
"articles only; it LAGS writes by minutes while embeddings index. Do NOT use it for " +
|
|
2381
|
+
"existence/idempotency/dedup checks ('already captured?') — a freshly-written article will " +
|
|
2382
|
+
"false-negative. Use knowledge_list (lag-free, all-status, exact meta.total_count) for that. " +
|
|
2383
|
+
"q is optional when tags and/or category are supplied: in that list mode it returns the " +
|
|
2384
|
+
"COMPLETE filtered set (no relevance ranking) paginated via offset/limit over " +
|
|
2385
|
+
"meta.total_count, so you can enumerate every article carrying a tag/category. " +
|
|
2386
|
+
"IMPORTANT: meta.total_count is mode-dependent — read meta.total_count_scope to know what " +
|
|
2387
|
+
"it counts: keyword_matches (stop-word-filtered tsquery matches; 'the' matches ~nothing), " +
|
|
2388
|
+
"ranked_corpus (semantic ranks all EMBEDDED published articles — that embedded set's size, " +
|
|
2389
|
+
"not a match count, and <= the published count), merged_candidates (combined: deduped UNION of " +
|
|
2390
|
+
"a keyword and a semantic sub-search, each capped at 100, so up to ~200), or filtered_set " +
|
|
2391
|
+
"(list mode: the full set). Do NOT use a relevance-mode total_count to size the wiki — use " +
|
|
2392
|
+
"list mode or knowledge_stats. " +
|
|
1586
2393
|
"Pass story_id when working on a loopctl story so reads attribute correctly.",
|
|
1587
2394
|
inputSchema: {
|
|
1588
2395
|
type: "object",
|
|
1589
2396
|
properties: {
|
|
1590
2397
|
q: {
|
|
1591
2398
|
type: "string",
|
|
1592
|
-
description:
|
|
2399
|
+
description:
|
|
2400
|
+
"Search query string. Optional when tags/category are supplied (enumeration mode).",
|
|
1593
2401
|
},
|
|
1594
2402
|
project_id: {
|
|
1595
2403
|
type: "string",
|
|
@@ -1609,6 +2417,11 @@ const TOOLS = [
|
|
|
1609
2417
|
type: "string",
|
|
1610
2418
|
description: "Optional: comma-separated tags to filter by.",
|
|
1611
2419
|
},
|
|
2420
|
+
match: {
|
|
2421
|
+
type: "string",
|
|
2422
|
+
enum: ["any", "all"],
|
|
2423
|
+
description: "Optional: tag match mode — 'any' (default, OR) or 'all' (AND, every tag).",
|
|
2424
|
+
},
|
|
1612
2425
|
mode: {
|
|
1613
2426
|
type: "string",
|
|
1614
2427
|
enum: ["keyword", "semantic", "combined"],
|
|
@@ -1618,8 +2431,12 @@ const TOOLS = [
|
|
|
1618
2431
|
type: "integer",
|
|
1619
2432
|
description: "Optional: maximum number of results to return.",
|
|
1620
2433
|
},
|
|
2434
|
+
offset: {
|
|
2435
|
+
type: "integer",
|
|
2436
|
+
description: "Optional: results to skip for pagination (default 0).",
|
|
2437
|
+
},
|
|
1621
2438
|
},
|
|
1622
|
-
required: [
|
|
2439
|
+
required: [],
|
|
1623
2440
|
},
|
|
1624
2441
|
},
|
|
1625
2442
|
{
|
|
@@ -1653,7 +2470,11 @@ const TOOLS = [
|
|
|
1653
2470
|
name: "knowledge_context",
|
|
1654
2471
|
description:
|
|
1655
2472
|
"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."
|
|
2473
|
+
"Pass story_id when working on a loopctl story so reads attribute correctly. For agent " +
|
|
2474
|
+
"memory, scope to a memory_type/agent/conversation via the memory_types/agents/" +
|
|
2475
|
+
"conversation_id filters (articles whose metadata carries those keys). NOTE (#163): for " +
|
|
2476
|
+
"an agent key, another agent's private/owner memories are never returned (results AND " +
|
|
2477
|
+
"linked refs) regardless of the agents= filter — visibility is enforced, not advisory.",
|
|
1657
2478
|
inputSchema: {
|
|
1658
2479
|
type: "object",
|
|
1659
2480
|
properties: {
|
|
@@ -1681,6 +2502,20 @@ const TOOLS = [
|
|
|
1681
2502
|
minimum: 0,
|
|
1682
2503
|
maximum: 1,
|
|
1683
2504
|
},
|
|
2505
|
+
memory_types: {
|
|
2506
|
+
type: "string",
|
|
2507
|
+
description:
|
|
2508
|
+
"Optional agent-memory scope: comma-separated memory_types (OR) — " +
|
|
2509
|
+
"observation|finding|summary|decision|question|task.",
|
|
2510
|
+
},
|
|
2511
|
+
agents: {
|
|
2512
|
+
type: "string",
|
|
2513
|
+
description: "Optional agent-memory scope: comma-separated agent_ids (OR).",
|
|
2514
|
+
},
|
|
2515
|
+
conversation_id: {
|
|
2516
|
+
type: "string",
|
|
2517
|
+
description: "Optional agent-memory scope: exact conversation_id.",
|
|
2518
|
+
},
|
|
1684
2519
|
},
|
|
1685
2520
|
required: ["query"],
|
|
1686
2521
|
},
|
|
@@ -1689,7 +2524,15 @@ const TOOLS = [
|
|
|
1689
2524
|
name: "knowledge_create",
|
|
1690
2525
|
description:
|
|
1691
2526
|
"Create a new knowledge article. Use to file findings, document patterns, or record decisions " +
|
|
1692
|
-
"discovered during implementation."
|
|
2527
|
+
"discovered during implementation. Articles are PUBLISHED IMMEDIATELY by default and are visible " +
|
|
2528
|
+
"to agents (search/index/context) right away — no separate publish step is needed. Pass " +
|
|
2529
|
+
"draft: true to stage the article for later review instead; the response `note` says which " +
|
|
2530
|
+
"outcome occurred, and a draft can be published afterwards with knowledge_publish. " +
|
|
2531
|
+
"Concurrency-safe: if a create races/retries against an " +
|
|
2532
|
+
"existing article with the same title AND an identical body (ignoring surrounding whitespace), the " +
|
|
2533
|
+
"server returns that existing article idempotently (HTTP 200) instead of a 422. A same-title create " +
|
|
2534
|
+
"with a DIFFERENT body returns 409 title_conflict — do not retry; choose a different title or PATCH " +
|
|
2535
|
+
"the existing article.",
|
|
1693
2536
|
inputSchema: {
|
|
1694
2537
|
type: "object",
|
|
1695
2538
|
properties: {
|
|
@@ -1714,6 +2557,47 @@ const TOOLS = [
|
|
|
1714
2557
|
type: "string",
|
|
1715
2558
|
description: "Optional: associate the article with a project UUID.",
|
|
1716
2559
|
},
|
|
2560
|
+
draft: {
|
|
2561
|
+
type: "boolean",
|
|
2562
|
+
description:
|
|
2563
|
+
"Optional: stage as a draft instead of publishing on create (default false → " +
|
|
2564
|
+
"published immediately). Publish later with knowledge_publish.",
|
|
2565
|
+
},
|
|
2566
|
+
idempotency_key: {
|
|
2567
|
+
type: "string",
|
|
2568
|
+
description:
|
|
2569
|
+
"Optional: stable per-article key for idempotent capture (max 255). Re-creating " +
|
|
2570
|
+
"with the same key is a no-op that returns a reference to the existing article " +
|
|
2571
|
+
"(deduplicated; id only, not its body) instead of a partial duplicate. Use a " +
|
|
2572
|
+
"HIGH-ENTROPY value (e.g. a content hash) — it is a per-tenant lookup key, not a " +
|
|
2573
|
+
"secret, so a guessable key lets another agent in your tenant probe which keys " +
|
|
2574
|
+
"exist. Distinct from source_type/source_id (which mark a shared source).",
|
|
2575
|
+
},
|
|
2576
|
+
metadata: {
|
|
2577
|
+
type: "object",
|
|
2578
|
+
description:
|
|
2579
|
+
"Optional: extensible JSONB. Set the agent-memory keys to file this article as a " +
|
|
2580
|
+
"scoped agent memory: `memory_type` (observation/finding/summary/decision/question/" +
|
|
2581
|
+
"task) and `visibility` (shared | private | owner). TRUST MODEL (#163): for an " +
|
|
2582
|
+
"agent key, `metadata.agent_id` is stamped server-side from your verified key " +
|
|
2583
|
+
"identity — do NOT set it (any value you pass is overridden); `private`/`owner` " +
|
|
2584
|
+
"memories are then readable only by you (other agents get 404/exclusion across all " +
|
|
2585
|
+
"knowledge reads), while `shared` (the default) is visible tenant-wide. An agent " +
|
|
2586
|
+
"key with no agent identity gets 403 agent_identity_required when writing memory " +
|
|
2587
|
+
"metadata. Higher roles may attribute on behalf of others.",
|
|
2588
|
+
},
|
|
2589
|
+
source_type: {
|
|
2590
|
+
type: "string",
|
|
2591
|
+
description:
|
|
2592
|
+
"Optional: advisory provenance for the originating source (e.g. 'web_article', " +
|
|
2593
|
+
"'newsletter'). Shared across articles from the same source; not an idempotency key.",
|
|
2594
|
+
},
|
|
2595
|
+
source_id: {
|
|
2596
|
+
type: "string",
|
|
2597
|
+
description:
|
|
2598
|
+
"Optional: UUID of the originating source entity (shared across articles from " +
|
|
2599
|
+
"that source). Pair with source_type.",
|
|
2600
|
+
},
|
|
1717
2601
|
},
|
|
1718
2602
|
required: ["title", "body"],
|
|
1719
2603
|
},
|
|
@@ -1738,19 +2622,48 @@ const TOOLS = [
|
|
|
1738
2622
|
{
|
|
1739
2623
|
name: "knowledge_bulk_publish",
|
|
1740
2624
|
description:
|
|
1741
|
-
"
|
|
1742
|
-
"
|
|
1743
|
-
"
|
|
1744
|
-
"
|
|
1745
|
-
"
|
|
2625
|
+
"Publish draft articles, partial-success style. REQUIRES LOOPCTL_USER_KEY " +
|
|
2626
|
+
"(user role — orchestrator is NOT sufficient). Every valid draft is published; " +
|
|
2627
|
+
"each other id gets a per-id outcome instead of failing the whole call: " +
|
|
2628
|
+
"published, skipped (already published — idempotent — or archived/superseded), " +
|
|
2629
|
+
"not_found, or errored. Duplicate ids are de-duplicated and there is NO 100-id " +
|
|
2630
|
+
"cap (larger requests are auto-chunked server-side). The response's meta.count " +
|
|
2631
|
+
"is the number actually published; meta.counts has the full breakdown and " +
|
|
2632
|
+
"meta.results is the per-id list in request order. Safe to retry — already " +
|
|
2633
|
+
"published ids are skipped, not errored.",
|
|
1746
2634
|
inputSchema: {
|
|
1747
2635
|
type: "object",
|
|
1748
2636
|
properties: {
|
|
1749
2637
|
article_ids: {
|
|
1750
2638
|
type: "array",
|
|
1751
2639
|
items: { type: "string" },
|
|
1752
|
-
description:
|
|
1753
|
-
|
|
2640
|
+
description:
|
|
2641
|
+
"Article UUIDs to publish. Any length (auto-chunked); duplicates ignored.",
|
|
2642
|
+
},
|
|
2643
|
+
},
|
|
2644
|
+
required: ["article_ids"],
|
|
2645
|
+
},
|
|
2646
|
+
},
|
|
2647
|
+
{
|
|
2648
|
+
name: "knowledge_bulk_unpublish",
|
|
2649
|
+
description:
|
|
2650
|
+
"Unpublish (published → draft) articles in bulk, partial-success style — the mirror " +
|
|
2651
|
+
"of knowledge_bulk_publish, for cleanup passes. REQUIRES LOOPCTL_USER_KEY (user role). " +
|
|
2652
|
+
"Every currently-published id is reverted to draft; each other id gets a per-id outcome: " +
|
|
2653
|
+
"unpublished, skipped (already draft — idempotent — or archived/superseded), not_found, " +
|
|
2654
|
+
"or errored. Duplicate ids de-duplicated; no 100-id cap (auto-chunked server-side, " +
|
|
2655
|
+
"bounded to 5000/call). meta.count = number actually unpublished; meta.counts has the " +
|
|
2656
|
+
"full breakdown; meta.results is the per-id list in request order. Safe to retry — " +
|
|
2657
|
+
"already-draft ids are skipped, not errored. Articles are NOT deleted (re-publish with " +
|
|
2658
|
+
"knowledge_bulk_publish); to archive/soft-delete use knowledge_bulk_delete.",
|
|
2659
|
+
inputSchema: {
|
|
2660
|
+
type: "object",
|
|
2661
|
+
properties: {
|
|
2662
|
+
article_ids: {
|
|
2663
|
+
type: "array",
|
|
2664
|
+
items: { type: "string" },
|
|
2665
|
+
description:
|
|
2666
|
+
"Article UUIDs to unpublish. Any length (auto-chunked); duplicates ignored.",
|
|
1754
2667
|
},
|
|
1755
2668
|
},
|
|
1756
2669
|
required: ["article_ids"],
|
|
@@ -1810,20 +2723,92 @@ const TOOLS = [
|
|
|
1810
2723
|
required: ["article_id"],
|
|
1811
2724
|
},
|
|
1812
2725
|
},
|
|
2726
|
+
{
|
|
2727
|
+
name: "knowledge_bulk_delete",
|
|
2728
|
+
description:
|
|
2729
|
+
"Bulk archive (default, reversible) or IRREVERSIBLE hard-delete of articles by selector. " +
|
|
2730
|
+
"REQUIRES LOOPCTL_USER_KEY (user role — orchestrator is NOT sufficient). Provide EXACTLY ONE " +
|
|
2731
|
+
"selector: article_ids (explicit list), source_type + source_id (every active article from " +
|
|
2732
|
+
"that source), or tag + confirm:true (every active article carrying the tag — high blast " +
|
|
2733
|
+
"radius, so confirm:true is required). " +
|
|
2734
|
+
"DEFAULT (soft archive): rows move to archived, never dropped; set-based + idempotent; " +
|
|
2735
|
+
"meta.count = archived, meta.counts/meta.results give the breakdown. " +
|
|
2736
|
+
"DRY-RUN: dry_run:true mutates NOTHING and returns meta.would_affect (and, for hard, a " +
|
|
2737
|
+
"single-use meta.token / for oversized selectors a meta.confirm_hash). " +
|
|
2738
|
+
"HARD DELETE (irreversible): first dry_run with hard:true to get a token, then call again " +
|
|
2739
|
+
"with hard:true + that token to FK-correctly delete the FROZEN id-set (links removed first, " +
|
|
2740
|
+
"access events cascade). The token is single-use and TTL-bounded. Bounded to 5000 per call.",
|
|
2741
|
+
inputSchema: {
|
|
2742
|
+
type: "object",
|
|
2743
|
+
properties: {
|
|
2744
|
+
article_ids: {
|
|
2745
|
+
type: "array",
|
|
2746
|
+
items: { type: "string" },
|
|
2747
|
+
description: "Explicit article UUIDs (selector 1).",
|
|
2748
|
+
},
|
|
2749
|
+
source_type: {
|
|
2750
|
+
type: "string",
|
|
2751
|
+
description: "With source_id: every active article from this source (selector 2).",
|
|
2752
|
+
},
|
|
2753
|
+
source_id: {
|
|
2754
|
+
type: "string",
|
|
2755
|
+
description: "With source_type: the source entity UUID (selector 2).",
|
|
2756
|
+
},
|
|
2757
|
+
tag: {
|
|
2758
|
+
type: "string",
|
|
2759
|
+
description:
|
|
2760
|
+
"Every active article carrying this tag (selector 3). Requires confirm:true.",
|
|
2761
|
+
},
|
|
2762
|
+
confirm: {
|
|
2763
|
+
type: "boolean",
|
|
2764
|
+
description: "Required (true) when selecting by tag — guards the high blast radius.",
|
|
2765
|
+
},
|
|
2766
|
+
dry_run: {
|
|
2767
|
+
type: "boolean",
|
|
2768
|
+
description:
|
|
2769
|
+
"Preview only — mutate nothing. Returns meta.would_affect; with hard:true also a " +
|
|
2770
|
+
"single-use meta.token (or meta.confirm_hash for oversized selectors).",
|
|
2771
|
+
},
|
|
2772
|
+
hard: {
|
|
2773
|
+
type: "boolean",
|
|
2774
|
+
description:
|
|
2775
|
+
"IRREVERSIBLE hard delete (vs default reversible archive). Run dry_run first to get a " +
|
|
2776
|
+
"token, then pass hard:true + token.",
|
|
2777
|
+
},
|
|
2778
|
+
token: {
|
|
2779
|
+
type: "string",
|
|
2780
|
+
description:
|
|
2781
|
+
"The single-use frozen-set token from a `dry_run:true, hard:true` preview. Required " +
|
|
2782
|
+
"for the hard delete.",
|
|
2783
|
+
},
|
|
2784
|
+
confirm_hash: {
|
|
2785
|
+
type: "string",
|
|
2786
|
+
description:
|
|
2787
|
+
"For an oversized hard-delete selector (no token): the meta.confirm_hash from the " +
|
|
2788
|
+
"dry-run, echoed back to re-confirm the id-set hasn't drifted.",
|
|
2789
|
+
},
|
|
2790
|
+
},
|
|
2791
|
+
required: [],
|
|
2792
|
+
},
|
|
2793
|
+
},
|
|
1813
2794
|
{
|
|
1814
2795
|
name: "knowledge_drafts",
|
|
1815
2796
|
description:
|
|
1816
2797
|
"List draft (unpublished) knowledge articles. Requires orchestrator role. " +
|
|
1817
|
-
"Returns paginated drafts with total_count in meta.
|
|
2798
|
+
"Returns paginated drafts with total_count in meta. Paginate via offset/limit " +
|
|
2799
|
+
"(limit honored up to 1000; a limit above the max is rejected with 400, not " +
|
|
2800
|
+
"silently clamped).",
|
|
1818
2801
|
inputSchema: {
|
|
1819
2802
|
type: "object",
|
|
1820
2803
|
properties: {
|
|
1821
2804
|
limit: {
|
|
1822
2805
|
type: "integer",
|
|
1823
|
-
description:
|
|
2806
|
+
description:
|
|
2807
|
+
"Max drafts per page (default 20, max 1000). A limit above the max is " +
|
|
2808
|
+
"rejected with 400 — not silently clamped — so offset pagination stays complete.",
|
|
1824
2809
|
default: 20,
|
|
1825
2810
|
minimum: 1,
|
|
1826
|
-
maximum:
|
|
2811
|
+
maximum: 1000,
|
|
1827
2812
|
},
|
|
1828
2813
|
offset: {
|
|
1829
2814
|
type: "integer",
|
|
@@ -1878,8 +2863,9 @@ const TOOLS = [
|
|
|
1878
2863
|
{
|
|
1879
2864
|
name: "knowledge_export",
|
|
1880
2865
|
description:
|
|
1881
|
-
"Export all knowledge articles as
|
|
1882
|
-
"
|
|
2866
|
+
"Export all knowledge articles as an OKF v0.1 bundle — gzipped tar archive, unbounded, bounded-memory streaming, " +
|
|
2867
|
+
"fail-closed (no partial bundles). Because binary cannot be returned as MCP content, this tool returns a curl command. " +
|
|
2868
|
+
"Optional: ?format=json for buffered in-memory JSON (capped at export_max_buffered_export_articles, 413 over cap).",
|
|
1883
2869
|
inputSchema: {
|
|
1884
2870
|
type: "object",
|
|
1885
2871
|
properties: {
|
|
@@ -1887,10 +2873,71 @@ const TOOLS = [
|
|
|
1887
2873
|
type: "string",
|
|
1888
2874
|
description: "Optional: scope export to a specific project UUID.",
|
|
1889
2875
|
},
|
|
2876
|
+
format: {
|
|
2877
|
+
type: "string",
|
|
2878
|
+
enum: ["tar.gz", "json"],
|
|
2879
|
+
description: "Optional: tar.gz (default, unbounded streaming) or json (buffered, capped).",
|
|
2880
|
+
},
|
|
1890
2881
|
},
|
|
1891
2882
|
required: [],
|
|
1892
2883
|
},
|
|
1893
2884
|
},
|
|
2885
|
+
{
|
|
2886
|
+
name: "knowledge_okf_export",
|
|
2887
|
+
description:
|
|
2888
|
+
"Export the knowledge wiki as a portable OKF (Open Knowledge Format) v0.1 bundle — a tree of " +
|
|
2889
|
+
"markdown files with YAML frontmatter. Requires LOOPCTL_USER_KEY. If out_dir is given, the bundle " +
|
|
2890
|
+
"is written there (one .md file per concept, plus index.md/log.md) and a summary is returned; " +
|
|
2891
|
+
"otherwise the bundle is returned inline as {files, meta}.",
|
|
2892
|
+
inputSchema: {
|
|
2893
|
+
type: "object",
|
|
2894
|
+
properties: {
|
|
2895
|
+
project_id: {
|
|
2896
|
+
type: "string",
|
|
2897
|
+
format: "uuid",
|
|
2898
|
+
description: "Optional: scope the export to a project (includes tenant-wide articles too).",
|
|
2899
|
+
},
|
|
2900
|
+
out_dir: {
|
|
2901
|
+
type: "string",
|
|
2902
|
+
description:
|
|
2903
|
+
"Optional: absolute path of a directory to write the bundle into. When omitted, the " +
|
|
2904
|
+
"bundle is returned inline.",
|
|
2905
|
+
},
|
|
2906
|
+
},
|
|
2907
|
+
required: [],
|
|
2908
|
+
},
|
|
2909
|
+
},
|
|
2910
|
+
{
|
|
2911
|
+
name: "knowledge_okf_import",
|
|
2912
|
+
description:
|
|
2913
|
+
"Import an OKF (Open Knowledge Format) v0.1 bundle from a local directory into the wiki. " +
|
|
2914
|
+
"Requires LOOPCTL_USER_KEY. Reserved files (index.md/log.md) are skipped; each concept is created, " +
|
|
2915
|
+
"or (with merge=true, the default) updated in place when it matches an existing article. Unknown " +
|
|
2916
|
+
"frontmatter types/keys are tolerated and preserved. Returns a per-file import report.",
|
|
2917
|
+
inputSchema: {
|
|
2918
|
+
type: "object",
|
|
2919
|
+
properties: {
|
|
2920
|
+
bundle_dir: {
|
|
2921
|
+
type: "string",
|
|
2922
|
+
description: "Absolute path of the OKF bundle directory to read .md files from.",
|
|
2923
|
+
},
|
|
2924
|
+
project_id: {
|
|
2925
|
+
type: "string",
|
|
2926
|
+
format: "uuid",
|
|
2927
|
+
description: "Optional: assign imported articles to a project.",
|
|
2928
|
+
},
|
|
2929
|
+
merge: {
|
|
2930
|
+
type: "boolean",
|
|
2931
|
+
description: "Update existing articles instead of skipping them (default true).",
|
|
2932
|
+
},
|
|
2933
|
+
dry_run: {
|
|
2934
|
+
type: "boolean",
|
|
2935
|
+
description: "Validate and plan only; write nothing (default false).",
|
|
2936
|
+
},
|
|
2937
|
+
},
|
|
2938
|
+
required: ["bundle_dir"],
|
|
2939
|
+
},
|
|
2940
|
+
},
|
|
1894
2941
|
|
|
1895
2942
|
// Knowledge Ingestion Tools
|
|
1896
2943
|
{
|
|
@@ -1898,7 +2945,9 @@ const TOOLS = [
|
|
|
1898
2945
|
description:
|
|
1899
2946
|
"Submit a URL or raw content for knowledge extraction. " +
|
|
1900
2947
|
"Enqueues an Oban job that fetches the content (if URL), extracts knowledge articles via LLM, " +
|
|
1901
|
-
"and inserts them
|
|
2948
|
+
"and inserts them. Extracted articles are DRAFTS by default (lower-trust LLM output, staged " +
|
|
2949
|
+
"for review) — unlike knowledge_create which publishes by default. Pass publish:true to " +
|
|
2950
|
+
"publish them on extraction. Requires orchestrator role.",
|
|
1902
2951
|
inputSchema: {
|
|
1903
2952
|
type: "object",
|
|
1904
2953
|
properties: {
|
|
@@ -1918,6 +2967,11 @@ const TOOLS = [
|
|
|
1918
2967
|
type: "string",
|
|
1919
2968
|
description: "Optional: scope extracted articles to a specific project UUID.",
|
|
1920
2969
|
},
|
|
2970
|
+
publish: {
|
|
2971
|
+
type: "boolean",
|
|
2972
|
+
description:
|
|
2973
|
+
"Optional: publish extracted articles immediately instead of staging them as drafts (default false).",
|
|
2974
|
+
},
|
|
1921
2975
|
},
|
|
1922
2976
|
required: ["source_type"],
|
|
1923
2977
|
},
|
|
@@ -1956,6 +3010,11 @@ const TOOLS = [
|
|
|
1956
3010
|
type: "string",
|
|
1957
3011
|
description: "Optional: scope the item to a specific project UUID.",
|
|
1958
3012
|
},
|
|
3013
|
+
publish: {
|
|
3014
|
+
type: "boolean",
|
|
3015
|
+
description:
|
|
3016
|
+
"Optional: publish this item's extracted articles immediately (default false → draft).",
|
|
3017
|
+
},
|
|
1959
3018
|
metadata: {
|
|
1960
3019
|
type: "object",
|
|
1961
3020
|
description: "Optional metadata map.",
|
|
@@ -1968,6 +3027,10 @@ const TOOLS = [
|
|
|
1968
3027
|
type: "string",
|
|
1969
3028
|
description: "Optional batch-level default project UUID applied to items that don't specify their own.",
|
|
1970
3029
|
},
|
|
3030
|
+
publish: {
|
|
3031
|
+
type: "boolean",
|
|
3032
|
+
description: "Optional batch-level default publish flag applied to items that don't specify their own.",
|
|
3033
|
+
},
|
|
1971
3034
|
},
|
|
1972
3035
|
required: ["items"],
|
|
1973
3036
|
},
|
|
@@ -2298,9 +3361,36 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
2298
3361
|
case "knowledge_index":
|
|
2299
3362
|
return await knowledgeIndex(args);
|
|
2300
3363
|
|
|
3364
|
+
case "knowledge_stats":
|
|
3365
|
+
return await knowledgeStats(args);
|
|
3366
|
+
|
|
3367
|
+
case "knowledge_count":
|
|
3368
|
+
return await knowledgeCount(args);
|
|
3369
|
+
|
|
3370
|
+
case "knowledge_facets":
|
|
3371
|
+
return await knowledgeFacets(args);
|
|
3372
|
+
|
|
3373
|
+
case "knowledge_graph":
|
|
3374
|
+
return await knowledgeGraph(args);
|
|
3375
|
+
|
|
3376
|
+
case "knowledge_suggest_links":
|
|
3377
|
+
return await knowledgeSuggestLinks(args);
|
|
3378
|
+
|
|
3379
|
+
case "knowledge_distant_pairs":
|
|
3380
|
+
return await knowledgeDistantPairs(args);
|
|
3381
|
+
|
|
3382
|
+
case "knowledge_novelty":
|
|
3383
|
+
return await knowledgeNovelty(args);
|
|
3384
|
+
|
|
3385
|
+
case "knowledge_random_walk":
|
|
3386
|
+
return await knowledgeRandomWalk(args);
|
|
3387
|
+
|
|
2301
3388
|
case "knowledge_search":
|
|
2302
3389
|
return await knowledgeSearch(args);
|
|
2303
3390
|
|
|
3391
|
+
case "knowledge_list":
|
|
3392
|
+
return await knowledgeList(args);
|
|
3393
|
+
|
|
2304
3394
|
case "knowledge_get":
|
|
2305
3395
|
return await knowledgeGet(args);
|
|
2306
3396
|
|
|
@@ -2317,6 +3407,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
2317
3407
|
case "knowledge_bulk_publish":
|
|
2318
3408
|
return await knowledgeBulkPublish(args);
|
|
2319
3409
|
|
|
3410
|
+
case "knowledge_bulk_unpublish":
|
|
3411
|
+
return await knowledgeBulkUnpublish(args);
|
|
3412
|
+
|
|
2320
3413
|
case "knowledge_unpublish":
|
|
2321
3414
|
return await knowledgeUnpublish(args);
|
|
2322
3415
|
|
|
@@ -2326,6 +3419,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
2326
3419
|
case "knowledge_delete":
|
|
2327
3420
|
return await knowledgeDelete(args);
|
|
2328
3421
|
|
|
3422
|
+
case "knowledge_bulk_delete":
|
|
3423
|
+
return await knowledgeBulkDelete(args);
|
|
3424
|
+
|
|
2329
3425
|
case "knowledge_drafts":
|
|
2330
3426
|
return await knowledgeDrafts(args);
|
|
2331
3427
|
|
|
@@ -2335,6 +3431,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
2335
3431
|
case "knowledge_export":
|
|
2336
3432
|
return await knowledgeExport(args);
|
|
2337
3433
|
|
|
3434
|
+
case "knowledge_okf_export":
|
|
3435
|
+
return await knowledgeOkfExport(args);
|
|
3436
|
+
|
|
3437
|
+
case "knowledge_okf_import":
|
|
3438
|
+
return await knowledgeOkfImport(args);
|
|
3439
|
+
|
|
2338
3440
|
// Knowledge Ingestion Tools
|
|
2339
3441
|
case "knowledge_ingest":
|
|
2340
3442
|
return await knowledgeIngest(args);
|