opencode-self-improvement 0.2.2 → 0.2.3

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 CHANGED
@@ -130,7 +130,7 @@ Add a `plugins` entry to your opencode config —
130
130
  ```json
131
131
  {
132
132
  "$schema": "https://opencode.ai/config.json",
133
- "plugins": ["opencode-self-improvement@0.2.1"]
133
+ "plugins": ["opencode-self-improvement@0.2.2"]
134
134
  }
135
135
  ```
136
136
 
@@ -144,7 +144,7 @@ bump the version in the config and restart. On Opencode v2 you can also manage
144
144
  it with the CLI:
145
145
 
146
146
  ```sh
147
- opencode plugin add opencode-self-improvement@0.2.1
147
+ opencode plugin add opencode-self-improvement@0.2.2
148
148
  opencode plugin update opencode-self-improvement
149
149
  ```
150
150
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-self-improvement",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
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/logfmt.ts CHANGED
@@ -5,10 +5,17 @@ import { mkdir } from "node:fs/promises";
5
5
 
6
6
  export type Fields = Record<string, unknown>;
7
7
 
8
+ // Escapes for quoted values. One record is one line, so a raw newline inside a
9
+ // value would end the record early and the rest of the value would be read
10
+ // back as a separate line of fields. Backslash is escaped too, so a value
11
+ // ending in `\` cannot swallow its closing quote.
12
+ const ESCAPE: Record<string, string> = { "\\": "\\\\", '"': '\\"', "\n": "\\n", "\r": "\\r", "\t": "\\t" };
13
+ const UNESCAPE: Record<string, string> = { "\\": "\\", '"': '"', n: "\n", r: "\r", t: "\t" };
14
+
8
15
  export function quote(value: unknown): string {
9
16
  const s = String(value);
10
17
  if (s === "") return '""';
11
- return /[\s"=]/.test(s) ? `"${s.replace(/"/g, '\\"')}"` : s;
18
+ return /[\s"=]/.test(s) ? `"${s.replace(/[\\"\n\r\t]/g, (c) => ESCAPE[c])}"` : s;
12
19
  }
13
20
 
14
21
  export function formatLine(fields: Fields): string {
@@ -20,9 +27,10 @@ export function formatLine(fields: Fields): string {
20
27
  return parts.join(" ");
21
28
  }
22
29
 
23
- // Unescape a quoted logfmt value and read it back.
30
+ // Unescape a quoted logfmt value and read it back. Unknown sequences are kept
31
+ // as written: older files stored backslashes unescaped (`C:\Program Files`).
24
32
  function unquote(s: string): string {
25
- return s.replace(/\\"/g, '"');
33
+ return s.replace(/\\(.)/g, (seq, c: string) => UNESCAPE[c] ?? seq);
26
34
  }
27
35
 
28
36
  export function parseLine(line: string): Fields | null {
@@ -28,6 +28,36 @@ function error(msg: string, status = 400): Response {
28
28
  return json({ error: msg }, status);
29
29
  }
30
30
 
31
+ // Binding 127.0.0.1 keeps other machines out, but not other websites open in
32
+ // the user's browser. Two checks close that:
33
+ // - Host must name this viewer. A DNS-rebinding page reaches 127.0.0.1 under
34
+ // its own hostname, so its requests arrive with a foreign Host.
35
+ // - Writes must be JSON from this viewer's own origin. A cross-site form or
36
+ // no-cors fetch can POST text/plain without a CORS preflight, and every
37
+ // memory edited here is injected into the agent's system prompt.
38
+ // A viewer deliberately exposed under another name (OPENCODE_VIEWER_HOST set
39
+ // to a LAN address, a hostname) can list it in OPENCODE_VIEWER_ALLOWED_HOSTS.
40
+ function allowedHostnames(): Set<string> {
41
+ const extra = (process.env.OPENCODE_VIEWER_ALLOWED_HOSTS ?? "").split(",");
42
+ const names = ["127.0.0.1", "localhost", "::1", HOST, ...extra]
43
+ .map((n) => n.trim().toLowerCase())
44
+ .filter(Boolean)
45
+ .map((n) => (n.includes(":") && !n.startsWith("[") ? `[${n}]` : n));
46
+ return new Set(names);
47
+ }
48
+
49
+ function rejectRequest(req: Request, url: URL, port: number): Response | null {
50
+ if (!allowedHostnames().has(url.hostname) || (url.port || "80") !== String(port)) {
51
+ return error("unexpected Host header", 403);
52
+ }
53
+ if (req.method === "GET" || req.method === "HEAD") return null;
54
+ const origin = req.headers.get("origin");
55
+ if (origin !== null && origin !== url.origin) return error("cross-origin request refused", 403);
56
+ const type = (req.headers.get("content-type") ?? "").split(";")[0].trim().toLowerCase();
57
+ if (type !== "application/json") return error("content-type must be application/json", 415);
58
+ return null;
59
+ }
60
+
31
61
  interface Row {
32
62
  ts: string;
33
63
  type: string;
@@ -86,7 +116,10 @@ export async function startViewer(port: number = PORT): Promise<ViewerServer> {
86
116
  hostname: HOST,
87
117
  port,
88
118
  fetch(req, srv) {
119
+ // Bun builds req.url from the request's Host header.
89
120
  const url = new URL(req.url);
121
+ const rejected = rejectRequest(req, url, server.port ?? port);
122
+ if (rejected) return rejected;
90
123
  if (url.pathname === "/") {
91
124
  return new Response(page, {
92
125
  headers: { "content-type": "text/html; charset=utf-8" },