pm-context 2026.7.25 → 2026.7.27
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/CHANGELOG.md +20 -0
- package/README.md +57 -6
- package/dist/context-usage.d.ts +166 -0
- package/dist/context-usage.d.ts.map +1 -0
- package/dist/context-usage.js +331 -0
- package/dist/context-usage.js.map +1 -0
- package/dist/index.d.ts +125 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +435 -23
- package/dist/index.js.map +1 -1
- package/manifest.json +1 -1
- package/package.json +6 -5
package/dist/index.js
CHANGED
|
@@ -1,6 +1,19 @@
|
|
|
1
|
-
import { spawnSync } from "node:child_process";
|
|
2
1
|
import { writeFileSync } from "node:fs";
|
|
3
|
-
|
|
2
|
+
import { readSettings, resolveRuntimeStatusRegistry } from "@unbrained/pm-cli/sdk";
|
|
3
|
+
import { list as pmList } from "@unbrained/pm-cli/sdk/core";
|
|
4
|
+
import { buildItemContextRelevanceCandidates, defaultScoreContextCandidates, packContextCandidates, recordContextUsageServing, } from "@unbrained/pm-cli/sdk/query";
|
|
5
|
+
import { DEFAULT_REPORT_LIMIT, renderUsageReport, reportContextUsage, resolveSince } from "./context-usage.js";
|
|
6
|
+
import { readContextUsageAffinity } from "@unbrained/pm-cli/sdk/query";
|
|
7
|
+
/**
|
|
8
|
+
* Runtime stand-in for the SDK's `defineExtension`.
|
|
9
|
+
*
|
|
10
|
+
* `defineExtension` is a documented zero-cost identity function. This package
|
|
11
|
+
* now resolves `@unbrained/pm-cli` at runtime (it is a peer dependency the pm
|
|
12
|
+
* host provides) for the `sdk/core` and `sdk/query` engines, but the authoring
|
|
13
|
+
* helper itself has no runtime behavior, so a local identity shim avoids a
|
|
14
|
+
* needless value import while still contract-checking the module against
|
|
15
|
+
* {@link ExtensionModule}.
|
|
16
|
+
*/
|
|
4
17
|
export const EXIT_CODE = {
|
|
5
18
|
GENERIC_FAILURE: 1,
|
|
6
19
|
USAGE: 2,
|
|
@@ -262,7 +275,8 @@ export function buildContextPack(allItems, options = {}) {
|
|
|
262
275
|
const selected = ids.length > 0
|
|
263
276
|
? ids.map((id) => byId.get(id)).filter((item) => Boolean(item))
|
|
264
277
|
: allItems.filter((item) => matchesFilters(item, options));
|
|
265
|
-
const
|
|
278
|
+
const ranker = options.ranker ?? sortContextItems;
|
|
279
|
+
const limited = ranker(selected).slice(0, options.limit ?? 25);
|
|
266
280
|
const selectedIds = new Set(limited.map((item) => item.id));
|
|
267
281
|
// Resolve the neighborhood depth. --without-neighborhood (neighborhood === false)
|
|
268
282
|
// forces depth 0. Otherwise default to 1 hop, capped at MAX_NEIGHBORHOOD_DEPTH.
|
|
@@ -299,12 +313,18 @@ export function buildContextPack(allItems, options = {}) {
|
|
|
299
313
|
neighborIds.delete(id);
|
|
300
314
|
let neighbors = sortContextItems([...neighborIds].map((id) => byId.get(id)).filter((item) => Boolean(item)))
|
|
301
315
|
.sort((a, b) => (neighborDepths.get(a.id) ?? Number.MAX_SAFE_INTEGER) - (neighborDepths.get(b.id) ?? Number.MAX_SAFE_INTEGER));
|
|
302
|
-
// --max-items caps the total item count (focus + neighbors).
|
|
303
|
-
// priority
|
|
304
|
-
//
|
|
316
|
+
// --max-items caps the total item count (focus + neighbors). The default
|
|
317
|
+
// packer gives focus items priority and trims neighbors to fit the remaining
|
|
318
|
+
// budget; the command path supplies a `packContextCandidates`-backed packer
|
|
319
|
+
// that selects under a token budget with projection degradation instead.
|
|
305
320
|
let focusItems = limited;
|
|
306
321
|
if (typeof options.maxItems === "number" && options.maxItems > 0) {
|
|
307
|
-
if (
|
|
322
|
+
if (options.packer) {
|
|
323
|
+
const packed = options.packer(focusItems, neighbors, options.maxItems);
|
|
324
|
+
focusItems = packed.focus;
|
|
325
|
+
neighbors = packed.neighbors;
|
|
326
|
+
}
|
|
327
|
+
else if (focusItems.length >= options.maxItems) {
|
|
308
328
|
focusItems = focusItems.slice(0, options.maxItems);
|
|
309
329
|
neighbors = [];
|
|
310
330
|
}
|
|
@@ -633,25 +653,300 @@ export function renderAgentHandoff(pack, options = {}) {
|
|
|
633
653
|
}
|
|
634
654
|
return output;
|
|
635
655
|
}
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
656
|
+
/**
|
|
657
|
+
* Read every pm item (full metadata + body) in-process through the SDK.
|
|
658
|
+
*
|
|
659
|
+
* Replaces the previous `spawnSync("pm", ["list-all", "--json", "--include-body"])`
|
|
660
|
+
* shell-out with the typed {@link list} action from `@unbrained/pm-cli/sdk/core`.
|
|
661
|
+
* `list-all` is the SDK alias for `list` with `excludeTerminal: false`; passing
|
|
662
|
+
* `full: true` + `includeBody: true` reproduces the full-metadata-with-body
|
|
663
|
+
* projection the shell-out parsed, including the CLI's default truncation
|
|
664
|
+
* semantics, so the downstream pack shape remains compatible.
|
|
665
|
+
*/
|
|
666
|
+
export async function readPmItems(pmRoot) {
|
|
667
|
+
let result;
|
|
668
|
+
try {
|
|
669
|
+
result = await pmList({ full: true, includeBody: true, excludeTerminal: false }, { pmRoot });
|
|
670
|
+
}
|
|
671
|
+
catch (err) {
|
|
672
|
+
throw new CommandError(`Could not read pm items via SDK list: ${err instanceof Error ? err.message : String(err)}`);
|
|
673
|
+
}
|
|
674
|
+
return result.items.filter((item) => typeof item.id === "string");
|
|
675
|
+
}
|
|
676
|
+
/** Per-item token estimate for one character of text (rough 4 chars/token). */
|
|
677
|
+
const TOKENS_PER_CHAR = 0.25;
|
|
678
|
+
/** Default token budget allocated per `--max-items` slot when token-budgeted packing runs. */
|
|
679
|
+
const TOKENS_PER_ITEM_SLOT = 220;
|
|
680
|
+
/**
|
|
681
|
+
* Coerce a {@link PmItem} into the {@link ItemMetadata} shape the SDK relevance
|
|
682
|
+
* engine requires, filling any absent required scalar with a neutral default.
|
|
683
|
+
* Items returned by {@link readPmItems} already carry every required field, so
|
|
684
|
+
* this is idempotent for real packs; it only papers over the optional fields of
|
|
685
|
+
* the loose `PmItem` projection so hand-built test fixtures still rank.
|
|
686
|
+
*/
|
|
687
|
+
function toItemMetadata(item, now) {
|
|
688
|
+
const priority = typeof item.priority === "number" && item.priority >= 0 && item.priority <= 4 ? item.priority : 3;
|
|
689
|
+
// `readPmItems` returns the SDK list engine's full projection, which already
|
|
690
|
+
// satisfies `ItemMetadata`; it flows through the loose `PmItem` interface only
|
|
691
|
+
// so `buildContextPack` can keep accepting hand-built fixtures. This cast
|
|
692
|
+
// restores the precise SDK type for the relevance engine — the required
|
|
693
|
+
// scalars are filled above, and the optional collection fields (dependencies,
|
|
694
|
+
// docs, files, blocked_by) come straight from the real item. PmItem types
|
|
695
|
+
// those as `unknown` (wider than ItemMetadata), so the assertion bridges that
|
|
696
|
+
// widening rather than hiding a real mismatch.
|
|
697
|
+
return {
|
|
698
|
+
...item,
|
|
699
|
+
id: item.id,
|
|
700
|
+
title: typeof item.title === "string" ? item.title : "",
|
|
701
|
+
description: typeof item.description === "string" ? item.description : "",
|
|
702
|
+
type: typeof item.type === "string" ? item.type : "Task",
|
|
703
|
+
status: typeof item.status === "string" ? item.status : "unknown",
|
|
704
|
+
priority: priority,
|
|
705
|
+
tags: Array.isArray(item.tags) ? item.tags.map((tag) => String(tag)) : [],
|
|
706
|
+
created_at: normalizeText(item.created_at) || normalizeText(item.updated_at) || now,
|
|
707
|
+
updated_at: normalizeText(item.updated_at) || normalizeText(item.created_at) || now,
|
|
708
|
+
};
|
|
709
|
+
}
|
|
710
|
+
/**
|
|
711
|
+
* Rank items with pm's deterministic weighted relevance model.
|
|
712
|
+
*
|
|
713
|
+
* Wraps {@link buildItemContextRelevanceCandidates} +
|
|
714
|
+
* {@link defaultScoreContextCandidates} so the command path uses the same
|
|
715
|
+
* relevance signals (`priority_pressure`, `recency`, `claim_focus`, …) as
|
|
716
|
+
* `pm context` / `pm next` instead of the hand-rolled priority-then-recency
|
|
717
|
+
* sort. Returns the items in ranked order and exposes the full report via
|
|
718
|
+
* {@link scoreContextItems} for `--explain`.
|
|
719
|
+
*/
|
|
720
|
+
export function rankContextItems(items, options) {
|
|
721
|
+
const report = scoreContextItems(items, options);
|
|
722
|
+
const byId = new Map(items.map((item) => [item.id, item]));
|
|
723
|
+
return report.ranked
|
|
724
|
+
.map((ranked) => byId.get(ranked.id))
|
|
725
|
+
.filter((item) => Boolean(item));
|
|
726
|
+
}
|
|
727
|
+
/**
|
|
728
|
+
* Score items with the SDK relevance model and return the full report.
|
|
729
|
+
*
|
|
730
|
+
* The report's `ranked[].contributions` map each item's score back to the
|
|
731
|
+
* individual signals that produced it — the data behind `pm context-pack
|
|
732
|
+
* --explain`.
|
|
733
|
+
*/
|
|
734
|
+
export function scoreContextItems(items, options) {
|
|
735
|
+
const now = options.now;
|
|
736
|
+
const byId = new Map(items.map((item) => [item.id, item]));
|
|
737
|
+
const candidates = buildItemContextRelevanceCandidates(items.map((item) => toItemMetadata(item, now)), {
|
|
738
|
+
statusRegistry: options.statusRegistry,
|
|
739
|
+
now,
|
|
740
|
+
author: options.author,
|
|
741
|
+
usageAffinity: options.usageAffinity,
|
|
640
742
|
});
|
|
641
|
-
|
|
642
|
-
|
|
743
|
+
// `defaultScoreContextCandidates` is the deterministic, extension-free core of
|
|
744
|
+
// `scoreContextCandidates`. The command path runs in-process and does not need
|
|
745
|
+
// the governed `context_relevance` service override, so the synchronous default
|
|
746
|
+
// keeps pack generation deterministic and side-effect free.
|
|
747
|
+
return defaultScoreContextCandidates(candidates.map((candidate) => ({ id: candidate.id, item: byIdOrFail(byId, candidate.id), signals: candidate.signals })));
|
|
748
|
+
}
|
|
749
|
+
/** Look up an item by id or throw a descriptive error (keeps callers honest). */
|
|
750
|
+
function byIdOrFail(byId, id) {
|
|
751
|
+
const item = byId.get(id);
|
|
752
|
+
if (!item)
|
|
753
|
+
throw new CommandError(`relevance candidate ${id} not found among items`);
|
|
754
|
+
return item;
|
|
755
|
+
}
|
|
756
|
+
/**
|
|
757
|
+
* Build a {@link ContextPackOptions.ranker} closure backed by the SDK relevance
|
|
758
|
+
* model. The closure ranks whatever focus subset {@link buildContextPack} hands
|
|
759
|
+
* it after id/status/type/tag filtering, preserving the SDK's weighted order.
|
|
760
|
+
*/
|
|
761
|
+
export function createSdkRanker(allItems, options) {
|
|
762
|
+
return (items) => {
|
|
763
|
+
if (items.length <= 1)
|
|
764
|
+
return items;
|
|
765
|
+
return rankContextItems(items, options);
|
|
766
|
+
};
|
|
767
|
+
}
|
|
768
|
+
/** Rough token estimate for one projection level of a context candidate. */
|
|
769
|
+
function estimateTokens(text) {
|
|
770
|
+
return Math.max(1, Math.ceil(text.length * TOKENS_PER_CHAR));
|
|
771
|
+
}
|
|
772
|
+
/**
|
|
773
|
+
* Estimate the identity / summary / full token costs for one item, matching the
|
|
774
|
+
* monotone projection ladder {@link packContextCandidates} upgrades through.
|
|
775
|
+
*/
|
|
776
|
+
function estimateProjectionCosts(item) {
|
|
777
|
+
const identity = estimateTokens(`${item.id} ${normalizeText(item.title)}`);
|
|
778
|
+
const meta = `${itemType(item)} ${itemStatus(item)} ${typeof item.priority === "number" ? item.priority : ""} ${(normalizeTags(item.tags)).join(" ")}`;
|
|
779
|
+
const summary = identity + estimateTokens(meta);
|
|
780
|
+
const body = normalizeText(item.body) || normalizeText(item.description);
|
|
781
|
+
const full = summary + (body ? estimateTokens(body) : 0);
|
|
782
|
+
return { identity, summary, full };
|
|
783
|
+
}
|
|
784
|
+
/**
|
|
785
|
+
* Build a {@link ContextPackOptions.packer} closure backed by
|
|
786
|
+
* {@link packContextCandidates}. Each `--max-items` slot maps to a token budget;
|
|
787
|
+
* focus items are required anchors, neighbors compete by relevance rank for the
|
|
788
|
+
* remaining budget. The packer first selects under that token budget with pm's
|
|
789
|
+
* projection-degradation optimizer, then enforces the command's explicit item
|
|
790
|
+
* ceiling while preserving the SDK-selected focus-first order.
|
|
791
|
+
*/
|
|
792
|
+
export function createSdkPacker(rankedFocus, rankedNeighbors) {
|
|
793
|
+
const focusRank = new Map(rankedFocus.map((item, index) => [item.id, index + 1]));
|
|
794
|
+
const neighborRank = new Map(rankedNeighbors.map((item, index) => [item.id, rankedFocus.length + index + 1]));
|
|
795
|
+
return (focus, neighbors, maxItems) => {
|
|
796
|
+
if (!(maxItems > 0))
|
|
797
|
+
return { focus, neighbors };
|
|
798
|
+
const tokenBudget = maxItems * TOKENS_PER_ITEM_SLOT;
|
|
799
|
+
const candidates = [
|
|
800
|
+
...focus.map((item) => ({
|
|
801
|
+
id: item.id,
|
|
802
|
+
item,
|
|
803
|
+
rank: focusRank.get(item.id) ?? 1,
|
|
804
|
+
score: 1 / (focusRank.get(item.id) ?? 1),
|
|
805
|
+
token_costs: estimateProjectionCosts(item),
|
|
806
|
+
required: true,
|
|
807
|
+
})),
|
|
808
|
+
...neighbors.map((item) => ({
|
|
809
|
+
id: item.id,
|
|
810
|
+
item,
|
|
811
|
+
rank: neighborRank.get(item.id) ?? focus.length + 1,
|
|
812
|
+
score: 1 / (neighborRank.get(item.id) ?? focus.length + 1),
|
|
813
|
+
token_costs: estimateProjectionCosts(item),
|
|
814
|
+
})),
|
|
815
|
+
];
|
|
816
|
+
const packed = packContextCandidates(candidates, { tokenBudget, profile: "context" });
|
|
817
|
+
const included = new Set(packed.included.map((candidate) => candidate.id));
|
|
818
|
+
const packedFocus = focus.filter((item) => included.has(item.id)).slice(0, maxItems);
|
|
819
|
+
const remainingSlots = Math.max(0, maxItems - packedFocus.length);
|
|
820
|
+
return {
|
|
821
|
+
focus: packedFocus,
|
|
822
|
+
neighbors: neighbors.filter((item) => included.has(item.id)).slice(0, remainingSlots),
|
|
823
|
+
};
|
|
824
|
+
};
|
|
825
|
+
}
|
|
826
|
+
/**
|
|
827
|
+
* Build the `--explain` report from the SDK relevance model for the emitted
|
|
828
|
+
* pack. Ranks and normalized scores are relative to this supplied population,
|
|
829
|
+
* not the complete workspace corpus.
|
|
830
|
+
*/
|
|
831
|
+
export function buildContextExplain(items, options) {
|
|
832
|
+
const report = scoreContextItems(items, options);
|
|
833
|
+
return {
|
|
834
|
+
generatedAt: options.now,
|
|
835
|
+
model: report.model,
|
|
836
|
+
ranking_scope: "emitted_pack",
|
|
837
|
+
available_signals: report.available_signals,
|
|
838
|
+
entries: report.ranked.map((ranked) => ({
|
|
839
|
+
id: ranked.id,
|
|
840
|
+
rank: ranked.rank,
|
|
841
|
+
score: ranked.score,
|
|
842
|
+
contributions: ranked.contributions,
|
|
843
|
+
})),
|
|
844
|
+
};
|
|
845
|
+
}
|
|
846
|
+
/**
|
|
847
|
+
* Render a {@link ContextExplainReport} as an agent-readable Markdown brief.
|
|
848
|
+
*
|
|
849
|
+
* Each focus item is listed with its relevance rank, normalized score, and the
|
|
850
|
+
* per-signal contributions that produced it (sorted most-contributing first), so
|
|
851
|
+
* an agent can judge whether the pack is trustworthy before acting on it.
|
|
852
|
+
*/
|
|
853
|
+
export function renderContextExplain(report, options = {}) {
|
|
854
|
+
const lines = [
|
|
855
|
+
"# pm context explain",
|
|
856
|
+
"",
|
|
857
|
+
`Generated: ${report.generatedAt}`,
|
|
858
|
+
`Model: ${report.model}`,
|
|
859
|
+
"Ranking scope: emitted pack (not workspace-wide)",
|
|
860
|
+
`Signals: ${report.available_signals.join(", ")}`,
|
|
861
|
+
"",
|
|
862
|
+
"## Relevance",
|
|
863
|
+
"",
|
|
864
|
+
];
|
|
865
|
+
if (report.entries.length === 0) {
|
|
866
|
+
lines.push("_No focus items to explain._", "");
|
|
643
867
|
}
|
|
644
|
-
|
|
645
|
-
const
|
|
646
|
-
|
|
868
|
+
else {
|
|
869
|
+
for (const entry of report.entries) {
|
|
870
|
+
const contributions = Object.entries(entry.contributions)
|
|
871
|
+
.sort((a, b) => b[1] - a[1])
|
|
872
|
+
.map(([signal, value]) => `${signal}=${value.toFixed(3)}`)
|
|
873
|
+
.join(", ");
|
|
874
|
+
lines.push(`- **${entry.id}** rank ${entry.rank} score ${entry.score.toFixed(3)} — ${contributions}`);
|
|
875
|
+
}
|
|
876
|
+
lines.push("");
|
|
647
877
|
}
|
|
878
|
+
let output = `${lines.join("\n")}\n`;
|
|
879
|
+
if (options.compress) {
|
|
880
|
+
output = `${output.split("\n").filter((line) => line.trim() !== "").join("\n")}\n`;
|
|
881
|
+
}
|
|
882
|
+
return output;
|
|
883
|
+
}
|
|
884
|
+
/**
|
|
885
|
+
* Resolve the SDK relevance rank options for one command invocation.
|
|
886
|
+
*
|
|
887
|
+
* Builds the workspace lifecycle status registry from pm settings (falling
|
|
888
|
+
* back to the built-in registry when the tracker has no settings file), pins a
|
|
889
|
+
* stable clock, and — when an author is resolvable — pulls decayed
|
|
890
|
+
* served-then-used affinity from the SDK context-usage store so the relevance
|
|
891
|
+
* model's `usage_affinity` signal reflects real agent feedback.
|
|
892
|
+
*/
|
|
893
|
+
async function resolveSdkRankOptions(ctx) {
|
|
894
|
+
const now = new Date().toISOString();
|
|
895
|
+
let statusRegistry;
|
|
648
896
|
try {
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
897
|
+
// readSettings returns built-in defaults even for an uninitialized tracker,
|
|
898
|
+
// so this yields a valid registry for any real workspace root.
|
|
899
|
+
const settings = await readSettings(ctx.pm_root);
|
|
900
|
+
statusRegistry = resolveRuntimeStatusRegistry(settings.schema);
|
|
652
901
|
}
|
|
653
902
|
catch (err) {
|
|
654
|
-
throw new CommandError(`Could not
|
|
903
|
+
throw new CommandError(`Could not resolve workspace status registry for relevance ranking: ${err instanceof Error ? err.message : String(err)}`);
|
|
904
|
+
}
|
|
905
|
+
const author = stringOption(ctx.options ?? {}, "author") ?? ctx.global?.author;
|
|
906
|
+
let usageAffinity;
|
|
907
|
+
if (author) {
|
|
908
|
+
try {
|
|
909
|
+
const affinity = await readContextUsageAffinity({ pmRoot: ctx.pm_root, author });
|
|
910
|
+
usageAffinity = affinity.affinity;
|
|
911
|
+
}
|
|
912
|
+
catch {
|
|
913
|
+
// No ledger yet, or unreadable; ranking proceeds without usage affinity.
|
|
914
|
+
usageAffinity = undefined;
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
return { statusRegistry, now, author, usageAffinity };
|
|
918
|
+
}
|
|
919
|
+
/**
|
|
920
|
+
* Record a context-serving event to pm's own context-usage ledger.
|
|
921
|
+
*
|
|
922
|
+
* This closes the feedback loop: every emitted focus and neighborhood item
|
|
923
|
+
* becomes an included `serve` row in the same `runtime/context-usage.jsonl`
|
|
924
|
+
* pm's `usage_affinity` signal reads, so a later
|
|
925
|
+
* `pm context-usage --author <agent>` can report whether the served context was
|
|
926
|
+
* actually touched. Skipped silently when no author is resolvable, since the
|
|
927
|
+
* ledger is per-author.
|
|
928
|
+
*/
|
|
929
|
+
async function recordPackServing(ctx, focus, neighbors) {
|
|
930
|
+
const author = stringOption(ctx.options ?? {}, "author") ?? ctx.global?.author;
|
|
931
|
+
if (!author)
|
|
932
|
+
return;
|
|
933
|
+
const rows = [
|
|
934
|
+
...focus.map((item, index) => ({ id: item.id, rank: index + 1, included: true })),
|
|
935
|
+
...neighbors.map((item, index) => ({ id: item.id, rank: focus.length + index + 1, included: true })),
|
|
936
|
+
];
|
|
937
|
+
if (rows.length === 0)
|
|
938
|
+
return;
|
|
939
|
+
try {
|
|
940
|
+
await recordContextUsageServing({
|
|
941
|
+
pmRoot: ctx.pm_root,
|
|
942
|
+
author,
|
|
943
|
+
surface: "context",
|
|
944
|
+
profile: "context",
|
|
945
|
+
rows,
|
|
946
|
+
});
|
|
947
|
+
}
|
|
948
|
+
catch {
|
|
949
|
+
// Recording is best-effort: a ledger write failure must not break a pack.
|
|
655
950
|
}
|
|
656
951
|
}
|
|
657
952
|
function setupCommands(api) {
|
|
@@ -671,6 +966,7 @@ function setupCommands(api) {
|
|
|
671
966
|
"pm context-pack --id pm-1234 --compress --format json",
|
|
672
967
|
"pm context-pack --id pm-1234 --format agent --include-deps --section focus --section blockers",
|
|
673
968
|
"pm context-pack --id pm-1234 --max-items 10",
|
|
969
|
+
"pm context-pack --status in_progress --explain",
|
|
674
970
|
],
|
|
675
971
|
flags: [
|
|
676
972
|
{ long: "--id", value_name: "id", description: "Focus item id (repeatable or comma-separated)", type: "string" },
|
|
@@ -692,6 +988,7 @@ function setupCommands(api) {
|
|
|
692
988
|
{ long: "--include-deps", description: "Include per-item dependency info in the context pack", type: "boolean" },
|
|
693
989
|
{ long: "--max-items", value_name: "n", description: "Maximum total items (focus + neighbors) in the pack", type: "string" },
|
|
694
990
|
{ long: "--section", value_name: "section", description: "Include sections (repeatable). Markdown: summary, focus, neighborhood, neighbors, links, deps. Agent/compact: focus, blockers, next-actions, recent, links, deps, refresh", type: "string" },
|
|
991
|
+
{ long: "--explain", description: "Explain emitted items with ranks/scores relative to the packed subset instead of emitting a pack", type: "boolean" },
|
|
695
992
|
],
|
|
696
993
|
async run(ctx) {
|
|
697
994
|
const options = ctx.options;
|
|
@@ -710,8 +1007,19 @@ function setupCommands(api) {
|
|
|
710
1007
|
const compress = boolOption(options, "compress");
|
|
711
1008
|
const includeDeps = boolOption(options, "include-deps", "includeDeps");
|
|
712
1009
|
const maxItems = intOptionMin0(options, ["max-items", "maxItems"], 0) || undefined;
|
|
1010
|
+
const explain = boolOption(options, "explain");
|
|
713
1011
|
const sections = validateSections(format, [...asArray(options.section), ...asArray(options.sections)]);
|
|
714
|
-
const
|
|
1012
|
+
const items = await readPmItems(ctx.pm_root);
|
|
1013
|
+
const rankOptions = await resolveSdkRankOptions(ctx);
|
|
1014
|
+
const ranker = createSdkRanker(items, rankOptions);
|
|
1015
|
+
// Pre-rank the full corpus so the token-budgeted packer has relevance ranks
|
|
1016
|
+
// for both focus and neighbor candidates before buildContextPack trims.
|
|
1017
|
+
let packer;
|
|
1018
|
+
if (maxItems) {
|
|
1019
|
+
const rankedAll = rankContextItems(items, rankOptions);
|
|
1020
|
+
packer = createSdkPacker(rankedAll, rankedAll);
|
|
1021
|
+
}
|
|
1022
|
+
const pack = buildContextPack(items, {
|
|
715
1023
|
ids: selection.ids,
|
|
716
1024
|
status: selection.status,
|
|
717
1025
|
type: selection.type,
|
|
@@ -723,7 +1031,22 @@ function setupCommands(api) {
|
|
|
723
1031
|
neighborhoodDepth,
|
|
724
1032
|
includeDeps,
|
|
725
1033
|
maxItems,
|
|
1034
|
+
ranker,
|
|
1035
|
+
packer,
|
|
726
1036
|
});
|
|
1037
|
+
if (explain) {
|
|
1038
|
+
const explainReport = buildContextExplain([...pack.items, ...pack.neighbors], rankOptions);
|
|
1039
|
+
const output = format === "json"
|
|
1040
|
+
? `${JSON.stringify(explainReport, null, compress ? 0 : 2)}\n`
|
|
1041
|
+
: renderContextExplain(explainReport, { compress });
|
|
1042
|
+
const outputPath = stringOption(options, "output");
|
|
1043
|
+
if (outputPath) {
|
|
1044
|
+
writeFileSync(outputPath, output, "utf-8");
|
|
1045
|
+
return { ok: true, format: format === "json" ? "json" : "markdown", explained: explainReport.entries.length };
|
|
1046
|
+
}
|
|
1047
|
+
return renderedCommandResult(output);
|
|
1048
|
+
}
|
|
1049
|
+
await recordPackServing(ctx, pack.items, pack.neighbors);
|
|
727
1050
|
const suggestedCommand = buildSuggestedAgentCommand({
|
|
728
1051
|
commandName: "context-pack",
|
|
729
1052
|
selection,
|
|
@@ -804,7 +1127,15 @@ function setupCommands(api) {
|
|
|
804
1127
|
const includeDeps = boolOption(options, "include-deps", "includeDeps");
|
|
805
1128
|
const maxItems = intOptionMin0(options, ["max-items", "maxItems"], 0) || undefined;
|
|
806
1129
|
const sections = validateSections(format, [...asArray(options.section), ...asArray(options.sections)]);
|
|
807
|
-
const
|
|
1130
|
+
const items = await readPmItems(ctx.pm_root);
|
|
1131
|
+
const rankOptions = await resolveSdkRankOptions(ctx);
|
|
1132
|
+
const ranker = createSdkRanker(items, rankOptions);
|
|
1133
|
+
let packer;
|
|
1134
|
+
if (maxItems) {
|
|
1135
|
+
const rankedAll = rankContextItems(items, rankOptions);
|
|
1136
|
+
packer = createSdkPacker(rankedAll, rankedAll);
|
|
1137
|
+
}
|
|
1138
|
+
const pack = buildContextPack(items, {
|
|
808
1139
|
ids: selection.ids,
|
|
809
1140
|
status: selection.status,
|
|
810
1141
|
type: selection.type,
|
|
@@ -815,7 +1146,10 @@ function setupCommands(api) {
|
|
|
815
1146
|
neighborhoodDepth,
|
|
816
1147
|
includeDeps,
|
|
817
1148
|
maxItems,
|
|
1149
|
+
ranker,
|
|
1150
|
+
packer,
|
|
818
1151
|
});
|
|
1152
|
+
await recordPackServing(ctx, pack.items, pack.neighbors);
|
|
819
1153
|
const suggestedCommand = buildSuggestedAgentCommand({
|
|
820
1154
|
commandName: "context-handoff",
|
|
821
1155
|
selection,
|
|
@@ -850,10 +1184,88 @@ function setupCommands(api) {
|
|
|
850
1184
|
return renderedCommandResult(output);
|
|
851
1185
|
},
|
|
852
1186
|
});
|
|
1187
|
+
api.registerCommand({
|
|
1188
|
+
name: "context-usage",
|
|
1189
|
+
description: "Report which items pm context/next served and which of those were actually touched.",
|
|
1190
|
+
intent: "measure whether served context is being used",
|
|
1191
|
+
examples: [
|
|
1192
|
+
"pm context-usage",
|
|
1193
|
+
"pm context-usage --format json",
|
|
1194
|
+
"pm context-usage --surface next --since 7d",
|
|
1195
|
+
"pm context-usage --by agent-a --limit 50",
|
|
1196
|
+
],
|
|
1197
|
+
flags: [
|
|
1198
|
+
{ long: "--by", value_name: "author", description: "Restrict to one recording author", type: "string" },
|
|
1199
|
+
{ long: "--surface", value_name: "surface", description: "Restrict serve events to one surface: context or next", type: "string" },
|
|
1200
|
+
{ long: "--since", value_name: "when", description: "Drop events at or before this point (ISO timestamp, or a day offset such as 7d)", type: "string" },
|
|
1201
|
+
{ long: "--limit", value_name: "n", description: `Maximum per-item rows (default: ${DEFAULT_REPORT_LIMIT})`, type: "string" },
|
|
1202
|
+
{ long: "--format", value_name: "format", description: "Output format: markdown or json (default: markdown)", type: "string" },
|
|
1203
|
+
],
|
|
1204
|
+
async run(ctx) {
|
|
1205
|
+
const options = ctx.options ?? {};
|
|
1206
|
+
const requestedFormat = stringOption(options, "format")?.toLowerCase();
|
|
1207
|
+
if (requestedFormat && requestedFormat !== "markdown" && requestedFormat !== "json") {
|
|
1208
|
+
throw new CommandError("--format must be markdown or json", EXIT_CODE.USAGE);
|
|
1209
|
+
}
|
|
1210
|
+
const surface = stringOption(options, "surface")?.toLowerCase();
|
|
1211
|
+
if (surface && surface !== "context" && surface !== "next") {
|
|
1212
|
+
throw new CommandError("--surface must be context or next", EXIT_CODE.USAGE);
|
|
1213
|
+
}
|
|
1214
|
+
const rawSince = stringOption(options, "since");
|
|
1215
|
+
const since = rawSince === undefined ? undefined : resolveSince(rawSince);
|
|
1216
|
+
if (rawSince !== undefined && since === null) {
|
|
1217
|
+
throw new CommandError(`--since '${rawSince}' is not an ISO timestamp or a day offset such as 7d`, EXIT_CODE.USAGE);
|
|
1218
|
+
}
|
|
1219
|
+
// This is a *filter* over recorded ledger events ("whose usage to report"),
|
|
1220
|
+
// which is a different concept from pm's host-owned global `--author`
|
|
1221
|
+
// ("override mutation author for this invocation"). Reusing the global
|
|
1222
|
+
// would silently narrow every report to the invoking agent, so this keeps
|
|
1223
|
+
// a distinct `--by`.
|
|
1224
|
+
const author = stringOption(options, "by");
|
|
1225
|
+
const report = reportContextUsage(ctx.pm_root, {
|
|
1226
|
+
author,
|
|
1227
|
+
surface,
|
|
1228
|
+
since: since ?? undefined,
|
|
1229
|
+
limit: intOption(options, "limit", DEFAULT_REPORT_LIMIT),
|
|
1230
|
+
});
|
|
1231
|
+
// When an author is selected, fold in pm's own decayed served-then-touched
|
|
1232
|
+
// affinity from the SDK context-usage store. This is the same `usage_affinity`
|
|
1233
|
+
// signal the relevance model consumes, surfaced directly so an agent can see
|
|
1234
|
+
// which items its prior touches reinforced — without this module re-implementing
|
|
1235
|
+
// pm's decay model. The event-based waste/misses/conversion report stays because
|
|
1236
|
+
// the SDK does not expose the raw ledger event stream (see SDK_PROBLEMS).
|
|
1237
|
+
let affinity;
|
|
1238
|
+
if (author) {
|
|
1239
|
+
try {
|
|
1240
|
+
affinity = await readContextUsageAffinity({ pmRoot: ctx.pm_root, author });
|
|
1241
|
+
}
|
|
1242
|
+
catch {
|
|
1243
|
+
affinity = undefined;
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
const reportWithAffinity = affinity ? { ...report, affinity } : report;
|
|
1247
|
+
// pm's global --json owns that flag name, so a command-level alias would be
|
|
1248
|
+
// silently shadowed and never populate ctx.options. Read the global instead,
|
|
1249
|
+
// so `pm context-usage --json` returns the raw report as an agent expects.
|
|
1250
|
+
const wantsJson = requestedFormat === "json" || ctx.global?.json === true;
|
|
1251
|
+
return wantsJson ? reportWithAffinity : renderedCommandResult(renderUsageReport(reportWithAffinity));
|
|
1252
|
+
},
|
|
1253
|
+
});
|
|
853
1254
|
}
|
|
1255
|
+
/**
|
|
1256
|
+
* Local stand-in for the SDK's `defineExtension` identity helper.
|
|
1257
|
+
*
|
|
1258
|
+
* This package resolves `@unbrained/pm-cli` at runtime for the `sdk/core` and
|
|
1259
|
+
* `sdk/query` engines (it is a peer dependency the pm host provides), but
|
|
1260
|
+
* `defineExtension` itself is a pure identity with no runtime behavior, so a
|
|
1261
|
+
* local shim avoids a needless value import while still contract-checking the
|
|
1262
|
+
* extension object against {@link ExtensionModule} exactly as the imported
|
|
1263
|
+
* helper would.
|
|
1264
|
+
*/
|
|
1265
|
+
const defineExtension = (module) => module;
|
|
854
1266
|
export default defineExtension({
|
|
855
1267
|
name: "pm-context",
|
|
856
|
-
version: "2026.7.
|
|
1268
|
+
version: "2026.7.27",
|
|
857
1269
|
description: "Generate deterministic pm context packs for agent handoffs, reviews, and status briefs",
|
|
858
1270
|
activate(api) {
|
|
859
1271
|
setupCommands(api);
|