@unifiedmemory/cli 1.3.13 → 1.3.15

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.
@@ -1,82 +1,76 @@
1
1
  // Unique marker to detect if instructions already present
2
- export const MEMORY_INSTRUCTIONS_MARKER = '<!-- UNIFIEDMEMORY_INSTRUCTIONS_V2 -->';
2
+ export const MEMORY_INSTRUCTIONS_MARKER = '<!-- UNIFIEDMEMORY_INSTRUCTIONS_V3 -->';
3
3
 
4
4
  export function getMemoryInstructions() {
5
5
  return `${MEMORY_INSTRUCTIONS_MARKER}
6
6
 
7
- # UnifiedMemory Integration (REQUIRED)
7
+ # STOP - Read Before ANY Code Task
8
8
 
9
- This project uses UnifiedMemory for persistent knowledge management. The \`unifiedmemory\` MCP server is configured and MUST be used for all relevant queries.
9
+ **MANDATORY**: Call \`search_notes\` BEFORE writing any code. Always. No exceptions.
10
10
 
11
- ## Available Tools
11
+ ## Pre-Implementation Checklist
12
12
 
13
- The \`unifiedmemory\` MCP server provides these tools (call them like any MCP tool):
13
+ Before ANY coding task, you MUST:
14
+ - [ ] Call \`search_notes\` with relevant keywords
15
+ - [ ] Review results for existing patterns/decisions
16
+ - [ ] Mention what you found (or didn't) in your response
17
+ - [ ] THEN start implementation
14
18
 
15
- | Tool | Purpose | Required Parameters |
16
- |------|---------|---------------------|
17
- | \`search_notes\` | **Primary tool** - Search project knowledge base | \`query\` (search text) |
18
- | \`create_note\` | Record decisions, plans, or completions | \`summary_text\`, \`topic\` |
19
- | \`list_notes\` | List all notes in current project | None |
20
- | \`get_note\` | Retrieve a specific note by ID | \`note_id\` |
21
- | \`list_topics\` | List available topics/categories | None |
22
- | \`create_topic\` | Create a new topic for organization | \`name\` |
23
- | \`list_projects\` | List available projects | None |
19
+ ---
24
20
 
25
- All tools automatically use the correct project context - no additional configuration needed.
21
+ ## WRONG
26
22
 
27
- ## MANDATORY: Search Before Implementing
23
+ \`\`\`
24
+ User: "Create a calculator app"
25
+ Assistant: [immediately writes code] ← NEVER DO THIS
26
+ \`\`\`
28
27
 
29
- **REQUIREMENT**: Before responding to ANY coding task, feature request, or implementation question, you MUST:
28
+ ## CORRECT
30
29
 
31
- 1. Call the \`search_notes\` tool with the user's query or relevant keywords
32
- 2. Review returned results for prior decisions, patterns, or context
33
- 3. Incorporate relevant findings into your response
34
- 4. Mention what context you found (or didn't find) in your response
30
+ \`\`\`
31
+ User: "Create a calculator app"
32
+ Assistant: [calls search_notes first]
33
+ Assistant: "No relevant patterns found in UnifiedMemory. Proceeding..."
34
+ Assistant: [writes code]
35
+ \`\`\`
35
36
 
36
- ### When to Search
37
+ ---
37
38
 
38
- You MUST search before:
39
- - Implementing any feature (search for existing patterns)
40
- - Fixing any bug (search for related issues)
41
- - Answering architecture questions (search for design decisions)
42
- - Modifying any component (search for existing conventions)
43
- - ANY coding task, no exceptions
39
+ ## When to Search (ALWAYS)
44
40
 
45
- ### Example Workflow
41
+ - Before implementing ANY feature ← search first
42
+ - Before fixing ANY bug ← search first
43
+ - Before answering architecture questions ← search first
44
+ - Before modifying ANY component ← search first
45
+ - For "simple" tasks ← search first (context always helps)
46
46
 
47
- \`\`\`
48
- User: "Add authentication to the API"
49
-
50
- Step 1: Call search_notes with query "authentication API security"
51
- Step 2: Review results - found: "Decision: Use JWT with refresh tokens"
52
- Step 3: Implement following the established pattern
53
- Step 4: Respond: "Based on prior decisions in UnifiedMemory, I'll use JWT..."
54
- \`\`\`
47
+ ---
55
48
 
56
- ## Do NOT Skip These Tools
49
+ ## MCP Tool Reference
57
50
 
58
- - NEVER start implementing without calling \`search_notes\` first
59
- - ❌ NEVER assume patterns without checking the knowledge base
60
- - NEVER skip search for "simple" tasks - context always helps
61
- - ALWAYS search even if you think you know the answer
62
- - ALWAYS mention search results (or lack thereof) in your response
63
- - ALWAYS use these tools when they can provide relevant context
51
+ | Tool | Purpose | Parameters |
52
+ |------|---------|------------|
53
+ | \`search_notes\` | **Search knowledge base** | \`body: {q: "search text"}\` |
54
+ | \`create_note\` | Record decisions/completions | \`body: {summary_text: "...", topic: "..."}\` |
55
+ | \`list_notes\` | Browse all notes | None |
56
+ | \`get_note\` | Get note by ID | \`body: {note_id: "..."}\` |
57
+ | \`list_topics\` | List categories | None |
58
+ | \`create_topic\` | Create category | \`body: {name: "..."}\` |
64
59
 
65
- ## Recording Knowledge with create_note
60
+ Project context is automatic - no extra config needed.
66
61
 
67
- After completing significant work, use \`create_note\` to record:
68
- - Implementation decisions and rationale
69
- - Completed features or fixes
70
- - Architectural patterns established
71
- - Gotchas or lessons learned
62
+ ---
72
63
 
73
- Keep notes concise (under 1000 characters) and focus on the "why" not the "what".
64
+ ## After Completing Work
74
65
 
75
- ## Additional Tools
66
+ Use \`create_note\` to record:
67
+ - Decisions and rationale (the "why")
68
+ - Patterns established
69
+ - Gotchas or lessons learned
76
70
 
77
- Use \`list_topics\` to understand project organization, \`list_notes\` to browse available knowledge, and \`get_note\` to retrieve full details of relevant notes found via search.
71
+ Keep notes under 1000 chars. Focus on "why" not "what".
78
72
 
79
73
  ---
80
- *UnifiedMemory CLI - Knowledge that persists*
74
+ *UnifiedMemory - \`search_notes\` FIRST, code SECOND*
81
75
  `;
82
76
  }
@@ -1,7 +1,6 @@
1
- import { getToken, saveToken } from './token-storage.js';
1
+ import { saveToken } from './token-storage.js';
2
2
  import { config } from './config.js';
3
3
  import { parseJWT } from './jwt-utils.js';
4
- import { getOrgScopedToken } from './clerk-api.js';
5
4
 
6
5
  /**
7
6
  * Check if token has expired
@@ -23,6 +22,7 @@ export function isTokenExpired(tokenData) {
23
22
 
24
23
  /**
25
24
  * Refresh access token using refresh token
25
+ * Simple refresh - Clerk preserves org context if it was set
26
26
  * @param {Object} tokenData - Current token data
27
27
  * @returns {Promise<Object>} - New token data
28
28
  */
@@ -74,36 +74,22 @@ export async function refreshAccessToken(tokenData) {
74
74
 
75
75
  // Parse refreshed JWT
76
76
  const refreshedToken = newTokenData.id_token || newTokenData.access_token;
77
- let finalIdToken = newTokenData.id_token;
78
- let decoded = parseJWT(refreshedToken);
77
+ const decoded = parseJWT(refreshedToken);
79
78
 
80
- // If we have org context, get org-scoped token to ensure JWT has org claims
81
- if (tokenData.selectedOrg?.id && decoded?.sid) {
82
- try {
83
- const orgToken = await getOrgScopedToken(
84
- decoded.sid,
85
- tokenData.selectedOrg.id,
86
- refreshedToken
87
- );
88
- finalIdToken = orgToken.jwt;
89
- decoded = parseJWT(orgToken.jwt);
90
- } catch (error) {
91
- // Log warning but continue with base token
92
- // The subsequent API call may fail, prompting re-login
93
- console.error(`⚠️ Could not refresh org-scoped token: ${error.message}`);
94
- }
95
- }
96
-
97
- // Build updated token object, preserving selectedOrg
79
+ // Build updated token object
80
+ // Note: Clerk preserves org context through refresh automatically
81
+ // We also preserve selectedOrgId/selectedOrgName from login state
98
82
  const updatedToken = {
99
83
  accessToken: newTokenData.access_token,
100
- idToken: finalIdToken,
84
+ idToken: newTokenData.id_token,
101
85
  tokenType: newTokenData.token_type || 'Bearer',
102
86
  expiresIn: newTokenData.expires_in,
103
87
  receivedAt: Date.now(),
104
88
  decoded: decoded,
105
- selectedOrg: tokenData.selectedOrg, // Preserve organization context
106
89
  refresh_token: newTokenData.refresh_token || tokenData.refresh_token,
90
+ sessionId: tokenData.sessionId, // Preserve sessionId through refresh
91
+ selectedOrgId: tokenData.selectedOrgId, // Preserve org selection through refresh
92
+ selectedOrgName: tokenData.selectedOrgName, // Preserve org name through refresh
107
93
  };
108
94
 
109
95
  // Save to storage
@@ -14,12 +14,6 @@ export function saveToken(tokenData) {
14
14
  // Ensure directory permissions are correct (in case it was created with wrong permissions)
15
15
  fs.chmodSync(TOKEN_DIR, 0o700);
16
16
 
17
- // Preserve existing organization context if not explicitly overwritten
18
- const existingData = getToken();
19
- if (existingData && existingData.selectedOrg && !tokenData.selectedOrg) {
20
- tokenData.selectedOrg = existingData.selectedOrg;
21
- }
22
-
23
17
  // Write token file with owner-only read/write permissions (0600)
24
18
  fs.writeFileSync(TOKEN_FILE, JSON.stringify(tokenData, null, 2));
25
19
  // Explicitly set file permissions (writeFileSync mode option doesn't always work with extended attributes)
@@ -46,31 +40,43 @@ export function clearToken() {
46
40
  }
47
41
 
48
42
  /**
49
- * Update the selected organization context
50
- * @param {Object|null} orgData - Organization data or null for personal context
43
+ * Get organization context from JWT claims
44
+ * JWT is the single source of truth for org context
45
+ * @returns {Object|null} Organization context or null if personal context
51
46
  */
52
- export function updateSelectedOrg(orgData) {
53
- const tokenData = getToken();
54
- if (!tokenData) {
55
- throw new Error('No token found. Please login first.');
47
+ export function getOrgContext() {
48
+ const token = getToken();
49
+
50
+ // Try nested 'o' object first (original OAuth token format)
51
+ if (token?.decoded?.o?.o_id) {
52
+ return {
53
+ id: token.decoded.o.o_id,
54
+ name: token.decoded.o.o_name,
55
+ slug: token.decoded.o.o_slug,
56
+ role: token.decoded.o.o_role
57
+ };
56
58
  }
57
59
 
58
- // Ensure directory permissions are correct
59
- if (fs.existsSync(TOKEN_DIR)) {
60
- fs.chmodSync(TOKEN_DIR, 0o700);
60
+ // Fall back to flat org_* claims (org-scoped token from Clerk)
61
+ if (token?.decoded?.org_id) {
62
+ return {
63
+ id: token.decoded.org_id,
64
+ name: token.decoded.org_name,
65
+ slug: token.decoded.org_slug,
66
+ role: token.decoded.org_role
67
+ };
61
68
  }
62
69
 
63
- tokenData.selectedOrg = orgData;
64
- // Write with owner-only read/write permissions (0600)
65
- fs.writeFileSync(TOKEN_FILE, JSON.stringify(tokenData, null, 2));
66
- fs.chmodSync(TOKEN_FILE, 0o600);
67
- }
70
+ // Fall back to selectedOrgId from login state parameter
71
+ // This is used when JWT doesn't have org claims (Clerk setActive limitation)
72
+ if (token?.selectedOrgId) {
73
+ return {
74
+ id: token.selectedOrgId,
75
+ name: token.selectedOrgName || null,
76
+ slug: null,
77
+ role: null
78
+ };
79
+ }
68
80
 
69
- /**
70
- * Get the currently selected organization context
71
- * @returns {Object|null} Organization data or null if personal context
72
- */
73
- export function getSelectedOrg() {
74
- const tokenData = getToken();
75
- return tokenData?.selectedOrg || null;
81
+ return null;
76
82
  }
@@ -1,11 +1,9 @@
1
- import { getToken, saveToken } from './token-storage.js';
1
+ import { getToken } from './token-storage.js';
2
2
  import { isTokenExpired, refreshAccessToken } from './token-refresh.js';
3
- import { getOrgScopedToken } from './clerk-api.js';
4
- import { parseJWT } from './jwt-utils.js';
5
- import { config } from './config.js';
6
3
 
7
4
  /**
8
5
  * Load token and refresh if expired
6
+ * JWT is the single source of truth - no recovery logic needed
9
7
  * @param {boolean} requireAuth - Whether to throw if not authenticated (default true)
10
8
  * @returns {Promise<Object|null>} - Token data or null
11
9
  * @throws {Error} - If not authenticated and requireAuth is true, or if refresh fails
@@ -46,86 +44,5 @@ export async function loadAndRefreshToken(requireAuth = true) {
46
44
  }
47
45
  }
48
46
 
49
- // Check if token has org context but lacks org claims in JWT
50
- // This can happen if getOrgScopedToken() failed during login
51
- if (tokenData.selectedOrg?.id && !tokenData.decoded?.o) {
52
- console.error('⚠️ Token missing org claims, attempting to fix...');
53
-
54
- // Check for sid in decoded token or preserved originalSid from login
55
- let sessionId = tokenData.decoded?.sid || tokenData.originalSid;
56
- let currentToken = tokenData.idToken || tokenData.accessToken;
57
-
58
- // If no sid, try to refresh first to get a new base token with sid
59
- if (!sessionId && tokenData.refresh_token) {
60
- console.error(' Refreshing to obtain session ID...');
61
- try {
62
- const tokenParams = {
63
- client_id: config.clerkClientId,
64
- refresh_token: tokenData.refresh_token,
65
- grant_type: 'refresh_token',
66
- };
67
-
68
- if (config.clerkClientSecret) {
69
- tokenParams.client_secret = config.clerkClientSecret;
70
- }
71
-
72
- const response = await fetch(`https://${config.clerkDomain}/oauth/token`, {
73
- method: 'POST',
74
- headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
75
- body: new URLSearchParams(tokenParams),
76
- });
77
-
78
- if (response.ok) {
79
- const newTokenData = await response.json();
80
- const refreshedToken = newTokenData.id_token || newTokenData.access_token;
81
- const refreshedDecoded = parseJWT(refreshedToken);
82
-
83
- sessionId = refreshedDecoded?.sid;
84
- currentToken = refreshedToken;
85
-
86
- // Update tokenData with refreshed values
87
- tokenData.accessToken = newTokenData.access_token;
88
- tokenData.idToken = newTokenData.id_token;
89
- tokenData.refresh_token = newTokenData.refresh_token || tokenData.refresh_token;
90
- tokenData.decoded = refreshedDecoded;
91
-
92
- if (sessionId) {
93
- console.error(' ✓ Obtained session ID');
94
- }
95
- }
96
- } catch (error) {
97
- console.error(` Could not refresh token: ${error.message}`);
98
- }
99
- }
100
-
101
- // Now try to get org-scoped token if we have sessionId
102
- if (sessionId) {
103
- try {
104
- const orgToken = await getOrgScopedToken(
105
- sessionId,
106
- tokenData.selectedOrg.id,
107
- currentToken
108
- );
109
-
110
- const decoded = parseJWT(orgToken.jwt);
111
- const updatedToken = {
112
- ...tokenData,
113
- idToken: orgToken.jwt,
114
- decoded: decoded,
115
- };
116
-
117
- saveToken(updatedToken);
118
- console.error('✓ Token updated with org claims');
119
- return updatedToken;
120
- } catch (error) {
121
- console.error(`⚠️ Could not get org-scoped token: ${error.message}`);
122
- // Continue with existing token - API calls may fail
123
- }
124
- } else {
125
- console.error('⚠️ Could not obtain session ID for org-scoped token');
126
- console.error(' Please run: um login');
127
- }
128
- }
129
-
130
47
  return tokenData;
131
48
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unifiedmemory/cli",
3
- "version": "1.3.13",
3
+ "version": "1.3.15",
4
4
  "description": "UnifiedMemory CLI - AI code assistant integration",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -221,7 +221,7 @@ describe('mcp-proxy', () => {
221
221
 
222
222
  const authContext = {
223
223
  decoded: { sub: 'user_123' },
224
- selectedOrg: { id: 'org_456' },
224
+ orgContext: { id: 'org_456' },
225
225
  };
226
226
 
227
227
  const projectContext = {
@@ -245,7 +245,7 @@ describe('mcp-proxy', () => {
245
245
  expect(capturedBody.params.arguments.headers['X-Org-Id']).toBe('org_456');
246
246
  });
247
247
 
248
- it('should fallback to user_id for org when no selectedOrg', async () => {
248
+ it('should not inject org when no orgContext (personal context)', async () => {
249
249
  let capturedBody = null;
250
250
 
251
251
  server.use(
@@ -263,7 +263,7 @@ describe('mcp-proxy', () => {
263
263
 
264
264
  const authContext = {
265
265
  decoded: { sub: 'user_123' },
266
- // No selectedOrg
266
+ // No orgContext - personal context
267
267
  };
268
268
 
269
269
  await callRemoteMCPTool(
@@ -274,9 +274,12 @@ describe('mcp-proxy', () => {
274
274
  null
275
275
  );
276
276
 
277
- // Org should fallback to user_id
278
- expect(capturedBody.params.arguments.pathParams.org).toBe('user_123');
279
- expect(capturedBody.params.arguments.headers['X-Org-Id']).toBe('user_123');
277
+ // No org injection for personal context - gateway handles this
278
+ expect(capturedBody.params.arguments.pathParams.org).toBeUndefined();
279
+ expect(capturedBody.params.arguments.headers['X-Org-Id']).toBeUndefined();
280
+ // User should still be injected
281
+ expect(capturedBody.params.arguments.pathParams.user).toBe('user_123');
282
+ expect(capturedBody.params.arguments.headers['X-User-Id']).toBe('user_123');
280
283
  });
281
284
 
282
285
  it('should return tool execution result', async () => {
@@ -44,17 +44,9 @@ vi.mock('../../lib/jwt-utils.js', () => ({
44
44
  }),
45
45
  }));
46
46
 
47
- vi.mock('../../lib/clerk-api.js', () => ({
48
- getOrgScopedToken: vi.fn().mockResolvedValue({
49
- jwt: 'org_scoped_jwt',
50
- object: 'token',
51
- }),
52
- }));
53
-
54
47
  // Import after mocking
55
48
  import { isTokenExpired, refreshAccessToken } from '../../lib/token-refresh.js';
56
49
  import { saveToken } from '../../lib/token-storage.js';
57
- import { getOrgScopedToken } from '../../lib/clerk-api.js';
58
50
 
59
51
  describe('isTokenExpired', () => {
60
52
  it('returns true for null tokenData', () => {
@@ -181,7 +173,7 @@ describe('refreshAccessToken', () => {
181
173
  );
182
174
  });
183
175
 
184
- it('preserves selectedOrg in refreshed token', async () => {
176
+ it('preserves selectedOrgId and selectedOrgName in refreshed token', async () => {
185
177
  global.fetch = vi.fn().mockResolvedValue({
186
178
  ok: true,
187
179
  json: () => Promise.resolve({
@@ -193,60 +185,42 @@ describe('refreshAccessToken', () => {
193
185
  }),
194
186
  });
195
187
 
196
- const selectedOrg = { id: 'org_456', name: 'Test Org', role: 'admin' };
197
- await refreshAccessToken({ refresh_token: 'rt_123', selectedOrg });
188
+ await refreshAccessToken({
189
+ refresh_token: 'rt_123',
190
+ selectedOrgId: 'org_456',
191
+ selectedOrgName: 'Test Org',
192
+ });
198
193
 
199
194
  expect(saveToken).toHaveBeenCalledWith(
200
195
  expect.objectContaining({
201
- selectedOrg: selectedOrg,
196
+ selectedOrgId: 'org_456',
197
+ selectedOrgName: 'Test Org',
202
198
  })
203
199
  );
204
200
  });
205
201
 
206
- it('gets org-scoped token when selectedOrg exists and refreshed token has sid', async () => {
202
+ it('preserves sessionId through refresh', async () => {
207
203
  global.fetch = vi.fn().mockResolvedValue({
208
204
  ok: true,
209
205
  json: () => Promise.resolve({
210
206
  access_token: 'new_access_token',
211
- id_token: 'new_id_token_with_sid',
207
+ id_token: 'new_id_token',
212
208
  refresh_token: 'new_refresh_token',
213
209
  token_type: 'Bearer',
214
210
  expires_in: 3600,
215
211
  }),
216
212
  });
217
213
 
218
- const selectedOrg = { id: 'org_456', name: 'Test Org', role: 'admin' };
219
- await refreshAccessToken({ refresh_token: 'rt_123', selectedOrg });
220
-
221
- expect(getOrgScopedToken).toHaveBeenCalledWith(
222
- 'sess_new_123',
223
- 'org_456',
224
- 'new_id_token_with_sid'
225
- );
226
- });
227
-
228
- it('continues with base token when getOrgScopedToken fails', async () => {
229
- global.fetch = vi.fn().mockResolvedValue({
230
- ok: true,
231
- json: () => Promise.resolve({
232
- access_token: 'new_access_token',
233
- id_token: 'new_id_token_with_sid',
234
- refresh_token: 'new_refresh_token',
235
- token_type: 'Bearer',
236
- expires_in: 3600,
237
- }),
214
+ await refreshAccessToken({
215
+ refresh_token: 'rt_123',
216
+ sessionId: 'sess_123',
238
217
  });
239
218
 
240
- // Make getOrgScopedToken fail
241
- vi.mocked(getOrgScopedToken).mockRejectedValueOnce(new Error('API error'));
242
-
243
- const selectedOrg = { id: 'org_456', name: 'Test Org', role: 'admin' };
244
-
245
- // Should not throw - continues with base token
246
- const result = await refreshAccessToken({ refresh_token: 'rt_123', selectedOrg });
247
-
248
- expect(result).toBeDefined();
249
- expect(result.idToken).toBe('new_id_token_with_sid'); // Falls back to base token
219
+ expect(saveToken).toHaveBeenCalledWith(
220
+ expect.objectContaining({
221
+ sessionId: 'sess_123',
222
+ })
223
+ );
250
224
  });
251
225
 
252
226
  it('throws error when OAuth refresh fails with 400', async () => {
@@ -132,7 +132,6 @@ describe('token-storage module behavior', () => {
132
132
  expect(typeof ts.saveToken).toBe('function');
133
133
  expect(typeof ts.getToken).toBe('function');
134
134
  expect(typeof ts.clearToken).toBe('function');
135
- expect(typeof ts.updateSelectedOrg).toBe('function');
136
- expect(typeof ts.getSelectedOrg).toBe('function');
135
+ expect(typeof ts.getOrgContext).toBe('function');
137
136
  });
138
137
  });