akm-cli 0.7.0-rc1 → 0.7.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/dist/src/cli.js +100 -16
- package/dist/src/commands/config-cli.js +42 -0
- package/dist/src/commands/history.js +78 -7
- package/dist/src/commands/registry-search.js +69 -6
- package/dist/src/commands/search.js +30 -3
- package/dist/src/commands/show.js +29 -0
- package/dist/src/commands/source-add.js +5 -1
- package/dist/src/commands/source-manage.js +7 -1
- package/dist/src/core/config.js +28 -0
- package/dist/src/indexer/db-search.js +1 -0
- package/dist/src/indexer/indexer.js +16 -2
- package/dist/src/indexer/matchers.js +1 -1
- package/dist/src/indexer/search-source.js +4 -2
- package/dist/src/integrations/agent/profiles.js +1 -1
- package/dist/src/integrations/agent/spawn.js +67 -16
- package/dist/src/integrations/github.js +9 -3
- package/dist/src/llm/embedders/remote.js +37 -3
- package/dist/src/output/cli-hints.js +15 -2
- package/dist/src/output/renderers.js +3 -1
- package/dist/src/output/shapes.js +8 -1
- package/dist/src/output/text.js +156 -3
- package/dist/src/registry/build-index.js +5 -4
- package/dist/src/registry/providers/static-index.js +3 -1
- package/dist/src/setup/setup.js +9 -0
- package/dist/src/wiki/wiki.js +54 -6
- package/dist/src/workflows/runs.js +37 -3
- package/dist/tests/architecture/agent-no-llm-sdk-guard.test.js +1 -1
- package/dist/tests/bench/attribution.test.js +24 -23
- package/dist/tests/bench/cleanup.js +31 -0
- package/dist/tests/bench/cli.js +366 -31
- package/dist/tests/bench/cli.test.js +282 -14
- package/dist/tests/bench/corpus.js +3 -0
- package/dist/tests/bench/corpus.test.js +10 -10
- package/dist/tests/bench/doctor.js +525 -0
- package/dist/tests/bench/driver.js +77 -22
- package/dist/tests/bench/driver.test.js +142 -1
- package/dist/tests/bench/environment.js +233 -0
- package/dist/tests/bench/environment.test.js +199 -0
- package/dist/tests/bench/evolve.js +67 -0
- package/dist/tests/bench/evolve.test.js +12 -4
- package/dist/tests/bench/failure-modes.test.js +52 -3
- package/dist/tests/bench/feedback-integrity.test.js +3 -2
- package/dist/tests/bench/leakage.test.js +105 -2
- package/dist/tests/bench/learning-curve.test.js +3 -2
- package/dist/tests/bench/metrics.js +102 -26
- package/dist/tests/bench/metrics.test.js +10 -4
- package/dist/tests/bench/opencode-config.js +194 -0
- package/dist/tests/bench/opencode-config.test.js +370 -0
- package/dist/tests/bench/report.js +73 -9
- package/dist/tests/bench/report.test.js +59 -10
- package/dist/tests/bench/run-config.js +355 -0
- package/dist/tests/bench/run-config.test.js +298 -0
- package/dist/tests/bench/run-curate-test.js +32 -0
- package/dist/tests/bench/run-failing-tasks.js +56 -0
- package/dist/tests/bench/run-full-bench.js +51 -0
- package/dist/tests/bench/run-items36-targeted.js +69 -0
- package/dist/tests/bench/run-nano-quick.js +42 -0
- package/dist/tests/bench/run-waveg-targeted.js +62 -0
- package/dist/tests/bench/runner.js +257 -94
- package/dist/tests/bench/tmp.js +90 -0
- package/dist/tests/bench/trajectory.js +2 -2
- package/dist/tests/bench/verifier.js +6 -1
- package/dist/tests/bench/workflow-spec.js +11 -24
- package/dist/tests/bench/workflow-spec.test.js +1 -1
- package/dist/tests/bench/workflow-trace.js +34 -0
- package/dist/tests/cli-errors.test.js +1 -0
- package/dist/tests/commands/history.test.js +195 -0
- package/dist/tests/config.test.js +25 -0
- package/dist/tests/e2e.test.js +23 -2
- package/dist/tests/fixtures/stashes/load.js +1 -1
- package/dist/tests/fixtures/stashes/load.test.js +11 -2
- package/dist/tests/indexer.test.js +12 -1
- package/dist/tests/output-baseline.test.js +2 -1
- package/dist/tests/output-shapes-unit.test.js +3 -1
- package/dist/tests/registry-build-index.test.js +17 -1
- package/dist/tests/registry-providers/static-index.test.js +34 -0
- package/dist/tests/registry-search.test.js +200 -0
- package/dist/tests/remember-frontmatter.test.js +11 -13
- package/dist/tests/source-qa-fixes.test.js +18 -0
- package/dist/tests/source-registry.test.js +3 -3
- package/dist/tests/source-source.test.js +61 -1
- package/dist/tests/workflow-qa-fixes.test.js +18 -0
- package/package.json +1 -1
package/dist/src/cli.js
CHANGED
|
@@ -183,10 +183,11 @@ const searchCommand = defineCommand({
|
|
|
183
183
|
},
|
|
184
184
|
async run({ args }) {
|
|
185
185
|
await runWithJsonErrors(async () => {
|
|
186
|
+
// An empty query enumerates all indexed assets (list mode).
|
|
187
|
+
// The guard that rejected empty queries was removed; akmSearch handles
|
|
188
|
+
// empty strings end-to-end via getAllEntries (DB path) and the
|
|
189
|
+
// substring-search fallback's query-less branch.
|
|
186
190
|
const query = (args.query ?? "").trim();
|
|
187
|
-
if (!query) {
|
|
188
|
-
throw new UsageError('A search query is required. Usage: akm search "<query>" [--type <type>] [--limit <n>]', "MISSING_REQUIRED_ARGUMENT", "Provide a query string. Filter by type with --type skill|command|...; limit results with --limit N.");
|
|
189
|
-
}
|
|
190
191
|
const type = args.type;
|
|
191
192
|
const limitRaw = args.limit ? parseInt(args.limit, 10) : undefined;
|
|
192
193
|
if (limitRaw !== undefined && Number.isNaN(limitRaw)) {
|
|
@@ -502,6 +503,15 @@ const showCommand = defineCommand({
|
|
|
502
503
|
},
|
|
503
504
|
async run({ args }) {
|
|
504
505
|
await runWithJsonErrors(async () => {
|
|
506
|
+
try {
|
|
507
|
+
parseAssetRef(args.ref);
|
|
508
|
+
}
|
|
509
|
+
catch (error) {
|
|
510
|
+
if (error instanceof UsageError && error.code === "MISSING_REQUIRED_ARGUMENT") {
|
|
511
|
+
throw new UsageError(error.message, "INVALID_FLAG_VALUE", error.hint());
|
|
512
|
+
}
|
|
513
|
+
throw error;
|
|
514
|
+
}
|
|
505
515
|
// The knowledge-view positional syntax (`akm show knowledge:foo section "Auth"`)
|
|
506
516
|
// is rewritten to `--akmView` / `--akmHeading` / `--akmStart` / `--akmEnd`
|
|
507
517
|
// by `normalizeShowArgv` before citty parses argv. We read those values
|
|
@@ -863,8 +873,8 @@ const registryCommand = defineCommand({
|
|
|
863
873
|
"build-index": defineCommand({
|
|
864
874
|
meta: { name: "build-index", description: "Build a v2 registry index from discovery and manual entries" },
|
|
865
875
|
args: {
|
|
866
|
-
out: { type: "string", description: "Output path for the generated index"
|
|
867
|
-
manual: { type: "string", description: "Manual entries JSON file"
|
|
876
|
+
out: { type: "string", description: "Output path for the generated index" },
|
|
877
|
+
manual: { type: "string", description: "Manual entries JSON file" },
|
|
868
878
|
"npm-registry": { type: "string", description: "Override npm registry base URL" },
|
|
869
879
|
"github-api": { type: "string", description: "Override GitHub API base URL" },
|
|
870
880
|
},
|
|
@@ -892,15 +902,26 @@ const registryCommand = defineCommand({
|
|
|
892
902
|
const feedbackCommand = defineCommand({
|
|
893
903
|
meta: {
|
|
894
904
|
name: "feedback",
|
|
895
|
-
description: "Record positive or negative feedback for any indexed stash asset"
|
|
905
|
+
description: "Record positive or negative feedback for any indexed stash asset.\n\n" +
|
|
906
|
+
"Positive feedback boosts an asset's EMA utility score, making it rank higher\n" +
|
|
907
|
+
"in future searches without requiring a full reindex.\n\n" +
|
|
908
|
+
"Negative feedback records a negative signal in usage_events and events.jsonl.\n" +
|
|
909
|
+
"It does NOT immediately lower the asset's ranking — the EMA utility score is\n" +
|
|
910
|
+
"updated the next time `akm index` runs (incremental or full). Run `akm index`\n" +
|
|
911
|
+
"after recording negative feedback to have it reflected in search results.",
|
|
896
912
|
},
|
|
897
913
|
args: {
|
|
898
914
|
// Optional in citty so run() is invoked even when omitted; we re-validate
|
|
899
915
|
// and throw a structured UsageError below so exit code is 2 (USAGE) rather
|
|
900
916
|
// than citty's default 0 (help banner).
|
|
901
917
|
ref: { type: "positional", description: "Asset ref (type:name)", required: false },
|
|
902
|
-
positive: { type: "boolean", description: "Record positive feedback", default: false },
|
|
903
|
-
negative: {
|
|
918
|
+
positive: { type: "boolean", description: "Record positive feedback (boosts ranking immediately)", default: false },
|
|
919
|
+
negative: {
|
|
920
|
+
type: "boolean",
|
|
921
|
+
description: "Record negative feedback (suppresses ranking after next `akm index`). " +
|
|
922
|
+
"Reindexing is required for the signal to affect search results.",
|
|
923
|
+
default: false,
|
|
924
|
+
},
|
|
904
925
|
note: { type: "string", description: "Optional note to attach to the feedback" },
|
|
905
926
|
},
|
|
906
927
|
run({ args }) {
|
|
@@ -924,6 +945,11 @@ const feedbackCommand = defineCommand({
|
|
|
924
945
|
if (entryId === undefined) {
|
|
925
946
|
throw new UsageError(`Ref "${ref}" is not in the current index. Run "akm index" and try again.`);
|
|
926
947
|
}
|
|
948
|
+
// Persist the feedback signal into usage_events. For positive signals,
|
|
949
|
+
// the EMA utility score is updated immediately on the next read path.
|
|
950
|
+
// For negative signals, the score is adjusted the next time `akm index`
|
|
951
|
+
// runs — the signal is durable in the DB but does NOT suppress ranking
|
|
952
|
+
// in search results until after reindexing.
|
|
927
953
|
insertUsageEvent(db, {
|
|
928
954
|
event_type: "feedback",
|
|
929
955
|
entry_ref: ref,
|
|
@@ -947,11 +973,22 @@ const feedbackCommand = defineCommand({
|
|
|
947
973
|
const historyCommand = defineCommand({
|
|
948
974
|
meta: {
|
|
949
975
|
name: "history",
|
|
950
|
-
description: "Show mutation/usage history for a single asset (--ref) or stash-wide
|
|
976
|
+
description: "Show mutation/usage history for a single asset (--ref) or stash-wide.\n\n" +
|
|
977
|
+
"Event sources:\n" +
|
|
978
|
+
" usage_events (default): search, show, and feedback events from the local index.\n" +
|
|
979
|
+
" events.jsonl (--include-proposals): proposal lifecycle events (promoted, rejected)\n" +
|
|
980
|
+
" emitted by `akm proposal accept` / `akm proposal reject`.\n\n" +
|
|
981
|
+
"Results from all active sources are merged and sorted chronologically.",
|
|
951
982
|
},
|
|
952
983
|
args: {
|
|
953
984
|
ref: { type: "string", description: "Asset ref (type:name). Omit for stash-wide history." },
|
|
954
985
|
since: { type: "string", description: "ISO timestamp or epoch ms — only events on/after this time" },
|
|
986
|
+
"include-proposals": {
|
|
987
|
+
type: "boolean",
|
|
988
|
+
description: "Also include proposal lifecycle events (promoted, rejected) from events.jsonl. " +
|
|
989
|
+
"Default: false (usage_events only).",
|
|
990
|
+
default: false,
|
|
991
|
+
},
|
|
955
992
|
format: { type: "string", description: "Output format (json|jsonl|text|yaml)" },
|
|
956
993
|
},
|
|
957
994
|
run({ args }) {
|
|
@@ -959,6 +996,7 @@ const historyCommand = defineCommand({
|
|
|
959
996
|
const result = await akmHistory({
|
|
960
997
|
ref: args.ref,
|
|
961
998
|
since: args.since,
|
|
999
|
+
includeProposals: args["include-proposals"],
|
|
962
1000
|
});
|
|
963
1001
|
output("history", result);
|
|
964
1002
|
});
|
|
@@ -1397,7 +1435,7 @@ const rememberCommand = defineCommand({
|
|
|
1397
1435
|
},
|
|
1398
1436
|
async run({ args }) {
|
|
1399
1437
|
return runWithJsonErrors(async () => {
|
|
1400
|
-
const body = readMemoryContent(args.content);
|
|
1438
|
+
const body = readMemoryContent(resolveRememberContentArg(args.content));
|
|
1401
1439
|
// Determine if the user has requested any structured metadata mode.
|
|
1402
1440
|
// Collect all --tag occurrences directly from process.argv because citty
|
|
1403
1441
|
// only exposes the last value for repeated string flags.
|
|
@@ -1415,8 +1453,8 @@ const rememberCommand = defineCommand({
|
|
|
1415
1453
|
if (typeof args.channel === "string" && args.channel.trim())
|
|
1416
1454
|
scopeFields.channel = args.channel.trim();
|
|
1417
1455
|
const hasScope = Object.keys(scopeFields).length > 0;
|
|
1418
|
-
const hasTagRequiringArgs = rawTags.length > 0 || !!args.expires || !!args.source || !!args.description || args.
|
|
1419
|
-
const hasStructuredArgs = hasTagRequiringArgs || hasScope;
|
|
1456
|
+
const hasTagRequiringArgs = rawTags.length > 0 || !!args.expires || !!args.source || !!args.description || args.enrich;
|
|
1457
|
+
const hasStructuredArgs = hasTagRequiringArgs || hasScope || args.auto;
|
|
1420
1458
|
if (!hasStructuredArgs) {
|
|
1421
1459
|
const result = await writeMarkdownAsset({
|
|
1422
1460
|
type: "memory",
|
|
@@ -1476,10 +1514,12 @@ const rememberCommand = defineCommand({
|
|
|
1476
1514
|
observed_at = enriched.observed_at;
|
|
1477
1515
|
}
|
|
1478
1516
|
// ── Required-field check (before any write) ───────────────────────────
|
|
1479
|
-
// Tags remain required when the user
|
|
1480
|
-
// (--tag / --
|
|
1481
|
-
//
|
|
1482
|
-
//
|
|
1517
|
+
// Tags remain required when the user explicitly asked for tag-bearing
|
|
1518
|
+
// metadata (--tag / --enrich / --description / --source / --expires).
|
|
1519
|
+
// `--auto` alone is allowed even when its heuristics derive zero tags.
|
|
1520
|
+
// Scope-only writes (`akm remember "..." --user u1`) also skip this
|
|
1521
|
+
// check — scope is independent metadata and a memory with only scope is
|
|
1522
|
+
// valid.
|
|
1483
1523
|
const missing = [];
|
|
1484
1524
|
if (hasTagRequiringArgs && tags.length === 0)
|
|
1485
1525
|
missing.push("tags");
|
|
@@ -1522,6 +1562,50 @@ const rememberCommand = defineCommand({
|
|
|
1522
1562
|
});
|
|
1523
1563
|
},
|
|
1524
1564
|
});
|
|
1565
|
+
function resolveRememberContentArg(content) {
|
|
1566
|
+
if (content === undefined)
|
|
1567
|
+
return undefined;
|
|
1568
|
+
const parsedFormat = parseFlagValue(process.argv, "--format");
|
|
1569
|
+
if (parsedFormat !== undefined &&
|
|
1570
|
+
content === parsedFormat &&
|
|
1571
|
+
wasRememberFlagValueConsumedAsContent(content, parsedFormat, "--format")) {
|
|
1572
|
+
return undefined;
|
|
1573
|
+
}
|
|
1574
|
+
const parsedDetail = parseFlagValue(process.argv, "--detail");
|
|
1575
|
+
if (parsedDetail !== undefined &&
|
|
1576
|
+
content === parsedDetail &&
|
|
1577
|
+
wasRememberFlagValueConsumedAsContent(content, parsedDetail, "--detail")) {
|
|
1578
|
+
return undefined;
|
|
1579
|
+
}
|
|
1580
|
+
return content;
|
|
1581
|
+
}
|
|
1582
|
+
function wasRememberFlagValueConsumedAsContent(content, flagValue, flagName) {
|
|
1583
|
+
const argv = process.argv.slice(2);
|
|
1584
|
+
const rememberIndex = argv.indexOf("remember");
|
|
1585
|
+
const tokens = rememberIndex >= 0 ? argv.slice(rememberIndex + 1) : argv;
|
|
1586
|
+
let flagIndex = -1;
|
|
1587
|
+
let flagConsumesNextToken = false;
|
|
1588
|
+
for (let i = 0; i < tokens.length; i += 1) {
|
|
1589
|
+
const token = tokens[i];
|
|
1590
|
+
if (token === flagName) {
|
|
1591
|
+
flagIndex = i;
|
|
1592
|
+
flagConsumesNextToken = true;
|
|
1593
|
+
break;
|
|
1594
|
+
}
|
|
1595
|
+
if (token === `${flagName}=${flagValue}`) {
|
|
1596
|
+
flagIndex = i;
|
|
1597
|
+
break;
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1600
|
+
if (flagIndex === -1)
|
|
1601
|
+
return false;
|
|
1602
|
+
if (tokens.slice(0, flagIndex).includes(content))
|
|
1603
|
+
return false;
|
|
1604
|
+
const firstTokenAfterFlag = flagIndex + (flagConsumesNextToken ? 2 : 1);
|
|
1605
|
+
if (tokens.slice(firstTokenAfterFlag).includes(content))
|
|
1606
|
+
return false;
|
|
1607
|
+
return true;
|
|
1608
|
+
}
|
|
1525
1609
|
const importKnowledgeCommand = defineCommand({
|
|
1526
1610
|
meta: {
|
|
1527
1611
|
name: "import",
|
|
@@ -31,6 +31,12 @@ export function parseConfigValue(key, value) {
|
|
|
31
31
|
return { embedding: mergeLlmLikeEmbedding(undefined, { model: requireNonEmptyString(value, key) }) };
|
|
32
32
|
case "embedding.apiKey":
|
|
33
33
|
return { embedding: mergeLlmLikeEmbedding(undefined, { apiKey: requireNonEmptyString(value, key) }) };
|
|
34
|
+
case "embedding.contextLength":
|
|
35
|
+
return { embedding: mergeLlmLikeEmbedding(undefined, { contextLength: parsePositiveInteger(value, key) }) };
|
|
36
|
+
case "embedding.ollamaOptions.numCtx":
|
|
37
|
+
return {
|
|
38
|
+
embedding: mergeLlmLikeEmbedding(undefined, { ollamaOptions: { num_ctx: parsePositiveInteger(value, key) } }),
|
|
39
|
+
};
|
|
34
40
|
case "llm":
|
|
35
41
|
return { llm: parseLlmConnectionValue(value) };
|
|
36
42
|
case "llm.endpoint":
|
|
@@ -82,6 +88,10 @@ export function getConfigValue(config, key) {
|
|
|
82
88
|
return config.embedding?.model ?? null;
|
|
83
89
|
case "embedding.apiKey":
|
|
84
90
|
return config.embedding?.apiKey ?? null;
|
|
91
|
+
case "embedding.contextLength":
|
|
92
|
+
return config.embedding?.contextLength ?? null;
|
|
93
|
+
case "embedding.ollamaOptions.numCtx":
|
|
94
|
+
return config.embedding?.ollamaOptions?.num_ctx ?? null;
|
|
85
95
|
case "llm":
|
|
86
96
|
return config.llm ?? null;
|
|
87
97
|
case "llm.endpoint":
|
|
@@ -152,6 +162,18 @@ export function setConfigValue(config, key, rawValue) {
|
|
|
152
162
|
...config,
|
|
153
163
|
embedding: mergeLlmLikeEmbedding(config.embedding, { apiKey: requireNonEmptyString(rawValue, key) }),
|
|
154
164
|
};
|
|
165
|
+
case "embedding.contextLength":
|
|
166
|
+
return {
|
|
167
|
+
...config,
|
|
168
|
+
embedding: mergeLlmLikeEmbedding(config.embedding, { contextLength: parsePositiveInteger(rawValue, key) }),
|
|
169
|
+
};
|
|
170
|
+
case "embedding.ollamaOptions.numCtx":
|
|
171
|
+
return {
|
|
172
|
+
...config,
|
|
173
|
+
embedding: mergeLlmLikeEmbedding(config.embedding, {
|
|
174
|
+
ollamaOptions: { ...(config.embedding?.ollamaOptions ?? {}), num_ctx: parsePositiveInteger(rawValue, key) },
|
|
175
|
+
}),
|
|
176
|
+
};
|
|
155
177
|
case "llm.endpoint":
|
|
156
178
|
return { ...config, llm: mergeLlmLike(config.llm, { endpoint: requireNonEmptyString(rawValue, key) }) };
|
|
157
179
|
case "llm.model":
|
|
@@ -190,6 +212,19 @@ export function unsetConfigValue(config, key) {
|
|
|
190
212
|
const { apiKey: _a, ...rest } = config.embedding;
|
|
191
213
|
return { ...config, embedding: rest };
|
|
192
214
|
}
|
|
215
|
+
case "embedding.contextLength": {
|
|
216
|
+
if (!config.embedding)
|
|
217
|
+
return config;
|
|
218
|
+
const { contextLength: _cl, ...rest } = config.embedding;
|
|
219
|
+
return { ...config, embedding: rest };
|
|
220
|
+
}
|
|
221
|
+
case "embedding.ollamaOptions.numCtx": {
|
|
222
|
+
if (!config.embedding?.ollamaOptions)
|
|
223
|
+
return config;
|
|
224
|
+
const { num_ctx: _nc, ...restOpts } = config.embedding.ollamaOptions;
|
|
225
|
+
const ollamaOptions = Object.keys(restOpts).length > 0 ? restOpts : undefined;
|
|
226
|
+
return { ...config, embedding: { ...config.embedding, ollamaOptions } };
|
|
227
|
+
}
|
|
193
228
|
case "llm":
|
|
194
229
|
return { ...config, llm: undefined };
|
|
195
230
|
case "llm.endpoint":
|
|
@@ -479,6 +514,13 @@ function parseUnknownPositiveInteger(value, key) {
|
|
|
479
514
|
}
|
|
480
515
|
return value;
|
|
481
516
|
}
|
|
517
|
+
function parsePositiveInteger(value, key) {
|
|
518
|
+
const n = Number(value);
|
|
519
|
+
if (!Number.isFinite(n) || !Number.isInteger(n) || n <= 0) {
|
|
520
|
+
throw new UsageError(`Invalid value for ${key}: expected a positive integer`);
|
|
521
|
+
}
|
|
522
|
+
return n;
|
|
523
|
+
}
|
|
482
524
|
function parseStashesValue(value) {
|
|
483
525
|
if (value === "null" || value === "")
|
|
484
526
|
return undefined;
|
|
@@ -1,16 +1,25 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `akm history` — surfaces internal mutation/usage events
|
|
3
|
-
*
|
|
2
|
+
* `akm history` — surfaces internal mutation/usage events for a single asset
|
|
3
|
+
* (`--ref`) or stash-wide.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
5
|
+
* Event sources:
|
|
6
|
+
* - `usage_events` SQLite table: search, show, and feedback events recorded
|
|
7
|
+
* by the local indexer during normal CLI use.
|
|
8
|
+
* - `events.jsonl` append-only stream (opt-in via `--include-proposals`):
|
|
9
|
+
* proposal lifecycle events (`promoted`, `rejected`) emitted by
|
|
10
|
+
* `akm proposal accept` / `akm proposal reject`. Use this flag to see
|
|
11
|
+
* the full proposal review trail alongside usage events.
|
|
12
|
+
*
|
|
13
|
+
* The two sources are merged and sorted chronologically (oldest first) so
|
|
14
|
+
* consumers see a coherent lifecycle trail in a single output.
|
|
9
15
|
*/
|
|
10
16
|
import { parseAssetRef } from "../core/asset-ref";
|
|
11
17
|
import { UsageError } from "../core/errors";
|
|
18
|
+
import { readEvents } from "../core/events";
|
|
12
19
|
import { closeDatabase, openDatabase } from "../indexer/db";
|
|
13
20
|
import { ensureUsageEventsSchema } from "../indexer/usage-events";
|
|
21
|
+
// Proposal lifecycle event types emitted by the proposal substrate (#225).
|
|
22
|
+
const PROPOSAL_EVENT_TYPES = new Set(["promoted", "rejected"]);
|
|
14
23
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
15
24
|
function normalizeSince(since) {
|
|
16
25
|
// Accept "YYYY-MM-DD", "YYYY-MM-DDTHH:MM:SSZ", epoch ms, or anything Date can parse.
|
|
@@ -62,11 +71,27 @@ function toEntry(row) {
|
|
|
62
71
|
createdAt: row.created_at,
|
|
63
72
|
};
|
|
64
73
|
}
|
|
74
|
+
/**
|
|
75
|
+
* Convert an ISO timestamp from events.jsonl ("2026-04-01T12:00:00.000Z")
|
|
76
|
+
* to the SQLite-style format used in HistoryEntry.createdAt
|
|
77
|
+
* ("2026-04-01 12:00:00") so entries sort consistently.
|
|
78
|
+
*/
|
|
79
|
+
function isoToSqliteTimestamp(ts) {
|
|
80
|
+
// Normalise to the "YYYY-MM-DD HH:MM:SS" format used by usage_events rows.
|
|
81
|
+
return ts
|
|
82
|
+
.replace("T", " ")
|
|
83
|
+
.replace(/\.\d+Z$/, "")
|
|
84
|
+
.replace("Z", "");
|
|
85
|
+
}
|
|
65
86
|
// ── Main ─────────────────────────────────────────────────────────────────────
|
|
66
87
|
/**
|
|
67
88
|
* Read mutation/usage history. When `ref` is provided, results are filtered to
|
|
68
89
|
* that asset (validated via `parseAssetRef`). Always returns chronological
|
|
69
90
|
* order (oldest first) so consumers can display a lifecycle trail.
|
|
91
|
+
*
|
|
92
|
+
* When `includeProposals` is true, proposal lifecycle events (`promoted`,
|
|
93
|
+
* `rejected`) from events.jsonl are merged into the result set. This provides
|
|
94
|
+
* one coherent view of both usage signals and proposal review decisions.
|
|
70
95
|
*/
|
|
71
96
|
export async function akmHistory(options = {}) {
|
|
72
97
|
let normalizedRef;
|
|
@@ -103,13 +128,59 @@ export async function akmHistory(options = {}) {
|
|
|
103
128
|
FROM usage_events ${where}
|
|
104
129
|
ORDER BY id ASC`;
|
|
105
130
|
const rows = db.prepare(sql).all(...params);
|
|
106
|
-
const
|
|
131
|
+
const usageEntries = rows.map(toEntry);
|
|
132
|
+
// ── Proposal lifecycle events (opt-in) ────────────────────────────────
|
|
133
|
+
const sources = ["usage_events"];
|
|
134
|
+
const proposalEntries = [];
|
|
135
|
+
if (options.includeProposals === true) {
|
|
136
|
+
sources.push("events.jsonl");
|
|
137
|
+
// Convert sinceNormalized ("YYYY-MM-DD HH:MM:SS") to ISO for readEvents
|
|
138
|
+
// which uses `ts >= since` where `ts` is ISO-8601.
|
|
139
|
+
const sinceIso = sinceNormalized !== undefined ? `${sinceNormalized.replace(" ", "T")}Z` : undefined;
|
|
140
|
+
const { events } = readEvents({
|
|
141
|
+
since: sinceIso,
|
|
142
|
+
ref: normalizedRef,
|
|
143
|
+
}, options.eventsCtx);
|
|
144
|
+
// Keep only proposal lifecycle event types.
|
|
145
|
+
let counter = -1_000_000; // negative ids mark proposal-stream entries
|
|
146
|
+
for (const event of events) {
|
|
147
|
+
if (!PROPOSAL_EVENT_TYPES.has(event.eventType))
|
|
148
|
+
continue;
|
|
149
|
+
const createdAt = event.ts ? isoToSqliteTimestamp(event.ts) : "";
|
|
150
|
+
// Skip if before `since` (readEvents already filters by ts >= since,
|
|
151
|
+
// but the isoToSqliteTimestamp conversion may introduce drift so we
|
|
152
|
+
// guard again with the normalised form).
|
|
153
|
+
if (sinceNormalized !== undefined && createdAt < sinceNormalized)
|
|
154
|
+
continue;
|
|
155
|
+
proposalEntries.push({
|
|
156
|
+
id: counter--,
|
|
157
|
+
eventType: event.eventType,
|
|
158
|
+
ref: event.ref ?? null,
|
|
159
|
+
entryId: null,
|
|
160
|
+
query: null,
|
|
161
|
+
signal: null,
|
|
162
|
+
metadata: event.metadata ?? null,
|
|
163
|
+
createdAt,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
// ── Merge and sort ────────────────────────────────────────────────────
|
|
168
|
+
const entries = [...usageEntries, ...proposalEntries].sort((a, b) => {
|
|
169
|
+
// Primary sort: chronological by createdAt (string compare is safe for
|
|
170
|
+
// "YYYY-MM-DD HH:MM:SS" format). Secondary sort: id ascending for ties.
|
|
171
|
+
if (a.createdAt < b.createdAt)
|
|
172
|
+
return -1;
|
|
173
|
+
if (a.createdAt > b.createdAt)
|
|
174
|
+
return 1;
|
|
175
|
+
return a.id - b.id;
|
|
176
|
+
});
|
|
107
177
|
const response = {
|
|
108
178
|
schemaVersion: 1,
|
|
109
179
|
...(normalizedRef !== undefined ? { ref: normalizedRef } : {}),
|
|
110
180
|
...(sinceNormalized !== undefined ? { since: sinceNormalized } : {}),
|
|
111
181
|
totalCount: entries.length,
|
|
112
182
|
entries,
|
|
183
|
+
sources,
|
|
113
184
|
};
|
|
114
185
|
return response;
|
|
115
186
|
}
|
|
@@ -23,6 +23,9 @@ export async function searchRegistry(query, options) {
|
|
|
23
23
|
return provider.search({ query: trimmed, limit, includeAssets: options?.includeAssets });
|
|
24
24
|
}));
|
|
25
25
|
// Merge results grouped by provider
|
|
26
|
+
// Each provider batch is normalized to [0, 1] before merging so that raw
|
|
27
|
+
// scores from different providers (e.g. static-index can exceed 1.85 while
|
|
28
|
+
// skills-sh uses installs-relative scoring) are comparable in the merged list.
|
|
26
29
|
const allHits = [];
|
|
27
30
|
const allAssetHits = [];
|
|
28
31
|
for (let i = 0; i < results.length; i++) {
|
|
@@ -36,23 +39,34 @@ export async function searchRegistry(query, options) {
|
|
|
36
39
|
continue;
|
|
37
40
|
const registryLabel = entries[i].name ? `"${entries[i].name}"` : entries[i].url;
|
|
38
41
|
let dropped = 0;
|
|
42
|
+
const validHits = [];
|
|
39
43
|
for (const hit of value.hits) {
|
|
40
44
|
if (isCompleteHit(hit)) {
|
|
41
|
-
|
|
45
|
+
validHits.push(hit);
|
|
42
46
|
}
|
|
43
47
|
else {
|
|
44
48
|
dropped++;
|
|
45
49
|
}
|
|
46
50
|
}
|
|
51
|
+
// Normalize scores within this provider's batch before merging
|
|
52
|
+
normalizeScores(validHits);
|
|
53
|
+
for (const hit of validHits) {
|
|
54
|
+
allHits.push(hit);
|
|
55
|
+
}
|
|
47
56
|
if (value.assetHits) {
|
|
57
|
+
const validAssetHits = [];
|
|
48
58
|
for (const hit of value.assetHits) {
|
|
49
59
|
if (isCompleteAssetHit(hit)) {
|
|
50
|
-
|
|
60
|
+
validAssetHits.push(hit);
|
|
51
61
|
}
|
|
52
62
|
else {
|
|
53
63
|
dropped++;
|
|
54
64
|
}
|
|
55
65
|
}
|
|
66
|
+
normalizeScores(validAssetHits);
|
|
67
|
+
for (const hit of validAssetHits) {
|
|
68
|
+
allAssetHits.push(hit);
|
|
69
|
+
}
|
|
56
70
|
}
|
|
57
71
|
if (dropped > 0) {
|
|
58
72
|
warnings.push(`Registry ${registryLabel} returned ${dropped} incomplete hit(s); dropped from response.`);
|
|
@@ -60,7 +74,7 @@ export async function searchRegistry(query, options) {
|
|
|
60
74
|
if (value.warnings)
|
|
61
75
|
warnings.push(...value.warnings);
|
|
62
76
|
}
|
|
63
|
-
// Sort merged hits by score descending, apply limit
|
|
77
|
+
// Sort merged hits by normalized score descending, apply limit
|
|
64
78
|
allHits.sort((a, b) => (b.score ?? 0) - (a.score ?? 0));
|
|
65
79
|
const limitedHits = allHits.slice(0, limit);
|
|
66
80
|
allAssetHits.sort((a, b) => (b.score ?? 0) - (a.score ?? 0));
|
|
@@ -80,6 +94,11 @@ export async function searchRegistry(query, options) {
|
|
|
80
94
|
* 1. AKM_REGISTRY_URL env var (CI override, comma-separated)
|
|
81
95
|
* 2. config.registries (filtered by enabled !== false)
|
|
82
96
|
* 3. Default registries from DEFAULT_CONFIG
|
|
97
|
+
*
|
|
98
|
+
* AKM_REGISTRY_URL syntax (comma-separated):
|
|
99
|
+
* - Bare URL: `https://example.com/index.json` → defaults to provider "static-index"
|
|
100
|
+
* - Typed URL: `skills-sh::https://skills.sh/api` → explicit provider type
|
|
101
|
+
* Format: `<provider-type>::<url>`
|
|
83
102
|
*/
|
|
84
103
|
export function resolveRegistries(configRegistries) {
|
|
85
104
|
// Allow env var override (comma-separated URLs) — CI escape hatch
|
|
@@ -87,14 +106,30 @@ export function resolveRegistries(configRegistries) {
|
|
|
87
106
|
if (envUrls) {
|
|
88
107
|
const entries = [];
|
|
89
108
|
for (const raw of envUrls.split(",")) {
|
|
90
|
-
const
|
|
91
|
-
if (!
|
|
109
|
+
const trimmed = raw.trim();
|
|
110
|
+
if (!trimmed)
|
|
92
111
|
continue;
|
|
112
|
+
// Parse optional `<provider-type>::<url>` prefix
|
|
113
|
+
let provider;
|
|
114
|
+
let url;
|
|
115
|
+
const colonColonIdx = trimmed.indexOf("::");
|
|
116
|
+
if (colonColonIdx !== -1 && !trimmed.startsWith("http://") && !trimmed.startsWith("https://")) {
|
|
117
|
+
// Only treat as `provider::url` if the prefix doesn't look like a URL scheme itself
|
|
118
|
+
provider = trimmed.slice(0, colonColonIdx).trim();
|
|
119
|
+
url = trimmed.slice(colonColonIdx + 2).trim();
|
|
120
|
+
if (!provider) {
|
|
121
|
+
warn(`[akm] Ignoring AKM_REGISTRY_URL entry: empty provider type before "::" in "${trimmed}"`);
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
url = trimmed;
|
|
127
|
+
}
|
|
93
128
|
if (!url.startsWith("http://") && !url.startsWith("https://")) {
|
|
94
129
|
warn(`[akm] Ignoring AKM_REGISTRY_URL entry: must start with http:// or https://, got "${url}"`);
|
|
95
130
|
continue;
|
|
96
131
|
}
|
|
97
|
-
entries.push({ url });
|
|
132
|
+
entries.push(provider ? { url, provider } : { url });
|
|
98
133
|
}
|
|
99
134
|
return entries;
|
|
100
135
|
}
|
|
@@ -113,6 +148,34 @@ function createProvider(entry, warnings) {
|
|
|
113
148
|
return factory(entry);
|
|
114
149
|
}
|
|
115
150
|
// ── Utilities ───────────────────────────────────────────────────────────────
|
|
151
|
+
/**
|
|
152
|
+
* Normalize the `score` field of a batch of hits in-place to [0, 1].
|
|
153
|
+
*
|
|
154
|
+
* Different registry providers use incompatible score scales
|
|
155
|
+
* (static-index can exceed 1.85; skills-sh uses installs-relative values
|
|
156
|
+
* in [0, 1]). Normalizing each provider's batch independently before merging
|
|
157
|
+
* makes the merged sort order meaningful.
|
|
158
|
+
*
|
|
159
|
+
* When all scores are identical (or absent), scores are left unchanged so
|
|
160
|
+
* relative ordering within the batch is preserved (all-same is effectively
|
|
161
|
+
* already normalized).
|
|
162
|
+
*/
|
|
163
|
+
function normalizeScores(hits) {
|
|
164
|
+
if (hits.length === 0)
|
|
165
|
+
return;
|
|
166
|
+
const rawScores = hits.map((h) => h.score ?? 0);
|
|
167
|
+
const max = Math.max(...rawScores);
|
|
168
|
+
if (max <= 0)
|
|
169
|
+
return; // all zero or negative — leave as-is
|
|
170
|
+
const min = Math.min(...rawScores);
|
|
171
|
+
const range = max - min;
|
|
172
|
+
for (let i = 0; i < hits.length; i++) {
|
|
173
|
+
const raw = rawScores[i];
|
|
174
|
+
// Min-max normalize: [0, 1]. When all scores are equal (range === 0),
|
|
175
|
+
// fall back to dividing by max so the value stays in [0, 1].
|
|
176
|
+
hits[i].score = range > 0 ? (raw - min) / range : raw / max;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
116
179
|
function clampLimit(limit) {
|
|
117
180
|
if (!limit || !Number.isFinite(limit))
|
|
118
181
|
return 20;
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import { loadConfig } from "../core/config";
|
|
12
12
|
import { UsageError } from "../core/errors";
|
|
13
|
+
import { appendEvent } from "../core/events";
|
|
13
14
|
import { closeDatabase, openDatabase } from "../indexer/db";
|
|
14
15
|
import { searchLocal } from "../indexer/db-search";
|
|
15
16
|
import { resolveSourceEntries } from "../indexer/search-source";
|
|
@@ -147,13 +148,30 @@ function resolveEntryIds(db, hits) {
|
|
|
147
148
|
/**
|
|
148
149
|
* Fire-and-forget: log a search event to the usage_events table.
|
|
149
150
|
* Never blocks the caller; errors are silently ignored.
|
|
151
|
+
*
|
|
152
|
+
* Result count semantics:
|
|
153
|
+
* - `stashHitCount`: number of local stash hits (response.hits, source-only
|
|
154
|
+
* entries). Always 0 for registry-only searches.
|
|
155
|
+
* - `registryHitCount`: number of registry hits (response.registryHits).
|
|
156
|
+
* Only non-zero when source is "registry" or "both".
|
|
157
|
+
* - `resultCount`: total across both pools so telemetry reflects the actual
|
|
158
|
+
* number of results the user saw, regardless of source mode.
|
|
159
|
+
*
|
|
160
|
+
* Per-entry events are recorded only for stash hits because registry hits
|
|
161
|
+
* have no local entry_id to reference.
|
|
150
162
|
*/
|
|
151
163
|
function logSearchEvent(query, response, existingDb) {
|
|
164
|
+
// Emit a structured event to events.jsonl so workflow-trace consumers
|
|
165
|
+
// detect akm search invocations without relying on stdout scraping.
|
|
166
|
+
const stashHits = response.hits.filter((h) => h.type !== "registry");
|
|
167
|
+
appendEvent({
|
|
168
|
+
eventType: "search",
|
|
169
|
+
metadata: { query, hitCount: stashHits.length, resultRefs: stashHits.map((h) => h.ref) },
|
|
170
|
+
});
|
|
152
171
|
try {
|
|
153
172
|
const db = existingDb ?? openDatabase();
|
|
154
173
|
try {
|
|
155
|
-
const
|
|
156
|
-
const resolved = resolveEntryIds(db, stashHits);
|
|
174
|
+
const resolved = resolveEntryIds(db, stashHits.slice(0, 50));
|
|
157
175
|
for (const { entryId, ref } of resolved) {
|
|
158
176
|
insertUsageEvent(db, {
|
|
159
177
|
event_type: "search",
|
|
@@ -162,10 +180,19 @@ function logSearchEvent(query, response, existingDb) {
|
|
|
162
180
|
entry_ref: ref,
|
|
163
181
|
});
|
|
164
182
|
}
|
|
183
|
+
// Count registry hits separately so registry-only searches record a
|
|
184
|
+
// non-zero resultCount. response.hits is always [] when source="registry".
|
|
185
|
+
const stashHitCount = response.hits.length;
|
|
186
|
+
const registryHitCount = Array.isArray(response.registryHits) ? response.registryHits.length : 0;
|
|
165
187
|
insertUsageEvent(db, {
|
|
166
188
|
event_type: "search",
|
|
167
189
|
query,
|
|
168
|
-
metadata: JSON.stringify({
|
|
190
|
+
metadata: JSON.stringify({
|
|
191
|
+
resultCount: stashHitCount + registryHitCount,
|
|
192
|
+
stashHitCount,
|
|
193
|
+
registryHitCount,
|
|
194
|
+
resolvedCount: resolved.length,
|
|
195
|
+
}),
|
|
169
196
|
});
|
|
170
197
|
}
|
|
171
198
|
finally {
|
|
@@ -24,6 +24,7 @@ import path from "node:path";
|
|
|
24
24
|
import { parseAssetRef } from "../core/asset-ref";
|
|
25
25
|
import { loadConfig } from "../core/config";
|
|
26
26
|
import { NotFoundError, UsageError } from "../core/errors";
|
|
27
|
+
import { appendEvent, readEvents } from "../core/events";
|
|
27
28
|
import { parseFrontmatter, toStringOrUndefined } from "../core/frontmatter";
|
|
28
29
|
import { closeDatabase, findEntryIdByRef, openDatabase } from "../indexer/db";
|
|
29
30
|
import { buildFileContext, buildRenderContext, getRenderer, runMatchers } from "../indexer/file-context";
|
|
@@ -35,6 +36,7 @@ import { resolveSourcesForOrigin } from "../registry/origin-resolve";
|
|
|
35
36
|
// Eagerly import source providers to trigger self-registration.
|
|
36
37
|
import "../sources/providers/index";
|
|
37
38
|
import { resolveAssetPath } from "../sources/resolve";
|
|
39
|
+
import { getActiveWorkflowRun } from "../workflows/runs";
|
|
38
40
|
/**
|
|
39
41
|
* Show a wiki root (no page path) — returns the same payload as
|
|
40
42
|
* `akm wiki show <name>`.
|
|
@@ -136,7 +138,13 @@ export async function akmShowUnified(input) {
|
|
|
136
138
|
if (input.scope && hasAnyScopeKey(input.scope) && result.path) {
|
|
137
139
|
enforceScopeOrThrow(result.path, ref, input.scope);
|
|
138
140
|
}
|
|
141
|
+
// Count prior shows of this ref before logging the current one.
|
|
142
|
+
const priorShowCount = recentShowCount(ref);
|
|
139
143
|
logShowEvent(ref);
|
|
144
|
+
if (priorShowCount >= 2) {
|
|
145
|
+
// Agent has shown this same asset 3+ times — inject a loop-break hint.
|
|
146
|
+
result.showLoopWarning = priorShowCount + 1;
|
|
147
|
+
}
|
|
140
148
|
return result;
|
|
141
149
|
}
|
|
142
150
|
function hasAnyScopeKey(scope) {
|
|
@@ -176,7 +184,24 @@ function enforceScopeOrThrow(filePath, ref, scope) {
|
|
|
176
184
|
}
|
|
177
185
|
}
|
|
178
186
|
}
|
|
187
|
+
/**
|
|
188
|
+
* Count how many times `ref` has been shown in the current session by reading
|
|
189
|
+
* recent events. Returns the count BEFORE the current invocation.
|
|
190
|
+
*/
|
|
191
|
+
function recentShowCount(ref) {
|
|
192
|
+
try {
|
|
193
|
+
const { events } = readEvents({ type: "show", ref });
|
|
194
|
+
return events.length;
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
return 0;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
179
200
|
function logShowEvent(ref, existingDb) {
|
|
201
|
+
// Emit a structured event to events.jsonl so workflow-trace consumers
|
|
202
|
+
// detect akm show invocations without relying on stdout scraping.
|
|
203
|
+
const parsed = parseAssetRef(ref);
|
|
204
|
+
appendEvent({ eventType: "show", ref, metadata: { type: parsed.type, name: parsed.name } });
|
|
180
205
|
try {
|
|
181
206
|
const db = existingDb ?? openDatabase();
|
|
182
207
|
try {
|
|
@@ -295,6 +320,10 @@ export async function showLocal(input) {
|
|
|
295
320
|
editable,
|
|
296
321
|
...(!editable ? { editHint: buildEditHint(assetPath, parsed.type, parsed.name, source?.registryId) } : {}),
|
|
297
322
|
};
|
|
323
|
+
const activeRun = getActiveWorkflowRun();
|
|
324
|
+
if (activeRun) {
|
|
325
|
+
fullResponse.activeRun = activeRun;
|
|
326
|
+
}
|
|
298
327
|
if (input.detail === "brief") {
|
|
299
328
|
return buildBriefResponse(fullResponse, assetPath);
|
|
300
329
|
}
|