akm-cli 0.9.0-beta.28 → 0.9.0-beta.29
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 +5 -15
- package/dist/cli.js +0 -2
- package/dist/commands/agent/contribute-cli.js +0 -20
- package/dist/commands/read/knowledge.js +4 -7
- package/dist/output/shapes/passthrough.js +0 -3
- package/package.json +1 -1
- package/dist/commands/fact/fact-cli.js +0 -155
- package/dist/commands/fact/fact-context.js +0 -149
package/CHANGELOG.md
CHANGED
|
@@ -6,23 +6,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
-
## [0.9.0-beta.
|
|
9
|
+
## [0.9.0-beta.29] — 2026-06-20
|
|
10
10
|
|
|
11
|
-
###
|
|
11
|
+
### Reverted
|
|
12
12
|
|
|
13
|
-
- **#630 — `fact` asset type phase 2
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
the index (pre-filtered on the `pinned` search hint), excludes
|
|
17
|
-
`stale`/`superseded`/`archived` facts from the core (they stay searchable), and
|
|
18
|
-
**fails soft** — a missing index or unreadable file yields an empty list, so
|
|
19
|
-
collection never blocks a dispatch. `buildPinnedFactsBlock()` assembles a
|
|
20
|
-
deterministic, category-grouped `## Stash facts` block, and `akm agent`
|
|
21
|
-
prepends it to the system prompt for user-facing runs (only when a task or agent
|
|
22
|
-
asset is present; bare interactive launches stay clean; opt out with
|
|
23
|
-
`--no-facts`). New `akm fact` CLI: `add <name> [body] --category <c> [--pinned]`
|
|
24
|
-
(hot-capture à la `akm remember`), `list [--category] [--pinned]`, and `context`
|
|
25
|
-
(print the assembled pinned core). Design note: `docs/design/fact-asset-type.md`.
|
|
13
|
+
- **#630 — `fact` asset type phase 2 reverted (#631).** The pinned-core assembly +
|
|
14
|
+
`akm fact` CLI shipped in beta.28 was reverted pending rework. Phase 1 (#629, the
|
|
15
|
+
`fact` asset type itself) remains in place.
|
|
26
16
|
|
|
27
17
|
## [0.9.0-beta.27] — 2026-06-20
|
|
28
18
|
|
package/dist/cli.js
CHANGED
|
@@ -63,7 +63,6 @@ import { generateBashCompletions, installBashCompletions } from "./commands/comp
|
|
|
63
63
|
import { configCommand } from "./commands/config-cli.js";
|
|
64
64
|
import { envCommand } from "./commands/env/env-cli.js";
|
|
65
65
|
import { secretCommand } from "./commands/env/secret-cli.js";
|
|
66
|
-
import { factCommand } from "./commands/fact/fact-cli.js";
|
|
67
66
|
import { feedbackCommand } from "./commands/feedback-cli.js";
|
|
68
67
|
import { graphCommand } from "./commands/graph/graph-cli.js";
|
|
69
68
|
import { akmHealth, parseWindowSpec, renderRunsDetailMd, renderWindowCompareMd, } from "./commands/health.js";
|
|
@@ -520,7 +519,6 @@ export const main = defineCommand({
|
|
|
520
519
|
secret: secretCommand,
|
|
521
520
|
wiki: wikiCommand,
|
|
522
521
|
tasks: tasksCommand,
|
|
523
|
-
fact: factCommand,
|
|
524
522
|
},
|
|
525
523
|
});
|
|
526
524
|
// ── Exit codes ──────────────────────────────────────────────────────────────
|
|
@@ -29,7 +29,6 @@ import { assertFlatAssetName, combineCreatePath, normalizeCreateSubPath } from "
|
|
|
29
29
|
import { loadConfig } from "../../core/config/config.js";
|
|
30
30
|
import { UsageError } from "../../core/errors.js";
|
|
31
31
|
import { getHyphenatedArg } from "../../output/context.js";
|
|
32
|
-
import { buildPinnedFactsContext } from "../fact/fact-context.js";
|
|
33
32
|
import { akmLint } from "../lint.js";
|
|
34
33
|
import { akmPropose } from "../proposal/propose.js";
|
|
35
34
|
import { akmAgentDispatch } from "./agent-dispatch.js";
|
|
@@ -58,11 +57,6 @@ export const agentCommand = defineCommand({
|
|
|
58
57
|
description: "Model override — accepts aliases (opus, sonnet, haiku) or exact platform model IDs. Overrides the model specified in the agent asset.",
|
|
59
58
|
},
|
|
60
59
|
"timeout-ms": { type: "string", description: "Override the agent CLI timeout in milliseconds" },
|
|
61
|
-
"no-facts": {
|
|
62
|
-
type: "boolean",
|
|
63
|
-
description: "Do not prepend the pinned stash facts (fact: assets with `pinned: true`) to the system prompt",
|
|
64
|
-
default: false,
|
|
65
|
-
},
|
|
66
60
|
},
|
|
67
61
|
async run({ args }) {
|
|
68
62
|
await runWithJsonErrors(async () => {
|
|
@@ -91,20 +85,6 @@ export const agentCommand = defineCommand({
|
|
|
91
85
|
const promptText = getStringArg(args, "prompt");
|
|
92
86
|
const commandRef = getStringArg(args, "command");
|
|
93
87
|
const workflowRef = getStringArg(args, "workflow");
|
|
94
|
-
// Phase 2: prepend the pinned-fact "core" (fact assets with
|
|
95
|
-
// `pinned: true`) to the system prompt for user-facing agent runs, so
|
|
96
|
-
// durable stash facts and conventions are always in context. Only injects
|
|
97
|
-
// when there is actually a task or agent asset — bare interactive launches
|
|
98
|
-
// (`akm agent claude`) stay clean. Opt out with --no-facts. Fails soft:
|
|
99
|
-
// buildPinnedFactsContext never throws, so fact collection can't block a
|
|
100
|
-
// dispatch.
|
|
101
|
-
if (getHyphenatedArg(args, "no-facts") !== true &&
|
|
102
|
-
(promptText ?? commandRef ?? workflowRef ?? agentRef)) {
|
|
103
|
-
const factsBlock = buildPinnedFactsContext();
|
|
104
|
-
if (factsBlock) {
|
|
105
|
-
systemPrompt = systemPrompt ? `${factsBlock}\n\n${systemPrompt}` : factsBlock;
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
88
|
// Only build a dispatch request when there is something to dispatch — a
|
|
109
89
|
// prompt, an agent asset, or a model override. When none of these are
|
|
110
90
|
// present the agent is launched interactively (no injected prompt, no
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
import fs from "node:fs";
|
|
12
12
|
import path from "node:path";
|
|
13
13
|
import { assertFlatAssetName, combineCreatePath, normalizeCreateSubPath } from "../../core/asset/asset-create.js";
|
|
14
|
-
import { resolveAssetPathFromName
|
|
14
|
+
import { resolveAssetPathFromName } from "../../core/asset/asset-spec.js";
|
|
15
15
|
import { isHttpUrl, isWithin, tryReadStdinText } from "../../core/common.js";
|
|
16
16
|
import { loadConfig } from "../../core/config/config.js";
|
|
17
17
|
import { UsageError } from "../../core/errors.js";
|
|
@@ -109,9 +109,7 @@ export async function readKnowledgeInput(source) {
|
|
|
109
109
|
}
|
|
110
110
|
// ── Asset writing ────────────────────────────────────────────────────────────
|
|
111
111
|
/**
|
|
112
|
-
* Write a markdown asset (knowledge
|
|
113
|
-
* target. The type's stash subdirectory is resolved from the asset registry
|
|
114
|
-
* (`TYPE_DIRS`), so adding a markdown type here needs no further changes.
|
|
112
|
+
* Write a markdown asset (knowledge or memory) to the resolved write target.
|
|
115
113
|
*
|
|
116
114
|
* Resolves the write target via the v1 precedence chain (`--target` →
|
|
117
115
|
* `defaultWriteTarget` → working stash), validates the path is within the
|
|
@@ -122,7 +120,7 @@ export async function writeMarkdownAsset(options) {
|
|
|
122
120
|
const cfg = loadConfig();
|
|
123
121
|
const target = resolveWriteTarget(cfg, options.target);
|
|
124
122
|
const { source, config } = target;
|
|
125
|
-
const typeRoot = path.join(source.path,
|
|
123
|
+
const typeRoot = path.join(source.path, options.type === "knowledge" ? "knowledge" : "memories");
|
|
126
124
|
// `--name` is the flat asset name; `--path` is the subdirectory under the
|
|
127
125
|
// type root. Combine them into the nested name the path resolver expects.
|
|
128
126
|
const subPath = normalizeCreateSubPath(options.path);
|
|
@@ -136,8 +134,7 @@ export async function writeMarkdownAsset(options) {
|
|
|
136
134
|
throw new UsageError(`Resolved ${options.type} path escapes the stash: "${normalizedName}"`);
|
|
137
135
|
}
|
|
138
136
|
if (fs.existsSync(assetPath) && !options.force) {
|
|
139
|
-
|
|
140
|
-
throw new UsageError(`${label} "${normalizedName}" already exists. Re-run with --force to overwrite it.`, "RESOURCE_ALREADY_EXISTS");
|
|
137
|
+
throw new UsageError(`${options.type === "knowledge" ? "Knowledge" : "Memory"} "${normalizedName}" already exists. Re-run with --force to overwrite it.`, "RESOURCE_ALREADY_EXISTS");
|
|
141
138
|
}
|
|
142
139
|
const ref = { type: options.type, name: normalizedName };
|
|
143
140
|
const result = await writeAssetToSource(source, config, ref, options.content);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "akm-cli",
|
|
3
|
-
"version": "0.9.0-beta.
|
|
3
|
+
"version": "0.9.0-beta.29",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "akm (Agent Knowledge Management) — A package manager for AI agent skills, commands, tools, and knowledge. Works with Claude Code, OpenCode, Cursor, and any AI coding assistant.",
|
|
6
6
|
"keywords": [
|
|
@@ -1,155 +0,0 @@
|
|
|
1
|
-
// This Source Code Form is subject to the terms of the Mozilla Public
|
|
2
|
-
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
3
|
-
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
4
|
-
/**
|
|
5
|
-
* `akm fact` — manage durable stash-level facts (the `fact` asset type, phase 2).
|
|
6
|
-
*
|
|
7
|
-
* Subcommands:
|
|
8
|
-
* - `add <name> [content]` — hot-capture a fact (à la `akm remember`), writing
|
|
9
|
-
* `facts/<category>/<name>.md` with `category` + optional `pinned`.
|
|
10
|
-
* - `list` — list indexed facts, optionally filtered by `--category` / `--pinned`.
|
|
11
|
-
* - `context` — print the assembled "pinned core" block (the same block the
|
|
12
|
-
* `akm agent` dispatch prepends to a system prompt). Useful for previewing
|
|
13
|
-
* or piping into an AGENTS.md / CLAUDE.md.
|
|
14
|
-
*/
|
|
15
|
-
import { defineCommand } from "citty";
|
|
16
|
-
import { defineJsonCommand, output } from "../../cli/shared.js";
|
|
17
|
-
import { serializeFrontmatter } from "../../core/asset/asset-serialize.js";
|
|
18
|
-
import { toErrorMessage, tryReadStdinText } from "../../core/common.js";
|
|
19
|
-
import { UsageError } from "../../core/errors.js";
|
|
20
|
-
import { appendEvent } from "../../core/events.js";
|
|
21
|
-
import { closeDatabase, getAllEntries, openExistingDatabase } from "../../indexer/db/db.js";
|
|
22
|
-
import { assertFlatAssetName, writeMarkdownAsset } from "../read/knowledge.js";
|
|
23
|
-
import { buildPinnedFactsBlock, collectPinnedFacts } from "./fact-context.js";
|
|
24
|
-
/** Recommended categories (mirrors the fact linter). Free values are allowed but linted. */
|
|
25
|
-
const KNOWN_CATEGORIES = ["personal", "team", "project", "convention", "meta"];
|
|
26
|
-
function buildFactFrontmatter(fields) {
|
|
27
|
-
const obj = { category: fields.category };
|
|
28
|
-
if (fields.description?.trim())
|
|
29
|
-
obj.description = fields.description.trim();
|
|
30
|
-
if (fields.pinned)
|
|
31
|
-
obj.pinned = true;
|
|
32
|
-
obj.updated = new Date().toISOString().slice(0, 10);
|
|
33
|
-
return `---\n${serializeFrontmatter(obj)}\n---`;
|
|
34
|
-
}
|
|
35
|
-
const addCommand = defineJsonCommand({
|
|
36
|
-
meta: { name: "add", description: "Record a durable stash fact (facts/<category>/<name>.md)" },
|
|
37
|
-
args: {
|
|
38
|
-
name: {
|
|
39
|
-
type: "positional",
|
|
40
|
-
description: "Fact name (flat, no '/'; use --category for the subdirectory)",
|
|
41
|
-
required: false,
|
|
42
|
-
},
|
|
43
|
-
content: { type: "positional", description: "Fact body. Omit to read markdown from stdin.", required: false },
|
|
44
|
-
category: {
|
|
45
|
-
type: "string",
|
|
46
|
-
description: `Fact category — scopes the fact (one of: ${KNOWN_CATEGORIES.join(", ")}; free values allowed but linted)`,
|
|
47
|
-
},
|
|
48
|
-
pinned: { type: "boolean", description: "Mark this fact as always-injected core context", default: false },
|
|
49
|
-
description: { type: "string", description: "Short description written to frontmatter" },
|
|
50
|
-
path: { type: "string", description: "Subdirectory under facts/ (defaults to --category)" },
|
|
51
|
-
force: { type: "boolean", description: "Overwrite an existing fact with the same name", default: false },
|
|
52
|
-
target: { type: "string", description: "Override the write destination (a configured source name)" },
|
|
53
|
-
},
|
|
54
|
-
async run({ args }) {
|
|
55
|
-
if (!args.name || typeof args.name !== "string") {
|
|
56
|
-
throw new UsageError("Usage: akm fact add <name> [content] --category <category>", "MISSING_REQUIRED_ARGUMENT", "Provide a flat fact name; pass the body as a quoted argument or via stdin.");
|
|
57
|
-
}
|
|
58
|
-
assertFlatAssetName(args.name);
|
|
59
|
-
const category = typeof args.category === "string" ? args.category.trim() : "";
|
|
60
|
-
if (!category) {
|
|
61
|
-
throw new UsageError(`A --category is required (one of: ${KNOWN_CATEGORIES.join(", ")}).`, "MISSING_REQUIRED_ARGUMENT");
|
|
62
|
-
}
|
|
63
|
-
const body = (typeof args.content === "string" ? args.content : undefined) ?? tryReadStdinText();
|
|
64
|
-
if (!body?.trim()) {
|
|
65
|
-
throw new UsageError("Fact content is required. Pass quoted text or pipe markdown into stdin.");
|
|
66
|
-
}
|
|
67
|
-
const description = typeof args.description === "string" ? args.description : undefined;
|
|
68
|
-
const pinned = args.pinned === true;
|
|
69
|
-
const frontmatter = buildFactFrontmatter({ description, category, pinned });
|
|
70
|
-
const subPath = typeof args.path === "string" && args.path.trim() ? args.path.trim() : category;
|
|
71
|
-
const result = await writeMarkdownAsset({
|
|
72
|
-
type: "fact",
|
|
73
|
-
content: `${frontmatter}\n${body}`,
|
|
74
|
-
name: args.name,
|
|
75
|
-
fallbackPrefix: "fact",
|
|
76
|
-
force: args.force === true,
|
|
77
|
-
target: typeof args.target === "string" ? args.target : undefined,
|
|
78
|
-
path: subPath,
|
|
79
|
-
});
|
|
80
|
-
appendEvent({
|
|
81
|
-
eventType: "fact_add",
|
|
82
|
-
ref: result.ref,
|
|
83
|
-
metadata: { path: result.path, category, pinned, force: args.force === true },
|
|
84
|
-
});
|
|
85
|
-
output("fact-add", { ok: true, ...result, category, pinned });
|
|
86
|
-
},
|
|
87
|
-
});
|
|
88
|
-
/** Pull the `category:<x>` value out of an entry's search hints, if present. */
|
|
89
|
-
function categoryFromHints(hints) {
|
|
90
|
-
const hit = hints?.find((h) => h.startsWith("category:"));
|
|
91
|
-
return hit ? hit.slice("category:".length) : undefined;
|
|
92
|
-
}
|
|
93
|
-
const listCommand = defineJsonCommand({
|
|
94
|
-
meta: { name: "list", description: "List indexed facts (optionally filtered by --category / --pinned)" },
|
|
95
|
-
args: {
|
|
96
|
-
category: { type: "string", description: "Only list facts in this category" },
|
|
97
|
-
pinned: { type: "boolean", description: "Only list pinned (always-injected core) facts", default: false },
|
|
98
|
-
},
|
|
99
|
-
run({ args }) {
|
|
100
|
-
const categoryFilter = typeof args.category === "string" ? args.category.trim() : undefined;
|
|
101
|
-
const pinnedOnly = args.pinned === true;
|
|
102
|
-
let db;
|
|
103
|
-
let facts = [];
|
|
104
|
-
try {
|
|
105
|
-
db = openExistingDatabase();
|
|
106
|
-
facts = getAllEntries(db, "fact")
|
|
107
|
-
.map((row) => {
|
|
108
|
-
const category = categoryFromHints(row.entry.searchHints);
|
|
109
|
-
return {
|
|
110
|
-
ref: `fact:${row.entry.name}`,
|
|
111
|
-
name: row.entry.name,
|
|
112
|
-
...(category ? { category } : {}),
|
|
113
|
-
pinned: row.entry.searchHints?.includes("pinned") ?? false,
|
|
114
|
-
...(row.entry.description ? { description: row.entry.description } : {}),
|
|
115
|
-
};
|
|
116
|
-
})
|
|
117
|
-
.filter((f) => (categoryFilter ? f.category === categoryFilter : true))
|
|
118
|
-
.filter((f) => (pinnedOnly ? f.pinned : true))
|
|
119
|
-
.sort((a, b) => a.name.localeCompare(b.name));
|
|
120
|
-
}
|
|
121
|
-
catch (err) {
|
|
122
|
-
// No index yet → empty list rather than a hard error.
|
|
123
|
-
void toErrorMessage(err);
|
|
124
|
-
facts = [];
|
|
125
|
-
}
|
|
126
|
-
finally {
|
|
127
|
-
if (db)
|
|
128
|
-
closeDatabase(db);
|
|
129
|
-
}
|
|
130
|
-
output("fact-list", { ok: true, facts, totalCount: facts.length });
|
|
131
|
-
},
|
|
132
|
-
});
|
|
133
|
-
const contextCommand = defineJsonCommand({
|
|
134
|
-
meta: {
|
|
135
|
-
name: "context",
|
|
136
|
-
description: "Print the assembled pinned-fact core block (what `akm agent` injects into the system prompt)",
|
|
137
|
-
},
|
|
138
|
-
run() {
|
|
139
|
-
const facts = collectPinnedFacts();
|
|
140
|
-
output("fact-context", { ok: true, content: buildPinnedFactsBlock(facts), count: facts.length });
|
|
141
|
-
},
|
|
142
|
-
});
|
|
143
|
-
const factSubCommands = {
|
|
144
|
-
add: addCommand,
|
|
145
|
-
list: listCommand,
|
|
146
|
-
context: contextCommand,
|
|
147
|
-
};
|
|
148
|
-
export const FACT_SUBCOMMAND_SET = new Set(Object.keys(factSubCommands));
|
|
149
|
-
export const factCommand = defineCommand({
|
|
150
|
-
meta: {
|
|
151
|
-
name: "fact",
|
|
152
|
-
description: "Manage durable stash-level facts (the `fact` asset type): personal/team/project details, coding conventions, and stash-meta. Subcommands: add, list, context.",
|
|
153
|
-
},
|
|
154
|
-
subCommands: factSubCommands,
|
|
155
|
-
});
|
|
@@ -1,149 +0,0 @@
|
|
|
1
|
-
// This Source Code Form is subject to the terms of the Mozilla Public
|
|
2
|
-
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
3
|
-
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
4
|
-
/**
|
|
5
|
-
* Pinned-fact context assembly (fact asset type, phase 2).
|
|
6
|
-
*
|
|
7
|
-
* The `fact` type (see docs/design/fact-asset-type.md) stores durable
|
|
8
|
-
* stash-level semantic knowledge. Facts marked `pinned: true` form the small
|
|
9
|
-
* always-injected "core" — the handful of high-signal facts an agent should
|
|
10
|
-
* always have in context (personal identity, team conventions, the
|
|
11
|
-
* "constitution"). Everything else stays on "disk" and is reached via normal
|
|
12
|
-
* `akm search` / `akm curate` just-in-time retrieval.
|
|
13
|
-
*
|
|
14
|
-
* This module collects the pinned core from the index and assembles it into a
|
|
15
|
-
* compact markdown block that the `akm agent` dispatch prepends to the agent's
|
|
16
|
-
* system prompt. The pure assembly + parse helpers are exported so they can be
|
|
17
|
-
* unit-tested without a database.
|
|
18
|
-
*/
|
|
19
|
-
import fs from "node:fs";
|
|
20
|
-
import { parseFrontmatter } from "../../core/asset/frontmatter.js";
|
|
21
|
-
import { asNonEmptyString } from "../../core/common.js";
|
|
22
|
-
import { closeDatabase, getAllEntries, openExistingDatabase } from "../../indexer/db/db.js";
|
|
23
|
-
/**
|
|
24
|
-
* Fact `status` values that EXCLUDE a fact from the always-injected core.
|
|
25
|
-
* Durable facts go stale; rather than delete them, an author can mark a fact
|
|
26
|
-
* `status: stale` (or `superseded`) and it drops out of the pinned context
|
|
27
|
-
* while remaining searchable. (Design note phase 2 — staleness handling.)
|
|
28
|
-
*/
|
|
29
|
-
const EXCLUDED_STATUSES = new Set(["stale", "superseded", "archived"]);
|
|
30
|
-
/**
|
|
31
|
-
* Parse a single fact file into a {@link PinnedFact}, or return `null` when the
|
|
32
|
-
* fact is NOT part of the pinned core: not `pinned: true`, or carrying an
|
|
33
|
-
* excluded `status`. Pure — takes the raw file content so it is unit-testable
|
|
34
|
-
* without touching disk.
|
|
35
|
-
*/
|
|
36
|
-
export function parsePinnedFact(name, raw) {
|
|
37
|
-
const parsed = parseFrontmatter(raw);
|
|
38
|
-
const fm = parsed.data;
|
|
39
|
-
if (fm.pinned !== true)
|
|
40
|
-
return null;
|
|
41
|
-
const status = asNonEmptyString(fm.status)?.toLowerCase();
|
|
42
|
-
if (status && EXCLUDED_STATUSES.has(status))
|
|
43
|
-
return null;
|
|
44
|
-
const body = parsed.content.trim();
|
|
45
|
-
return {
|
|
46
|
-
ref: `fact:${name}`,
|
|
47
|
-
name,
|
|
48
|
-
...(asNonEmptyString(fm.category) ? { category: asNonEmptyString(fm.category) } : {}),
|
|
49
|
-
...(asNonEmptyString(fm.description) ? { description: asNonEmptyString(fm.description) } : {}),
|
|
50
|
-
body,
|
|
51
|
-
};
|
|
52
|
-
}
|
|
53
|
-
/**
|
|
54
|
-
* Render a list of pinned facts into a compact markdown context block, grouped
|
|
55
|
-
* by category. Returns `""` for an empty list so callers can treat "no facts"
|
|
56
|
-
* uniformly. Pure.
|
|
57
|
-
*/
|
|
58
|
-
export function buildPinnedFactsBlock(facts) {
|
|
59
|
-
if (facts.length === 0)
|
|
60
|
-
return "";
|
|
61
|
-
// Stable order: by category then name, so the block is deterministic.
|
|
62
|
-
const sorted = [...facts].sort((a, b) => {
|
|
63
|
-
const ca = a.category ?? "";
|
|
64
|
-
const cb = b.category ?? "";
|
|
65
|
-
if (ca !== cb)
|
|
66
|
-
return ca.localeCompare(cb);
|
|
67
|
-
return a.name.localeCompare(b.name);
|
|
68
|
-
});
|
|
69
|
-
const lines = [
|
|
70
|
-
"## Stash facts",
|
|
71
|
-
"",
|
|
72
|
-
"Durable, authoritative facts about this user / team / project. Treat them as background context and follow any conventions they state.",
|
|
73
|
-
"",
|
|
74
|
-
];
|
|
75
|
-
let lastCategory;
|
|
76
|
-
for (const fact of sorted) {
|
|
77
|
-
const category = fact.category ?? "general";
|
|
78
|
-
if (category !== lastCategory) {
|
|
79
|
-
lines.push(`### ${category}`, "");
|
|
80
|
-
lastCategory = category;
|
|
81
|
-
}
|
|
82
|
-
const heading = fact.description ? `${fact.name} — ${fact.description}` : fact.name;
|
|
83
|
-
lines.push(`- **${heading}**`);
|
|
84
|
-
if (fact.body) {
|
|
85
|
-
// Indent the body two spaces so it reads as part of the bullet.
|
|
86
|
-
for (const bodyLine of fact.body.split("\n")) {
|
|
87
|
-
lines.push(bodyLine.length > 0 ? ` ${bodyLine}` : "");
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
lines.push("");
|
|
91
|
-
}
|
|
92
|
-
return lines.join("\n").trimEnd();
|
|
93
|
-
}
|
|
94
|
-
/**
|
|
95
|
-
* Collect the pinned core from the index. Opens the existing index DB (unless
|
|
96
|
-
* one is injected for tests), reads every `fact` entry whose `pinned` search
|
|
97
|
-
* hint is set, then parses each file from disk. Fails soft: any error (missing
|
|
98
|
-
* index, unreadable file) yields an empty list rather than throwing, so callers
|
|
99
|
-
* on the hot path (agent dispatch) are never broken by fact collection.
|
|
100
|
-
*/
|
|
101
|
-
export function collectPinnedFacts(injectedDb) {
|
|
102
|
-
let db;
|
|
103
|
-
let ownsDb = false;
|
|
104
|
-
try {
|
|
105
|
-
db = injectedDb ?? openExistingDatabase();
|
|
106
|
-
ownsDb = injectedDb === undefined;
|
|
107
|
-
}
|
|
108
|
-
catch {
|
|
109
|
-
return [];
|
|
110
|
-
}
|
|
111
|
-
try {
|
|
112
|
-
const entries = getAllEntries(db, "fact");
|
|
113
|
-
const facts = [];
|
|
114
|
-
for (const row of entries) {
|
|
115
|
-
// Fast pre-filter on the indexed `pinned` search hint so we only read
|
|
116
|
-
// files for facts that are actually part of the core.
|
|
117
|
-
if (!row.entry.searchHints?.includes("pinned"))
|
|
118
|
-
continue;
|
|
119
|
-
let raw;
|
|
120
|
-
try {
|
|
121
|
-
raw = fs.readFileSync(row.filePath, "utf8");
|
|
122
|
-
}
|
|
123
|
-
catch {
|
|
124
|
-
continue;
|
|
125
|
-
}
|
|
126
|
-
const fact = parsePinnedFact(row.entry.name, raw);
|
|
127
|
-
if (fact)
|
|
128
|
-
facts.push(fact);
|
|
129
|
-
}
|
|
130
|
-
return facts;
|
|
131
|
-
}
|
|
132
|
-
catch {
|
|
133
|
-
return [];
|
|
134
|
-
}
|
|
135
|
-
finally {
|
|
136
|
-
if (ownsDb && db)
|
|
137
|
-
closeDatabase(db);
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
/**
|
|
141
|
-
* Convenience for the agent-dispatch seam: collect the pinned core and return
|
|
142
|
-
* the assembled markdown block, or `undefined` when there are no pinned facts.
|
|
143
|
-
*/
|
|
144
|
-
export function buildPinnedFactsContext(injectedDb) {
|
|
145
|
-
const facts = collectPinnedFacts(injectedDb);
|
|
146
|
-
if (facts.length === 0)
|
|
147
|
-
return undefined;
|
|
148
|
-
return buildPinnedFactsBlock(facts);
|
|
149
|
-
}
|