@sporhq/spor 0.29.0 → 0.29.1
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/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/API.md +7 -4
- package/bin/spor.js +20 -11
- package/lib/kernel/graph.js +41 -7
- package/lib/tar.js +79 -24
- package/package.json +1 -1
- package/scripts/engines/prompt-context.js +10 -2
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "spor",
|
|
3
3
|
"displayName": "Spor Context Compiler",
|
|
4
4
|
"description": "Maintains a typed, versioned knowledge graph and compiles compact briefings from it: session-start injection, per-prompt relevance digests, capture at discovery, end-of-session distillation, decision queue.",
|
|
5
|
-
"version": "0.29.
|
|
5
|
+
"version": "0.29.1",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "losthammer"
|
|
8
8
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "spor",
|
|
3
|
-
"version": "0.29.
|
|
3
|
+
"version": "0.29.1",
|
|
4
4
|
"description": "Maintains a typed, versioned knowledge graph and compiles compact briefings from it: session-start injection, per-prompt relevance digests, capture at discovery, end-of-session distillation, decision queue.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Spor",
|
package/API.md
CHANGED
|
@@ -601,7 +601,7 @@ agent subject sees no graph content; a coarse read-only/CI flag is not a bypass.
|
|
|
601
601
|
| `DELETE /v1/me/tokens/{hash-prefix}` | `spor token revoke` | revoke one of the caller's OWN PATs by hash prefix → `{revoked, hash_prefix, oauth_grants_revoked}`; a prefix that isn't one of the caller's is `404` (never another person's token). `403` if unbound. Shares the admin revoke's OAuth-grant cascade-completeness invariant (issue-cc-pat-revoke-cascades-all-oauth-grants) |
|
|
602
602
|
| `GET /v1/briefing/{project}` | session-start | read the `brief-<project>` node → `{found, version, body, project_brief?, graph_status}`. The slug resolves through project-node aliases (GRAPH.md "Project identity nodes") before lookup. A BARE repo slug also rides up to its home-project grouping: the grouping's `brief-<grouping>` node returns alongside as `project_brief` (the product context spanning sibling repos), matching the shared up-resolution (dec-spor-queue-slug-resolves-to-grouping); passing the repo NODE id (`repo-<slug>`) is the escape hatch that returns only the repo brief, no `project_brief`. Optional `?fp=root:<sha>,remote:<host/path>,...` carries the repo's fingerprints: the server learns them onto the owning project node, and an unknown slug with a known fingerprint files an alias proposal in the queue |
|
|
603
603
|
| `POST /v1/digest` `{query, root?, project?, min_sim?}` | prompt-context, /spor:brief | digest-mode compile → `{found, text}`; `found: false` is a successful empty result. `root` is the structural-walk twin of `query` (the two are mutually exclusive; `root` wins, an unknown id is `422`). Optional `project` is the session slug: the server scopes the compile to it — the same-project relevance boost, the grouping union, and the `always_on` norm `applies_to_*` ride-along — resolving the slug through project-node aliases/groupings inside compile (dec-spor-queue-slug-resolves-to-grouping), exactly as `/v1/queue` does. A bad slug is `422`; **omitting `project` runs the digest project-blind (byte-identical to before)**, so older clients that send only `{query}` are unaffected. **Digest rerank (task-spor-compile-jev-rerank-stage, per-org opt-in, default OFF, server half task-split-spor-server-eb6de3fca31e):** when the org has opted in, the server scores the candidate pool (cosine top-30 ∪ top structural) with `jev.rerank` and hands the verdicts to `compile()` as `opts.rerankScores` — a `{ [id]: { score, noul } }` map, `score` an ordinal 0-3 (irrelevant / background / directly relevant / required) and `noul` a 0-1 probability that that candidate answers or resolves the query. `compile()` stays pure — it never calls Jev itself, it only reorders the section it already builds under `DIGEST_CAP`: pinned → scored candidates (`noul` desc, `score` secondary, cosine-sim tiebreak) → remaining structural (unscored, original score-desc order) → remaining content (unscored, original sim-desc order) — and reports it back as `meta.rerank: {applied: true, candidates: <n>}` (absent when rerank did not run) for the response's own additive `rerank` summary field to echo. Fail-open: an org that hasn't opted in, local mode (no server to call Jev with), or a call that omits/empties `opts.rerankScores` all render byte-identical to before (norm-cc-byte-identical-refactor). **Optional `intent`** (task-spor-digest-intent-jev-gate / task-split-spor-server-bca885114354): when the org has Jev enabled and the request carries a `query`, the server also returns `intent: {warranted: bool, needs_history: 0–1, digest_helps: 0–1, source: "jev"}` — the digest-intent verdict computed server-side (`warranted` = the deterministic prompt heuristics, then max(noul) ≥ 0.5). It is **absent** whenever it was not computed (Jev disabled/shed/timed out/failed, a `root` walk, an older server): fail-open, never `warranted: false` by default. The prompt-context hook suppresses THIS prompt's digest only on an explicit boolean `warranted: false`, and only when its digest intent gate is on (`digest.async`, a tri-state: explicit `true`/`false`, unset = the client default); an absent or non-boolean field is ignored, and so is one on a `found: false` response (its `digest_helps` judged an empty team digest, not the client's personal-graph merge) |
|
|
604
|
-
| `GET /v1/nodes/{id}` | /spor:brief | `get_node` semantics; hidden and absent ids both return `404 not_found`. The node's active schema may attach read-time enrichment via a `get(node, ctx)` hook (GRAPH.md) — the seed `question`/`issue`/`task`/`incident` schemas attach `resolution`: a live **visible** inbound resolves/answers edge carrying the resolver's `summary`/`title` and a `lagging` flag (set when it contradicts a still-open status, clear when the node is already terminal, e.g. an answered question pointing at its answer). Open visible gardener findings about the node ride along as `open_findings` (`bin/spor.js`'s `dispatchDeclineFindingCheck` reads it to refuse re-dispatching a node a prior run DECLINED — task-spor-decline-finding-gates-redispatch — filtering for a live `find-declined-*` entry), and a node marked stale by a visible inbound supersedes edge as `superseded_by`. Returned `raw`/`frontmatter.edges` redact invisible edge targets. All enrichment is additive top-level keys; ignore unknown ones. Reserved: a top-level `inert` boolean — this node's status evaluated against the FULL type-aware queue-liveness-dead partition, including graph-resident schema overrides a graph-less client can't see (issue-spor-type-blind-terminal-status-fallbacks). The graph-less client callers that need it (`bin/spor.js` `dispatchResolutionReason`, `distill.js` `sessionEndLease`) already read it opportunistically when present and fall back to an offline seed-registry check otherwise, but no server response emits the key yet — implementing it server-side is separate, tracked work |
|
|
604
|
+
| `GET /v1/nodes/{id}` | /spor:brief | `get_node` semantics; hidden and absent ids both return `404 not_found`. The node's active schema may attach read-time enrichment via a `get(node, ctx)` hook (GRAPH.md) — the seed `question`/`issue`/`task`/`incident` schemas attach `resolution`: a live **visible** inbound resolves/answers edge carrying the resolver's `summary`/`title` and a `lagging` flag (set when it contradicts a still-open status, clear when the node is already terminal, e.g. an answered question pointing at its answer). Open visible gardener findings about the node ride along as `open_findings` (`bin/spor.js`'s `dispatchDeclineFindingCheck` reads it to refuse re-dispatching a node a prior run DECLINED — task-spor-decline-finding-gates-redispatch — filtering for a live `find-declined-*` entry), and a node marked stale by a visible inbound supersedes edge as `superseded_by`. Returned `raw`/`frontmatter.edges` redact invisible edge targets. `?inbound=1` adds `inbound_edges`: every inbound edge as `{from, type}` in the order a `GET /v1/export` sweep yields (sources sorted by id, each source's edges in file order) — what `spor get --json` uses instead of downloading the export per read (issue-spor-remote-get-json-full-export-per-call); omitted by default. All enrichment is additive top-level keys; ignore unknown ones. Reserved: a top-level `inert` boolean — this node's status evaluated against the FULL type-aware queue-liveness-dead partition, including graph-resident schema overrides a graph-less client can't see (issue-spor-type-blind-terminal-status-fallbacks). The graph-less client callers that need it (`bin/spor.js` `dispatchResolutionReason`, `distill.js` `sessionEndLease`) already read it opportunistically when present and fall back to an offline seed-registry check otherwise, but no server response emits the key yet — implementing it server-side is separate, tracked work |
|
|
605
605
|
| `POST /v1/nodes/batch` `{ids?\|cursor?}` | `get_nodes` | bounded plural `get_node`: start with 1–100 ids, deduplicated by first occurrence; hidden/absent ids appear only in `missing_ids`. Returns `{nodes, returned_ids, missing_ids, truncated, next_cursor}` with the same per-node read enrichment as the single route, capped at 48 KiB without splitting entries. Resume a truncated logical request by sending only its opaque `cursor`; the final page carries `next_cursor: null` |
|
|
606
606
|
| `GET /v1/nodes/{id}/history?limit=N` | `spor history <id>`, the `node_history` MCP tool | per-node commit lineage — a `git log` projection over `nodes/{id}.md` → `{id, head, count, history: [{sha, short, actor, actor_name, actor_email, date, message, internal, person}]}`, newest first. Each revision is labeled `internal:true` for a server-internal write (boot reconcile / migration, `server@spor.invalid`) vs. a real actor, and mapped to its `person` node by author email. Deliberately NOT `git log --follow` (node files share heavy frontmatter boilerplate, so similarity-based rename detection crosses node boundaries — dec-spor-node-history-git-log-projection). `limit` defaults to 50, max 200. The frontmatter `author` re-stamps to the LAST editor on every write, so this is the only durable record of the full chain of editors. A node with no commit history (unknown id) is `404`; a bad id is `422`. The `spor history <id>` CLI verb is the shell front-door (remote reads this; local mode runs the same projection over the graph home) |
|
|
607
607
|
| `GET /v1/nodes/{id}/history/{sha}` | `spor history <id> <sha>`, `node_history` (sha mode) | one revision's detail, the expensive half gated behind an explicit per-sha fetch → the history record for that commit plus `{change, patch, content}`: the change type (`A`/`M`/`D`/`R`), the patch this commit introduced to the node file, and the full node content at that revision (`null` when the commit deleted it). The `sha` must be one from the node's own history — a sha that didn't touch the node, or an unresolvable sha, is `404`; a malformed sha is `422` |
|
|
@@ -888,9 +888,12 @@ rather than re-POSTed forever; `429` and `5xx` stay transient and are
|
|
|
888
888
|
retried with backoff.
|
|
889
889
|
|
|
890
890
|
`GET /v1/export` response headers: `x-substrate-head` carries the graph
|
|
891
|
-
commit, `x-substrate-node-count` the entry count (plus
|
|
892
|
-
`
|
|
893
|
-
|
|
891
|
+
commit, `x-substrate-node-count` the entry count (plus `x-substrate-auth-files`
|
|
892
|
+
on an `?auth=1` export — the count of `auth/*.json` files bundled). A path too
|
|
893
|
+
long for any ustar name/prefix split (a node id near the 200-byte limit) is
|
|
894
|
+
written under a POSIX pax extended header carrying its full `path`, so no entry
|
|
895
|
+
is omitted; the legacy `x-substrate-skipped` header is no longer emitted and a
|
|
896
|
+
client should treat its absence as zero. A
|
|
894
897
|
`?history=1` bundle carries only `x-substrate-head` (a git bundle has no node
|
|
895
898
|
count). These header names are a wire contract and were deliberately **not**
|
|
896
899
|
renamed in the Spor rename — clients should keep reading the `x-substrate-*`
|
package/bin/spor.js
CHANGED
|
@@ -1103,7 +1103,11 @@ async function cmdGet(cfg, { positionals, values }) {
|
|
|
1103
1103
|
return 1;
|
|
1104
1104
|
}
|
|
1105
1105
|
if (cfg.mode() === "remote") {
|
|
1106
|
-
|
|
1106
|
+
// --json asks the server for the node's inbound edges in the same read
|
|
1107
|
+
// (?inbound=1, issue-spor-remote-get-json-full-export-per-call); an older
|
|
1108
|
+
// server ignores the param and the export sweep below fills them instead.
|
|
1109
|
+
const q = values.json ? "?inbound=1" : "";
|
|
1110
|
+
const r = await remote.get(cfg, `/v1/nodes/${encodeURIComponent(id)}${q}`, { timeoutMs: 6000 });
|
|
1107
1111
|
if (r.transport) {
|
|
1108
1112
|
err(`offline — could not reach server (${r.error})`);
|
|
1109
1113
|
return 1;
|
|
@@ -1122,9 +1126,10 @@ async function cmdGet(cfg, { positionals, values }) {
|
|
|
1122
1126
|
return 0;
|
|
1123
1127
|
}
|
|
1124
1128
|
// --json: parse the raw with the SAME lib parser as local (parity), take the
|
|
1125
|
-
// server's git-blob-sha revision, and
|
|
1126
|
-
//
|
|
1127
|
-
//
|
|
1129
|
+
// server's git-blob-sha revision, and take inbound edges from the server's
|
|
1130
|
+
// inbound_edges when it sent them. Only an older server without ?inbound=1
|
|
1131
|
+
// falls back to the graph-wide sweep via GET /v1/export (the path `spor
|
|
1132
|
+
// query --to` walks) — a full download per read, so never the first choice.
|
|
1128
1133
|
const graphLib = require(path.join(ROOT, "lib", "graph.js"));
|
|
1129
1134
|
const raw = r.json && r.json.raw;
|
|
1130
1135
|
if (typeof raw !== "string") {
|
|
@@ -1132,13 +1137,17 @@ async function cmdGet(cfg, { positionals, values }) {
|
|
|
1132
1137
|
return 1;
|
|
1133
1138
|
}
|
|
1134
1139
|
const node = graphLib.parseFrontmatter(raw, `${id}.md`);
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1140
|
+
let inbound = Array.isArray(r.json.inbound_edges)
|
|
1141
|
+
? r.json.inbound_edges.map((e) => ({ from: e.from, type: e.type }))
|
|
1142
|
+
: null;
|
|
1143
|
+
if (!inbound) {
|
|
1144
|
+
const fetched = await fetchRemoteExportNodes(cfg, "get");
|
|
1145
|
+
if (fetched.error) return 1; // already reported
|
|
1146
|
+
try {
|
|
1147
|
+
inbound = inboundEdges(graphLib.loadGraph(fetched.nodesDir), node.id);
|
|
1148
|
+
} finally {
|
|
1149
|
+
fetched.cleanup();
|
|
1150
|
+
}
|
|
1142
1151
|
}
|
|
1143
1152
|
out(JSON.stringify(getNodeJson(node, inbound, r.json.revision), null, 2));
|
|
1144
1153
|
return 0;
|
package/lib/kernel/graph.js
CHANGED
|
@@ -1010,6 +1010,18 @@ function compile(graph, opts = {}) {
|
|
|
1010
1010
|
// along is a schema flag now (always_on: true; the seed sets it on norm).
|
|
1011
1011
|
const reg = graph.registry ?? seedRegistry(opts.seedSchemas ?? []);
|
|
1012
1012
|
|
|
1013
|
+
// Supersession by STATUS as well as by edge (issue-spor-superseded-context-
|
|
1014
|
+
// injected-as-live): a node whose own status reads `superseded` but whose
|
|
1015
|
+
// replacement never earned a `supersedes` edge used to render with only a
|
|
1016
|
+
// `, superseded` parenthetical — which the prompt hook's compaction dropped —
|
|
1017
|
+
// so it reached ambient context reading as live guidance. Both forms now get
|
|
1018
|
+
// the same treatment: ⚠ block + summary-only in briefings, dropped from the
|
|
1019
|
+
// digest (unless a correction pinned it), never riding along as a norm.
|
|
1020
|
+
const isSuperseded = (n) => !!supersededBy[n.id] || String(n.status ?? "").toLowerCase() === "superseded";
|
|
1021
|
+
const supersededLabel = (n) => supersededBy[n.id]
|
|
1022
|
+
? `SUPERSEDED by ${supersededBy[n.id]}`
|
|
1023
|
+
: "SUPERSEDED (status: superseded; no superseding node linked)";
|
|
1024
|
+
|
|
1013
1025
|
// Project scoping (issue-cc-digest-unscoped-cross-project-ranking): under a
|
|
1014
1026
|
// single-org-graph topology tf-idf ranks every prompt against ALL teams'
|
|
1015
1027
|
// nodes equally, so shared vocabulary ("auth", "deploy", "migration")
|
|
@@ -1256,6 +1268,11 @@ function compile(graph, opts = {}) {
|
|
|
1256
1268
|
};
|
|
1257
1269
|
const normCandidates = Object.values(nodes).filter((n) =>
|
|
1258
1270
|
reg.isAlwaysOn(n.type) && normRidesAlong(n) &&
|
|
1271
|
+
// A superseded or retired norm is no longer team policy; the ride-along
|
|
1272
|
+
// injects unasked into every session, so it must never carry one
|
|
1273
|
+
// (issue-spor-superseded-context-injected-as-live). It can still surface
|
|
1274
|
+
// through the relevance arms, where render() flags it.
|
|
1275
|
+
!isSuperseded(n) && !resolution.isTerminalStatus(n.status, n.type, graph) &&
|
|
1259
1276
|
!structuralSet.has(n.id) && !contentSet.has(n.id) && !pinned.has(n.id) && !excluded.has(n.id));
|
|
1260
1277
|
// When the candidate set exceeds the cap, KEEP the most topically relevant
|
|
1261
1278
|
// (then by id, deterministic) but RENDER them in the original insertion
|
|
@@ -1292,13 +1309,13 @@ function compile(graph, opts = {}) {
|
|
|
1292
1309
|
// whatever its status says, so a terminal status there is reported as HELD
|
|
1293
1310
|
// rather than as done — the read-time twin of the queue's rule.
|
|
1294
1311
|
function statusTag(n) {
|
|
1295
|
-
if (
|
|
1312
|
+
if (isSuperseded(n) || !resolution.isTerminalStatus(n.status, n.type, graph)) return "";
|
|
1296
1313
|
return resolution.executionHeld(n) ? `, ${n.status.toLowerCase()} — HELD by execution ${n.execution}, not retired` : `, ${n.status.toLowerCase()}`;
|
|
1297
1314
|
}
|
|
1298
1315
|
// Inline ⚠ for an edge-retired node whose status still reads live, mirroring
|
|
1299
1316
|
// the get_node lead line. Superseded nodes are handled separately.
|
|
1300
1317
|
function resolutionWarn(n) {
|
|
1301
|
-
if (
|
|
1318
|
+
if (isSuperseded(n) || resolution.isTerminalStatus(n.status, n.type, graph)) return "";
|
|
1302
1319
|
const r = resMap[n.id];
|
|
1303
1320
|
if (!r) return "";
|
|
1304
1321
|
const when = r.date ? ` (${r.date})` : "";
|
|
@@ -1317,10 +1334,10 @@ function compile(graph, opts = {}) {
|
|
|
1317
1334
|
// ---------- rendering ----------
|
|
1318
1335
|
function render(n, full, provenance) {
|
|
1319
1336
|
const head = `### ${n.id} — ${n.title} (${n.type}, ${n.date}${statusTag(n)}${crossTag(n)}${machineAuthorshipTag(n)})\n*selected via: ${provenance}*\n`;
|
|
1320
|
-
const stale =
|
|
1321
|
-
? `\n> ⚠
|
|
1337
|
+
const stale = isSuperseded(n)
|
|
1338
|
+
? `\n> ⚠ ${supersededLabel(n)}. Do not follow; included only so you recognize stale references.\n`
|
|
1322
1339
|
: resolutionWarn(n) ? `\n>${resolutionWarn(n)}.\n` : "";
|
|
1323
|
-
return (full && !
|
|
1340
|
+
return (full && !isSuperseded(n)) ? `${head}${stale}\n${n.body}\n` : `${head}${stale}\n${n.summary}\n`;
|
|
1324
1341
|
}
|
|
1325
1342
|
|
|
1326
1343
|
// Norm rendering (issue-cc-norm-always-on-injection): an always_on norm rides
|
|
@@ -1360,8 +1377,14 @@ function compile(graph, opts = {}) {
|
|
|
1360
1377
|
const add = (n, tag) => {
|
|
1361
1378
|
if (seen.has(n.id)) return;
|
|
1362
1379
|
seen.add(n.id);
|
|
1363
|
-
|
|
1364
|
-
|
|
1380
|
+
// The digest is AMBIENT context (the prompt and session-start hooks inject
|
|
1381
|
+
// it unasked, a handful of slots wide), so a superseded node is dropped
|
|
1382
|
+
// rather than flagged — its replacement is already pulled in by the
|
|
1383
|
+
// supersession fixups above. A correction's explicit pin still renders
|
|
1384
|
+
// it, flagged (issue-spor-superseded-context-injected-as-live).
|
|
1385
|
+
if (isSuperseded(n) && !pinned.has(n.id)) return;
|
|
1386
|
+
const warn = isSuperseded(n)
|
|
1387
|
+
? ` ⚠ ${supersededLabel(n)} — do not follow`
|
|
1365
1388
|
: resolutionWarn(n);
|
|
1366
1389
|
const proj = n.project ? `, ${n.project}` : "";
|
|
1367
1390
|
lines.push(`- **${n.id} — ${n.title}** (${n.type}${proj}, ${n.date}${statusTag(n)}${crossTag(n)}${machineAuthorshipTag(n)}${tag ? `, ${tag}` : ""}): ${n.summary}${warn}`);
|
|
@@ -1846,6 +1869,17 @@ function validateGraphFiles(files, seedSchemas = [], opts = {}) {
|
|
|
1846
1869
|
}
|
|
1847
1870
|
}
|
|
1848
1871
|
|
|
1872
|
+
// A `status: superseded` with no inbound `supersedes` edge names no
|
|
1873
|
+
// replacement, so no reader can follow it to what is now in force
|
|
1874
|
+
// (issue-spor-superseded-context-injected-as-live).
|
|
1875
|
+
const supersededTargets = new Set();
|
|
1876
|
+
for (const n of Object.values(nodes)) for (const e of n.edges) if (e.type === "supersedes") supersededTargets.add(e.to);
|
|
1877
|
+
for (const n of Object.values(nodes)) {
|
|
1878
|
+
if (String(n.status ?? "").toLowerCase() === "superseded" && !supersededTargets.has(n.id)) {
|
|
1879
|
+
warnings.push(`${n.file}: status superseded but no node supersedes it — add a supersedes edge from its replacement so readers can find what is in force`);
|
|
1880
|
+
}
|
|
1881
|
+
}
|
|
1882
|
+
|
|
1849
1883
|
const byType = {};
|
|
1850
1884
|
for (const n of Object.values(nodes)) byType[n.type] = (byType[n.type] ?? 0) + 1;
|
|
1851
1885
|
|
package/lib/tar.js
CHANGED
|
@@ -54,24 +54,61 @@ function splitUstarName(name) {
|
|
|
54
54
|
function tarHeader(name, size, mtime) {
|
|
55
55
|
const split = splitUstarName(name);
|
|
56
56
|
if (!split) throw new Error(`ustar: '${name}' has no /-boundary split that fits prefix(155)+name(100)`);
|
|
57
|
+
return rawTarHeader(split.name, split.prefix, size, mtime, "0");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// One 512-byte ustar header block with the name/prefix fields already split.
|
|
61
|
+
function rawTarHeader(name, prefix, size, mtime, typeflag) {
|
|
57
62
|
const buf = Buffer.alloc(512);
|
|
58
|
-
buf.write(
|
|
63
|
+
buf.write(name, 0, 100, "utf8");
|
|
59
64
|
buf.write("0000644\0", 100); // mode
|
|
60
65
|
buf.write("0000000\0", 108); // uid
|
|
61
66
|
buf.write("0000000\0", 116); // gid
|
|
62
67
|
buf.write(size.toString(8).padStart(11, "0") + "\0", 124);
|
|
63
68
|
buf.write(mtime.toString(8).padStart(11, "0") + "\0", 136);
|
|
64
69
|
buf.write(" ", 148); // chksum: spaces while summing
|
|
65
|
-
buf.write(
|
|
70
|
+
buf.write(typeflag, 156); // '0' regular file, 'x' pax extended header
|
|
66
71
|
buf.write("ustar\0", 257);
|
|
67
72
|
buf.write("00", 263);
|
|
68
|
-
if (
|
|
73
|
+
if (prefix) buf.write(prefix, 345, 155, "utf8");
|
|
69
74
|
let sum = 0;
|
|
70
75
|
for (const b of buf) sum += b;
|
|
71
76
|
buf.write(sum.toString(8).padStart(6, "0") + "\0 ", 148);
|
|
72
77
|
return buf;
|
|
73
78
|
}
|
|
74
79
|
|
|
80
|
+
// A pax extended-header record, "<len> <key>=<value>\n", where <len> counts the
|
|
81
|
+
// whole record including its own digits.
|
|
82
|
+
function paxRecord(key, value) {
|
|
83
|
+
const body = ` ${key}=${value}\n`;
|
|
84
|
+
const bodyLen = Buffer.byteLength(body, "utf8");
|
|
85
|
+
let digits = 1;
|
|
86
|
+
while (String(bodyLen + digits).length !== digits) digits++;
|
|
87
|
+
return `${bodyLen + digits}${body}`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// The header bytes for one archive entry — the byte-faithful twin of the
|
|
91
|
+
// server's tarEntryHeader (spor-server server/rest.js). A path the ustar prefix
|
|
92
|
+
// split represents gets the plain ustar header; one it can't (a node id too long
|
|
93
|
+
// for the 100-byte name field) gets a POSIX pax extended header carrying the
|
|
94
|
+
// full `path`, then the regular header under a short deterministic stub name
|
|
95
|
+
// (issue-spor-export-drops-ids-over-ustar-name-limit).
|
|
96
|
+
function tarEntryHeader(name, size, mtime) {
|
|
97
|
+
const split = splitUstarName(name);
|
|
98
|
+
if (split) return rawTarHeader(split.name, split.prefix, size, mtime, "0");
|
|
99
|
+
const dir = name.includes("/") ? name.slice(0, name.lastIndexOf("/")) : "";
|
|
100
|
+
const hash = require("crypto").createHash("sha1").update(name).digest("hex").slice(0, 16);
|
|
101
|
+
const stub = `${dir ? `${dir}/` : ""}longname-${hash}`;
|
|
102
|
+
const pax = Buffer.from(paxRecord("path", name), "utf8");
|
|
103
|
+
const pad = (512 - (pax.length % 512)) % 512;
|
|
104
|
+
return Buffer.concat([
|
|
105
|
+
rawTarHeader(`${dir ? `${dir}/` : ""}PaxHeaders/${hash}`, "", pax.length, mtime, "x"),
|
|
106
|
+
pax,
|
|
107
|
+
Buffer.alloc(pad),
|
|
108
|
+
rawTarHeader(stub, "", size, mtime, "0"),
|
|
109
|
+
]);
|
|
110
|
+
}
|
|
111
|
+
|
|
75
112
|
// Concatenate the descriptors ({name, abs}) into a ustar archive Buffer: a
|
|
76
113
|
// header per file, its bytes, padding up to the next 512 boundary, then two
|
|
77
114
|
// closing zero blocks. mtime mirrors the server (file mtime, second precision).
|
|
@@ -80,7 +117,7 @@ function buildTarball(descriptors) {
|
|
|
80
117
|
for (const d of descriptors) {
|
|
81
118
|
const data = fs.readFileSync(d.abs);
|
|
82
119
|
const mtime = Math.max(0, Math.floor(fs.statSync(d.abs).mtimeMs / 1000));
|
|
83
|
-
parts.push(
|
|
120
|
+
parts.push(tarEntryHeader(d.name, data.length, mtime));
|
|
84
121
|
parts.push(data);
|
|
85
122
|
const pad = (512 - (data.length % 512)) % 512;
|
|
86
123
|
if (pad) parts.push(Buffer.alloc(pad));
|
|
@@ -90,27 +127,17 @@ function buildTarball(descriptors) {
|
|
|
90
127
|
}
|
|
91
128
|
|
|
92
129
|
// The export entry list for a graph home's nodes/ dir, mirroring the server's
|
|
93
|
-
// selection: every *.md file, sorted by name, as a `nodes/<name>` entry.
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
// still unrepresentable in this archive format).
|
|
130
|
+
// selection: every *.md file, sorted by name, as a `nodes/<name>` entry. A long
|
|
131
|
+
// id the prefix split can't represent rides a pax header (tarEntryHeader), so
|
|
132
|
+
// nothing is dropped; `skipped` stays in the return shape (always 0) for callers.
|
|
97
133
|
function collectNodeEntries(nodesDir) {
|
|
98
134
|
const names = fs
|
|
99
135
|
.readdirSync(nodesDir, { withFileTypes: true })
|
|
100
136
|
.filter((d) => d.isFile() && d.name.endsWith(".md"))
|
|
101
137
|
.map((d) => d.name)
|
|
102
138
|
.sort();
|
|
103
|
-
const descriptors =
|
|
104
|
-
|
|
105
|
-
for (const name of names) {
|
|
106
|
-
const entry = `nodes/${name}`;
|
|
107
|
-
if (!splitUstarName(entry)) {
|
|
108
|
-
skipped++;
|
|
109
|
-
continue;
|
|
110
|
-
}
|
|
111
|
-
descriptors.push({ name: entry, abs: path.join(nodesDir, name) });
|
|
112
|
-
}
|
|
113
|
-
return { descriptors, skipped };
|
|
139
|
+
const descriptors = names.map((name) => ({ name: `nodes/${name}`, abs: path.join(nodesDir, name) }));
|
|
140
|
+
return { descriptors, skipped: 0 };
|
|
114
141
|
}
|
|
115
142
|
|
|
116
143
|
// The whole local export in one call: { buffer, count, skipped } where buffer
|
|
@@ -141,24 +168,52 @@ function tarField(header, start, len) {
|
|
|
141
168
|
// `prefix` field (the tarHeader/splitUstarName rescue for a long path) is
|
|
142
169
|
// rejoined as `prefix + "/" + name`, matching how a real ustar reader
|
|
143
170
|
// reconstructs the full path — same reconstruction rule splitUstarName's
|
|
144
|
-
// comment describes for why the split can only land on an existing "/".
|
|
171
|
+
// comment describes for why the split can only land on an existing "/". A pax
|
|
172
|
+
// extended header (typeflag 'x') is consumed, not returned: its `path` record
|
|
173
|
+
// names the entry that follows (the tarEntryHeader rescue for an id too long
|
|
174
|
+
// for any ustar split, issue-spor-export-drops-ids-over-ustar-name-limit).
|
|
145
175
|
function extract(buf) {
|
|
146
176
|
const entries = [];
|
|
147
177
|
let off = 0;
|
|
178
|
+
let paxPath = null; // a pending pax `path` record, applied to the NEXT entry only
|
|
148
179
|
while (off + 512 <= buf.length) {
|
|
149
180
|
const header = buf.subarray(off, off + 512);
|
|
150
181
|
if (header.every((b) => b === 0)) break; // end-of-archive zero block
|
|
151
182
|
const prefix = tarField(header, 345, 155);
|
|
152
183
|
const name = prefix ? `${prefix}/${tarField(header, 0, 100)}` : tarField(header, 0, 100);
|
|
153
184
|
const size = parseInt(tarField(header, 124, 12).trim() || "0", 8) || 0;
|
|
154
|
-
const typeflag = header[156]; // 0x30 '0' or 0x00 → regular file
|
|
185
|
+
const typeflag = header[156]; // 0x30 '0' or 0x00 → regular file; 0x78 'x' → pax
|
|
155
186
|
off += 512; // advance past the header to the data
|
|
156
|
-
if (
|
|
157
|
-
|
|
187
|
+
if (typeflag === 0x78) {
|
|
188
|
+
paxPath = parsePaxPath(buf.subarray(off, off + size));
|
|
189
|
+
} else {
|
|
190
|
+
if (typeflag === 0x30 || typeflag === 0) {
|
|
191
|
+
const full = paxPath || name;
|
|
192
|
+
if (full) entries.push({ name: full, data: Buffer.from(buf.subarray(off, off + size)) });
|
|
193
|
+
}
|
|
194
|
+
paxPath = null;
|
|
158
195
|
}
|
|
159
196
|
off += Math.ceil(size / 512) * 512; // skip the data + its padding to the next block
|
|
160
197
|
}
|
|
161
198
|
return entries;
|
|
162
199
|
}
|
|
163
200
|
|
|
164
|
-
|
|
201
|
+
// The `path` value from a pax extended-header body ("<len> key=value\n"...), or
|
|
202
|
+
// null when it carries none.
|
|
203
|
+
function parsePaxPath(data) {
|
|
204
|
+
let pos = 0;
|
|
205
|
+
let found = null;
|
|
206
|
+
while (pos < data.length) {
|
|
207
|
+
const sp = data.indexOf(0x20, pos);
|
|
208
|
+
if (sp < 0) break;
|
|
209
|
+
const len = parseInt(data.toString("utf8", pos, sp), 10);
|
|
210
|
+
if (!(len > 0)) break;
|
|
211
|
+
const rec = data.toString("utf8", sp + 1, pos + len - 1); // drop the trailing \n
|
|
212
|
+
const eq = rec.indexOf("=");
|
|
213
|
+
if (eq > 0 && rec.slice(0, eq) === "path") found = rec.slice(eq + 1);
|
|
214
|
+
pos += len;
|
|
215
|
+
}
|
|
216
|
+
return found;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
module.exports = { tarHeader, tarEntryHeader, paxRecord, splitUstarName, buildTarball, collectNodeEntries, exportNodesDir, extract };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sporhq/spor",
|
|
3
|
-
"version": "0.29.
|
|
3
|
+
"version": "0.29.1",
|
|
4
4
|
"description": "Spor — a shared memory substrate for teams and agents. Decisions, their reasons, and the traces they leave. Knowledge-graph context compiler: session-start briefings, per-prompt digests, capture at discovery, end-of-session distillation, decision queue.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Anthony Allen",
|
|
@@ -165,7 +165,7 @@ function compactNodeLine(line) {
|
|
|
165
165
|
const [, id, title, meta, rest] = m;
|
|
166
166
|
const warning = rest.match(/ ⚠ .+$/);
|
|
167
167
|
const summary = warning ? rest.slice(0, warning.index).trim() : rest.trim();
|
|
168
|
-
const statusMatch = meta.match(/\b(resolved|done|rejected|abandoned|answered)\b/i);
|
|
168
|
+
const statusMatch = meta.match(/\b(resolved|done|rejected|abandoned|answered|superseded|settled)\b/i);
|
|
169
169
|
const status = statusMatch ? ` (${statusMatch[1].toLowerCase()})` : "";
|
|
170
170
|
return `- ${id}: ${title}${status} — ${summary}${warning ? warning[0] : ""}`;
|
|
171
171
|
}
|
|
@@ -174,7 +174,15 @@ function microDigest(digest, maxNodes = MICRO_MAX_NODES, maxBytes = MICRO_MAX_BY
|
|
|
174
174
|
const { nodes, corrections } = parseDigest(digest);
|
|
175
175
|
if (!nodes.length) return u.byteHead(digest, maxBytes);
|
|
176
176
|
let out = "Spor context (top matches; run /spor:brief for full):\n";
|
|
177
|
-
|
|
177
|
+
// Whole node lines only: a line's ⚠ note (superseded / resolved) sits at
|
|
178
|
+
// its END, so a mid-line byte cut would inject the node minus the warning
|
|
179
|
+
// that says not to follow it (issue-spor-superseded-context-injected-as-live).
|
|
180
|
+
// The first line always goes in (byteHead below still bounds it).
|
|
181
|
+
for (const [i, line] of nodes.slice(0, maxNodes).entries()) {
|
|
182
|
+
const l = `${compactNodeLine(line)}\n`;
|
|
183
|
+
if (i > 0 && Buffer.byteLength(out + l, "utf8") > maxBytes) break;
|
|
184
|
+
out += l;
|
|
185
|
+
}
|
|
178
186
|
if (corrections.length) {
|
|
179
187
|
out += "\nStanding corrections:\n";
|
|
180
188
|
for (const line of corrections) out += `${line}\n`;
|