@eleboucher/opencode-memini 0.5.12 → 0.6.0

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.
Files changed (2) hide show
  1. package/memini.js +111 -4
  2. package/package.json +1 -1
package/memini.js CHANGED
@@ -16,6 +16,11 @@
16
16
  * the environment. See the options/env table in ../README.md.
17
17
  */
18
18
 
19
+ import { execSync } from "node:child_process";
20
+ import { readFileSync } from "node:fs";
21
+ import { join, resolve, sep } from "node:path";
22
+ import { homedir } from "node:os";
23
+
19
24
  const DEFAULT_BASE_URL = "http://localhost:8080";
20
25
  const DEFAULT_TIMEOUT_MS = 30000;
21
26
  const DEFAULT_RECALL_LIMIT = 3;
@@ -44,13 +49,113 @@ export function deriveNamespace(worktree) {
44
49
  return sanitizeNamespace(base);
45
50
  }
46
51
 
52
+ // gitProject derives {project} the way the other integrations do when a config
53
+ // file is present: git remote repo name > git toplevel basename > cwd basename.
54
+ // Used whenever a config file is present (tenant-matched or not) — without a
55
+ // config file the namespace stays the legacy cwd basename.
56
+ function gitProject(cwd) {
57
+ const gitOut = (args) => {
58
+ try {
59
+ return execSync(`git ${args}`, { cwd, stdio: ["ignore", "pipe", "ignore"], timeout: 500 })
60
+ .toString()
61
+ .trim();
62
+ } catch {
63
+ return "";
64
+ }
65
+ };
66
+ const remote = gitOut("remote get-url origin");
67
+ if (remote) {
68
+ const cleaned = remote.replace(/\/+$/, "").replace(/\.git$/i, "");
69
+ const scpMatch = cleaned.match(/^[^/:]+:[^/]/);
70
+ const p = scpMatch ? cleaned.slice(scpMatch[0].indexOf(":") + 1) : cleaned;
71
+ const name = sanitizeNamespace(p.split("/").filter(Boolean).pop() || "");
72
+ if (name) return name;
73
+ }
74
+ const toplevel = gitOut("rev-parse --show-toplevel");
75
+ if (toplevel) return deriveNamespace(toplevel);
76
+ return deriveNamespace(cwd);
77
+ }
78
+
79
+ // matchTenant returns the tenant name if cwd is under a configured tenant root,
80
+ // else "". Each segment stays header-safe on its own so the tenant path keeps
81
+ // its "/" separator (work/memini must not flatten to work-memini).
82
+ function matchTenant(cwd, config) {
83
+ if (!Array.isArray(config.tenantRoots)) return "";
84
+ const resolvedCwd = resolve(cwd);
85
+ for (const root of config.tenantRoots) {
86
+ if (!root || typeof root !== "object") continue;
87
+ let rootPath = root.path;
88
+ // An empty/missing path would startsWith-match every cwd; skip it.
89
+ if (typeof rootPath !== "string" || !rootPath) continue;
90
+ if (rootPath === "~") rootPath = homedir();
91
+ else if (rootPath.startsWith("~/")) rootPath = join(homedir(), rootPath.slice(2));
92
+ rootPath = resolve(rootPath);
93
+ if (resolvedCwd === rootPath || resolvedCwd.startsWith(rootPath + sep)) {
94
+ const tenant = String(root.tenant || "")
95
+ .replace(/[^A-Za-z0-9._-]+/g, "-")
96
+ .replace(/^-+|-+$/g, "");
97
+ if (tenant) return tenant;
98
+ }
99
+ }
100
+ return "";
101
+ }
102
+
103
+ // resolveConfigNamespace reads ~/.config/memini/config.json and renders the
104
+ // config template over the resolved segments. Returns null only when no config
105
+ // file exists (or it's unreadable/malformed), so the caller falls back to the
106
+ // legacy deriveNamespace chain. When a config file is present, {project} is
107
+ // always the git-derived name (repo name > toplevel > cwd basename), matching
108
+ // pi/the shared resolver — even when cwd is under no tenant root.
109
+ function resolveConfigNamespace(cwd) {
110
+ let config;
111
+ try {
112
+ const xdg = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
113
+ const configPath = join(xdg, "memini", "config.json");
114
+ config = JSON.parse(readFileSync(configPath, "utf8"));
115
+ } catch {
116
+ return null; // no config file -> today's behavior, zero migration
117
+ }
118
+ if (!config || typeof config !== "object") return null;
119
+ const tenant = matchTenant(cwd, config);
120
+ const project = gitProject(cwd);
121
+ const agent = (process.env.MEMINI_AGENT || "")
122
+ .trim()
123
+ .replace(/[^A-Za-z0-9._-]+/g, "-")
124
+ .replace(/^-+|-+$/g, "");
125
+ const template =
126
+ typeof config.template === "string" && config.template
127
+ ? config.template
128
+ : "{tenant}/{project}/{agent}";
129
+ const ns = template
130
+ .replace(/\{tenant\}/g, tenant)
131
+ .replace(/\{project\}/g, project)
132
+ .replace(/\{agent\}/g, agent)
133
+ .replace(/\{namespace\}/g, "")
134
+ .replace(/\/{2,}/g, "/")
135
+ .replace(/^\/+|\/+$/g, "");
136
+ return ns || null;
137
+ }
138
+
47
139
  // resolveConfig merges env vars with the options object (options win), filling
48
140
  // in defaults. Exported for testing.
49
141
  export function resolveConfig(env, options, worktree) {
50
142
  const e = env || {};
51
143
  const o = options || {};
52
- const namespace =
53
- o.namespace || e.MEMINI_NAMESPACE || deriveNamespace(worktree) || DEFAULT_NAMESPACE;
144
+ // An explicit namespace (option or MEMINI_NAMESPACE env) wins and is used
145
+ // raw-trimmed: the server validates the header, and flattening "/" here would
146
+ // split a tenant path like work/memini from the other integrations.
147
+ const explicit = o.namespace || e.MEMINI_NAMESPACE;
148
+ let namespace;
149
+ if (explicit && String(explicit).trim()) {
150
+ namespace = String(explicit).trim();
151
+ } else {
152
+ // Config present -> render the config template (tenant segments already
153
+ // sanitized, "/" preserved); otherwise fall back to the legacy cwd chain.
154
+ namespace =
155
+ resolveConfigNamespace(worktree || process.cwd()) ||
156
+ deriveNamespace(worktree) ||
157
+ DEFAULT_NAMESPACE;
158
+ }
54
159
  // Number.isFinite guard: malformed env / option falls through to the next
55
160
  // source instead of NaN flowing into the request body.
56
161
  const recall_limit = (() => {
@@ -62,7 +167,10 @@ export function resolveConfig(env, options, worktree) {
62
167
  })();
63
168
  return {
64
169
  base_url: o.base_url || e.MEMINI_BASE_URL || e.MEMINI_URL || DEFAULT_BASE_URL,
65
- namespace: sanitizeNamespace(namespace) || DEFAULT_NAMESPACE,
170
+ // namespace is already resolved above (explicit raw-trimmed, or a
171
+ // per-segment-sanitized config/derived value); re-sanitizing here would
172
+ // flatten tenant "/" separators.
173
+ namespace: namespace || DEFAULT_NAMESPACE,
66
174
  recall: o.recall !== undefined ? o.recall !== false : envBool(e.MEMINI_RECALL, true),
67
175
  capture: o.capture !== undefined ? o.capture !== false : envBool(e.MEMINI_CAPTURE, true),
68
176
  recall_limit,
@@ -476,7 +584,6 @@ export const MeminiPlugin = async ({ client, worktree, directory }, options) =>
476
584
  if (lastAssistantFailed(res && res.data)) metadata.failed = true;
477
585
  const stored = await rest.postJson("/v1/memories", {
478
586
  content: `${userText.slice(0, 1000)}\n\n${assistantText.slice(0, 3000)}`,
479
- tier: "episodic",
480
587
  tags: ["opencode"],
481
588
  metadata,
482
589
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eleboucher/opencode-memini",
3
- "version": "0.5.12",
3
+ "version": "0.6.0",
4
4
  "description": "Automatic cross-session memory for opencode via memini — recall before each turn, capture after.",
5
5
  "keywords": [
6
6
  "memini",