opencode-self-improvement 0.8.6 → 0.8.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +47 -9
- package/package.json +1 -1
- package/src/capture.ts +14 -5
- package/src/config.ts +13 -0
- package/src/gates.ts +29 -0
- package/src/index.ts +18 -10
- package/src/inject.ts +65 -13
- package/src/memory.ts +25 -12
- package/src/portable.ts +24 -5
- package/src/secrets.ts +39 -0
package/README.md
CHANGED
|
@@ -50,6 +50,33 @@ LLMs waste a lot of tokens reading and re-reading your code and asking you for c
|
|
|
50
50
|
- Automatically looks for insights when you complete the verification gates (tests, linting, etc)
|
|
51
51
|
- Always defaults to memories first before sending a prompt to the LLM, which saves tokens!
|
|
52
52
|
|
|
53
|
+
## Secret redaction
|
|
54
|
+
|
|
55
|
+
`src/secrets.ts` provides one best-effort credential rule set. It runs over the
|
|
56
|
+
auto-capture prompt before that prompt leaves the plugin, at the write gate
|
|
57
|
+
before content is hashed or analysed, and again in `appendMemory`, the final
|
|
58
|
+
write choke point used by gated writes and the viewer's edit/restore-entry
|
|
59
|
+
paths. Credential-shaped values in memory content become `[redacted]`.
|
|
60
|
+
Credential-shaped `scope`, `tags`, or `issue` metadata is rejected instead of
|
|
61
|
+
being silently rewritten.
|
|
62
|
+
|
|
63
|
+
The rules cover OpenAI `sk-` tokens; GitHub `ghp_`, `gho_`, and `github_pat_`
|
|
64
|
+
tokens; Slack `xoxb-` and `xoxp-` tokens; AWS `AKIA` access-key IDs; Google
|
|
65
|
+
`AIza` API keys; bearer tokens containing digits; PEM private-key blocks; and
|
|
66
|
+
high-entropy values assigned to names ending in `_KEY`, `_TOKEN`, `_SECRET`,
|
|
67
|
+
or `_PASSWORD` (plus `password`). This is deliberately best-effort, not a
|
|
68
|
+
guarantee: it can miss hex-only secrets, passwords with few character classes,
|
|
69
|
+
and credentials written in prose without an assignment.
|
|
70
|
+
|
|
71
|
+
`memory_remember` still replies `Remembered` when content was redacted, so the
|
|
72
|
+
stored text can differ from what was sent. Redaction is forward-only: existing
|
|
73
|
+
stores are not scrubbed, and `memory_export` writes whatever is already stored
|
|
74
|
+
verbatim. See [issue #74](https://github.com/abogartz/opencode-self-improvement/issues/74)
|
|
75
|
+
for the separate existing-store work.
|
|
76
|
+
|
|
77
|
+
Auto-capture is off by default, so capture-prompt redaction protects opt-in
|
|
78
|
+
installs. The write-gate and `appendMemory` checks apply to every install.
|
|
79
|
+
|
|
53
80
|
## Token cost, on purpose
|
|
54
81
|
|
|
55
82
|
Memory plugins have a reputation for burning tokens and busting the provider's
|
|
@@ -65,8 +92,10 @@ prompt cache on every message. This one is built the other way around:
|
|
|
65
92
|
queries (one or two words — a deliberate search) need only one. Injection
|
|
66
93
|
stays quiet on single-word coincidences, which `memory_recall` still
|
|
67
94
|
reaches on the pull path — rare and right beats frequent and maybe.
|
|
68
|
-
- **Hard character
|
|
69
|
-
|
|
95
|
+
- **Hard character budgets.** The relevance block and each contextual pull are
|
|
96
|
+
capped at 1200 characters by default; the session-start open-threads frontier
|
|
97
|
+
has its own 3600-character cap (see Options). All are trimmed at whole-memory
|
|
98
|
+
boundaries — never cut mid-sentence.
|
|
70
99
|
- **Evidence is never injected**, and the standing system digest is two lines.
|
|
71
100
|
- **Receipts, not promises.** The plugin measures your session's actual
|
|
72
101
|
prompt-cache usage (from each turn's `tokens.cache` accounting) and shows it
|
|
@@ -263,13 +292,14 @@ the plugin entry in `opencode.json(c)` instead of the plain string:
|
|
|
263
292
|
|
|
264
293
|
```json
|
|
265
294
|
{
|
|
266
|
-
"plugins": [{ "package": "opencode-self-improvement", "options": { "injectBudget": 1200 } }]
|
|
295
|
+
"plugins": [{ "package": "opencode-self-improvement", "options": { "injectBudget": 1200, "frontierBudget": 3600 } }]
|
|
267
296
|
}
|
|
268
297
|
```
|
|
269
298
|
|
|
270
299
|
| Option | Default | What it does |
|
|
271
300
|
| --- | --- | --- |
|
|
272
|
-
| `injectBudget` | `1200` | Character cap on the injected relevance block
|
|
301
|
+
| `injectBudget` | `1200` | Character cap on the injected relevance block and each contextual pull; trimmed at whole-memory boundaries. Env fallback: `OPENCODE_MEMORY_INJECT_BUDGET`. |
|
|
302
|
+
| `frontierBudget` | `3 × injectBudget` (`3600` by default) | Character cap on the session-start open-threads block. A thread longer than this budget is not injected and is visibly reported as omitted; raise this option (or `OPENCODE_MEMORY_FRONTIER_BUDGET`) to include longer planning threads. |
|
|
273
303
|
| `autoCapture` | `false` (off) | When on, each session-idle event makes one cheap model pass that proposes memory candidates. Every candidate still goes through the 4-gate write pipeline — capture can never bypass the gates. Off by default: the zero-token install stays zero-token. |
|
|
274
304
|
| `captureModel` | — | `"provider/id"` model used for auto-capture. Required when `autoCapture` is on; a missing or invalid value disables capture with a startup warning. |
|
|
275
305
|
| `captureMax` | `3` | Maximum candidates proposed per idle event. |
|
|
@@ -349,15 +379,23 @@ and the tool reports a tally of outcomes per line —
|
|
|
349
379
|
| `collapsed` | near-duplicate of an existing memory; nothing written |
|
|
350
380
|
| `blocked` | rejected by a gate; reason is in `memory_evidence` |
|
|
351
381
|
| `skipped` | malformed line; never reaches the gates |
|
|
352
|
-
| `downgraded` | `status:"open"` without an export header to vouch for it; imported as settled |
|
|
382
|
+
| `downgraded` | `status:"open"` without an export header to vouch for it, claiming `repo:"global"`, or carrying an unverifiable stamp when the importing session has no repo scope; imported as settled |
|
|
353
383
|
|
|
354
384
|
The frontier policy matters because an open row is exempt from ref grounding:
|
|
355
385
|
a hand-written JSONL can't hand its rows that free pass (the header is
|
|
356
386
|
friction, not a signature — the real defense is that imported content always
|
|
357
|
-
injects framed as data and marked `[via import]`).
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
387
|
+
injects framed as data and marked `[via import]`). Even with a header, an
|
|
388
|
+
imported open row claiming `repo:"global"` is downgraded, so one file can't add
|
|
389
|
+
an open thread to every repo. Imported `repo` and `repoPath` stamps are honored
|
|
390
|
+
only when they match the importing session's current repo (an explicit
|
|
391
|
+
`repo:"global"` on a settled row is kept); foreign stamps are discarded and the
|
|
392
|
+
row is stamped for the importing repo instead. When there is no current repo
|
|
393
|
+
scope, stamps are kept as they are (dropping them would widen the row to
|
|
394
|
+
global) and a stamped open row is downgraded, since its stamp can't be verified.
|
|
395
|
+
|
|
396
|
+
So re-running the same import in the same repo is a no-op (everything
|
|
397
|
+
collapses). Copying an export to a fresh machine and importing it from that
|
|
398
|
+
repo is how you seed a new laptop from an old one.
|
|
361
399
|
|
|
362
400
|
## Memory at a glance
|
|
363
401
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-self-improvement",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.8",
|
|
4
4
|
"description": "Durable memory for opencode: remember facts, decisions, and commands across sessions, plus a one-command per-repo setup (/repo-init).",
|
|
5
5
|
"main": "index.ts",
|
|
6
6
|
"exports": {
|
package/src/capture.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
// never a throw.
|
|
9
9
|
import { STATUSES, TYPES } from "./types.ts";
|
|
10
10
|
import { flattenMemoryText } from "./sanitize.ts";
|
|
11
|
+
import { redactSecrets } from "./secrets.ts";
|
|
11
12
|
|
|
12
13
|
export interface CaptureOptions {
|
|
13
14
|
model: string; // "provider/id"
|
|
@@ -46,7 +47,7 @@ export function buildCapturePrompt(transcript: string, max = 3): string {
|
|
|
46
47
|
"- If nothing is worth remembering, output [ ].",
|
|
47
48
|
"",
|
|
48
49
|
"Transcript:",
|
|
49
|
-
transcript,
|
|
50
|
+
redactSecrets(transcript),
|
|
50
51
|
].join("\n");
|
|
51
52
|
}
|
|
52
53
|
|
|
@@ -152,15 +153,23 @@ export function flattenTranscript(msgs: unknown): string {
|
|
|
152
153
|
const role =
|
|
153
154
|
typeof info?.role === "string" ? info.role : typeof m.role === "string" ? m.role : "";
|
|
154
155
|
let text = "";
|
|
156
|
+
let hadPrivatePart = false;
|
|
155
157
|
if (Array.isArray(m.parts)) {
|
|
156
158
|
text = m.parts
|
|
157
|
-
.map((p) =>
|
|
158
|
-
p
|
|
159
|
-
|
|
159
|
+
.map((p) => {
|
|
160
|
+
if (!p || typeof p !== "object") return undefined;
|
|
161
|
+
const part = p as Record<string, unknown>;
|
|
162
|
+
const partType = typeof part.type === "string" ? part.type.toLowerCase() : "";
|
|
163
|
+
if (partType === "reasoning" || partType === "thinking") {
|
|
164
|
+
hadPrivatePart = true;
|
|
165
|
+
return undefined;
|
|
166
|
+
}
|
|
167
|
+
return part.text;
|
|
168
|
+
})
|
|
160
169
|
.filter((t): t is string => typeof t === "string" && t.length > 0)
|
|
161
170
|
.join(" ");
|
|
162
171
|
}
|
|
163
|
-
if (!text) {
|
|
172
|
+
if (!text && !hadPrivatePart) {
|
|
164
173
|
for (const k of ["text", "content"]) {
|
|
165
174
|
if (typeof m[k] === "string") {
|
|
166
175
|
text = m[k] as string;
|
package/src/config.ts
CHANGED
|
@@ -54,6 +54,19 @@ export function injectBudget(): number {
|
|
|
54
54
|
return 1200;
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
// Character budget for the session-start open-thread frontier. Kept separate
|
|
58
|
+
// from relevance injection and contextual pulls: planning rows are often much
|
|
59
|
+
// longer, and raising this cap must not raise those per-request surfaces too.
|
|
60
|
+
// Defaults to 3x the effective injection budget.
|
|
61
|
+
export function frontierBudget(baseBudget = injectBudget()): number {
|
|
62
|
+
const raw = process.env.OPENCODE_MEMORY_FRONTIER_BUDGET;
|
|
63
|
+
if (raw) {
|
|
64
|
+
const n = Number.parseInt(raw, 10);
|
|
65
|
+
if (Number.isFinite(n) && n > 0) return n;
|
|
66
|
+
}
|
|
67
|
+
return baseBudget * 3;
|
|
68
|
+
}
|
|
69
|
+
|
|
57
70
|
// Optional live-graph binary. When present the resolver cross-checks the local
|
|
58
71
|
// source scan against the codebase-memory-mcp file graph; when absent the local
|
|
59
72
|
// scan alone is authoritative (install-from-scratch requirement).
|
package/src/gates.ts
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
} from "./memory.ts";
|
|
16
16
|
import { chainId, contentHash, record, recordWriteBlocked } from "./evidence.ts";
|
|
17
17
|
import { flattenMemoryText } from "./sanitize.ts";
|
|
18
|
+
import { containsSecret, redactSecrets } from "./secrets.ts";
|
|
18
19
|
import {
|
|
19
20
|
displayCall,
|
|
20
21
|
extractCallForms,
|
|
@@ -251,6 +252,34 @@ export async function runWriteGate(
|
|
|
251
252
|
};
|
|
252
253
|
}
|
|
253
254
|
|
|
255
|
+
const credentialMetadata = [
|
|
256
|
+
containsSecret(candidate.scope) ? "scope" : undefined,
|
|
257
|
+
candidate.tags?.some(containsSecret) ? "tags" : undefined,
|
|
258
|
+
containsSecret(candidate.issue) ? "issue" : undefined,
|
|
259
|
+
].filter((field): field is string => field !== undefined);
|
|
260
|
+
if (credentialMetadata.length) {
|
|
261
|
+
await recordWriteBlocked(
|
|
262
|
+
4,
|
|
263
|
+
"credential_in_metadata",
|
|
264
|
+
credentialMetadata.map((field) => `field=${field}`),
|
|
265
|
+
chain,
|
|
266
|
+
);
|
|
267
|
+
return {
|
|
268
|
+
action: "blocked",
|
|
269
|
+
reason: "credential_in_metadata",
|
|
270
|
+
chain,
|
|
271
|
+
hint: `Credential-shaped value in ${credentialMetadata.join(", ")}; remove it and retry. Only memory content is redacted automatically.`,
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// This is the common boundary for manual, imported and auto-captured writes.
|
|
276
|
+
// Scrub content before hashing, analysis and persistence. Metadata is
|
|
277
|
+
// rejected above rather than silently changing where a row can be found.
|
|
278
|
+
candidate = {
|
|
279
|
+
...candidate,
|
|
280
|
+
content: redactSecrets(candidate.content),
|
|
281
|
+
};
|
|
282
|
+
|
|
254
283
|
const ch = contentHash(candidate.content);
|
|
255
284
|
await record("gate_start", {
|
|
256
285
|
chain,
|
package/src/index.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
// helper modules.
|
|
13
13
|
import type { Cleanup, Plugin } from "@opencode/plugin/promise/plugin";
|
|
14
14
|
import { basename, join } from "node:path";
|
|
15
|
-
import { evidenceDir, injectBudget, memoryRoot, dateStr } from "./config.ts";
|
|
15
|
+
import { evidenceDir, frontierBudget, injectBudget, memoryRoot, dateStr } from "./config.ts";
|
|
16
16
|
import { STATUSES, TYPES } from "./types.ts";
|
|
17
17
|
import {
|
|
18
18
|
clearSuperseded,
|
|
@@ -66,6 +66,7 @@ import { getCurrent, setCurrent } from "./state.ts";
|
|
|
66
66
|
import { resolveRepo, setRepoScope, rememberSessionDir, sessionDir } from "./repokey.ts";
|
|
67
67
|
import { readLines } from "./logfmt.ts";
|
|
68
68
|
import { buildCapturePrompt, flattenTranscript, parseCandidates, parseModelRef } from "./capture.ts";
|
|
69
|
+
import { redactSecrets } from "./secrets.ts";
|
|
69
70
|
import { flattenMemoryText } from "./sanitize.ts";
|
|
70
71
|
|
|
71
72
|
// Tool/input boundaries are zod schemas (src/schema.ts): the JSON Schema the
|
|
@@ -178,10 +179,12 @@ const impl = {
|
|
|
178
179
|
const liveRoot = setupRoot ?? process.cwd();
|
|
179
180
|
setRepoScope(setupRoot ? resolveRepo(setupRoot) : null);
|
|
180
181
|
|
|
181
|
-
// Character
|
|
182
|
-
//
|
|
183
|
-
//
|
|
182
|
+
// Character budgets: relevance injection and contextual pulls share the
|
|
183
|
+
// existing cap; the session-start frontier has its own larger cap. Plugin
|
|
184
|
+
// options win over env fallbacks, and the frontier defaults to 3x the
|
|
185
|
+
// effective injection budget.
|
|
184
186
|
const budget = Number(ctx.options?.injectBudget) || injectBudget();
|
|
187
|
+
const openBudget = Number(ctx.options?.frontierBudget) || frontierBudget(budget);
|
|
185
188
|
|
|
186
189
|
// Mirror the /repo-init slash command into opencode's global commands dir
|
|
187
190
|
// (idempotent, write-if-missing). OpenCode 2.x loads `*.md` from
|
|
@@ -481,7 +484,7 @@ const impl = {
|
|
|
481
484
|
editor.add(validated(ExportArgs, {
|
|
482
485
|
name: "memory_export",
|
|
483
486
|
description:
|
|
484
|
-
"Export active memories as a JSONL file for backup or transfer to another machine. The first line is a metadata header ({_export,version,exportedAt,count}); each later line is one JSON object {ts,type,scope,content,status,tags,issue}. The header is what lets memory_import keep status:\"open\" frontier rows — a hand-written file without it imports such rows as settled. Defaults to <memory-root>/exports/<timestamp>.jsonl; pass `file` to choose the path, `scope` to export one scope. Superseded rows are not exported (history stays in the store).",
|
|
487
|
+
"Export active memories as a JSONL file for backup or transfer to another machine. The first line is a metadata header ({_export,version,exportedAt,count}); each later line is one JSON object {ts,type,scope,content,status,tags,issue}. The header is what lets memory_import keep repo-scoped status:\"open\" frontier rows — a hand-written file without it imports such rows as settled, as does an imported open row claiming repo:\"global\". Defaults to <memory-root>/exports/<timestamp>.jsonl; pass `file` to choose the path, `scope` to export one scope. Superseded rows are not exported (history stays in the store).",
|
|
485
488
|
options: direct,
|
|
486
489
|
async execute(args): Promise<{ content: string }> {
|
|
487
490
|
const res = await exportMemories({ scope: args.scope, file: args.file });
|
|
@@ -492,7 +495,7 @@ const impl = {
|
|
|
492
495
|
editor.add(validated(ImportArgs, {
|
|
493
496
|
name: "memory_import",
|
|
494
497
|
description:
|
|
495
|
-
"Import memories from a JSONL export (see memory_export). Every row goes through the same 4-gate write pipeline as memory_remember (tagged `import`, original tags preserved), so duplicates collapse and ungrounded rows are blocked exactly as usual — those are normal tallied outcomes, not errors. Malformed lines are skipped and counted. Frontier policy: status:\"open\" exempts a row from ref grounding, so it
|
|
498
|
+
"Import memories from a JSONL export (see memory_export). Every row goes through the same 4-gate write pipeline as memory_remember (tagged `import`, original tags preserved), so duplicates collapse and ungrounded rows are blocked exactly as usual — those are normal tallied outcomes, not errors. Malformed lines are skipped and counted. Frontier policy: status:\"open\" exempts a row from ref grounding, so it requires a memory_export header and cannot claim global scope; imported repo stamps are kept only when they match the importing session's repo, otherwise the importing repo is stamped. A stamped open row is downgraded when there is no current repo scope to verify it. Imported content is unverified by construction: injected rows surface a `[via import]` provenance marker.",
|
|
496
499
|
options: direct,
|
|
497
500
|
async execute(args): Promise<{ content: string }> {
|
|
498
501
|
try {
|
|
@@ -507,7 +510,7 @@ const impl = {
|
|
|
507
510
|
`Imported ${args.file}: ${t.promoted} promoted, ${t.collapsed} duplicates collapsed, ` +
|
|
508
511
|
`${t.blocked} blocked by the gate, ${t.skipped} malformed lines skipped.` +
|
|
509
512
|
(t.downgraded
|
|
510
|
-
? ` ${t.downgraded} open rows downgraded to settled (
|
|
513
|
+
? ` ${t.downgraded} open rows downgraded to settled (missing eligible memory_export header or verifiable repo scope).`
|
|
511
514
|
: ""),
|
|
512
515
|
};
|
|
513
516
|
} catch (err) {
|
|
@@ -734,7 +737,7 @@ const impl = {
|
|
|
734
737
|
setCurrent(input);
|
|
735
738
|
const out = { output: resultText(event?.result), metadata: resultMeta(event?.result) };
|
|
736
739
|
await afterTool(input, out);
|
|
737
|
-
await contextualPull(input, out as { output?: unknown });
|
|
740
|
+
await contextualPull(input, out as { output?: unknown }, budget);
|
|
738
741
|
// Write a contextual-pull append back when the tool result is text we own.
|
|
739
742
|
if (event?.result && typeof event.result === "object" && typeof event.result.content === "string") {
|
|
740
743
|
const text = resultText(event.result);
|
|
@@ -753,7 +756,11 @@ const impl = {
|
|
|
753
756
|
const mems = await injectableRows();
|
|
754
757
|
if (typeof sid === "string") await refreshEpoch(sid, mems);
|
|
755
758
|
const parts: string[] = [];
|
|
756
|
-
await systemDigest(
|
|
759
|
+
await systemDigest(
|
|
760
|
+
{ sessionID: event?.sessionID },
|
|
761
|
+
{ system: parts },
|
|
762
|
+
{ rows: mems, frontierBudget: openBudget },
|
|
763
|
+
);
|
|
757
764
|
for (const text of parts) event?.system?.push?.({ type: "text", text });
|
|
758
765
|
// Deliver this epoch's relevance-ranked memory via the system channel.
|
|
759
766
|
// The UI never renders system parts, unlike appended prompt text.
|
|
@@ -933,7 +940,8 @@ const impl = {
|
|
|
933
940
|
);
|
|
934
941
|
await record(
|
|
935
942
|
`auto_capture_${outcome.action}`,
|
|
936
|
-
|
|
943
|
+
// A blocked credential-shaped scope must not reach the evidence log either.
|
|
944
|
+
{ session: sid, type: c.type, scope: redactSecrets(c.scope), reason: outcome.reason ?? "" },
|
|
937
945
|
false,
|
|
938
946
|
);
|
|
939
947
|
}
|
package/src/inject.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import { createHash } from "node:crypto";
|
|
9
9
|
import { isActive, loadAll, visibleHere, type Memory } from "./memory.ts";
|
|
10
10
|
import { record } from "./evidence.ts";
|
|
11
|
-
import { dateStr } from "./config.ts";
|
|
11
|
+
import { dateStr, frontierBudget, injectBudget } from "./config.ts";
|
|
12
12
|
import { bracketList } from "./logfmt.ts";
|
|
13
13
|
import { recentConvergenceSummary } from "./triggers.ts";
|
|
14
14
|
import { flattenMemoryText } from "./sanitize.ts";
|
|
@@ -135,17 +135,63 @@ function openRank(m: Memory): number {
|
|
|
135
135
|
}
|
|
136
136
|
|
|
137
137
|
// Only the newest open threads are surfaced, and each session sees the block
|
|
138
|
-
// once (per process)
|
|
138
|
+
// once (per process). Rows can be long (up to the content cap), so the block is
|
|
139
|
+
// also held to its own frontierBudget, with any omission shown in the block.
|
|
139
140
|
const OPEN_THREAD_LIMIT = 3;
|
|
140
141
|
const frontierInjectedSessions = new Set<string>();
|
|
141
142
|
|
|
143
|
+
function omittedLine(count: number): string {
|
|
144
|
+
return `+ ${count} open ${count === 1 ? "thread" : "threads"} omitted: over budget`;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function cappedFrontier(open: Memory[], budget: number): {
|
|
148
|
+
block: string;
|
|
149
|
+
shown: Memory[];
|
|
150
|
+
omitted: number;
|
|
151
|
+
} {
|
|
152
|
+
const header = `## Open threads (frontier)${FRAME}`;
|
|
153
|
+
const rendered = open.map(formatMemory);
|
|
154
|
+
const full = [header, ...rendered].join("\n");
|
|
155
|
+
if (full.length <= budget) return { block: full, shown: open, omitted: 0 };
|
|
156
|
+
|
|
157
|
+
// Keep a strict newest-first prefix. Budget the omission marker alongside
|
|
158
|
+
// each candidate row so an older row can never replace a newer one that did
|
|
159
|
+
// not fit, and the drop is always visible.
|
|
160
|
+
const kept: string[] = [];
|
|
161
|
+
let used = header.length;
|
|
162
|
+
for (let i = 0; i < rendered.length; i++) {
|
|
163
|
+
const row = rendered[i];
|
|
164
|
+
if (row === undefined) break;
|
|
165
|
+
const remaining = rendered.length - i - 1;
|
|
166
|
+
const marker = remaining ? omittedLine(remaining) : "";
|
|
167
|
+
const projected = used + 1 + row.length + (marker ? 1 + marker.length : 0);
|
|
168
|
+
if (projected > budget) break;
|
|
169
|
+
kept.push(row);
|
|
170
|
+
used += 1 + row.length;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const omitted = open.length - kept.length;
|
|
174
|
+
const lines = [header, ...kept];
|
|
175
|
+
if (omitted) lines.push(omittedLine(omitted));
|
|
176
|
+
// The header plus omission marker is the minimum useful diagnostic. For a
|
|
177
|
+
// tiny budget that cannot hold even those two lines, emit them anyway rather
|
|
178
|
+
// than silently making an existing frontier disappear.
|
|
179
|
+
return { block: lines.join("\n"), shown: open.slice(0, kept.length), omitted };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export interface DigestOptions {
|
|
183
|
+
// Rows the caller already loaded for this request (shared with the epoch).
|
|
184
|
+
rows?: Memory[];
|
|
185
|
+
frontierBudget?: number;
|
|
186
|
+
}
|
|
187
|
+
|
|
142
188
|
export async function systemDigest(
|
|
143
189
|
_input: unknown,
|
|
144
190
|
output: TransformOutput,
|
|
145
|
-
|
|
191
|
+
opts: DigestOptions = {},
|
|
146
192
|
): Promise<void> {
|
|
147
193
|
// Repo-scoped: model-facing reads see this repo + global rows.
|
|
148
|
-
const mems = rows ?? visibleHere((await loadAll()).filter(isActive));
|
|
194
|
+
const mems = opts.rows ?? visibleHere((await loadAll()).filter(isActive));
|
|
149
195
|
if (!mems.length) return;
|
|
150
196
|
const latest = flattenMemoryText(mems.map((m) => m.ts).sort().pop() ?? "?");
|
|
151
197
|
output.system.push(
|
|
@@ -160,9 +206,8 @@ export async function systemDigest(
|
|
|
160
206
|
.slice(0, OPEN_THREAD_LIMIT);
|
|
161
207
|
if (!open.length) return;
|
|
162
208
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
);
|
|
209
|
+
const frontier = cappedFrontier(open, opts.frontierBudget ?? frontierBudget());
|
|
210
|
+
output.system.push(`\n${frontier.block}`);
|
|
166
211
|
|
|
167
212
|
// Aggregate of past sessions' convergence (one line, gated on data existing;
|
|
168
213
|
// I3: it is a derived fact, never the raw log). Gives the next session the
|
|
@@ -183,8 +228,10 @@ export async function systemDigest(
|
|
|
183
228
|
"frontier_injected",
|
|
184
229
|
{
|
|
185
230
|
session: sid,
|
|
186
|
-
scopes: bracketList([...new Set(
|
|
187
|
-
shown:
|
|
231
|
+
scopes: bracketList([...new Set(frontier.shown.map((m) => flattenMemoryText(m.scope)))]),
|
|
232
|
+
shown: frontier.shown.length,
|
|
233
|
+
omitted: frontier.omitted,
|
|
234
|
+
...(frontier.omitted ? { reason: "over_budget" } : {}),
|
|
188
235
|
open_total: openMems.length,
|
|
189
236
|
ts_since: dateStr(),
|
|
190
237
|
},
|
|
@@ -274,7 +321,11 @@ export async function compactionAnchor(limit = 12): Promise<string> {
|
|
|
274
321
|
return capToBudget(block, ANCHOR_BUDGET);
|
|
275
322
|
}
|
|
276
323
|
|
|
277
|
-
export async function contextualPull(
|
|
324
|
+
export async function contextualPull(
|
|
325
|
+
input: PullInput,
|
|
326
|
+
output: PullOutput,
|
|
327
|
+
budget = injectBudget(),
|
|
328
|
+
): Promise<void> {
|
|
278
329
|
const tool = typeof input.tool === "string" ? input.tool : "";
|
|
279
330
|
if (!["grep", "glob", "read"].includes(tool)) return;
|
|
280
331
|
const term = argText(input.args);
|
|
@@ -283,8 +334,9 @@ export async function contextualPull(input: PullInput, output: PullOutput): Prom
|
|
|
283
334
|
const mems = visibleHere((await loadAll()).filter(isActive));
|
|
284
335
|
const top = scoreMemories(term, mems, 3);
|
|
285
336
|
if (!top.length) return;
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
337
|
+
// Held to the injection budget too: three long rows were ~12 KB per call.
|
|
338
|
+
const block = capToBudget(`## Related durable memory (pull)${FRAME}\n${top.map(formatMemory).join("\n")}`, budget);
|
|
339
|
+
if (block && typeof output.output === "string") {
|
|
340
|
+
output.output += `\n${block}`;
|
|
289
341
|
}
|
|
290
342
|
}
|
package/src/memory.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { getCurrent } from "./state.ts";
|
|
|
11
11
|
import { appendLog, ensureDir, formatLine, parseLine, readLines, writeAtomic, type Fields } from "./logfmt.ts";
|
|
12
12
|
import { withStoreLock, LOCK_WRITE } from "./lock.ts";
|
|
13
13
|
import { blockedTs } from "./blocklist.ts";
|
|
14
|
+
import { containsSecret, redactSecrets } from "./secrets.ts";
|
|
14
15
|
import { flattenMemoryText } from "./sanitize.ts";
|
|
15
16
|
|
|
16
17
|
export type MemoryStatus = "open" | "settled";
|
|
@@ -139,9 +140,9 @@ export interface AppendMemoryInput {
|
|
|
139
140
|
issue?: string;
|
|
140
141
|
tags?: string[];
|
|
141
142
|
status?: MemoryStatus;
|
|
142
|
-
// "global" for explicit cross-project writes; imports carry
|
|
143
|
-
// origin stamp (
|
|
144
|
-
//
|
|
143
|
+
// "global" for explicit cross-project writes; imports may carry a validated
|
|
144
|
+
// same-repo origin stamp (see portable.ts). Any missing value is resolved by
|
|
145
|
+
// appendMemory against the live repo scope.
|
|
145
146
|
repo?: string;
|
|
146
147
|
repoPath?: string;
|
|
147
148
|
}
|
|
@@ -159,11 +160,12 @@ async function existingTs(file: string): Promise<Set<string>> {
|
|
|
159
160
|
return set;
|
|
160
161
|
}
|
|
161
162
|
|
|
162
|
-
// Append a durable memory.
|
|
163
|
+
// Append a durable memory. GATE-4 promotions and the viewer's edit/restore-entry
|
|
164
|
+
// paths all call this single write choke point.
|
|
163
165
|
export async function appendMemory(input: AppendMemoryInput): Promise<string> {
|
|
164
166
|
// Absolute floor: every write SHOULD arrive through the gate,
|
|
165
|
-
// which rejects
|
|
166
|
-
//
|
|
167
|
+
// which rejects invalid input politely; viewer writes do not, so enforce the
|
|
168
|
+
// same storage rules here before anything reaches disk.
|
|
167
169
|
if (typeof input.content !== "string") throw new Error("memory content must be a string");
|
|
168
170
|
if (input.content.length > MAX_CONTENT_CHARS) {
|
|
169
171
|
throw new Error(`memory content exceeds ${MAX_CONTENT_CHARS} chars (${input.content.length})`);
|
|
@@ -180,11 +182,22 @@ export async function appendMemory(input: AppendMemoryInput): Promise<string> {
|
|
|
180
182
|
if (input.tags?.some((tag) => flattenMemoryText(tag) !== tag)) {
|
|
181
183
|
throw new Error("memory tags must not contain control characters");
|
|
182
184
|
}
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
185
|
+
const credentialMetadata = [
|
|
186
|
+
containsSecret(input.scope) ? "scope" : undefined,
|
|
187
|
+
input.tags?.some(containsSecret) ? "tags" : undefined,
|
|
188
|
+
containsSecret(input.issue) ? "issue" : undefined,
|
|
189
|
+
].filter((field): field is string => field !== undefined);
|
|
190
|
+
if (credentialMetadata.length) {
|
|
191
|
+
throw new Error(
|
|
192
|
+
`memory metadata contains credential-shaped value in: ${credentialMetadata.join(", ")}`,
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
const content = redactSecrets(input.content);
|
|
196
|
+
// Repo stamp at the single write choke point: gated writes plus viewer edits
|
|
197
|
+
// and restore-entry writes all pass through here. Explicit "global" survives;
|
|
198
|
+
// an unscoped process (viewer) stamps nothing = global. Session identity wins
|
|
199
|
+
// over the module scope so a server hosting several workspaces stamps the
|
|
200
|
+
// writing session's repo.
|
|
188
201
|
let repo = input.repo;
|
|
189
202
|
let repoPath = input.repoPath;
|
|
190
203
|
if (repo === undefined) {
|
|
@@ -214,7 +227,7 @@ export async function appendMemory(input: AppendMemoryInput): Promise<string> {
|
|
|
214
227
|
ts,
|
|
215
228
|
type: input.type,
|
|
216
229
|
scope: input.scope,
|
|
217
|
-
content
|
|
230
|
+
content,
|
|
218
231
|
issue: input.issue,
|
|
219
232
|
tags: input.tags?.length ? input.tags.join(",") : undefined,
|
|
220
233
|
status: input.status,
|
package/src/portable.ts
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
import { join } from "node:path";
|
|
7
7
|
import { memoryRoot } from "./config.ts";
|
|
8
8
|
import { isActive, isVisibleHere, loadAll, type Memory } from "./memory.ts";
|
|
9
|
+
import { repoScopeFor } from "./repokey.ts";
|
|
10
|
+
import { getCurrent } from "./state.ts";
|
|
9
11
|
import { TYPES } from "./types.ts";
|
|
10
12
|
import type { WriteCandidate } from "./gates.ts";
|
|
11
13
|
import { flattenMemoryText } from "./sanitize.ts";
|
|
@@ -63,8 +65,8 @@ export async function exportMemories(opts: ExportOptions = {}): Promise<ExportRe
|
|
|
63
65
|
status: m.status,
|
|
64
66
|
tags: m.tags,
|
|
65
67
|
issue: m.issue,
|
|
66
|
-
// Origin stamps ride along so
|
|
67
|
-
//
|
|
68
|
+
// Origin stamps ride along so a same-repo machine hop can retain them.
|
|
69
|
+
// Import validates them against its current repo before promotion.
|
|
68
70
|
repo: m.repo,
|
|
69
71
|
repoPath: m.repoPath,
|
|
70
72
|
}),
|
|
@@ -80,7 +82,7 @@ export interface ImportTally {
|
|
|
80
82
|
collapsed: number;
|
|
81
83
|
blocked: number;
|
|
82
84
|
skipped: number;
|
|
83
|
-
/** Open rows downgraded
|
|
85
|
+
/** Open rows downgraded because the file cannot grant that frontier scope. */
|
|
84
86
|
downgraded: number;
|
|
85
87
|
}
|
|
86
88
|
|
|
@@ -141,7 +143,12 @@ function parseRow(line: string): WriteCandidate | null {
|
|
|
141
143
|
// Frontier policy: status="open" exempts a row from ref grounding
|
|
142
144
|
// at the gate, so it is honored ONLY when the file opens with our export
|
|
143
145
|
// header (version <= ours); otherwise every open row is downgraded to settled
|
|
144
|
-
// and tallied.
|
|
146
|
+
// and tallied. Imported origin stamps are honored only when they match the
|
|
147
|
+
// importing session's current repo (or are an explicit "global"); foreign
|
|
148
|
+
// stamps are removed so the write choke point stamps the importing repo
|
|
149
|
+
// instead. With no current scope, stamps are kept (removing them would widen
|
|
150
|
+
// the row to global) and a stamped open row is downgraded, since its stamp
|
|
151
|
+
// cannot be verified. Only a missing file throws.
|
|
145
152
|
export async function importMemories(
|
|
146
153
|
path: string,
|
|
147
154
|
gate: (c: WriteCandidate) => Promise<{ action: string }>,
|
|
@@ -149,6 +156,7 @@ export async function importMemories(
|
|
|
149
156
|
const file = Bun.file(path);
|
|
150
157
|
if (!(await file.exists())) throw new Error(`import file not found: ${path}`);
|
|
151
158
|
const tally: ImportTally = { promoted: 0, collapsed: 0, blocked: 0, skipped: 0, downgraded: 0 };
|
|
159
|
+
const currentScope = repoScopeFor(getCurrent().sid);
|
|
152
160
|
let allowFrontier = false;
|
|
153
161
|
let first = true;
|
|
154
162
|
for (const line of (await file.text()).split("\n")) {
|
|
@@ -166,7 +174,18 @@ export async function importMemories(
|
|
|
166
174
|
tally.skipped++;
|
|
167
175
|
continue;
|
|
168
176
|
}
|
|
169
|
-
|
|
177
|
+
const claimedGlobal = c.repo === "global";
|
|
178
|
+
const hadStamp = c.repo !== undefined || c.repoPath !== undefined;
|
|
179
|
+
// A foreign stamp is treated like no stamp, so appendMemory stamps the
|
|
180
|
+
// importing repo. Explicit "global" is not a foreign repo and is kept (open
|
|
181
|
+
// global rows are downgraded below). With no current scope there is nothing
|
|
182
|
+
// to re-stamp with: dropping the stamp would widen the row to global, so it
|
|
183
|
+
// is kept and only the open-row exemption is withdrawn.
|
|
184
|
+
if (currentScope && !claimedGlobal) {
|
|
185
|
+
if (c.repo !== currentScope.key) delete c.repo;
|
|
186
|
+
if (c.repoPath !== currentScope.path) delete c.repoPath;
|
|
187
|
+
}
|
|
188
|
+
if (c.status === "open" && (!allowFrontier || claimedGlobal || (hadStamp && !currentScope))) {
|
|
170
189
|
c.status = "settled";
|
|
171
190
|
tally.downgraded++;
|
|
172
191
|
}
|
package/src/secrets.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Small, conservative credential scrubber shared by capture egress and the
|
|
2
|
+
// write gate. Prefix patterns are intentionally limited to well-known token
|
|
3
|
+
// families; generic values are redacted only when assigned to a credential-
|
|
4
|
+
// named field and they look high-entropy, so ordinary identifiers survive.
|
|
5
|
+
export const REDACTED_SECRET = "[redacted]";
|
|
6
|
+
|
|
7
|
+
const PRIVATE_KEY_BLOCK =
|
|
8
|
+
/-----BEGIN(?: [A-Z0-9]+)* PRIVATE KEY-----[\s\S]*?-----END(?: [A-Z0-9]+)* PRIVATE KEY-----/g;
|
|
9
|
+
const PREFIXED_SECRET =
|
|
10
|
+
/\b(?:sk-[A-Za-z0-9_-]{16,}|gh[po]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|xox[bp]-[A-Za-z0-9-]{16,}|AKIA[A-Z0-9]{16}|AIza[A-Za-z0-9_-]{20,})\b/g;
|
|
11
|
+
// A real bearer token has digits; prose like "Bearer authentication-scheme" doesn't.
|
|
12
|
+
const BEARER_SECRET = /\bBearer\s+(?=[A-Za-z0-9._~+/=-]*\d)[A-Za-z0-9._~+/=-]{16,}/gi;
|
|
13
|
+
const ASSIGNED_SECRET =
|
|
14
|
+
/(\b(?:[A-Za-z][A-Za-z0-9_]*(?:_KEY|_TOKEN|_SECRET|_PASSWORD)|password)\b["']?\s*(?::|=)\s*["']?)([A-Za-z0-9+/_=.-]{16,})/gi;
|
|
15
|
+
// `API_KEY=process.env.OPENAI_API_KEY` names where the key lives; keep it.
|
|
16
|
+
const DOTTED_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)+$/;
|
|
17
|
+
|
|
18
|
+
function looksHighEntropy(value: string): boolean {
|
|
19
|
+
if (value.length < 16 || new Set(value).size < 10) return false;
|
|
20
|
+
if (!/[0-9]/.test(value) || DOTTED_IDENTIFIER.test(value)) return false;
|
|
21
|
+
const classes = [/[a-z]/, /[A-Z]/, /[0-9]/, /[^A-Za-z0-9]/].filter((re) =>
|
|
22
|
+
re.test(value),
|
|
23
|
+
).length;
|
|
24
|
+
return classes >= 3;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function redactSecrets(text: string): string {
|
|
28
|
+
if (!text) return text;
|
|
29
|
+
let out = text.replace(PRIVATE_KEY_BLOCK, REDACTED_SECRET);
|
|
30
|
+
out = out.replace(PREFIXED_SECRET, REDACTED_SECRET);
|
|
31
|
+
out = out.replace(BEARER_SECRET, REDACTED_SECRET);
|
|
32
|
+
return out.replace(ASSIGNED_SECRET, (match, prefix: string, value: string) =>
|
|
33
|
+
looksHighEntropy(value) ? `${prefix}${REDACTED_SECRET}` : match,
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function containsSecret(text: string | undefined): boolean {
|
|
38
|
+
return typeof text === "string" && redactSecrets(text) !== text;
|
|
39
|
+
}
|