@handsupmin/gc-tree 0.8.14 → 0.8.16

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': {
package/dist/src/hook.js CHANGED
@@ -24,19 +24,24 @@ 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)}]`,
40
+ shortContext,
41
+ `details: ${showDoc}`,
42
+ ].join('\n');
38
43
  })
39
- .join('\n');
44
+ .join('\n\n');
40
45
  }
41
46
  function buildSessionStartContext({ gcBranch, currentRepo, repoScopeStatus, }) {
42
47
  return `[gc-tree] active gc-branch="${gcBranch}" repo="${currentRepo || 'unscoped'}" scope=${repoScopeStatus}.`;
@@ -53,8 +58,10 @@ function buildNoMatchContext({ gcBranch, currentRepo, cached, }) {
53
58
  function buildMatchContext({ gcBranch, currentRepo, repoScopeStatus, matches, }) {
54
59
  const lines = [
55
60
  `[gc-tree] USE FIRST: ${Math.min(matches.length, 3)} docs gc-branch="${gcBranch}" repo="${currentRepo || 'unscoped'}" scope=${repoScopeStatus}.`,
61
+ '',
56
62
  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.`,
63
+ '',
64
+ `Rule: 위 문서를 먼저 근거로 삼고, 부족하면 details 명령으로 세부 정보를 확인.`,
58
65
  ];
59
66
  if (repoScopeStatus === 'unmapped' && currentRepo) {
60
67
  lines.push(`Note: repo "${currentRepo}" is unmapped. If it belongs here, offer to run: gctree set-repo-scope --branch ${gcBranch} --include`);
@@ -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 {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@handsupmin/gc-tree",
3
- "version": "0.8.14",
3
+ "version": "0.8.16",
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,