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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-self-improvement",
3
- "version": "0.8.1",
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 type = typeof o.type === "string" ? o.type.trim() : "";
101
- const scope = typeof o.scope === "string" ? o.scope.trim() : "";
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()].sort((a, b) => b[1] - a[1]).map(([k, c]) => ` ${k}: ${c}`).join("\n");
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((m) => ` ${m.ts} ${m.type}/${m.scope}: ${m.content.slice(0, 100)}`)
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 content = m.content.replace(/[\r\n\t]+/g, " ");
58
- return `- [${dateOf(m.ts)}]${tag}${src} ${m.type}/${m.scope}: ${content}`;
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: an unusable status/tags/issue is dropped.
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;
@@ -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: z.string().describe("Scope/area (e.g., auth, api, ctl)"),
39
+ scope: memoryField.describe("Scope/area (e.g., auth, api, ctl)"),
30
40
  content: z.string().describe("The memory content"),
31
- issue: z.string().describe("Related issue").optional(),
32
- tags: z.array(z.string()).optional(),
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: z.string(),
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: z.string().min(1),
153
+ scope: memoryField.min(1),
144
154
  content: z.string().min(1),
145
- issue: z.string().optional(),
146
- tags: z.array(z.string()).optional(),
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: z.string().min(1),
169
+ scope: memoryField.min(1),
160
170
  content: z.string().min(1),
161
171
  });
162
172