opencode-self-improvement 0.8.1 → 0.8.2
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/package.json +1 -1
- package/src/capture.ts +9 -2
- package/src/gates.ts +29 -0
- package/src/index.ts +9 -2
- package/src/inject.ts +9 -5
- package/src/memory.ts +13 -0
- package/src/portable.ts +9 -1
- package/src/sanitize.ts +9 -0
- package/src/schema.ts +18 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-self-improvement",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.2",
|
|
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
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
// All parsing functions are TOTAL: any malformed input yields [] / "" / null,
|
|
8
8
|
// never a throw.
|
|
9
9
|
import { STATUSES, TYPES } from "./types.ts";
|
|
10
|
+
import { flattenMemoryText } from "./sanitize.ts";
|
|
10
11
|
|
|
11
12
|
export interface CaptureOptions {
|
|
12
13
|
model: string; // "provider/id"
|
|
@@ -97,10 +98,16 @@ export function parseCandidates(raw: string, max: number): CaptureCandidate[] {
|
|
|
97
98
|
if (out.length >= max) break;
|
|
98
99
|
if (!item || typeof item !== "object" || Array.isArray(item)) continue;
|
|
99
100
|
const o = item as Record<string, unknown>;
|
|
100
|
-
const
|
|
101
|
-
const
|
|
101
|
+
const rawType = typeof o.type === "string" ? o.type : "";
|
|
102
|
+
const rawScope = typeof o.scope === "string" ? o.scope : "";
|
|
103
|
+
const type = rawType.trim();
|
|
104
|
+
const scope = rawScope.trim();
|
|
102
105
|
const content = typeof o.content === "string" ? o.content.trim() : "";
|
|
103
106
|
if (!type || !scope || !content) continue;
|
|
107
|
+
if (
|
|
108
|
+
flattenMemoryText(rawType) !== rawType ||
|
|
109
|
+
flattenMemoryText(rawScope) !== rawScope
|
|
110
|
+
) continue;
|
|
104
111
|
if (!VALID_TYPES.has(type)) continue;
|
|
105
112
|
if (content.length < 20 || content.length > 600) continue;
|
|
106
113
|
const status = o.status === "open" || o.status === "settled" ? o.status : undefined;
|
package/src/gates.ts
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
type Memory,
|
|
15
15
|
} from "./memory.ts";
|
|
16
16
|
import { chainId, contentHash, record, recordWriteBlocked } from "./evidence.ts";
|
|
17
|
+
import { flattenMemoryText } from "./sanitize.ts";
|
|
17
18
|
import {
|
|
18
19
|
displayCall,
|
|
19
20
|
extractCallForms,
|
|
@@ -222,6 +223,34 @@ export async function runWriteGate(
|
|
|
222
223
|
};
|
|
223
224
|
}
|
|
224
225
|
|
|
226
|
+
const badMetadata: string[] = ([
|
|
227
|
+
["type", candidate.type],
|
|
228
|
+
["scope", candidate.scope],
|
|
229
|
+
["issue", candidate.issue],
|
|
230
|
+
] as const)
|
|
231
|
+
.filter(
|
|
232
|
+
([, value]) =>
|
|
233
|
+
value !== undefined &&
|
|
234
|
+
(typeof value !== "string" || flattenMemoryText(value) !== value),
|
|
235
|
+
)
|
|
236
|
+
.map(([field]) => field);
|
|
237
|
+
if (
|
|
238
|
+
candidate.tags !== undefined &&
|
|
239
|
+
(!Array.isArray(candidate.tags) ||
|
|
240
|
+
candidate.tags.some((tag) => typeof tag !== "string" || flattenMemoryText(tag) !== tag))
|
|
241
|
+
) {
|
|
242
|
+
badMetadata.push("tags");
|
|
243
|
+
}
|
|
244
|
+
if (badMetadata.length) {
|
|
245
|
+
await recordWriteBlocked(4, "invalid_metadata", [`fields=${badMetadata.join(",")}`], chain);
|
|
246
|
+
return {
|
|
247
|
+
action: "blocked",
|
|
248
|
+
reason: "invalid_metadata",
|
|
249
|
+
chain,
|
|
250
|
+
hint: `${badMetadata.join(", ")} must not contain control characters.`,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
225
254
|
const ch = contentHash(candidate.content);
|
|
226
255
|
await record("gate_start", {
|
|
227
256
|
chain,
|
package/src/index.ts
CHANGED
|
@@ -65,6 +65,7 @@ import { getCurrent, setCurrent } from "./state.ts";
|
|
|
65
65
|
import { resolveRepo, setRepoScope, rememberSessionDir, sessionDir } from "./repokey.ts";
|
|
66
66
|
import { readLines } from "./logfmt.ts";
|
|
67
67
|
import { buildCapturePrompt, flattenTranscript, parseCandidates, parseModelRef } from "./capture.ts";
|
|
68
|
+
import { flattenMemoryText } from "./sanitize.ts";
|
|
68
69
|
|
|
69
70
|
// Tool/input boundaries are zod schemas (src/schema.ts): the JSON Schema the
|
|
70
71
|
// host sees is GENERATED from them, and raw args are re-parsed at execution.
|
|
@@ -387,7 +388,10 @@ const impl = {
|
|
|
387
388
|
types.set(m.type, (types.get(m.type) ?? 0) + 1);
|
|
388
389
|
}
|
|
389
390
|
const fmt = (map: Map<string, number>) =>
|
|
390
|
-
[...map.entries()]
|
|
391
|
+
[...map.entries()]
|
|
392
|
+
.sort((a, b) => b[1] - a[1])
|
|
393
|
+
.map(([k, c]) => ` ${flattenMemoryText(k)}: ${c}`)
|
|
394
|
+
.join("\n");
|
|
391
395
|
return { content: `Total: ${mems.length}\n\nScopes:\n${fmt(scopes)}\n\nTypes:\n${fmt(types)}` };
|
|
392
396
|
},
|
|
393
397
|
}));
|
|
@@ -523,7 +527,10 @@ const impl = {
|
|
|
523
527
|
return { content: `No active open threads older than ${days} days.` };
|
|
524
528
|
}
|
|
525
529
|
const listing = stale
|
|
526
|
-
.map(
|
|
530
|
+
.map(
|
|
531
|
+
(m) =>
|
|
532
|
+
` ${flattenMemoryText(m.ts)} ${flattenMemoryText(m.type)}/${flattenMemoryText(m.scope)}: ${flattenMemoryText(m.content).slice(0, 100)}`,
|
|
533
|
+
)
|
|
527
534
|
.join("\n");
|
|
528
535
|
if (dryRun) {
|
|
529
536
|
await record(
|
package/src/inject.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { record } from "./evidence.ts";
|
|
|
10
10
|
import { dateStr } from "./config.ts";
|
|
11
11
|
import { bracketList } from "./logfmt.ts";
|
|
12
12
|
import { recentConvergenceSummary } from "./triggers.ts";
|
|
13
|
+
import { flattenMemoryText } from "./sanitize.ts";
|
|
13
14
|
|
|
14
15
|
export interface TransformOutput {
|
|
15
16
|
system: string[];
|
|
@@ -53,9 +54,12 @@ export function formatMemory(m: Memory): string {
|
|
|
53
54
|
// One memory is ONE line in every injected block: the store round-trips real
|
|
54
55
|
// newlines, so a planted "\n## Fake header" would forge its own section and
|
|
55
56
|
// escape the framing (and throw capToBudget's line accounting off). Line
|
|
56
|
-
// breaks collapse to spaces; ordinary spacing survives.
|
|
57
|
-
const
|
|
58
|
-
|
|
57
|
+
// breaks and other controls collapse to spaces; ordinary spacing survives.
|
|
58
|
+
const date = flattenMemoryText(dateOf(m.ts));
|
|
59
|
+
const type = flattenMemoryText(m.type);
|
|
60
|
+
const scope = flattenMemoryText(m.scope);
|
|
61
|
+
const content = flattenMemoryText(m.content);
|
|
62
|
+
return `- [${date}]${tag}${src} ${type}/${scope}: ${content}`;
|
|
59
63
|
}
|
|
60
64
|
|
|
61
65
|
function argText(args: unknown): string {
|
|
@@ -141,7 +145,7 @@ export async function systemDigest(
|
|
|
141
145
|
// Repo-scoped: model-facing reads see this repo + global rows.
|
|
142
146
|
const mems = visibleHere((await loadAll()).filter(isActive));
|
|
143
147
|
if (!mems.length) return;
|
|
144
|
-
const latest = mems.map((m) => m.ts).sort().pop() ?? "?";
|
|
148
|
+
const latest = flattenMemoryText(mems.map((m) => m.ts).sort().pop() ?? "?");
|
|
145
149
|
output.system.push(
|
|
146
150
|
"\n## Persistent memory (opencode-self-improvement)\n" +
|
|
147
151
|
`${mems.length} durable memories; latest: ${latest}. ` +
|
|
@@ -177,7 +181,7 @@ export async function systemDigest(
|
|
|
177
181
|
"frontier_injected",
|
|
178
182
|
{
|
|
179
183
|
session: sid,
|
|
180
|
-
scopes: bracketList([...new Set(open.map((m) => m.scope))]),
|
|
184
|
+
scopes: bracketList([...new Set(open.map((m) => flattenMemoryText(m.scope)))]),
|
|
181
185
|
shown: open.length,
|
|
182
186
|
open_total: openMems.length,
|
|
183
187
|
ts_since: dateStr(),
|
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 { flattenMemoryText } from "./sanitize.ts";
|
|
14
15
|
|
|
15
16
|
export type MemoryStatus = "open" | "settled";
|
|
16
17
|
|
|
@@ -167,6 +168,18 @@ export async function appendMemory(input: AppendMemoryInput): Promise<string> {
|
|
|
167
168
|
if (input.content.length > MAX_CONTENT_CHARS) {
|
|
168
169
|
throw new Error(`memory content exceeds ${MAX_CONTENT_CHARS} chars (${input.content.length})`);
|
|
169
170
|
}
|
|
171
|
+
for (const [field, value] of [
|
|
172
|
+
["type", input.type],
|
|
173
|
+
["scope", input.scope],
|
|
174
|
+
["issue", input.issue],
|
|
175
|
+
] as const) {
|
|
176
|
+
if (value !== undefined && flattenMemoryText(value) !== value) {
|
|
177
|
+
throw new Error(`memory ${field} must not contain control characters`);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
if (input.tags?.some((tag) => flattenMemoryText(tag) !== tag)) {
|
|
181
|
+
throw new Error("memory tags must not contain control characters");
|
|
182
|
+
}
|
|
170
183
|
// Repo stamp at the single write choke point: gated writes, viewer restores
|
|
171
184
|
// and edits all pass through here. Explicit "global" survives; an unscoped
|
|
172
185
|
// process (viewer) stamps nothing = global. Session identity wins over the
|
package/src/portable.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { memoryRoot } from "./config.ts";
|
|
|
8
8
|
import { isActive, isVisibleHere, loadAll, type Memory } from "./memory.ts";
|
|
9
9
|
import { TYPES } from "./types.ts";
|
|
10
10
|
import type { WriteCandidate } from "./gates.ts";
|
|
11
|
+
import { flattenMemoryText } from "./sanitize.ts";
|
|
11
12
|
|
|
12
13
|
// --- export -------------------------------------------------------------------
|
|
13
14
|
|
|
@@ -101,7 +102,8 @@ function exportHeader(line: string): { version: number } | null {
|
|
|
101
102
|
|
|
102
103
|
// A line is malformed (skipped, never gated) unless it is a JSON object with a
|
|
103
104
|
// known type, a non-empty scope, and non-empty content. Optional fields are
|
|
104
|
-
// sanitized rather than fatal
|
|
105
|
+
// sanitized rather than fatal where noted. Control characters in structured
|
|
106
|
+
// metadata make the row malformed; content alone may remain multiline.
|
|
105
107
|
function parseRow(line: string): WriteCandidate | null {
|
|
106
108
|
let row: unknown;
|
|
107
109
|
try {
|
|
@@ -113,7 +115,13 @@ function parseRow(line: string): WriteCandidate | null {
|
|
|
113
115
|
const r = row as Record<string, unknown>;
|
|
114
116
|
if (typeof r.type !== "string" || !(TYPES as readonly string[]).includes(r.type)) return null;
|
|
115
117
|
if (typeof r.scope !== "string" || !r.scope.trim()) return null;
|
|
118
|
+
if (flattenMemoryText(r.scope) !== r.scope) return null;
|
|
116
119
|
if (typeof r.content !== "string" || !r.content.trim()) return null;
|
|
120
|
+
if (
|
|
121
|
+
Array.isArray(r.tags) &&
|
|
122
|
+
r.tags.some((t) => typeof t === "string" && flattenMemoryText(t) !== t)
|
|
123
|
+
) return null;
|
|
124
|
+
if (typeof r.issue === "string" && flattenMemoryText(r.issue) !== r.issue) return null;
|
|
117
125
|
const c: WriteCandidate = { type: r.type, scope: r.scope, content: r.content };
|
|
118
126
|
if (r.status === "open" || r.status === "settled") c.status = r.status;
|
|
119
127
|
if (Array.isArray(r.tags) && r.tags.every((t) => typeof t === "string")) c.tags = r.tags;
|
package/src/sanitize.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Memory content may legitimately be multiline in the store, but every field
|
|
2
|
+
// rendered into a model-facing line must stay on that line. C0/C1 controls and
|
|
3
|
+
// Unicode line/paragraph separators all collapse through this one helper so
|
|
4
|
+
// renderers and write-boundary validators cannot drift.
|
|
5
|
+
const CONTROL_CHARS = /[\p{Cc}\p{Zl}\p{Zp}]+/gu;
|
|
6
|
+
|
|
7
|
+
export function flattenMemoryText(value: string): string {
|
|
8
|
+
return value.replace(CONTROL_CHARS, " ");
|
|
9
|
+
}
|
package/src/schema.ts
CHANGED
|
@@ -16,20 +16,30 @@
|
|
|
16
16
|
// check in memory.ts (row identity), where the row schema lives.
|
|
17
17
|
import { z, type ZodTypeAny } from "zod";
|
|
18
18
|
import { STATUSES, TYPES } from "./types.ts";
|
|
19
|
+
import { flattenMemoryText } from "./sanitize.ts";
|
|
19
20
|
|
|
20
21
|
type JSONSchema = Record<string, unknown>;
|
|
21
22
|
|
|
22
23
|
/** Row identity anywhere it arrives off a boundary: an ISO instant. */
|
|
23
24
|
export const isoTs = z.iso.datetime();
|
|
24
25
|
|
|
26
|
+
// Structured row fields become labels in model-facing text. Content remains
|
|
27
|
+
// multiline by design and is flattened only when rendered; metadata controls
|
|
28
|
+
// are rejected at every write boundary instead. Lookup-only scopes (recall,
|
|
29
|
+
// forget, export) stay plain strings so a row stored before this check can
|
|
30
|
+
// still be found and forgotten.
|
|
31
|
+
const memoryField = z.string().refine((value) => flattenMemoryText(value) === value, {
|
|
32
|
+
message: "must not contain control characters",
|
|
33
|
+
});
|
|
34
|
+
|
|
25
35
|
// ---- tool argument schemas (JSON Schema generated from these) ------------
|
|
26
36
|
|
|
27
37
|
export const RememberArgs = z.object({
|
|
28
38
|
type: z.enum(TYPES).describe("Type of memory"),
|
|
29
|
-
scope:
|
|
39
|
+
scope: memoryField.describe("Scope/area (e.g., auth, api, ctl)"),
|
|
30
40
|
content: z.string().describe("The memory content"),
|
|
31
|
-
issue:
|
|
32
|
-
tags: z.array(
|
|
41
|
+
issue: memoryField.describe("Related issue").optional(),
|
|
42
|
+
tags: z.array(memoryField).optional(),
|
|
33
43
|
status: z
|
|
34
44
|
.enum(STATUSES)
|
|
35
45
|
.describe(
|
|
@@ -60,7 +70,7 @@ export const RecallArgs = z.object({
|
|
|
60
70
|
});
|
|
61
71
|
|
|
62
72
|
export const UpdateArgs = z.object({
|
|
63
|
-
scope:
|
|
73
|
+
scope: memoryField,
|
|
64
74
|
type: z.enum(TYPES),
|
|
65
75
|
content: z.string(),
|
|
66
76
|
query: z
|
|
@@ -140,10 +150,10 @@ export const DecayArgs = z.object({
|
|
|
140
150
|
export const EditBody = z.object({
|
|
141
151
|
ts: isoTs,
|
|
142
152
|
type: z.enum(TYPES),
|
|
143
|
-
scope:
|
|
153
|
+
scope: memoryField.min(1),
|
|
144
154
|
content: z.string().min(1),
|
|
145
|
-
issue:
|
|
146
|
-
tags: z.array(
|
|
155
|
+
issue: memoryField.optional(),
|
|
156
|
+
tags: z.array(memoryField).optional(),
|
|
147
157
|
// min NOT enforced here: the mutate layer owns the audit-reason contract
|
|
148
158
|
// (and its tests pin its message). Shape is the boundary's job only.
|
|
149
159
|
reason: z.string(),
|
|
@@ -156,7 +166,7 @@ export const UnblockBody = z.object({ ts: isoTs });
|
|
|
156
166
|
// POSTs append rows with empty type/scope/content.
|
|
157
167
|
export const RestoreEntryBody = z.object({
|
|
158
168
|
type: z.enum(TYPES),
|
|
159
|
-
scope:
|
|
169
|
+
scope: memoryField.min(1),
|
|
160
170
|
content: z.string().min(1),
|
|
161
171
|
});
|
|
162
172
|
|