@handsupmin/gc-tree 0.8.13 → 0.8.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.
package/dist/src/cli.js CHANGED
@@ -86,6 +86,22 @@ function compactMatchCommands(id, home, branch) {
86
86
  related: `gctree related --id ${JSON.stringify(id)} ${homeArg} ${branchArg}`,
87
87
  };
88
88
  }
89
+ function formatResolveText({ status, gcBranch, currentRepo, matches, message, }) {
90
+ if (status !== 'matched') {
91
+ return `[gc-tree] ${message}\n`;
92
+ }
93
+ const lines = [
94
+ `[gc-tree] 관련 문서 ${matches.length}개 gc-branch="${gcBranch}" repo="${currentRepo || 'unscoped'}"`,
95
+ ];
96
+ for (const match of matches.slice(0, 5)) {
97
+ const keyword = (match.label || match.title || match.id).trim();
98
+ const context = (match.summary || match.excerpt || '').trim();
99
+ const shortContext = context.length > 140 ? `${context.slice(0, 137).trim()}...` : context;
100
+ lines.push(`[${keyword}] 관련 문서 존재${shortContext ? `: ${shortContext}` : ''}`);
101
+ lines.push(`[${keyword}] 세부 정보 탐색: gctree show-doc --id "${match.id}" --branch "${gcBranch}"`);
102
+ }
103
+ return `${lines.join('\n')}\n`;
104
+ }
89
105
  function normalizeProvider(value) {
90
106
  if (!value)
91
107
  return undefined;
@@ -360,6 +376,7 @@ async function main() {
360
376
  const currentRepo = resolved.current_repo;
361
377
  let scopeStatus = resolved.scope_status;
362
378
  const settings = await readSettings(home);
379
+ const jsonOutput = hasFlag('--json');
363
380
  if (currentRepo && scopeStatus === 'unmapped') {
364
381
  const decision = await promptResolveScopeDecision(gcBranch, currentRepo, settings?.preferred_language || 'English');
365
382
  if (decision === 'always-use') {
@@ -368,19 +385,31 @@ async function main() {
368
385
  }
369
386
  else if (decision === 'ignore') {
370
387
  await setRepoScopeForBranch({ home, branch: gcBranch, repo: currentRepo, mode: 'exclude' });
371
- console.log(JSON.stringify({
388
+ const payload = {
389
+ status: 'excluded',
372
390
  gc_branch: gcBranch,
373
391
  query,
374
392
  current_repo: currentRepo,
375
393
  source: resolved.source,
376
394
  repo_scope_status: 'excluded',
395
+ message: `Repo "${currentRepo}" is excluded from gc-branch "${gcBranch}".`,
377
396
  matches: [],
378
- }, null, 2));
397
+ };
398
+ if (jsonOutput)
399
+ console.log(JSON.stringify(payload, null, 2));
400
+ else
401
+ stdout.write(formatResolveText({
402
+ status: payload.status,
403
+ gcBranch,
404
+ currentRepo,
405
+ matches: [],
406
+ message: payload.message,
407
+ }));
379
408
  return;
380
409
  }
381
410
  }
382
411
  if (currentRepo && scopeStatus === 'excluded') {
383
- console.log(JSON.stringify({
412
+ const payload = {
384
413
  status: 'excluded',
385
414
  gc_branch: gcBranch,
386
415
  query,
@@ -389,12 +418,22 @@ async function main() {
389
418
  repo_scope_status: 'excluded',
390
419
  message: `Repo "${currentRepo}" is excluded from gc-branch "${gcBranch}".`,
391
420
  matches: [],
392
- }, null, 2));
421
+ };
422
+ if (jsonOutput)
423
+ console.log(JSON.stringify(payload, null, 2));
424
+ else
425
+ stdout.write(formatResolveText({
426
+ status: payload.status,
427
+ gcBranch,
428
+ currentRepo,
429
+ matches: [],
430
+ message: payload.message,
431
+ }));
393
432
  return;
394
433
  }
395
434
  const branchStatus = await statusForBranch(home, gcBranch);
396
435
  if (branchStatus.doc_count === 0) {
397
- console.log(JSON.stringify({
436
+ const payload = {
398
437
  status: 'empty_branch',
399
438
  gc_branch: gcBranch,
400
439
  query,
@@ -403,25 +442,49 @@ async function main() {
403
442
  repo_scope_status: scopeStatus,
404
443
  message: `gc-branch "${gcBranch}" has no docs yet. Run gctree onboard to add durable context.`,
405
444
  matches: [],
406
- }, null, 2));
445
+ };
446
+ if (jsonOutput)
447
+ console.log(JSON.stringify(payload, null, 2));
448
+ else
449
+ stdout.write(formatResolveText({
450
+ status: payload.status,
451
+ gcBranch,
452
+ currentRepo,
453
+ matches: [],
454
+ message: payload.message,
455
+ }));
407
456
  return;
408
457
  }
409
458
  const result = await resolveContext({ home, branch: gcBranch, query });
410
- console.log(JSON.stringify({
411
- status: result.matches.length > 0 ? 'matched' : 'no_match',
459
+ const status = result.matches.length > 0 ? 'matched' : 'no_match';
460
+ const message = result.matches.length > 0
461
+ ? `Found ${result.matches.length} matching docs. Use show-doc/related for progressive disclosure.`
462
+ : `No matching docs found in gc-branch "${gcBranch}" for this query.`;
463
+ const payload = {
464
+ status,
412
465
  gc_branch: result.branch,
413
466
  query: result.query,
414
467
  current_repo: currentRepo,
415
468
  source: resolved.source,
416
469
  repo_scope_status: scopeStatus,
417
- message: result.matches.length > 0
418
- ? `Found ${result.matches.length} matching docs. Use show-doc/related for progressive disclosure.`
419
- : `No matching docs found in gc-branch "${gcBranch}" for this query.`,
470
+ message,
420
471
  matches: result.matches.map((match) => ({
421
472
  ...match,
422
473
  commands: compactMatchCommands(match.id, home, gcBranch),
423
474
  })),
424
- }, null, 2));
475
+ };
476
+ if (jsonOutput) {
477
+ console.log(JSON.stringify(payload, null, 2));
478
+ }
479
+ else {
480
+ stdout.write(formatResolveText({
481
+ status,
482
+ gcBranch,
483
+ currentRepo,
484
+ matches: result.matches,
485
+ message,
486
+ }));
487
+ }
425
488
  return;
426
489
  }
427
490
  case 'show-doc': {
@@ -532,13 +595,35 @@ async function main() {
532
595
  usage();
533
596
  const explicitTarget = readArg('--target');
534
597
  const isGlobal = !explicitTarget || Object.values({ codex: gctreeGlobalRoot('codex'), claude: gctreeGlobalRoot('claude-code') }).some((r) => resolve(explicitTarget) === resolve(r));
535
- const result = await ensureScaffold({
598
+ if (isGlobal) {
599
+ const result = await ensureScaffold({
600
+ providerMode: host,
601
+ scope: 'global',
602
+ force: hasFlag('--force'),
603
+ home,
604
+ });
605
+ console.log(JSON.stringify(result, null, 2));
606
+ return;
607
+ }
608
+ const globalResult = await ensureScaffold({
536
609
  providerMode: host,
537
- ...(isGlobal ? { scope: 'global' } : { targetDir: explicitTarget, scope: 'local' }),
610
+ scope: 'global',
538
611
  force: hasFlag('--force'),
539
612
  home,
540
613
  });
541
- console.log(JSON.stringify(result, null, 2));
614
+ const localResult = await ensureScaffold({
615
+ providerMode: host,
616
+ targetDir: explicitTarget,
617
+ scope: 'local',
618
+ force: hasFlag('--force'),
619
+ home,
620
+ });
621
+ console.log(JSON.stringify({
622
+ hosts: host,
623
+ target_dir: explicitTarget,
624
+ written: [...globalResult.written, ...localResult.written],
625
+ skipped_existing: [...globalResult.skipped_existing, ...localResult.skipped_existing],
626
+ }, null, 2));
542
627
  return;
543
628
  }
544
629
  case 'update': {
package/dist/src/hook.js CHANGED
@@ -1,8 +1,8 @@
1
- import { mkdir, readFile, writeFile } from 'node:fs/promises';
1
+ import { mkdir, open, readFile, rm, stat, writeFile } from 'node:fs/promises';
2
2
  import { createHash } from 'node:crypto';
3
3
  import { dirname } from 'node:path';
4
4
  import { DEFAULT_BRANCH } from './paths.js';
5
- import { hookCachePath, } from './paths.js';
5
+ import { hookCachePath, hookCacheDir, } from './paths.js';
6
6
  import { branchScopeStatus, readBranchRepoMap, resolveBranchForRepo } from './repo-map.js';
7
7
  import { resolveContext } from './resolve.js';
8
8
  import { readHead, statusForBranch } from './store.js';
@@ -24,17 +24,21 @@ function recentDuplicate(previousAt, now, windowMs = 5000) {
24
24
  function limitMatches(matches, max = 3) {
25
25
  return matches.slice(0, max);
26
26
  }
27
+ function displayKeyword(match) {
28
+ return (match.label || match.title || match.id).trim();
29
+ }
27
30
  function formatMatches(matches) {
28
31
  return limitMatches(matches)
29
- .map((match, index) => {
32
+ .map((match) => {
30
33
  const summary = match.summary?.trim() ?? '';
31
34
  const excerpt = match.excerpt?.trim() ?? '';
32
- // Use summary if it looks actionable (long enough to contain patterns).
33
- // Fall back to a trimmed excerpt when summary is a short description-only sentence.
34
- const context = summary.length >= 60 ? summary : (excerpt.slice(0, 300) || summary);
35
- return context
36
- ? `${index + 1}. ${match.title} [${match.id}]\n > ${context}`
37
- : `${index + 1}. ${match.title} [${match.id}]`;
35
+ const context = summary || excerpt;
36
+ const shortContext = context.length > 180 ? `${context.slice(0, 177).trim()}...` : context;
37
+ const showDoc = `gctree show-doc --id "${match.id}"`;
38
+ return [
39
+ `[${displayKeyword(match)}] 관련 문서 존재${shortContext ? `: ${shortContext}` : ''}`,
40
+ `[${displayKeyword(match)}] 세부 정보 탐색: ${showDoc}`,
41
+ ].join('\n');
38
42
  })
39
43
  .join('\n');
40
44
  }
@@ -54,7 +58,7 @@ function buildMatchContext({ gcBranch, currentRepo, repoScopeStatus, matches, })
54
58
  const lines = [
55
59
  `[gc-tree] USE FIRST: ${Math.min(matches.length, 3)} docs gc-branch="${gcBranch}" repo="${currentRepo || 'unscoped'}" scope=${repoScopeStatus}.`,
56
60
  formatMatches(matches),
57
- `Rule: summaries above are mandatory context read them now. Only run \`gctree resolve --id <id>\` if a summary is insufficient for the task.`,
61
+ `Rule: 문서를 먼저 근거로 삼고, 부족하면 표시된 show-doc 명령으로 세부 정보를 확인.`,
58
62
  ];
59
63
  if (repoScopeStatus === 'unmapped' && currentRepo) {
60
64
  lines.push(`Note: repo "${currentRepo}" is unmapped. If it belongs here, offer to run: gctree set-repo-scope --branch ${gcBranch} --include`);
@@ -107,127 +111,166 @@ function readSessionId(payload) {
107
111
  const raw = normalizeText(payload.session_id || '');
108
112
  return raw || 'default-session';
109
113
  }
114
+ function sleep(ms) {
115
+ return new Promise((resolve) => setTimeout(resolve, ms));
116
+ }
117
+ async function acquireHookLock(home, sessionId) {
118
+ const lockPath = `${hookCachePath(home, sessionId)}.lock`;
119
+ await mkdir(hookCacheDir(home), { recursive: true });
120
+ for (let attempt = 0; attempt < 50; attempt += 1) {
121
+ try {
122
+ const handle = await open(lockPath, 'wx');
123
+ await handle.close();
124
+ return async () => {
125
+ await rm(lockPath, { force: true });
126
+ };
127
+ }
128
+ catch (error) {
129
+ const code = error.code;
130
+ if (code !== 'EEXIST')
131
+ throw error;
132
+ try {
133
+ const info = await stat(lockPath);
134
+ if (Date.now() - info.mtimeMs > 10_000) {
135
+ await rm(lockPath, { force: true });
136
+ continue;
137
+ }
138
+ }
139
+ catch {
140
+ continue;
141
+ }
142
+ await sleep(25);
143
+ }
144
+ }
145
+ return async () => { };
146
+ }
110
147
  export async function dispatchGcTreeHook({ event, home, payload, }) {
111
- const cwd = payload.cwd || process.cwd();
112
- const head = (await readHead(home)) || DEFAULT_BRANCH;
113
- const resolved = await resolveBranchForRepo({ home, head, cwd });
114
- const gcBranch = resolved.gc_branch;
115
- const currentRepo = resolved.current_repo;
116
- const mapping = await readBranchRepoMap(home);
117
- const repoScopeStatus = branchScopeStatus(mapping, gcBranch, currentRepo);
118
148
  const sessionId = readSessionId(payload);
119
- const now = new Date();
120
- if (event === 'SessionStart') {
149
+ const releaseLock = await acquireHookLock(home, sessionId);
150
+ try {
151
+ const cwd = payload.cwd || process.cwd();
152
+ const head = (await readHead(home)) || DEFAULT_BRANCH;
153
+ const resolved = await resolveBranchForRepo({ home, head, cwd });
154
+ const gcBranch = resolved.gc_branch;
155
+ const currentRepo = resolved.current_repo;
156
+ const mapping = await readBranchRepoMap(home);
157
+ const repoScopeStatus = branchScopeStatus(mapping, gcBranch, currentRepo);
158
+ const now = new Date();
159
+ if (event === 'SessionStart') {
160
+ const previousCache = await readHookCache(home, sessionId);
161
+ const signature = hashQuery(`${event}:${gcBranch}:${currentRepo || ''}:${repoScopeStatus}`);
162
+ if (previousCache?.last_session_start_signature === signature &&
163
+ recentDuplicate(previousCache.last_session_start_at, now)) {
164
+ return null;
165
+ }
166
+ await writeHookCache(home, {
167
+ version: 1,
168
+ session_id: sessionId,
169
+ gc_branch: gcBranch,
170
+ current_repo: currentRepo,
171
+ repo_scope_status: repoScopeStatus,
172
+ branch_empty: previousCache?.branch_empty ?? false,
173
+ branch_excluded: previousCache?.branch_excluded ?? false,
174
+ no_match_signatures: previousCache?.no_match_signatures ?? [],
175
+ unmapped_shown_repos: previousCache?.unmapped_shown_repos ?? [],
176
+ prompt_count: previousCache?.prompt_count ?? 0,
177
+ last_session_start_signature: signature,
178
+ last_session_start_at: now.toISOString(),
179
+ last_prompt_signature: previousCache?.last_prompt_signature,
180
+ last_prompt_at: previousCache?.last_prompt_at,
181
+ updated_at: now.toISOString(),
182
+ });
183
+ return {
184
+ hookSpecificOutput: {
185
+ hookEventName: event,
186
+ additionalContext: buildSessionStartContext({
187
+ gcBranch,
188
+ currentRepo,
189
+ repoScopeStatus,
190
+ }),
191
+ },
192
+ };
193
+ }
194
+ const prompt = normalizeText(payload.user_prompt || payload.prompt || '');
195
+ if (!prompt)
196
+ return null;
197
+ const branchStatus = await statusForBranch(home, gcBranch);
121
198
  const previousCache = await readHookCache(home, sessionId);
122
- const signature = hashQuery(`${event}:${gcBranch}:${currentRepo || ''}:${repoScopeStatus}`);
123
- if (previousCache?.last_session_start_signature === signature &&
124
- recentDuplicate(previousCache.last_session_start_at, now)) {
199
+ const promptSignature = hashQuery(`${event}:${gcBranch}:${currentRepo || ''}:${repoScopeStatus}:${prompt}`);
200
+ if (previousCache?.last_prompt_signature === promptSignature &&
201
+ recentDuplicate(previousCache.last_prompt_at, now)) {
125
202
  return null;
126
203
  }
127
- await writeHookCache(home, {
128
- version: 1,
129
- session_id: sessionId,
130
- gc_branch: gcBranch,
131
- current_repo: currentRepo,
132
- repo_scope_status: repoScopeStatus,
133
- branch_empty: previousCache?.branch_empty ?? false,
134
- branch_excluded: previousCache?.branch_excluded ?? false,
135
- no_match_signatures: previousCache?.no_match_signatures ?? [],
136
- unmapped_shown_repos: previousCache?.unmapped_shown_repos ?? [],
137
- prompt_count: previousCache?.prompt_count ?? 0,
138
- last_session_start_signature: signature,
139
- last_session_start_at: now.toISOString(),
140
- last_prompt_signature: previousCache?.last_prompt_signature,
141
- last_prompt_at: previousCache?.last_prompt_at,
142
- updated_at: now.toISOString(),
143
- });
144
- return {
145
- hookSpecificOutput: {
146
- hookEventName: event,
147
- additionalContext: buildSessionStartContext({
148
- gcBranch,
149
- currentRepo,
150
- repoScopeStatus,
151
- }),
152
- },
153
- };
154
- }
155
- const prompt = normalizeText(payload.user_prompt || payload.prompt || '');
156
- if (!prompt)
157
- return null;
158
- const branchStatus = await statusForBranch(home, gcBranch);
159
- const previousCache = await readHookCache(home, sessionId);
160
- const promptSignature = hashQuery(`${event}:${gcBranch}:${currentRepo || ''}:${repoScopeStatus}:${prompt}`);
161
- if (previousCache?.last_prompt_signature === promptSignature &&
162
- recentDuplicate(previousCache.last_prompt_at, now)) {
163
- return null;
164
- }
165
- const cache = nextCacheState(previousCache, {
166
- sessionId,
167
- gcBranch,
168
- currentRepo,
169
- repoScopeStatus,
170
- });
171
- cache.last_prompt_signature = promptSignature;
172
- cache.last_prompt_at = now.toISOString();
173
- let additionalContext;
174
- if (repoScopeStatus === 'excluded') {
175
- cache.branch_excluded = true;
176
- additionalContext = buildExcludedContext({
204
+ const cache = nextCacheState(previousCache, {
205
+ sessionId,
177
206
  gcBranch,
178
207
  currentRepo,
179
- cached: previousCache?.branch_excluded === true,
208
+ repoScopeStatus,
180
209
  });
181
- }
182
- else if (branchStatus.doc_count === 0) {
183
- const wasCached = cache.branch_empty;
184
- cache.branch_empty = true;
185
- additionalContext = buildEmptyBranchContext({ gcBranch, currentRepo, cached: wasCached });
186
- }
187
- else if (repoScopeStatus === 'unmapped' && currentRepo && cache.unmapped_shown_repos.includes(currentRepo)) {
188
- // Already showed context for this unmapped repo this session — skip silently.
189
- cache.updated_at = now.toISOString();
190
- await writeHookCache(home, cache);
191
- return null;
192
- }
193
- else {
194
- // For unmapped repos: use prompt only (no repo prefix) to avoid bias; apply higher score threshold.
195
- const isUnmapped = repoScopeStatus === 'unmapped';
196
- const query = !isUnmapped && currentRepo ? `${currentRepo} ${prompt}` : prompt;
197
- const signature = hashQuery(query);
198
- if (!isUnmapped && cache.no_match_signatures.includes(signature)) {
199
- additionalContext = buildNoMatchContext({ gcBranch, currentRepo, cached: true });
210
+ cache.last_prompt_signature = promptSignature;
211
+ cache.last_prompt_at = now.toISOString();
212
+ let additionalContext;
213
+ if (repoScopeStatus === 'excluded') {
214
+ cache.branch_excluded = true;
215
+ additionalContext = buildExcludedContext({
216
+ gcBranch,
217
+ currentRepo,
218
+ cached: previousCache?.branch_excluded === true,
219
+ });
220
+ }
221
+ else if (branchStatus.doc_count === 0) {
222
+ const wasCached = cache.branch_empty;
223
+ cache.branch_empty = true;
224
+ additionalContext = buildEmptyBranchContext({ gcBranch, currentRepo, cached: wasCached });
225
+ }
226
+ else if (repoScopeStatus === 'unmapped' && currentRepo && cache.unmapped_shown_repos.includes(currentRepo)) {
227
+ // Already showed context for this unmapped repo this session — skip silently.
228
+ cache.updated_at = now.toISOString();
229
+ await writeHookCache(home, cache);
230
+ return null;
200
231
  }
201
232
  else {
202
- const result = await resolveContext({ home, branch: gcBranch, query });
203
- const effectiveMatches = result.matches;
204
- if (effectiveMatches.length === 0) {
205
- if (isUnmapped) {
206
- // Unmapped + no strong match: skip silently, no noise.
207
- cache.updated_at = now.toISOString();
208
- await writeHookCache(home, cache);
209
- return null;
210
- }
211
- cache.no_match_signatures = [...new Set([...cache.no_match_signatures, signature])];
212
- additionalContext = buildNoMatchContext({ gcBranch, currentRepo, cached: false });
233
+ // For unmapped repos: use prompt only (no repo prefix) to avoid bias; apply higher score threshold.
234
+ const isUnmapped = repoScopeStatus === 'unmapped';
235
+ const query = !isUnmapped && currentRepo ? `${currentRepo} ${prompt}` : prompt;
236
+ const signature = hashQuery(query);
237
+ if (!isUnmapped && cache.no_match_signatures.includes(signature)) {
238
+ additionalContext = buildNoMatchContext({ gcBranch, currentRepo, cached: true });
213
239
  }
214
240
  else {
215
- if (!isUnmapped) {
216
- cache.no_match_signatures = cache.no_match_signatures.filter((entry) => entry !== signature);
241
+ const result = await resolveContext({ home, branch: gcBranch, query });
242
+ const effectiveMatches = result.matches;
243
+ if (effectiveMatches.length === 0) {
244
+ if (isUnmapped) {
245
+ // Unmapped + no strong match: skip silently, no noise.
246
+ cache.updated_at = now.toISOString();
247
+ await writeHookCache(home, cache);
248
+ return null;
249
+ }
250
+ cache.no_match_signatures = [...new Set([...cache.no_match_signatures, signature])];
251
+ additionalContext = buildNoMatchContext({ gcBranch, currentRepo, cached: false });
217
252
  }
218
- else if (currentRepo) {
219
- // Mark this unmapped repo as shown so we don't repeat next prompt.
220
- cache.unmapped_shown_repos = [...new Set([...cache.unmapped_shown_repos, currentRepo])];
253
+ else {
254
+ if (!isUnmapped) {
255
+ cache.no_match_signatures = cache.no_match_signatures.filter((entry) => entry !== signature);
256
+ }
257
+ else if (currentRepo) {
258
+ // Mark this unmapped repo as shown so we don't repeat next prompt.
259
+ cache.unmapped_shown_repos = [...new Set([...cache.unmapped_shown_repos, currentRepo])];
260
+ }
261
+ additionalContext = buildMatchContext({ gcBranch, currentRepo, repoScopeStatus, matches: effectiveMatches });
221
262
  }
222
- additionalContext = buildMatchContext({ gcBranch, currentRepo, repoScopeStatus, matches: effectiveMatches });
263
+ }
264
+ if (cache.prompt_count > 0 && cache.prompt_count % SELF_REVIEW_INTERVAL === 0) {
265
+ additionalContext += buildSelfReviewAppend(cache.prompt_count);
266
+ cache.prompt_count = 0;
223
267
  }
224
268
  }
225
- if (cache.prompt_count > 0 && cache.prompt_count % SELF_REVIEW_INTERVAL === 0) {
226
- additionalContext += buildSelfReviewAppend(cache.prompt_count);
227
- cache.prompt_count = 0;
228
- }
269
+ cache.updated_at = now.toISOString();
270
+ await writeHookCache(home, cache);
271
+ return { hookSpecificOutput: { hookEventName: event, additionalContext } };
272
+ }
273
+ finally {
274
+ await releaseLock();
229
275
  }
230
- cache.updated_at = now.toISOString();
231
- await writeHookCache(home, cache);
232
- return { hookSpecificOutput: { hookEventName: event, additionalContext } };
233
276
  }
@@ -295,14 +295,31 @@ export function extractTitle(markdown) {
295
295
  return match?.[1]?.trim() || '';
296
296
  }
297
297
  export function extractExcerpt(markdown, query) {
298
- const content = String(markdown || '').replace(/\s+/g, ' ').trim();
298
+ const content = String(markdown || '')
299
+ .replace(/^#+\s+/gm, '')
300
+ .replace(/\s+/g, ' ')
301
+ .trim();
299
302
  if (!content)
300
303
  return '';
301
- const normalized = query.toLowerCase();
302
- const index = content.toLowerCase().indexOf(normalized);
303
- if (index === -1)
304
- return content.slice(0, 140);
305
- const start = Math.max(0, index - 40);
306
- const end = Math.min(content.length, index + normalized.length + 80);
307
- return content.slice(start, end).trim();
304
+ const tokens = String(query || '')
305
+ .toLowerCase()
306
+ .split(/[^\p{L}\p{N}]+/u)
307
+ .flatMap((t) => t.split(/(?<=[a-z0-9])(?=[^\x00-\x7f])|(?<=[^\x00-\x7f])(?=[a-z0-9])/))
308
+ .filter((t) => t.length >= 2);
309
+ if (tokens.length === 0)
310
+ return content.slice(0, 180);
311
+ const chunks = content
312
+ .split(/(?<=[.!?。!?])\s+|\s+-\s+|(?=\b[A-Z][A-Za-z ]+:)/)
313
+ .map((chunk) => chunk.trim())
314
+ .filter(Boolean);
315
+ const scored = chunks
316
+ .map((chunk, index) => ({
317
+ chunk,
318
+ index,
319
+ score: tokens.reduce((sum, token) => sum + (chunk.toLowerCase().includes(token) ? 1 : 0), 0),
320
+ }))
321
+ .filter((entry) => entry.score > 0)
322
+ .sort((a, b) => b.score - a.score || a.index - b.index);
323
+ const best = scored[0]?.chunk || content;
324
+ return best.length > 220 ? `${best.slice(0, 217).trim()}...` : best;
308
325
  }
@@ -27,8 +27,7 @@ function makeTokenRegex(token) {
27
27
  ? new RegExp(`\\b${token}\\b`)
28
28
  : new RegExp(`(?<!\\p{L})${token}(?!\\p{L})`, 'u');
29
29
  }
30
- function scoreText(text, query) {
31
- const tokens = tokenize(query);
30
+ function countTokenMatches(text, tokens) {
32
31
  if (tokens.length === 0)
33
32
  return 0;
34
33
  const haystack = String(text || '').toLowerCase();
@@ -42,13 +41,58 @@ function scoreText(text, query) {
42
41
  }
43
42
  }, 0);
44
43
  }
44
+ function scoreText(text, query) {
45
+ return countTokenMatches(text, tokenize(query));
46
+ }
47
+ function exactPhraseScore(text, query) {
48
+ const phrase = String(query || '').trim().toLowerCase();
49
+ if (phrase.length < 4)
50
+ return 0;
51
+ return String(text || '').toLowerCase().includes(phrase) ? 6 : 0;
52
+ }
53
+ function scoreDoc(entry, query) {
54
+ const tokens = tokenize(query);
55
+ if (tokens.length === 0)
56
+ return 0;
57
+ const labelScore = countTokenMatches(entry.label, tokens) * 10;
58
+ const titleScore = countTokenMatches(entry.title, tokens) * 7;
59
+ const summaryScore = countTokenMatches(entry.summary, tokens) * 5;
60
+ const categoryScore = countTokenMatches(entry.category, tokens) * 2;
61
+ const pathScore = countTokenMatches(entry.path, tokens) * 2;
62
+ const contentScore = countTokenMatches(entry.content, tokens);
63
+ const phraseScore = exactPhraseScore(entry.label, query) +
64
+ exactPhraseScore(entry.title, query) +
65
+ exactPhraseScore(entry.summary, query) +
66
+ exactPhraseScore(entry.content, query);
67
+ return labelScore + titleScore + summaryScore + categoryScore + pathScore + contentScore + phraseScore;
68
+ }
69
+ function minimumUsefulScore(query) {
70
+ return tokenize(query).length <= 1 ? 1 : 2;
71
+ }
72
+ function labelQueryPosition(label, query) {
73
+ const normalizedQuery = String(query || '').toLowerCase();
74
+ const positions = tokenize(label)
75
+ .map((token) => normalizedQuery.indexOf(token))
76
+ .filter((index) => index >= 0);
77
+ return positions.length > 0 ? Math.min(...positions) : Number.MAX_SAFE_INTEGER;
78
+ }
79
+ function betterMatch(candidate, previous, query) {
80
+ return (!previous ||
81
+ candidate.score > previous.score ||
82
+ (candidate.score === previous.score &&
83
+ labelQueryPosition(candidate.label, query) < labelQueryPosition(previous.label, query)));
84
+ }
45
85
  async function readBranchDocs(home, branch) {
46
86
  const indexRaw = await readFile(branchIndexPath(home, branch), 'utf8');
47
87
  const entries = parseIndexEntries(indexRaw);
48
- const uniqueEntries = [...new Map(entries.map((entry) => [entry.path, entry])).values()];
49
- return Promise.all(uniqueEntries.map(async (entry) => {
88
+ const contentCache = new Map();
89
+ return Promise.all(entries.map(async (entry) => {
50
90
  const fullPath = join(branchDir(home, branch), entry.path);
51
- const raw = await readFile(fullPath, 'utf8');
91
+ let raw = contentCache.get(fullPath);
92
+ if (!raw) {
93
+ raw = await readFile(fullPath, 'utf8');
94
+ contentCache.set(fullPath, raw);
95
+ }
52
96
  return {
53
97
  id: entry.id || docIdFromPath(entry.path),
54
98
  title: extractTitle(raw) || entry.title,
@@ -62,16 +106,13 @@ async function readBranchDocs(home, branch) {
62
106
  }
63
107
  export async function resolveContext({ home, branch, query, }) {
64
108
  const entries = await readBranchDocs(home, branch);
65
- const matches = [];
109
+ const matchesById = new Map();
110
+ const minScore = minimumUsefulScore(query);
66
111
  for (const entry of entries) {
67
- const combined = `${entry.title}\n${entry.summary}\n${entry.content}`;
68
- // Title matches count double (higher signal density)
69
- const titleScore = scoreText(entry.title, query);
70
- const bodyScore = scoreText(`${entry.summary}\n${entry.content}`, query);
71
- const score = titleScore * 2 + bodyScore;
72
- if (score <= 0)
112
+ const score = scoreDoc(entry, query);
113
+ if (score < minScore)
73
114
  continue;
74
- matches.push({
115
+ const match = {
75
116
  id: entry.id,
76
117
  label: entry.label,
77
118
  category: entry.category,
@@ -80,12 +121,15 @@ export async function resolveContext({ home, branch, query, }) {
80
121
  score,
81
122
  summary: entry.summary,
82
123
  excerpt: extractExcerpt(entry.content, query),
83
- });
124
+ };
125
+ if (betterMatch(match, matchesById.get(match.id), query)) {
126
+ matchesById.set(match.id, match);
127
+ }
84
128
  }
85
129
  return {
86
130
  branch,
87
131
  query,
88
- matches: matches.sort((a, b) => b.score - a.score || a.title.localeCompare(b.title)),
132
+ matches: [...matchesById.values()].sort((a, b) => b.score - a.score || a.title.localeCompare(b.title)),
89
133
  };
90
134
  }
91
135
  export async function getDocById({ home, branch, id, }) {
@@ -102,26 +146,26 @@ export async function findRelatedDocs({ home, branch, id, limit = 5, }) {
102
146
  return { status: 'doc_not_found', selected: null, matches: [] };
103
147
  }
104
148
  const query = `${selected.title}\n${selected.summary}`;
105
- const matches = docs
106
- .filter((doc) => doc.id !== selected.id)
107
- .map((doc) => {
108
- const titleScore = scoreText(doc.title, query);
109
- const bodyScore = scoreText(`${doc.summary}\n${doc.content}`, query);
110
- const score = titleScore * 2 + bodyScore;
111
- return score > 0
112
- ? {
113
- id: doc.id,
114
- label: doc.label,
115
- category: doc.category,
116
- title: doc.title,
117
- path: doc.path,
118
- score,
119
- summary: doc.summary,
120
- excerpt: extractExcerpt(doc.content, query),
121
- }
122
- : null;
123
- })
124
- .filter((match) => Boolean(match))
149
+ const matchesById = new Map();
150
+ for (const doc of docs.filter((doc) => doc.id !== selected.id)) {
151
+ const score = scoreDoc(doc, query);
152
+ if (score <= 0)
153
+ continue;
154
+ const match = {
155
+ id: doc.id,
156
+ label: doc.label,
157
+ category: doc.category,
158
+ title: doc.title,
159
+ path: doc.path,
160
+ score,
161
+ summary: doc.summary,
162
+ excerpt: extractExcerpt(doc.content, query),
163
+ };
164
+ if (betterMatch(match, matchesById.get(match.id), query)) {
165
+ matchesById.set(match.id, match);
166
+ }
167
+ }
168
+ const matches = [...matchesById.values()]
125
169
  .sort((a, b) => b.score - a.score || a.title.localeCompare(b.title))
126
170
  .slice(0, limit);
127
171
  return {
@@ -301,11 +301,19 @@ export async function scaffoldHostIntegration({ host, targetDir, force = false,
301
301
  await unmergeGcTreeHooksJson(oldHooksPath);
302
302
  }
303
303
  }
304
- await mergeGcTreeHooksJson({
305
- filePath: hookPath,
306
- target: isCodex ? 'codex' : 'claude-code',
307
- });
308
- written.push(hookPath);
304
+ if (scope === 'local') {
305
+ const status = await unmergeGcTreeHooksJson(hookPath);
306
+ if (status !== 'missing') {
307
+ written.push(hookPath);
308
+ }
309
+ }
310
+ else {
311
+ await mergeGcTreeHooksJson({
312
+ filePath: hookPath,
313
+ target: isCodex ? 'codex' : 'claude-code',
314
+ });
315
+ written.push(hookPath);
316
+ }
309
317
  const targets = files.map((file) => ({
310
318
  ...file,
311
319
  fullPath: join(resolvedTargetDir, file.path),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@handsupmin/gc-tree",
3
- "version": "0.8.13",
3
+ "version": "0.8.15",
4
4
  "description": "Global Context Tree, a lightweight branch-aware global context orchestrator for AI coding tools",
5
5
  "type": "module",
6
6
  "private": false,