loopctl-mcp-server 2.1.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 +49 -12
- package/index.js +1262 -67
- 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;
|
|
633
853
|
|
|
634
|
-
|
|
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;
|
|
859
|
+
|
|
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,15 +880,141 @@ 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
|
+
|
|
923
|
+
return toContent(result);
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
async function knowledgeUnpublish({ article_id }) {
|
|
927
|
+
const result = await apiCall(
|
|
928
|
+
"POST",
|
|
929
|
+
`/api/v1/articles/${article_id}/unpublish`,
|
|
930
|
+
null,
|
|
931
|
+
process.env.LOOPCTL_USER_KEY
|
|
932
|
+
);
|
|
933
|
+
return toContent(result);
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
async function knowledgeArchive({ article_id }) {
|
|
937
|
+
const result = await apiCall(
|
|
938
|
+
"POST",
|
|
939
|
+
`/api/v1/articles/${article_id}/archive`,
|
|
940
|
+
null,
|
|
941
|
+
process.env.LOOPCTL_USER_KEY
|
|
942
|
+
);
|
|
943
|
+
return toContent(result);
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
async function knowledgeDelete({ article_id }) {
|
|
947
|
+
const result = await apiCall(
|
|
948
|
+
"DELETE",
|
|
949
|
+
`/api/v1/articles/${article_id}`,
|
|
950
|
+
null,
|
|
951
|
+
process.env.LOOPCTL_USER_KEY
|
|
952
|
+
);
|
|
953
|
+
return toContent(result);
|
|
954
|
+
}
|
|
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
|
+
|
|
652
1009
|
return toContent(result);
|
|
653
1010
|
}
|
|
654
1011
|
|
|
655
1012
|
async function knowledgeDrafts({ limit, offset, project_id }) {
|
|
656
1013
|
const params = new URLSearchParams();
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
);
|
|
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));
|
|
661
1018
|
if (offset != null) params.set("offset", String(offset));
|
|
662
1019
|
if (project_id) params.set("project_id", project_id);
|
|
663
1020
|
const path = `/api/v1/knowledge/drafts?${params.toString()}`;
|
|
@@ -679,24 +1036,28 @@ async function knowledgeLint({ project_id, stale_days, min_coverage, max_per_cat
|
|
|
679
1036
|
return toContent(result);
|
|
680
1037
|
}
|
|
681
1038
|
|
|
682
|
-
async function knowledgeIngest({ url, content, source_type, project_id }) {
|
|
1039
|
+
async function knowledgeIngest({ url, content, source_type, project_id, publish }) {
|
|
683
1040
|
const body = { source_type };
|
|
684
1041
|
if (url) body.url = url;
|
|
685
1042
|
if (content) body.content = content;
|
|
686
1043
|
if (project_id) body.project_id = project_id;
|
|
1044
|
+
if (publish) body.publish = true;
|
|
687
1045
|
const result = await apiCall("POST", "/api/v1/knowledge/ingest", body, process.env.LOOPCTL_ORCH_KEY);
|
|
688
1046
|
return toContent(result);
|
|
689
1047
|
}
|
|
690
1048
|
|
|
691
|
-
async function knowledgeIngestBatch({ items, project_id }) {
|
|
692
|
-
//
|
|
693
|
-
//
|
|
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.
|
|
694
1052
|
const resolvedItems = Array.isArray(items)
|
|
695
|
-
? items.map((item) =>
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
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
|
+
})
|
|
700
1061
|
: items;
|
|
701
1062
|
|
|
702
1063
|
const result = await apiCall(
|
|
@@ -822,12 +1183,18 @@ async function knowledgeExport({ project_id }) {
|
|
|
822
1183
|
? `/api/v1/projects/${project_id}/knowledge/export`
|
|
823
1184
|
: "/api/v1/knowledge/export";
|
|
824
1185
|
const baseUrl = getBaseUrl();
|
|
825
|
-
|
|
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`;
|
|
826
1190
|
return {
|
|
827
1191
|
content: [{
|
|
828
1192
|
type: "text",
|
|
829
1193
|
text: JSON.stringify({
|
|
830
|
-
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`.",
|
|
831
1198
|
command: downloadCmd,
|
|
832
1199
|
endpoint: `${baseUrl}${basePath}`,
|
|
833
1200
|
}, null, 2),
|
|
@@ -835,6 +1202,136 @@ async function knowledgeExport({ project_id }) {
|
|
|
835
1202
|
};
|
|
836
1203
|
}
|
|
837
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
|
+
|
|
838
1335
|
// --- Discovery Tools ---
|
|
839
1336
|
|
|
840
1337
|
async function listRoutes() {
|
|
@@ -1068,8 +1565,8 @@ const TOOLS = [
|
|
|
1068
1565
|
description:
|
|
1069
1566
|
"List stories for a project, optionally filtered by agent_status, verified_status, or epic_id. " +
|
|
1070
1567
|
"Returns compact results (no acceptance_criteria/description) — use get_story for full details. " +
|
|
1071
|
-
"
|
|
1072
|
-
"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.",
|
|
1073
1570
|
inputSchema: {
|
|
1074
1571
|
type: "object",
|
|
1075
1572
|
properties: {
|
|
@@ -1092,7 +1589,7 @@ const TOOLS = [
|
|
|
1092
1589
|
},
|
|
1093
1590
|
limit: {
|
|
1094
1591
|
type: "integer",
|
|
1095
|
-
description: "Maximum number of stories to return.",
|
|
1592
|
+
description: "Maximum number of stories to return (default 20, max 500).",
|
|
1096
1593
|
},
|
|
1097
1594
|
offset: {
|
|
1098
1595
|
type: "integer",
|
|
@@ -1111,7 +1608,7 @@ const TOOLS = [
|
|
|
1111
1608
|
description:
|
|
1112
1609
|
"List stories that are ready to be worked on (contracted, dependencies met). " +
|
|
1113
1610
|
"Returns compact results — use get_story for full details. " +
|
|
1114
|
-
"
|
|
1611
|
+
"Defaults to 20 per page; pass `limit` up to 500 to page larger. Response includes total_count.",
|
|
1115
1612
|
inputSchema: {
|
|
1116
1613
|
type: "object",
|
|
1117
1614
|
properties: {
|
|
@@ -1121,7 +1618,7 @@ const TOOLS = [
|
|
|
1121
1618
|
},
|
|
1122
1619
|
limit: {
|
|
1123
1620
|
type: "integer",
|
|
1124
|
-
description: "Maximum number of stories to return.",
|
|
1621
|
+
description: "Maximum number of stories to return (default 20, max 500).",
|
|
1125
1622
|
},
|
|
1126
1623
|
},
|
|
1127
1624
|
required: ["project_id"],
|
|
@@ -1530,7 +2027,13 @@ const TOOLS = [
|
|
|
1530
2027
|
{
|
|
1531
2028
|
name: "knowledge_index",
|
|
1532
2029
|
description:
|
|
1533
|
-
"
|
|
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. " +
|
|
1534
2037
|
"Pass story_id when working on a loopctl story so reads attribute correctly.",
|
|
1535
2038
|
inputSchema: {
|
|
1536
2039
|
type: "object",
|
|
@@ -1545,51 +2048,395 @@ const TOOLS = [
|
|
|
1545
2048
|
format: "uuid",
|
|
1546
2049
|
description: "Optional: loopctl story UUID for attribution tracking.",
|
|
1547
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
|
+
},
|
|
1548
2082
|
},
|
|
1549
2083
|
required: [],
|
|
1550
2084
|
},
|
|
1551
2085
|
},
|
|
1552
2086
|
{
|
|
1553
|
-
name: "
|
|
2087
|
+
name: "knowledge_list",
|
|
1554
2088
|
description:
|
|
1555
|
-
"
|
|
1556
|
-
"
|
|
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.",
|
|
1557
2102
|
inputSchema: {
|
|
1558
2103
|
type: "object",
|
|
1559
2104
|
properties: {
|
|
1560
|
-
q: {
|
|
1561
|
-
type: "string",
|
|
1562
|
-
description: "Search query string.",
|
|
1563
|
-
},
|
|
1564
2105
|
project_id: {
|
|
1565
2106
|
type: "string",
|
|
1566
2107
|
format: "uuid",
|
|
1567
|
-
description: "Optional: scope
|
|
2108
|
+
description: "Optional: scope to a project UUID.",
|
|
1568
2109
|
},
|
|
1569
|
-
|
|
2110
|
+
category: {
|
|
1570
2111
|
type: "string",
|
|
1571
|
-
|
|
1572
|
-
description: "Optional: loopctl story UUID for attribution tracking.",
|
|
2112
|
+
description: "Optional: filter by category.",
|
|
1573
2113
|
},
|
|
1574
|
-
|
|
2114
|
+
status: {
|
|
1575
2115
|
type: "string",
|
|
1576
|
-
description: "Optional: filter
|
|
2116
|
+
description: "Optional: filter by status (draft, published, archived, superseded).",
|
|
1577
2117
|
},
|
|
1578
2118
|
tags: {
|
|
1579
2119
|
type: "string",
|
|
1580
|
-
description: "Optional: comma-separated tags
|
|
2120
|
+
description: "Optional: comma-separated tags; matches articles carrying ANY of them.",
|
|
1581
2121
|
},
|
|
1582
|
-
|
|
2122
|
+
match: {
|
|
1583
2123
|
type: "string",
|
|
1584
|
-
enum: ["
|
|
1585
|
-
description: "Optional:
|
|
2124
|
+
enum: ["any", "all"],
|
|
2125
|
+
description: "Optional: tag match mode — 'any' (default, OR) or 'all' (AND, every tag).",
|
|
1586
2126
|
},
|
|
1587
|
-
|
|
1588
|
-
type: "
|
|
1589
|
-
description: "Optional:
|
|
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)." },
|
|
2243
|
+
},
|
|
2244
|
+
required: [],
|
|
2245
|
+
},
|
|
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).",
|
|
1590
2300
|
},
|
|
1591
2301
|
},
|
|
1592
|
-
required: ["
|
|
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
|
+
},
|
|
2376
|
+
{
|
|
2377
|
+
name: "knowledge_search",
|
|
2378
|
+
description:
|
|
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. " +
|
|
2393
|
+
"Pass story_id when working on a loopctl story so reads attribute correctly.",
|
|
2394
|
+
inputSchema: {
|
|
2395
|
+
type: "object",
|
|
2396
|
+
properties: {
|
|
2397
|
+
q: {
|
|
2398
|
+
type: "string",
|
|
2399
|
+
description:
|
|
2400
|
+
"Search query string. Optional when tags/category are supplied (enumeration mode).",
|
|
2401
|
+
},
|
|
2402
|
+
project_id: {
|
|
2403
|
+
type: "string",
|
|
2404
|
+
format: "uuid",
|
|
2405
|
+
description: "Optional: scope search to a specific project UUID.",
|
|
2406
|
+
},
|
|
2407
|
+
story_id: {
|
|
2408
|
+
type: "string",
|
|
2409
|
+
format: "uuid",
|
|
2410
|
+
description: "Optional: loopctl story UUID for attribution tracking.",
|
|
2411
|
+
},
|
|
2412
|
+
category: {
|
|
2413
|
+
type: "string",
|
|
2414
|
+
description: "Optional: filter results by category.",
|
|
2415
|
+
},
|
|
2416
|
+
tags: {
|
|
2417
|
+
type: "string",
|
|
2418
|
+
description: "Optional: comma-separated tags to filter by.",
|
|
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
|
+
},
|
|
2425
|
+
mode: {
|
|
2426
|
+
type: "string",
|
|
2427
|
+
enum: ["keyword", "semantic", "combined"],
|
|
2428
|
+
description: "Optional: search mode (keyword, semantic, or combined).",
|
|
2429
|
+
},
|
|
2430
|
+
limit: {
|
|
2431
|
+
type: "integer",
|
|
2432
|
+
description: "Optional: maximum number of results to return.",
|
|
2433
|
+
},
|
|
2434
|
+
offset: {
|
|
2435
|
+
type: "integer",
|
|
2436
|
+
description: "Optional: results to skip for pagination (default 0).",
|
|
2437
|
+
},
|
|
2438
|
+
},
|
|
2439
|
+
required: [],
|
|
1593
2440
|
},
|
|
1594
2441
|
},
|
|
1595
2442
|
{
|
|
@@ -1623,7 +2470,11 @@ const TOOLS = [
|
|
|
1623
2470
|
name: "knowledge_context",
|
|
1624
2471
|
description:
|
|
1625
2472
|
"Get ranked full articles for a task query. Returns best knowledge with linked references. " +
|
|
1626
|
-
"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.",
|
|
1627
2478
|
inputSchema: {
|
|
1628
2479
|
type: "object",
|
|
1629
2480
|
properties: {
|
|
@@ -1651,6 +2502,20 @@ const TOOLS = [
|
|
|
1651
2502
|
minimum: 0,
|
|
1652
2503
|
maximum: 1,
|
|
1653
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
|
+
},
|
|
1654
2519
|
},
|
|
1655
2520
|
required: ["query"],
|
|
1656
2521
|
},
|
|
@@ -1659,7 +2524,15 @@ const TOOLS = [
|
|
|
1659
2524
|
name: "knowledge_create",
|
|
1660
2525
|
description:
|
|
1661
2526
|
"Create a new knowledge article. Use to file findings, document patterns, or record decisions " +
|
|
1662
|
-
"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.",
|
|
1663
2536
|
inputSchema: {
|
|
1664
2537
|
type: "object",
|
|
1665
2538
|
properties: {
|
|
@@ -1684,6 +2557,47 @@ const TOOLS = [
|
|
|
1684
2557
|
type: "string",
|
|
1685
2558
|
description: "Optional: associate the article with a project UUID.",
|
|
1686
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
|
+
},
|
|
1687
2601
|
},
|
|
1688
2602
|
required: ["title", "body"],
|
|
1689
2603
|
},
|
|
@@ -1708,38 +2622,193 @@ const TOOLS = [
|
|
|
1708
2622
|
{
|
|
1709
2623
|
name: "knowledge_bulk_publish",
|
|
1710
2624
|
description:
|
|
1711
|
-
"
|
|
1712
|
-
"
|
|
1713
|
-
"
|
|
1714
|
-
"
|
|
1715
|
-
"
|
|
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.",
|
|
1716
2634
|
inputSchema: {
|
|
1717
2635
|
type: "object",
|
|
1718
2636
|
properties: {
|
|
1719
2637
|
article_ids: {
|
|
1720
2638
|
type: "array",
|
|
1721
2639
|
items: { type: "string" },
|
|
1722
|
-
description:
|
|
1723
|
-
|
|
2640
|
+
description:
|
|
2641
|
+
"Article UUIDs to publish. Any length (auto-chunked); duplicates ignored.",
|
|
1724
2642
|
},
|
|
1725
2643
|
},
|
|
1726
2644
|
required: ["article_ids"],
|
|
1727
2645
|
},
|
|
1728
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.",
|
|
2667
|
+
},
|
|
2668
|
+
},
|
|
2669
|
+
required: ["article_ids"],
|
|
2670
|
+
},
|
|
2671
|
+
},
|
|
2672
|
+
{
|
|
2673
|
+
name: "knowledge_unpublish",
|
|
2674
|
+
description:
|
|
2675
|
+
"Revert a published article back to draft state. The article stops being visible " +
|
|
2676
|
+
"in agent search/context but is not deleted — re-publish with knowledge_publish. " +
|
|
2677
|
+
"REQUIRES LOOPCTL_USER_KEY (user role — orchestrator role is NOT sufficient for " +
|
|
2678
|
+
"this destructive operation).",
|
|
2679
|
+
inputSchema: {
|
|
2680
|
+
type: "object",
|
|
2681
|
+
properties: {
|
|
2682
|
+
article_id: {
|
|
2683
|
+
type: "string",
|
|
2684
|
+
description: "The UUID of the published article to unpublish.",
|
|
2685
|
+
},
|
|
2686
|
+
},
|
|
2687
|
+
required: ["article_id"],
|
|
2688
|
+
},
|
|
2689
|
+
},
|
|
2690
|
+
{
|
|
2691
|
+
name: "knowledge_archive",
|
|
2692
|
+
description:
|
|
2693
|
+
"Archive an article (soft delete). The article is hidden from search, context, " +
|
|
2694
|
+
"and the index but the row is retained for audit/history. Works for drafts and " +
|
|
2695
|
+
"published articles. REQUIRES LOOPCTL_USER_KEY (user role — orchestrator role is " +
|
|
2696
|
+
"NOT sufficient for this destructive operation).",
|
|
2697
|
+
inputSchema: {
|
|
2698
|
+
type: "object",
|
|
2699
|
+
properties: {
|
|
2700
|
+
article_id: {
|
|
2701
|
+
type: "string",
|
|
2702
|
+
description: "The UUID of the article to archive.",
|
|
2703
|
+
},
|
|
2704
|
+
},
|
|
2705
|
+
required: ["article_id"],
|
|
2706
|
+
},
|
|
2707
|
+
},
|
|
2708
|
+
{
|
|
2709
|
+
name: "knowledge_delete",
|
|
2710
|
+
description:
|
|
2711
|
+
"Delete an article. Under the hood this performs the same soft-delete (archive) " +
|
|
2712
|
+
"as knowledge_archive — use whichever name is clearer at the call site. The row " +
|
|
2713
|
+
"is retained for audit; there is no hard delete. REQUIRES LOOPCTL_USER_KEY (user " +
|
|
2714
|
+
"role — orchestrator role is NOT sufficient for this destructive operation).",
|
|
2715
|
+
inputSchema: {
|
|
2716
|
+
type: "object",
|
|
2717
|
+
properties: {
|
|
2718
|
+
article_id: {
|
|
2719
|
+
type: "string",
|
|
2720
|
+
description: "The UUID of the article to delete.",
|
|
2721
|
+
},
|
|
2722
|
+
},
|
|
2723
|
+
required: ["article_id"],
|
|
2724
|
+
},
|
|
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
|
+
},
|
|
1729
2794
|
{
|
|
1730
2795
|
name: "knowledge_drafts",
|
|
1731
2796
|
description:
|
|
1732
2797
|
"List draft (unpublished) knowledge articles. Requires orchestrator role. " +
|
|
1733
|
-
"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).",
|
|
1734
2801
|
inputSchema: {
|
|
1735
2802
|
type: "object",
|
|
1736
2803
|
properties: {
|
|
1737
2804
|
limit: {
|
|
1738
2805
|
type: "integer",
|
|
1739
|
-
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.",
|
|
1740
2809
|
default: 20,
|
|
1741
2810
|
minimum: 1,
|
|
1742
|
-
maximum:
|
|
2811
|
+
maximum: 1000,
|
|
1743
2812
|
},
|
|
1744
2813
|
offset: {
|
|
1745
2814
|
type: "integer",
|
|
@@ -1794,8 +2863,9 @@ const TOOLS = [
|
|
|
1794
2863
|
{
|
|
1795
2864
|
name: "knowledge_export",
|
|
1796
2865
|
description:
|
|
1797
|
-
"Export all knowledge articles as
|
|
1798
|
-
"
|
|
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).",
|
|
1799
2869
|
inputSchema: {
|
|
1800
2870
|
type: "object",
|
|
1801
2871
|
properties: {
|
|
@@ -1803,10 +2873,71 @@ const TOOLS = [
|
|
|
1803
2873
|
type: "string",
|
|
1804
2874
|
description: "Optional: scope export to a specific project UUID.",
|
|
1805
2875
|
},
|
|
2876
|
+
format: {
|
|
2877
|
+
type: "string",
|
|
2878
|
+
enum: ["tar.gz", "json"],
|
|
2879
|
+
description: "Optional: tar.gz (default, unbounded streaming) or json (buffered, capped).",
|
|
2880
|
+
},
|
|
2881
|
+
},
|
|
2882
|
+
required: [],
|
|
2883
|
+
},
|
|
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
|
+
},
|
|
1806
2906
|
},
|
|
1807
2907
|
required: [],
|
|
1808
2908
|
},
|
|
1809
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
|
+
},
|
|
1810
2941
|
|
|
1811
2942
|
// Knowledge Ingestion Tools
|
|
1812
2943
|
{
|
|
@@ -1814,7 +2945,9 @@ const TOOLS = [
|
|
|
1814
2945
|
description:
|
|
1815
2946
|
"Submit a URL or raw content for knowledge extraction. " +
|
|
1816
2947
|
"Enqueues an Oban job that fetches the content (if URL), extracts knowledge articles via LLM, " +
|
|
1817
|
-
"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.",
|
|
1818
2951
|
inputSchema: {
|
|
1819
2952
|
type: "object",
|
|
1820
2953
|
properties: {
|
|
@@ -1834,6 +2967,11 @@ const TOOLS = [
|
|
|
1834
2967
|
type: "string",
|
|
1835
2968
|
description: "Optional: scope extracted articles to a specific project UUID.",
|
|
1836
2969
|
},
|
|
2970
|
+
publish: {
|
|
2971
|
+
type: "boolean",
|
|
2972
|
+
description:
|
|
2973
|
+
"Optional: publish extracted articles immediately instead of staging them as drafts (default false).",
|
|
2974
|
+
},
|
|
1837
2975
|
},
|
|
1838
2976
|
required: ["source_type"],
|
|
1839
2977
|
},
|
|
@@ -1872,6 +3010,11 @@ const TOOLS = [
|
|
|
1872
3010
|
type: "string",
|
|
1873
3011
|
description: "Optional: scope the item to a specific project UUID.",
|
|
1874
3012
|
},
|
|
3013
|
+
publish: {
|
|
3014
|
+
type: "boolean",
|
|
3015
|
+
description:
|
|
3016
|
+
"Optional: publish this item's extracted articles immediately (default false → draft).",
|
|
3017
|
+
},
|
|
1875
3018
|
metadata: {
|
|
1876
3019
|
type: "object",
|
|
1877
3020
|
description: "Optional metadata map.",
|
|
@@ -1884,6 +3027,10 @@ const TOOLS = [
|
|
|
1884
3027
|
type: "string",
|
|
1885
3028
|
description: "Optional batch-level default project UUID applied to items that don't specify their own.",
|
|
1886
3029
|
},
|
|
3030
|
+
publish: {
|
|
3031
|
+
type: "boolean",
|
|
3032
|
+
description: "Optional batch-level default publish flag applied to items that don't specify their own.",
|
|
3033
|
+
},
|
|
1887
3034
|
},
|
|
1888
3035
|
required: ["items"],
|
|
1889
3036
|
},
|
|
@@ -2214,9 +3361,36 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
2214
3361
|
case "knowledge_index":
|
|
2215
3362
|
return await knowledgeIndex(args);
|
|
2216
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
|
+
|
|
2217
3388
|
case "knowledge_search":
|
|
2218
3389
|
return await knowledgeSearch(args);
|
|
2219
3390
|
|
|
3391
|
+
case "knowledge_list":
|
|
3392
|
+
return await knowledgeList(args);
|
|
3393
|
+
|
|
2220
3394
|
case "knowledge_get":
|
|
2221
3395
|
return await knowledgeGet(args);
|
|
2222
3396
|
|
|
@@ -2233,6 +3407,21 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
2233
3407
|
case "knowledge_bulk_publish":
|
|
2234
3408
|
return await knowledgeBulkPublish(args);
|
|
2235
3409
|
|
|
3410
|
+
case "knowledge_bulk_unpublish":
|
|
3411
|
+
return await knowledgeBulkUnpublish(args);
|
|
3412
|
+
|
|
3413
|
+
case "knowledge_unpublish":
|
|
3414
|
+
return await knowledgeUnpublish(args);
|
|
3415
|
+
|
|
3416
|
+
case "knowledge_archive":
|
|
3417
|
+
return await knowledgeArchive(args);
|
|
3418
|
+
|
|
3419
|
+
case "knowledge_delete":
|
|
3420
|
+
return await knowledgeDelete(args);
|
|
3421
|
+
|
|
3422
|
+
case "knowledge_bulk_delete":
|
|
3423
|
+
return await knowledgeBulkDelete(args);
|
|
3424
|
+
|
|
2236
3425
|
case "knowledge_drafts":
|
|
2237
3426
|
return await knowledgeDrafts(args);
|
|
2238
3427
|
|
|
@@ -2242,6 +3431,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
2242
3431
|
case "knowledge_export":
|
|
2243
3432
|
return await knowledgeExport(args);
|
|
2244
3433
|
|
|
3434
|
+
case "knowledge_okf_export":
|
|
3435
|
+
return await knowledgeOkfExport(args);
|
|
3436
|
+
|
|
3437
|
+
case "knowledge_okf_import":
|
|
3438
|
+
return await knowledgeOkfImport(args);
|
|
3439
|
+
|
|
2245
3440
|
// Knowledge Ingestion Tools
|
|
2246
3441
|
case "knowledge_ingest":
|
|
2247
3442
|
return await knowledgeIngest(args);
|