metame-cli 1.4.15 → 1.4.18

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/scripts/utils.js CHANGED
@@ -3,6 +3,7 @@
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
5
  const os = require('os');
6
+ const crypto = require('crypto');
6
7
 
7
8
  /**
8
9
  * utils.js — Pure utility functions extracted for testability.
@@ -30,6 +31,9 @@ async function writeBrainFileSafe(content, brainFile = BRAIN_FILE_DEFAULT) {
30
31
  const retryDelay = 150; // ms between retries
31
32
  const staleTimeout = 30000; // 30s: lock older than this is stale
32
33
 
34
+ const lockDir = path.dirname(BRAIN_LOCK_FILE);
35
+ if (!fs.existsSync(lockDir)) fs.mkdirSync(lockDir, { recursive: true });
36
+
33
37
  let acquired = false;
34
38
  for (let i = 0; i < maxRetries; i++) {
35
39
  try {
@@ -49,6 +53,10 @@ async function writeBrainFileSafe(content, brainFile = BRAIN_FILE_DEFAULT) {
49
53
  }
50
54
  }
51
55
 
56
+ if (!acquired) {
57
+ throw new Error('Failed to acquire brain.lock for profile write');
58
+ }
59
+
52
60
  const tmp = brainFile + '.tmp.' + process.pid;
53
61
  try {
54
62
  fs.writeFileSync(tmp, content, 'utf8');
@@ -118,10 +126,109 @@ function createPathMap() {
118
126
  return { shortenPath, expandPath };
119
127
  }
120
128
 
129
+ // ---------------------------------------------------------
130
+ // PROJECT SCOPE (stable workspace identity)
131
+ // ---------------------------------------------------------
132
+ function normalizeProjectPath(cwd) {
133
+ if (!cwd || typeof cwd !== 'string') return null;
134
+ const trimmed = cwd.trim();
135
+ if (!trimmed) return null;
136
+
137
+ const expanded = trimmed.startsWith('~')
138
+ ? path.join(os.homedir(), trimmed.slice(1))
139
+ : trimmed;
140
+
141
+ const resolved = path.resolve(expanded);
142
+ try {
143
+ return fs.realpathSync.native ? fs.realpathSync.native(resolved) : fs.realpathSync(resolved);
144
+ } catch {
145
+ return resolved;
146
+ }
147
+ }
148
+
149
+ function projectScopeFromCwd(cwd) {
150
+ const normalized = normalizeProjectPath(cwd);
151
+ if (!normalized) return null;
152
+ const digest = crypto.createHash('sha1').update(normalized).digest('hex').slice(0, 16);
153
+ return `proj_${digest}`;
154
+ }
155
+
156
+ function deriveProjectInfo(cwd) {
157
+ const projectPath = normalizeProjectPath(cwd);
158
+ if (!projectPath) return { project: null, project_id: null, project_path: null };
159
+ return {
160
+ project: path.basename(projectPath),
161
+ project_id: projectScopeFromCwd(projectPath),
162
+ project_path: projectPath,
163
+ };
164
+ }
165
+
166
+ // ---------------------------------------------------------
167
+ // TOPIC DRIFT HELPERS
168
+ // ---------------------------------------------------------
169
+ function buildTopicSignature(text, maxTokens = 16) {
170
+ const input = String(text || '').toLowerCase();
171
+ if (!input) return [];
172
+
173
+ const stop = new Set(['help', 'please', 'this', 'that', 'with', 'from', 'into', 'have', 'been', 'will', 'just']);
174
+ const seen = new Set();
175
+ const tokens = [];
176
+ const push = (token) => {
177
+ const t = String(token || '').trim();
178
+ if (!t || seen.has(t) || stop.has(t)) return;
179
+ seen.add(t);
180
+ tokens.push(t);
181
+ };
182
+
183
+ // ASCII-like identifiers, commands, paths
184
+ const ascii = input.match(/[a-z0-9_./-]{2,}/g) || [];
185
+ for (const t of ascii) {
186
+ push(t);
187
+ if (tokens.length >= maxTokens) return tokens;
188
+ }
189
+
190
+ // Chinese: use 2-char shingles so short prompts still produce enough features.
191
+ const hanRuns = input.match(/[\u4e00-\u9fff]{2,}/g) || [];
192
+ for (const run of hanRuns) {
193
+ if (run.length === 2) {
194
+ push(run);
195
+ } else {
196
+ for (let i = 0; i < run.length - 1; i++) {
197
+ push(run.slice(i, i + 2));
198
+ if (tokens.length >= maxTokens) return tokens;
199
+ }
200
+ }
201
+ if (tokens.length >= maxTokens) return tokens;
202
+ }
203
+
204
+ return tokens;
205
+ }
206
+
207
+ function hasTopicDrift(prevSig, currSig, minTokens = 3, threshold = 0.25) {
208
+ if (!Array.isArray(prevSig) || !Array.isArray(currSig)) return false;
209
+ if (prevSig.length < minTokens || currSig.length < minTokens) return false;
210
+ const a = new Set(prevSig);
211
+ const b = new Set(currSig);
212
+ let common = 0;
213
+ for (const t of a) if (b.has(t)) common++;
214
+ const minBase = Math.min(a.size, b.size) || 1;
215
+ const overlapByMin = common / minBase;
216
+ if (overlapByMin >= 0.34) return false;
217
+ const union = a.size + b.size - common;
218
+ if (union <= 0) return false;
219
+ const jaccard = common / union;
220
+ return jaccard < threshold;
221
+ }
222
+
121
223
  module.exports = {
122
224
  parseInterval,
123
225
  formatRelativeTime,
124
226
  createPathMap,
227
+ normalizeProjectPath,
228
+ projectScopeFromCwd,
229
+ deriveProjectInfo,
230
+ buildTopicSignature,
231
+ hasTopicDrift,
125
232
  writeBrainFileSafe,
126
233
  BRAIN_LOCK_FILE,
127
234
  };
@@ -2,7 +2,16 @@
2
2
 
3
3
  const { describe, it, beforeEach } = require('node:test');
4
4
  const assert = require('node:assert/strict');
5
- const { parseInterval, formatRelativeTime, createPathMap } = require('./utils');
5
+ const {
6
+ parseInterval,
7
+ formatRelativeTime,
8
+ createPathMap,
9
+ normalizeProjectPath,
10
+ projectScopeFromCwd,
11
+ deriveProjectInfo,
12
+ buildTopicSignature,
13
+ hasTopicDrift,
14
+ } = require('./utils');
6
15
 
7
16
  // ---------------------------------------------------------
8
17
  // parseInterval
@@ -123,3 +132,54 @@ describe('createPathMap', () => {
123
132
  assert.ok(Buffer.byteLength(id) < 10, `id "${id}" too long`);
124
133
  });
125
134
  });
135
+
136
+ // ---------------------------------------------------------
137
+ // project scope helpers
138
+ // ---------------------------------------------------------
139
+ describe('project scope helpers', () => {
140
+ it('normalizes absolute paths', () => {
141
+ assert.equal(normalizeProjectPath('/tmp/./metame/../metame'), '/tmp/metame');
142
+ });
143
+
144
+ it('returns deterministic scope ids for the same cwd', () => {
145
+ const a = projectScopeFromCwd('/tmp/metame');
146
+ const b = projectScopeFromCwd('/tmp/./metame');
147
+ assert.equal(a, b);
148
+ assert.match(a, /^proj_[a-f0-9]{16}$/);
149
+ });
150
+
151
+ it('derives project info from cwd', () => {
152
+ const info = deriveProjectInfo('/tmp/demo-repo');
153
+ assert.equal(info.project, 'demo-repo');
154
+ assert.equal(info.project_path, '/tmp/demo-repo');
155
+ assert.match(info.project_id, /^proj_[a-f0-9]{16}$/);
156
+ });
157
+ });
158
+
159
+ // ---------------------------------------------------------
160
+ // topic drift helpers
161
+ // ---------------------------------------------------------
162
+ describe('topic drift helpers', () => {
163
+ it('extracts signature tokens for Chinese prompts', () => {
164
+ const sig = buildTopicSignature('修复登录超时并优化缓存策略');
165
+ assert.ok(sig.length >= 3, `expected >=3 tokens, got ${sig.length}`);
166
+ });
167
+
168
+ it('extracts signature tokens for mixed Chinese/English prompts', () => {
169
+ const sig = buildTopicSignature('修复 daemon memory-extract timeout');
170
+ assert.ok(sig.includes('daemon'));
171
+ assert.ok(sig.some(t => /[\u4e00-\u9fff]/.test(t)));
172
+ });
173
+
174
+ it('detects drift when signatures diverge', () => {
175
+ const a = buildTopicSignature('修复登录超时和重试策略');
176
+ const b = buildTopicSignature('重构支付流水和对账报表');
177
+ assert.equal(hasTopicDrift(a, b), true);
178
+ });
179
+
180
+ it('does not flag drift for similar topics', () => {
181
+ const a = buildTopicSignature('优化 memory 检索和缓存命中');
182
+ const b = buildTopicSignature('memory 检索改进与缓存策略');
183
+ assert.equal(hasTopicDrift(a, b), false);
184
+ });
185
+ });