@eleboucher/opencode-memini 0.5.11 → 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.
- package/memini.js +117 -4
- 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
|
-
|
|
53
|
-
|
|
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
|
|
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,
|
|
@@ -446,6 +554,12 @@ export const MeminiPlugin = async ({ client, worktree, directory }, options) =>
|
|
|
446
554
|
`current workspace state and the user's instructions):`,
|
|
447
555
|
...fit.items,
|
|
448
556
|
];
|
|
557
|
+
// /v1/search sets `degraded: "keyword_only"` (plus a `note`) when the
|
|
558
|
+
// query embed was unavailable and it fell back to keyword-only matching;
|
|
559
|
+
// both are already on `result`, so surfacing them is a one-line addition.
|
|
560
|
+
if (result && result.degraded) {
|
|
561
|
+
lines.push(`[memini: ${result.note || "semantic search unavailable — results are keyword-only and may be incomplete"}]`);
|
|
562
|
+
}
|
|
449
563
|
if (fit.dropped > 0) lines.push(`[... ${fit.dropped} item(s) truncated by token budget]`);
|
|
450
564
|
// opencode's part schema requires ids to start with `prt`.
|
|
451
565
|
output.parts.unshift({
|
|
@@ -470,7 +584,6 @@ export const MeminiPlugin = async ({ client, worktree, directory }, options) =>
|
|
|
470
584
|
if (lastAssistantFailed(res && res.data)) metadata.failed = true;
|
|
471
585
|
const stored = await rest.postJson("/v1/memories", {
|
|
472
586
|
content: `${userText.slice(0, 1000)}\n\n${assistantText.slice(0, 3000)}`,
|
|
473
|
-
tier: "episodic",
|
|
474
587
|
tags: ["opencode"],
|
|
475
588
|
metadata,
|
|
476
589
|
});
|