Compare commits
2 Commits
26cde3afb0
...
98d533f045
| Author | SHA1 | Date | |
|---|---|---|---|
| 98d533f045 | |||
| 443488a3b6 |
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"result": "직답 결과 — single-pass mock 응답입니다.",
|
"result": "직답 결과 — single-pass mock 응답입니다.",
|
||||||
"createdAt": 1783247674941,
|
"createdAt": 1783259143996,
|
||||||
"modelVersion": "unknown"
|
"modelVersion": "unknown"
|
||||||
}
|
}
|
||||||
+2
-2
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"result": "---\nid: wiki_on\ndate: 2026-07-05T10:34:34.941Z\ntype: knowledge_artifact\nstandard: P-Reinforce v3.0\ntags: [automated, connect_ai, brain_sync]\n---\n\n## 📌 Brief Summary\n직답 결과 — single-pass mock 응답입니다.\n\n직답 결과 — single-pass mock 응답입니다.\n---\n## 🛡️ Reliability & Audit Summary\n> [!NOTE]\n> 이 문서는 ConnectAI의 **Intelligent Resilience** 엔진에 의해 검증 및 정제되었습니다.\n\n| Metric | Value | Status |\n| :--- | :--- | :--- |\n| **Conflict Risk** | `0/100` | ✅ Low |\n| **Fallbacks Used** | `0` | ✅ None |\n| **Auto Retries** | `0` | ✅ Stable |\n| **Deduplication** | `0` | Standard |\n| **Processing Time** | `0.0s` | ✅ Fast |\n\n### 🔍 Decision Audit Trail\n- **[DIRECT]** 답변 작성 중... (단일 호출 fast-path) (11ms)\n",
|
"result": "---\nid: wiki_on\ndate: 2026-07-05T13:45:43.997Z\ntype: knowledge_artifact\nstandard: P-Reinforce v3.0\ntags: [automated, connect_ai, brain_sync]\n---\n\n## 📌 Brief Summary\n직답 결과 — single-pass mock 응답입니다.\n\n직답 결과 — single-pass mock 응답입니다.\n---\n## 🛡️ Reliability & Audit Summary\n> [!NOTE]\n> 이 문서는 ConnectAI의 **Intelligent Resilience** 엔진에 의해 검증 및 정제되었습니다.\n\n| Metric | Value | Status |\n| :--- | :--- | :--- |\n| **Conflict Risk** | `0/100` | ✅ Low |\n| **Fallbacks Used** | `0` | ✅ None |\n| **Auto Retries** | `0` | ✅ Stable |\n| **Deduplication** | `0` | Standard |\n| **Processing Time** | `0.0s` | ✅ Fast |\n\n### 🔍 Decision Audit Trail\n- **[DIRECT]** 답변 작성 중... (단일 호출 fast-path) (12ms)\n",
|
||||||
"createdAt": 1783247674941,
|
"createdAt": 1783259143997,
|
||||||
"modelVersion": "unknown"
|
"modelVersion": "unknown"
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"missionId": "wiki_on",
|
"missionId": "wiki_on",
|
||||||
"status": "completed",
|
"status": "completed",
|
||||||
"startTime": "2026-07-05T10:34:34.929Z",
|
"startTime": "2026-07-05T13:45:43.984Z",
|
||||||
"totalElapsedMs": 12,
|
"totalElapsedMs": 13,
|
||||||
"results": {
|
"results": {
|
||||||
"direct": "직답 결과 — single-pass mock 응답입니다."
|
"direct": "직답 결과 — single-pass mock 응답입니다."
|
||||||
},
|
},
|
||||||
@@ -12,16 +12,16 @@
|
|||||||
{
|
{
|
||||||
"from": "idle",
|
"from": "idle",
|
||||||
"to": "direct",
|
"to": "direct",
|
||||||
"durationMs": 11,
|
"durationMs": 12,
|
||||||
"message": "답변 작성 중... (단일 호출 fast-path)",
|
"message": "답변 작성 중... (단일 호출 fast-path)",
|
||||||
"ts": "2026-07-05T10:34:34.940Z"
|
"ts": "2026-07-05T13:45:43.996Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"from": "direct",
|
"from": "direct",
|
||||||
"to": "completed",
|
"to": "completed",
|
||||||
"durationMs": 1,
|
"durationMs": 1,
|
||||||
"message": "미션 완료",
|
"message": "미션 완료",
|
||||||
"ts": "2026-07-05T10:34:34.941Z"
|
"ts": "2026-07-05T13:45:43.997Z"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"resilienceMetrics": {
|
"resilienceMetrics": {
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"result": "Final report with inconsistencies. This should be long enough to pass validation.",
|
"result": "Final report with inconsistencies. This should be long enough to pass validation.",
|
||||||
"createdAt": 1783247682033,
|
"createdAt": 1783259150864,
|
||||||
"modelVersion": "unknown"
|
"modelVersion": "unknown"
|
||||||
}
|
}
|
||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"result": "Final report with inconsistencies. This should be long enough to pass validation.",
|
"result": "Final report with inconsistencies. This should be long enough to pass validation.",
|
||||||
"createdAt": 1783247682033,
|
"createdAt": 1783259150864,
|
||||||
"modelVersion": "unknown"
|
"modelVersion": "unknown"
|
||||||
}
|
}
|
||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"result": "[{\"heading\":\"본문\",\"scope\":\"전체 답변\"}]",
|
"result": "[{\"heading\":\"본문\",\"scope\":\"전체 답변\"}]",
|
||||||
"createdAt": 1783247682031,
|
"createdAt": 1783259150863,
|
||||||
"modelVersion": "unknown"
|
"modelVersion": "unknown"
|
||||||
}
|
}
|
||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"result": "[CONFLICT WARNING] 성능이 200% 증가했습니다. vs 그러나 동시에 50% 감소했습니다. 최적화와 성능 저하가 동시에 발견됨.",
|
"result": "[CONFLICT WARNING] 성능이 200% 증가했습니다. vs 그러나 동시에 50% 감소했습니다. 최적화와 성능 저하가 동시에 발견됨.",
|
||||||
"createdAt": 1783247682032,
|
"createdAt": 1783259150863,
|
||||||
"modelVersion": "unknown"
|
"modelVersion": "unknown"
|
||||||
}
|
}
|
||||||
+8
-8
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"missionId": "stress_conflict_1783247682020",
|
"missionId": "stress_conflict_1783259150850",
|
||||||
"status": "completed",
|
"status": "completed",
|
||||||
"startTime": "2026-07-05T10:34:42.020Z",
|
"startTime": "2026-07-05T13:45:50.850Z",
|
||||||
"totalElapsedMs": 13,
|
"totalElapsedMs": 14,
|
||||||
"results": {
|
"results": {
|
||||||
"outline": "[{\"heading\":\"본문\",\"scope\":\"전체 답변\"}]",
|
"outline": "[{\"heading\":\"본문\",\"scope\":\"전체 답변\"}]",
|
||||||
"section_0": "[CONFLICT WARNING] 성능이 200% 증가했습니다. vs 그러나 동시에 50% 감소했습니다. 최적화와 성능 저하가 동시에 발견됨.",
|
"section_0": "[CONFLICT WARNING] 성능이 200% 증가했습니다. vs 그러나 동시에 50% 감소했습니다. 최적화와 성능 저하가 동시에 발견됨.",
|
||||||
@@ -14,30 +14,30 @@
|
|||||||
{
|
{
|
||||||
"from": "idle",
|
"from": "idle",
|
||||||
"to": "outline",
|
"to": "outline",
|
||||||
"durationMs": 11,
|
"durationMs": 12,
|
||||||
"message": "답변 구조 잡는 중...",
|
"message": "답변 구조 잡는 중...",
|
||||||
"ts": "2026-07-05T10:34:42.031Z"
|
"ts": "2026-07-05T13:45:50.862Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"from": "outline",
|
"from": "outline",
|
||||||
"to": "section",
|
"to": "section",
|
||||||
"durationMs": 1,
|
"durationMs": 1,
|
||||||
"message": "본문 작성 중...",
|
"message": "본문 작성 중...",
|
||||||
"ts": "2026-07-05T10:34:42.032Z"
|
"ts": "2026-07-05T13:45:50.863Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"from": "section",
|
"from": "section",
|
||||||
"to": "polish",
|
"to": "polish",
|
||||||
"durationMs": 0,
|
"durationMs": 0,
|
||||||
"message": "최종 다듬기 중...",
|
"message": "최종 다듬기 중...",
|
||||||
"ts": "2026-07-05T10:34:42.032Z"
|
"ts": "2026-07-05T13:45:50.863Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"from": "polish",
|
"from": "polish",
|
||||||
"to": "completed",
|
"to": "completed",
|
||||||
"durationMs": 1,
|
"durationMs": 1,
|
||||||
"message": "미션 완료",
|
"message": "미션 완료",
|
||||||
"ts": "2026-07-05T10:34:42.033Z"
|
"ts": "2026-07-05T13:45:50.864Z"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"resilienceMetrics": {
|
"resilienceMetrics": {
|
||||||
@@ -1,5 +1,13 @@
|
|||||||
# Astra Patch Notes
|
# Astra Patch Notes
|
||||||
|
|
||||||
|
## v2.2.297 (2026-07-05)
|
||||||
|
### ☁️ stocks 목록 클라우드 백업 — 컴퓨터 바꿔도 워치리스트 유지
|
||||||
|
stocks.json 이 로컬 워크스페이스에만 있어 기기를 바꾸면 목록이 사라지던 문제 해결. 기존 Sheet1~3 sync 는 분류 표시용이라 복원이 불가능했다.
|
||||||
|
- **자동 백업**: 목록이 바뀔 때마다(add/remove/check/discover 자동편입/워처 가격갱신 — writeStocksStore 성공 시 리스너 발화) 30초 디바운스 후 기존 spreadsheet(`g1nation.stocks.spreadsheetId`)의 `_backup` 탭에 **원본 JSON 그대로** 백업. 탭은 없으면 자동 생성(batchUpdate addSheet), 목록이 줄어도 잔여 행이 안 남게 클리어 후 덮어쓰기. `g1nation.stocks.autoBackup`(기본 ON)으로 끄기 가능.
|
||||||
|
- **`/stocks backup`**: 수동 즉시 백업. **`/stocks restore`**: 새 컴퓨터에서 한 번으로 복원(로컬 목록 교체, silent write 로 되백업 루프 차단, 손상 행은 skip).
|
||||||
|
- sheetsApi 에 `ensureSheetTab`(탭 존재 확인+생성) / `clearSheetRange`(values:clear) 추가 — OAuth·인프라는 기존 calendar/sheets 공유 그대로.
|
||||||
|
검증: tsc 무오류 + jest 818 통과 + esbuild 정상.
|
||||||
|
|
||||||
## v2.2.296 (2026-07-05)
|
## v2.2.296 (2026-07-05)
|
||||||
### 🧠 소형 모델 추론 강화 2탄 — 코딩 실행 루프 + 도구 라우팅 + 근거 재검색
|
### 🧠 소형 모델 추론 강화 2탄 — 코딩 실행 루프 + 도구 라우팅 + 근거 재검색
|
||||||
로드맵 2·3·4번 완결. 1탄(calculate·지식 스코프)과 합쳐 "모델은 판단만, 계산·실행·기억은 바깥에서" 아키텍처가 완성됐다.
|
로드맵 2·3·4번 완결. 1탄(calculate·지식 스코프)과 합쳐 "모델은 판단만, 계산·실행·기억은 바깥에서" 아키텍처가 완성됐다.
|
||||||
|
|||||||
+6
-1
@@ -2,7 +2,7 @@
|
|||||||
"name": "astra",
|
"name": "astra",
|
||||||
"displayName": "Astra",
|
"displayName": "Astra",
|
||||||
"description": "The personal intelligence layer for Antigravity and VS Code. A private cognitive partner for deep project context, memory, and proactive strategic decision-making.",
|
"description": "The personal intelligence layer for Antigravity and VS Code. A private cognitive partner for deep project context, memory, and proactive strategic decision-making.",
|
||||||
"version": "2.2.296",
|
"version": "2.2.297",
|
||||||
"publisher": "g1nation",
|
"publisher": "g1nation",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"icon": "assets/icon.png",
|
"icon": "assets/icon.png",
|
||||||
@@ -1238,6 +1238,11 @@
|
|||||||
"default": "",
|
"default": "",
|
||||||
"markdownDescription": "**Stocks Google Sheets ID** — `https://docs.google.com/spreadsheets/d/<여기>/...` URL 의 ID 부분. 미설정이면 watcher / `/stocks sync` 가 silent skip. OAuth scope 은 calendar 와 공유 (sheets scope 이미 포함)."
|
"markdownDescription": "**Stocks Google Sheets ID** — `https://docs.google.com/spreadsheets/d/<여기>/...` URL 의 ID 부분. 미설정이면 watcher / `/stocks sync` 가 silent skip. OAuth scope 은 calendar 와 공유 (sheets scope 이미 포함)."
|
||||||
},
|
},
|
||||||
|
"g1nation.stocks.autoBackup": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": true,
|
||||||
|
"markdownDescription": "**stocks.json 자동 클라우드 백업** (v2.2.297) — 목록이 바뀔 때마다(추가/제거/가격갱신/발굴 편입) 30초 디바운스 후 `g1nation.stocks.spreadsheetId` spreadsheet 의 `_backup` 탭에 원본 JSON 그대로 백업. 컴퓨터를 바꿔도 `/stocks restore` 한 번으로 복원. spreadsheetId 미설정이면 조용히 skip. 수동 백업은 `/stocks backup`."
|
||||||
|
},
|
||||||
"g1nation.stocks.sheetSwing": {
|
"g1nation.stocks.sheetSwing": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"default": "Sheet1",
|
"default": "Sheet1",
|
||||||
|
|||||||
+3
-1
@@ -39,7 +39,7 @@ import { getBrainTokenIndex, clearBrainTokenIndex } from './retrieval';
|
|||||||
import { lessonTemplate, lessonSlug, parseLessonFrontmatter, normalizeLessonTitle, bumpLessonOccurrences } from './retrieval/lessonHelpers';
|
import { lessonTemplate, lessonSlug, parseLessonFrontmatter, normalizeLessonTitle, bumpLessonOccurrences } from './retrieval/lessonHelpers';
|
||||||
import { runConnectGoogleCalendarIcal, runConnectGoogleCalendarOAuth } from './extension/calendarSetup';
|
import { runConnectGoogleCalendarIcal, runConnectGoogleCalendarOAuth } from './extension/calendarSetup';
|
||||||
import { runInitialSetup } from './extension/initialSetup';
|
import { runInitialSetup } from './extension/initialSetup';
|
||||||
import { startStocksWatcher } from './features/stocks';
|
import { startStocksWatcher, registerStocksAutoBackup } from './features/stocks';
|
||||||
import { startDailyBriefingWatcher } from './features/briefing/dailyBriefing';
|
import { startDailyBriefingWatcher } from './features/briefing/dailyBriefing';
|
||||||
import { startEmailWatcher } from './features/email/emailWatcher';
|
import { startEmailWatcher } from './features/email/emailWatcher';
|
||||||
import { ensureDefaultBrainConfigured } from './extension/brainBootstrap';
|
import { ensureDefaultBrainConfigured } from './extension/brainBootstrap';
|
||||||
@@ -327,6 +327,8 @@ export async function activate(context: vscode.ExtensionContext) {
|
|||||||
// Stocks watcher — VS Code 시작 시 자동 활성화. KST 09:00 / 15:00 자동 트리거.
|
// Stocks watcher — VS Code 시작 시 자동 활성화. KST 09:00 / 15:00 자동 트리거.
|
||||||
// disposable 은 subscriptions 에 푸시해 종료 시 timer cleanup.
|
// disposable 은 subscriptions 에 푸시해 종료 시 timer cleanup.
|
||||||
context.subscriptions.push(startStocksWatcher(context));
|
context.subscriptions.push(startStocksWatcher(context));
|
||||||
|
// Stocks 자동 백업 — stocks.json 변경 시 Sheets `_backup` 탭으로 (v2.2.297).
|
||||||
|
context.subscriptions.push(registerStocksAutoBackup(context));
|
||||||
|
|
||||||
// Daily briefing — 평일 KST 09:30 오늘의 일정·할일을 텔레그램으로 발송.
|
// Daily briefing — 평일 KST 09:30 오늘의 일정·할일을 텔레그램으로 발송.
|
||||||
// 텔레그램/캘린더 미연결이면 fire 시점에 조용히 skip (로그만).
|
// 텔레그램/캘린더 미연결이면 fire 시점에 조용히 skip (로그만).
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ export {
|
|||||||
readSheetRange,
|
readSheetRange,
|
||||||
writeSheetRange,
|
writeSheetRange,
|
||||||
appendSheetRows,
|
appendSheetRows,
|
||||||
|
clearSheetRange,
|
||||||
|
ensureSheetTab,
|
||||||
parseTsvBody,
|
parseTsvBody,
|
||||||
valuesToMarkdownTable,
|
valuesToMarkdownTable,
|
||||||
SheetCell,
|
SheetCell,
|
||||||
|
|||||||
@@ -127,6 +127,75 @@ export async function appendSheetRows(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* range 의 값 지우기 — 셀 서식은 유지, 값만 제거.
|
||||||
|
* 백업 탭 덮어쓰기 전 잔여 행 제거용 (목록이 줄었을 때 아래 행이 남지 않게).
|
||||||
|
*/
|
||||||
|
export async function clearSheetRange(
|
||||||
|
context: vscode.ExtensionContext,
|
||||||
|
spreadsheetId: string,
|
||||||
|
range: string,
|
||||||
|
): Promise<{ ok: true } | ApiFailure> {
|
||||||
|
if (!spreadsheetId.trim()) return { ok: false, error: 'spreadsheet_id 비어있음' };
|
||||||
|
if (!range.trim()) return { ok: false, error: 'range 비어있음' };
|
||||||
|
const tok = await getFreshAccessToken(context);
|
||||||
|
if (!tok.ok) return { ok: false, error: tok.error };
|
||||||
|
const url = `${API_BASE}/${encodeURIComponent(spreadsheetId)}/values/${encodeURIComponent(range)}:clear`;
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${tok.accessToken}` },
|
||||||
|
signal: AbortSignal.timeout(15000),
|
||||||
|
});
|
||||||
|
const json: any = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) return { ok: false, error: json?.error?.message || `HTTP ${res.status}` };
|
||||||
|
return { ok: true };
|
||||||
|
} catch (e: any) {
|
||||||
|
return { ok: false, error: e?.message ?? String(e) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* title 이름의 탭이 spreadsheet 에 없으면 batchUpdate(addSheet) 로 생성.
|
||||||
|
* 이미 있으면 no-op. 백업 탭처럼 사용자가 미리 만들어 두지 않는 탭에 쓰기 전 호출.
|
||||||
|
*/
|
||||||
|
export async function ensureSheetTab(
|
||||||
|
context: vscode.ExtensionContext,
|
||||||
|
spreadsheetId: string,
|
||||||
|
title: string,
|
||||||
|
): Promise<{ ok: true; created: boolean } | ApiFailure> {
|
||||||
|
if (!spreadsheetId.trim()) return { ok: false, error: 'spreadsheet_id 비어있음' };
|
||||||
|
if (!title.trim()) return { ok: false, error: 'sheet title 비어있음' };
|
||||||
|
const tok = await getFreshAccessToken(context);
|
||||||
|
if (!tok.ok) return { ok: false, error: tok.error };
|
||||||
|
try {
|
||||||
|
const metaUrl = `${API_BASE}/${encodeURIComponent(spreadsheetId)}?fields=sheets.properties.title`;
|
||||||
|
const metaRes = await fetch(metaUrl, {
|
||||||
|
headers: { Authorization: `Bearer ${tok.accessToken}` },
|
||||||
|
signal: AbortSignal.timeout(15000),
|
||||||
|
});
|
||||||
|
const meta: any = await metaRes.json().catch(() => ({}));
|
||||||
|
if (!metaRes.ok) return { ok: false, error: meta?.error?.message || `HTTP ${metaRes.status}` };
|
||||||
|
const titles: string[] = (meta.sheets ?? []).map((s: any) => s?.properties?.title).filter(Boolean);
|
||||||
|
if (titles.includes(title)) return { ok: true, created: false };
|
||||||
|
|
||||||
|
const updRes = await fetch(`${API_BASE}/${encodeURIComponent(spreadsheetId)}:batchUpdate`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${tok.accessToken}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ requests: [{ addSheet: { properties: { title } } }] }),
|
||||||
|
signal: AbortSignal.timeout(15000),
|
||||||
|
});
|
||||||
|
const upd: any = await updRes.json().catch(() => ({}));
|
||||||
|
if (!updRes.ok) return { ok: false, error: upd?.error?.message || `HTTP ${updRes.status}` };
|
||||||
|
return { ok: true, created: true };
|
||||||
|
} catch (e: any) {
|
||||||
|
return { ok: false, error: e?.message ?? String(e) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ────────────── 파싱 헬퍼 — action tag 본문 TSV 해석 ──────────────
|
// ────────────── 파싱 헬퍼 — action tag 본문 TSV 해석 ──────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import * as vscode from 'vscode';
|
||||||
|
import { clearSheetRange, ensureSheetTab, readSheetRange, writeSheetRange, type SheetValues } from '../sheets';
|
||||||
|
import { logError, logInfo } from '../../utils';
|
||||||
|
import { onStocksStoreChanged, readStocksStore, writeStocksStore } from './stocksStore';
|
||||||
|
import type { Stock } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* stocks.json 원본 클라우드 백업 — Google Sheets `_backup` 탭 (v2.2.297).
|
||||||
|
*
|
||||||
|
* 문제: 목록의 source of truth 가 로컬 `.astra/stocks.json` 뿐이라 컴퓨터를 바꾸면
|
||||||
|
* 데이터가 사라진다. Sheet1~3 sync 는 분류 *표시용* 이라 복원 불가.
|
||||||
|
*
|
||||||
|
* 해결: 같은 spreadsheet(`g1nation.stocks.spreadsheetId`)에 `_backup` 탭을 자동
|
||||||
|
* 생성하고, 종목당 한 행으로 **원본 JSON 그대로** 백업. 새 컴퓨터에서는
|
||||||
|
* `/stocks restore` 한 번으로 복원.
|
||||||
|
*
|
||||||
|
* 백업 시점: writeStocksStore 성공 → onStocksStoreChanged → 디바운스 30초 후 1회.
|
||||||
|
* (add/remove/check/discover 자동편입/워처 가격갱신 전부 이 경로를 지남.)
|
||||||
|
*
|
||||||
|
* 레이아웃:
|
||||||
|
* A1: BACKUP_MARKER B1: ISO timestamp C1: 종목 수
|
||||||
|
* A2~: 심볼 B2~: 이름 C2~: JSON.stringify(stock)
|
||||||
|
* 심볼/이름 컬럼은 사람이 시트에서 훑어보기용 — 복원은 C열 JSON 만 사용.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const BACKUP_TAB = '_backup';
|
||||||
|
const BACKUP_MARKER = 'ASTRA_STOCKS_BACKUP_V1';
|
||||||
|
const DEBOUNCE_MS = 30_000;
|
||||||
|
|
||||||
|
function getSpreadsheetId(): string {
|
||||||
|
return (vscode.workspace.getConfiguration('g1nation').get<string>('stocks.spreadsheetId') || '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function autoBackupEnabled(): boolean {
|
||||||
|
return vscode.workspace.getConfiguration('g1nation').get<boolean>('stocks.autoBackup', true) !== false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 지금 즉시 백업 1회. spreadsheetId 미설정이면 skip (ok:false + reason). */
|
||||||
|
export async function backupToSheet(
|
||||||
|
context: vscode.ExtensionContext,
|
||||||
|
): Promise<{ ok: boolean; reason?: string; count?: number }> {
|
||||||
|
const spreadsheetId = getSpreadsheetId();
|
||||||
|
if (!spreadsheetId) {
|
||||||
|
return { ok: false, reason: 'g1nation.stocks.spreadsheetId 미설정 — Settings 에서 spreadsheet ID 를 넣어주세요.' };
|
||||||
|
}
|
||||||
|
const store = readStocksStore();
|
||||||
|
|
||||||
|
const ensured = await ensureSheetTab(context, spreadsheetId, BACKUP_TAB);
|
||||||
|
if (!ensured.ok) return { ok: false, reason: `백업 탭 확인 실패: ${ensured.error}` };
|
||||||
|
if (ensured.created) logInfo(`Stocks backup: '${BACKUP_TAB}' 탭 생성.`);
|
||||||
|
|
||||||
|
// 목록이 줄었을 때 잔여 행이 남지 않게 먼저 클리어. 클리어 실패는 치명적이지
|
||||||
|
// 않으므로 (덮어쓰기는 어차피 진행) 로그만 남기고 계속.
|
||||||
|
const cleared = await clearSheetRange(context, spreadsheetId, `${BACKUP_TAB}!A:C`);
|
||||||
|
if (!cleared.ok) logError('Stocks backup: 기존 행 클리어 실패 — 덮어쓰기로 계속.', { error: cleared.error });
|
||||||
|
|
||||||
|
const rows: SheetValues = [[BACKUP_MARKER, new Date().toISOString(), store.length]];
|
||||||
|
for (const s of store) {
|
||||||
|
rows.push([s.심볼, s.이름, JSON.stringify(s)]);
|
||||||
|
}
|
||||||
|
const wrote = await writeSheetRange(context, spreadsheetId, `${BACKUP_TAB}!A1`, rows);
|
||||||
|
if (!wrote.ok) return { ok: false, reason: `백업 쓰기 실패: ${wrote.error}` };
|
||||||
|
|
||||||
|
logInfo('Stocks backup: 완료.', { count: store.length });
|
||||||
|
return { ok: true, count: store.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `_backup` 탭에서 목록 복원 — 로컬 stocks.json 을 백업 내용으로 **교체**.
|
||||||
|
* silent write — 복원 직후 같은 내용을 되백업하는 루프 방지.
|
||||||
|
*/
|
||||||
|
export async function restoreFromSheet(
|
||||||
|
context: vscode.ExtensionContext,
|
||||||
|
): Promise<{ ok: boolean; reason?: string; count?: number; backedUpAt?: string; previousCount?: number }> {
|
||||||
|
const spreadsheetId = getSpreadsheetId();
|
||||||
|
if (!spreadsheetId) {
|
||||||
|
return { ok: false, reason: 'g1nation.stocks.spreadsheetId 미설정 — Settings 에서 spreadsheet ID 를 넣어주세요.' };
|
||||||
|
}
|
||||||
|
const read = await readSheetRange(context, spreadsheetId, `${BACKUP_TAB}!A1:C`);
|
||||||
|
if (!read.ok) return { ok: false, reason: `백업 탭 읽기 실패: ${read.error} — 아직 백업한 적이 없다면 /stocks backup 먼저.` };
|
||||||
|
|
||||||
|
const values = read.values;
|
||||||
|
const header = values[0] ?? [];
|
||||||
|
if (String(header[0] ?? '') !== BACKUP_MARKER) {
|
||||||
|
return { ok: false, reason: `'${BACKUP_TAB}' 탭이 백업 형식이 아닙니다 (헤더 마커 불일치).` };
|
||||||
|
}
|
||||||
|
const backedUpAt = String(header[1] ?? '');
|
||||||
|
|
||||||
|
const restored: Stock[] = [];
|
||||||
|
for (const row of values.slice(1)) {
|
||||||
|
const raw = String(row[2] ?? '').trim();
|
||||||
|
if (!raw) continue;
|
||||||
|
try {
|
||||||
|
const s = JSON.parse(raw);
|
||||||
|
if (s && typeof s === 'object' && typeof s.심볼 === 'string' && typeof s.이름 === 'string') {
|
||||||
|
restored.push(s as Stock);
|
||||||
|
}
|
||||||
|
} catch { /* 손상 행은 skip — 나머지는 살린다 */ }
|
||||||
|
}
|
||||||
|
if (restored.length === 0) {
|
||||||
|
return { ok: false, reason: '백업에 유효한 종목이 없습니다 — 로컬 목록을 건드리지 않았습니다.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousCount = readStocksStore().length;
|
||||||
|
if (!writeStocksStore(restored, { silent: true })) {
|
||||||
|
return { ok: false, reason: 'stocks.json 쓰기 실패 (워크스페이스 확인).' };
|
||||||
|
}
|
||||||
|
logInfo('Stocks restore: 완료.', { count: restored.length, backedUpAt, previousCount });
|
||||||
|
return { ok: true, count: restored.length, backedUpAt, previousCount };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 자동 백업 배선 — extension activate 에서 1회 호출.
|
||||||
|
* store 변경 → 30초 디바운스 → backupToSheet. spreadsheetId 미설정/설정 OFF 면 조용히 skip.
|
||||||
|
*/
|
||||||
|
export function registerStocksAutoBackup(context: vscode.ExtensionContext): vscode.Disposable {
|
||||||
|
let timer: NodeJS.Timeout | undefined;
|
||||||
|
const sub = onStocksStoreChanged(() => {
|
||||||
|
if (!autoBackupEnabled() || !getSpreadsheetId()) return;
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
timer = setTimeout(async () => {
|
||||||
|
timer = undefined;
|
||||||
|
const r = await backupToSheet(context);
|
||||||
|
if (!r.ok) logError('Stocks 자동 백업 실패.', { reason: r.reason });
|
||||||
|
}, DEBOUNCE_MS);
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
dispose: () => {
|
||||||
|
sub.dispose();
|
||||||
|
if (timer) { clearTimeout(timer); timer = undefined; }
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,2 +1,3 @@
|
|||||||
export { startStocksWatcher, runOnceNow } from './stocksWatcher';
|
export { startStocksWatcher, runOnceNow } from './stocksWatcher';
|
||||||
export { handleStocksCommand } from './slashStocks';
|
export { handleStocksCommand } from './slashStocks';
|
||||||
|
export { registerStocksAutoBackup } from './backupSync';
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { AIService } from '../../core/services';
|
|||||||
import { classifyAll } from './signalClassifier';
|
import { classifyAll } from './signalClassifier';
|
||||||
import { writeStocksStore } from './stocksStore';
|
import { writeStocksStore } from './stocksStore';
|
||||||
import { syncToSheets } from './sheetsSync';
|
import { syncToSheets } from './sheetsSync';
|
||||||
|
import { backupToSheet, restoreFromSheet } from './backupSync';
|
||||||
import { judgeStock } from './llmJudge';
|
import { judgeStock } from './llmJudge';
|
||||||
import { sendStocksReport, buildReportText } from './telegramReport';
|
import { sendStocksReport, buildReportText } from './telegramReport';
|
||||||
import { runOnceNow } from './stocksWatcher';
|
import { runOnceNow } from './stocksWatcher';
|
||||||
@@ -27,6 +28,8 @@ import type { ClassifiedStock, Stock } from './types';
|
|||||||
* /stocks check — 현재가 갱신 (Yahoo)
|
* /stocks check — 현재가 갱신 (Yahoo)
|
||||||
* /stocks signal — 매수사정권 종목만
|
* /stocks signal — 매수사정권 종목만
|
||||||
* /stocks sync — Google Sheets 동기화
|
* /stocks sync — Google Sheets 동기화
|
||||||
|
* /stocks backup — 원본 목록을 Sheets `_backup` 탭에 백업
|
||||||
|
* /stocks restore — `_backup` 탭에서 목록 복원 (기기 이전용)
|
||||||
* /stocks add <심볼> <이름> [투자성향]
|
* /stocks add <심볼> <이름> [투자성향]
|
||||||
* /stocks remove <심볼>
|
* /stocks remove <심볼>
|
||||||
* /stocks judge <심볼> — LLM 4-criteria 평가
|
* /stocks judge <심볼> — LLM 4-criteria 평가
|
||||||
@@ -116,6 +119,25 @@ async function cmdSync(view: Webview | undefined, context: vscode.ExtensionConte
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function cmdBackup(view: Webview | undefined, context: vscode.ExtensionContext): Promise<void> {
|
||||||
|
chunk(view, '\n☁️ Google Sheets `_backup` 탭으로 백업 중...\n');
|
||||||
|
const r = await backupToSheet(context);
|
||||||
|
chunk(view, r.ok
|
||||||
|
? `\n✅ 백업 완료: ${r.count}개 종목. 새 컴퓨터에서는 \`/stocks restore\` 로 복원하세요.\n`
|
||||||
|
: `\n❌ 백업 실패: ${r.reason}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cmdRestore(view: Webview | undefined, context: vscode.ExtensionContext): Promise<void> {
|
||||||
|
chunk(view, '\n☁️ Google Sheets `_backup` 탭에서 복원 중...\n');
|
||||||
|
const r = await restoreFromSheet(context);
|
||||||
|
if (!r.ok) {
|
||||||
|
chunk(view, `\n❌ 복원 실패: ${r.reason}\n`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const when = r.backedUpAt ? ` (백업 시각: ${r.backedUpAt})` : '';
|
||||||
|
chunk(view, `\n✅ 복원 완료: ${r.count}개 종목${when} — 기존 로컬 ${r.previousCount}개를 교체했습니다.\n`);
|
||||||
|
}
|
||||||
|
|
||||||
async function cmdAdd(arg: string, view: Webview | undefined): Promise<void> {
|
async function cmdAdd(arg: string, view: Webview | undefined): Promise<void> {
|
||||||
const parts = arg.split(/\s+/);
|
const parts = arg.split(/\s+/);
|
||||||
if (parts.length < 2) {
|
if (parts.length < 2) {
|
||||||
@@ -645,6 +667,8 @@ function cmdHelp(view: Webview | undefined): void {
|
|||||||
' `/stocks check` — 현재가 갱신',
|
' `/stocks check` — 현재가 갱신',
|
||||||
' `/stocks signal` — 매수사정권 종목만',
|
' `/stocks signal` — 매수사정권 종목만',
|
||||||
' `/stocks sync` — Google Sheets 동기화',
|
' `/stocks sync` — Google Sheets 동기화',
|
||||||
|
' `/stocks backup` — 목록 원본을 Sheets `_backup` 탭에 백업 (변경 시 자동 백업도 됨)',
|
||||||
|
' `/stocks restore` — `_backup` 탭에서 목록 복원 (새 컴퓨터 이전용)',
|
||||||
' `/stocks add <심볼> <이름>` — 종목 추가 (예: `add 005930 삼성전자` / `add AAPL Apple`)',
|
' `/stocks add <심볼> <이름>` — 종목 추가 (예: `add 005930 삼성전자` / `add AAPL Apple`)',
|
||||||
' `/stocks remove <심볼>` — 종목 제거',
|
' `/stocks remove <심볼>` — 종목 제거',
|
||||||
' `/stocks judge <심볼>` — LLM 4-criteria 평가 (stocks.json 등록 종목)',
|
' `/stocks judge <심볼>` — LLM 4-criteria 평가 (stocks.json 등록 종목)',
|
||||||
@@ -683,6 +707,12 @@ export async function handleStocksCommand(
|
|||||||
case 'sync':
|
case 'sync':
|
||||||
if (!context) { chunk(view, '\n❌ ExtensionContext 없음 (sync 불가).\n'); return true; }
|
if (!context) { chunk(view, '\n❌ ExtensionContext 없음 (sync 불가).\n'); return true; }
|
||||||
await cmdSync(view, context); return true;
|
await cmdSync(view, context); return true;
|
||||||
|
case 'backup':
|
||||||
|
if (!context) { chunk(view, '\n❌ ExtensionContext 없음 (backup 불가).\n'); return true; }
|
||||||
|
await cmdBackup(view, context); return true;
|
||||||
|
case 'restore':
|
||||||
|
if (!context) { chunk(view, '\n❌ ExtensionContext 없음 (restore 불가).\n'); return true; }
|
||||||
|
await cmdRestore(view, context); return true;
|
||||||
case 'add': await cmdAdd(rest, view); return true;
|
case 'add': await cmdAdd(rest, view); return true;
|
||||||
case 'remove': case 'rm': await cmdRemove(rest, view); return true;
|
case 'remove': case 'rm': await cmdRemove(rest, view); return true;
|
||||||
case 'judge': await cmdJudge(rest, view); return true;
|
case 'judge': await cmdJudge(rest, view); return true;
|
||||||
|
|||||||
@@ -17,6 +17,28 @@ import type { Stock, StocksStore } from './types';
|
|||||||
|
|
||||||
const STORE_REL_PATH = '.astra/stocks.json';
|
const STORE_REL_PATH = '.astra/stocks.json';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 저장 성공 시 알림 리스너 (v2.2.297) — 모든 변경 경로(add/remove/update/check/
|
||||||
|
* discover 자동편입/워처 가격갱신)가 writeStocksStore 한 곳을 지나므로, 여기서
|
||||||
|
* fire 하면 Sheets 자동 백업이 변경을 빠짐없이 따라간다.
|
||||||
|
* 리스너 예외는 삼킨다 — 백업 실패가 저장 자체를 깨면 안 됨.
|
||||||
|
*/
|
||||||
|
type StoreChangeListener = () => void;
|
||||||
|
const changeListeners = new Set<StoreChangeListener>();
|
||||||
|
|
||||||
|
export function onStocksStoreChanged(listener: StoreChangeListener): { dispose(): void } {
|
||||||
|
changeListeners.add(listener);
|
||||||
|
return { dispose: () => { changeListeners.delete(listener); } };
|
||||||
|
}
|
||||||
|
|
||||||
|
function fireStoreChanged(): void {
|
||||||
|
for (const l of changeListeners) {
|
||||||
|
try { l(); } catch (e: any) {
|
||||||
|
logError('stocks store 변경 리스너 예외.', { error: e?.message ?? String(e) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function getStocksFilePath(): string | null {
|
export function getStocksFilePath(): string | null {
|
||||||
const folders = vscode.workspace.workspaceFolders;
|
const folders = vscode.workspace.workspaceFolders;
|
||||||
if (!folders || folders.length === 0) return null;
|
if (!folders || folders.length === 0) return null;
|
||||||
@@ -41,8 +63,12 @@ export function readStocksStore(): StocksStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Atomic write — tmp + rename. 워크스페이스 없으면 false 반환 (caller 가 안내). */
|
/**
|
||||||
export function writeStocksStore(store: StocksStore): boolean {
|
* Atomic write — tmp + rename. 워크스페이스 없으면 false 반환 (caller 가 안내).
|
||||||
|
* opts.silent: 변경 리스너 미발화 — restore 처럼 백업에서 방금 읽어온 내용을
|
||||||
|
* 다시 백업하는 루프를 끊을 때만 사용.
|
||||||
|
*/
|
||||||
|
export function writeStocksStore(store: StocksStore, opts?: { silent?: boolean }): boolean {
|
||||||
const filePath = getStocksFilePath();
|
const filePath = getStocksFilePath();
|
||||||
if (!filePath) {
|
if (!filePath) {
|
||||||
logError('워크스페이스 폴더가 없어 stocks.json 쓰기 불가.');
|
logError('워크스페이스 폴더가 없어 stocks.json 쓰기 불가.');
|
||||||
@@ -54,6 +80,7 @@ export function writeStocksStore(store: StocksStore): boolean {
|
|||||||
const tmp = `${filePath}.tmp-${process.pid}-${Date.now()}`;
|
const tmp = `${filePath}.tmp-${process.pid}-${Date.now()}`;
|
||||||
fs.writeFileSync(tmp, JSON.stringify(store, null, 2), 'utf-8');
|
fs.writeFileSync(tmp, JSON.stringify(store, null, 2), 'utf-8');
|
||||||
fs.renameSync(tmp, filePath);
|
fs.renameSync(tmp, filePath);
|
||||||
|
if (!opts?.silent) fireStoreChanged();
|
||||||
return true;
|
return true;
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
logError('stocks.json 쓰기 실패.', { filePath, error: e?.message ?? String(e) });
|
logError('stocks.json 쓰기 실패.', { filePath, error: e?.message ?? String(e) });
|
||||||
|
|||||||
Reference in New Issue
Block a user