kindred-keeper 0.1.0__tar.gz

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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kindred Keeper maintainers
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,2 @@
1
+ recursive-include kindred_keeper/static *
2
+ recursive-include examples *
@@ -0,0 +1,113 @@
1
+ Metadata-Version: 2.4
2
+ Name: kindred-keeper
3
+ Version: 0.1.0
4
+ Summary: A local-first relationship memory desk for thoughtful follow-ups.
5
+ Author: Kindred Keeper maintainers
6
+ License-Expression: MIT
7
+ Keywords: relationships,personal-crm,local-first,planning
8
+ Classifier: Development Status :: 4 - Beta
9
+ Classifier: Intended Audience :: End Users/Desktop
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.10
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Requires-Python: >=3.10
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+ Dynamic: license-file
18
+
19
+ # Kindred Keeper
20
+
21
+ Kindred Keeper is a private, local-first relationship memory desk for thoughtful follow-ups. Add people you care about, jot down small details after calls or visits, and it builds a practical plan for check-ins, promises, birthdays, and meaningful moments.
22
+
23
+ Everything runs on your computer with the Python standard library. There is no account, upload, telemetry, or hosted database.
24
+
25
+ ![Kindred Keeper dashboard](docs/screenshot.svg)
26
+
27
+ ## Features
28
+
29
+ - Local browser app backed by a JSON file on your machine.
30
+ - Contact profiles with relationship, cadence, interests, and last touchpoint.
31
+ - Note analysis that extracts interests, promises, dates, sentiment, and relationship signals.
32
+ - Lightweight local classifier trained on celebration, support, commitment, and interest examples.
33
+ - Planning workflow that ranks overdue follow-ups, open commitments, and upcoming dates.
34
+ - Suggested message drafts that stay specific to the person and the reason for reaching out.
35
+ - Copy-ready weekly relationship plan.
36
+ - Seeded demo workspace so the full workflow is visible on first launch.
37
+
38
+ ## Installation
39
+
40
+ Requirements:
41
+
42
+ - Python 3.10 or newer
43
+
44
+ ```bash
45
+ git clone https://github.com/iwaheedsattar/kindred-keeper.git
46
+ cd kindred-keeper
47
+ python3 app.py
48
+ ```
49
+
50
+ Open [http://127.0.0.1:8768](http://127.0.0.1:8768).
51
+
52
+ To use a custom local data file:
53
+
54
+ ```bash
55
+ python3 app.py --data ./my-kindred-data.json
56
+ ```
57
+
58
+ ## Example Usage
59
+
60
+ 1. Launch the app with `python3 app.py`.
61
+ 2. Review the seeded relationship plan.
62
+ 3. Add a person you want to stay close to.
63
+ 4. Save a note such as:
64
+
65
+ ```text
66
+ Lina loves Korean food and ceramics. Promised to send the podcast link. Her recital is Jul 16.
67
+ ```
68
+
69
+ 5. Review the follow-up plan and copy the weekly plan into your calendar or notes app.
70
+
71
+ ## How It Works
72
+
73
+ Kindred Keeper uses a small local planning pipeline:
74
+
75
+ 1. Notes are tokenized and scored with a compact multinomial classifier.
76
+ 2. Extractors find promises, dates, and interest phrases.
77
+ 3. Each contact receives warmth and attention scores based on cadence, open commitments, support signals, and recent notes.
78
+ 4. The planner ranks next actions and writes a short message draft for each one.
79
+
80
+ The pipeline is intentionally small and transparent so personal relationship notes stay local and understandable.
81
+
82
+ ## Development
83
+
84
+ ```bash
85
+ python3 -m unittest discover -s tests
86
+ python3 app.py --no-open --data ./.kindred-keeper-data.json
87
+ ```
88
+
89
+ Package build:
90
+
91
+ ```bash
92
+ python3 -m build --no-isolation
93
+ ```
94
+
95
+ ## Project Layout
96
+
97
+ - `kindred_keeper/engine.py` contains note analysis, scoring, and planning.
98
+ - `kindred_keeper/storage.py` manages the local JSON workspace.
99
+ - `kindred_keeper/server.py` serves the local web app and JSON API.
100
+ - `static/` contains the browser UI.
101
+ - `tests/` covers extraction, summary scoring, and action planning.
102
+
103
+ ## Roadmap
104
+
105
+ - Calendar export for planned check-ins.
106
+ - Import from a simple contacts CSV.
107
+ - Reminder notifications in a desktop wrapper.
108
+ - Optional encrypted data file.
109
+ - Better recurring-date handling for birthdays and anniversaries.
110
+
111
+ ## License
112
+
113
+ MIT
@@ -0,0 +1,95 @@
1
+ # Kindred Keeper
2
+
3
+ Kindred Keeper is a private, local-first relationship memory desk for thoughtful follow-ups. Add people you care about, jot down small details after calls or visits, and it builds a practical plan for check-ins, promises, birthdays, and meaningful moments.
4
+
5
+ Everything runs on your computer with the Python standard library. There is no account, upload, telemetry, or hosted database.
6
+
7
+ ![Kindred Keeper dashboard](docs/screenshot.svg)
8
+
9
+ ## Features
10
+
11
+ - Local browser app backed by a JSON file on your machine.
12
+ - Contact profiles with relationship, cadence, interests, and last touchpoint.
13
+ - Note analysis that extracts interests, promises, dates, sentiment, and relationship signals.
14
+ - Lightweight local classifier trained on celebration, support, commitment, and interest examples.
15
+ - Planning workflow that ranks overdue follow-ups, open commitments, and upcoming dates.
16
+ - Suggested message drafts that stay specific to the person and the reason for reaching out.
17
+ - Copy-ready weekly relationship plan.
18
+ - Seeded demo workspace so the full workflow is visible on first launch.
19
+
20
+ ## Installation
21
+
22
+ Requirements:
23
+
24
+ - Python 3.10 or newer
25
+
26
+ ```bash
27
+ git clone https://github.com/iwaheedsattar/kindred-keeper.git
28
+ cd kindred-keeper
29
+ python3 app.py
30
+ ```
31
+
32
+ Open [http://127.0.0.1:8768](http://127.0.0.1:8768).
33
+
34
+ To use a custom local data file:
35
+
36
+ ```bash
37
+ python3 app.py --data ./my-kindred-data.json
38
+ ```
39
+
40
+ ## Example Usage
41
+
42
+ 1. Launch the app with `python3 app.py`.
43
+ 2. Review the seeded relationship plan.
44
+ 3. Add a person you want to stay close to.
45
+ 4. Save a note such as:
46
+
47
+ ```text
48
+ Lina loves Korean food and ceramics. Promised to send the podcast link. Her recital is Jul 16.
49
+ ```
50
+
51
+ 5. Review the follow-up plan and copy the weekly plan into your calendar or notes app.
52
+
53
+ ## How It Works
54
+
55
+ Kindred Keeper uses a small local planning pipeline:
56
+
57
+ 1. Notes are tokenized and scored with a compact multinomial classifier.
58
+ 2. Extractors find promises, dates, and interest phrases.
59
+ 3. Each contact receives warmth and attention scores based on cadence, open commitments, support signals, and recent notes.
60
+ 4. The planner ranks next actions and writes a short message draft for each one.
61
+
62
+ The pipeline is intentionally small and transparent so personal relationship notes stay local and understandable.
63
+
64
+ ## Development
65
+
66
+ ```bash
67
+ python3 -m unittest discover -s tests
68
+ python3 app.py --no-open --data ./.kindred-keeper-data.json
69
+ ```
70
+
71
+ Package build:
72
+
73
+ ```bash
74
+ python3 -m build --no-isolation
75
+ ```
76
+
77
+ ## Project Layout
78
+
79
+ - `kindred_keeper/engine.py` contains note analysis, scoring, and planning.
80
+ - `kindred_keeper/storage.py` manages the local JSON workspace.
81
+ - `kindred_keeper/server.py` serves the local web app and JSON API.
82
+ - `static/` contains the browser UI.
83
+ - `tests/` covers extraction, summary scoring, and action planning.
84
+
85
+ ## Roadmap
86
+
87
+ - Calendar export for planned check-ins.
88
+ - Import from a simple contacts CSV.
89
+ - Reminder notifications in a desktop wrapper.
90
+ - Optional encrypted data file.
91
+ - Better recurring-date handling for birthdays and anniversaries.
92
+
93
+ ## License
94
+
95
+ MIT
@@ -0,0 +1,3 @@
1
+ Maya loves ceramics and jazz. Her studio show opens Jul 8. Promised to send the harbour walk photos.
2
+ Omar sounded stressed after the move. Need to call about helping set up the kitchen shelves.
3
+ Nora likes gardening and asked about tomato seedlings. Birthday is Aug 12.
@@ -0,0 +1,3 @@
1
+ """Kindred Keeper local relationship planning package."""
2
+
3
+ __all__ = ["engine", "storage"]
@@ -0,0 +1,360 @@
1
+ from __future__ import annotations
2
+
3
+ from collections import Counter, defaultdict
4
+ from dataclasses import dataclass
5
+ from datetime import date, datetime, timedelta
6
+ import math
7
+ import re
8
+ from typing import Iterable
9
+
10
+
11
+ STOPWORDS = {
12
+ "a",
13
+ "and",
14
+ "are",
15
+ "as",
16
+ "at",
17
+ "be",
18
+ "for",
19
+ "from",
20
+ "has",
21
+ "have",
22
+ "i",
23
+ "in",
24
+ "is",
25
+ "it",
26
+ "of",
27
+ "on",
28
+ "or",
29
+ "she",
30
+ "he",
31
+ "they",
32
+ "the",
33
+ "their",
34
+ "to",
35
+ "we",
36
+ "with",
37
+ "you",
38
+ }
39
+
40
+ TRAINING = {
41
+ "celebration": [
42
+ "birthday dinner graduation promoted won anniversary new baby congratulations",
43
+ "party celebrate engagement wedding milestone good news proud achievement",
44
+ "finished marathon got accepted opening night launch recital performance",
45
+ ],
46
+ "support": [
47
+ "surgery sick hospital grief difficult stressed burned out tired lonely",
48
+ "lost job breakup funeral caring parent overwhelmed anxious worried",
49
+ "treatment recovering diagnosis trouble moving house emergency",
50
+ ],
51
+ "commitment": [
52
+ "promised send introduce follow up remind book tickets share recipe",
53
+ "owed call lend return plan coffee dinner help review",
54
+ "need to check ask about send photos schedule visit",
55
+ ],
56
+ "interest": [
57
+ "loves gardening jazz hiking chess cooking photography soccer books",
58
+ "favorite restaurant tea dogs movies ceramics cycling baking",
59
+ "interested in travel history podcasts painting theatre yoga",
60
+ ],
61
+ }
62
+
63
+ POSITIVE_WORDS = {
64
+ "happy",
65
+ "excited",
66
+ "proud",
67
+ "great",
68
+ "good",
69
+ "celebrate",
70
+ "better",
71
+ "enjoyed",
72
+ "love",
73
+ "kind",
74
+ "fun",
75
+ }
76
+ NEGATIVE_WORDS = {
77
+ "sad",
78
+ "hard",
79
+ "difficult",
80
+ "sick",
81
+ "stressed",
82
+ "worried",
83
+ "lonely",
84
+ "tired",
85
+ "overwhelmed",
86
+ "grief",
87
+ "emergency",
88
+ "anxious",
89
+ }
90
+
91
+ TOKEN_RE = re.compile(r"[a-z][a-z'-]{1,}")
92
+ DATE_RE = re.compile(
93
+ r"\b(?:(jan|feb|mar|apr|may|jun|jul|aug|sep|sept|oct|nov|dec)[a-z]*\.?\s+(\d{1,2})|(\d{4})-(\d{2})-(\d{2}))\b",
94
+ re.I,
95
+ )
96
+ PROMISE_RE = re.compile(
97
+ r"\b(?:promised|promise|owe|owed|need to|should|remember to|follow up|send|share|introduce|book|call|text|visit)\b[^.!\n]{0,90}",
98
+ re.I,
99
+ )
100
+ INTEREST_RE = re.compile(
101
+ r"\b(?:loves?|likes?|favorite|into|interested in|enjoys?)\s+([^.;\n]{2,80})",
102
+ re.I,
103
+ )
104
+
105
+
106
+ @dataclass
107
+ class Signal:
108
+ label: str
109
+ score: float
110
+ evidence: str
111
+
112
+
113
+ @dataclass
114
+ class PlannedAction:
115
+ contact_id: str
116
+ contact_name: str
117
+ title: str
118
+ reason: str
119
+ priority: int
120
+ due: str
121
+ tone: str
122
+
123
+
124
+ def tokenize(text: str) -> list[str]:
125
+ return [t for t in TOKEN_RE.findall(text.lower()) if t not in STOPWORDS]
126
+
127
+
128
+ class NaiveSignalClassifier:
129
+ def __init__(self) -> None:
130
+ self.label_counts: dict[str, Counter[str]] = {}
131
+ self.label_totals: dict[str, int] = {}
132
+ self.vocabulary: set[str] = set()
133
+ for label, examples in TRAINING.items():
134
+ counter: Counter[str] = Counter()
135
+ for example in examples:
136
+ counter.update(tokenize(example))
137
+ self.label_counts[label] = counter
138
+ self.label_totals[label] = sum(counter.values())
139
+ self.vocabulary.update(counter.keys())
140
+
141
+ def score(self, text: str) -> list[Signal]:
142
+ tokens = tokenize(text)
143
+ if not tokens:
144
+ return []
145
+ vocab_size = max(len(self.vocabulary), 1)
146
+ raw: dict[str, float] = {}
147
+ for label, counts in self.label_counts.items():
148
+ total = self.label_totals[label] + vocab_size
149
+ value = math.log(1 / len(self.label_counts))
150
+ for token in tokens:
151
+ value += math.log((counts[token] + 1) / total)
152
+ raw[label] = value
153
+ peak = max(raw.values())
154
+ exp = {label: math.exp(value - peak) for label, value in raw.items()}
155
+ total_exp = sum(exp.values()) or 1.0
156
+ ranked = []
157
+ for label, value in exp.items():
158
+ score = value / total_exp
159
+ if score >= 0.18:
160
+ ranked.append(Signal(label, round(score, 3), _evidence_for(label, text)))
161
+ return sorted(ranked, key=lambda item: item.score, reverse=True)
162
+
163
+
164
+ CLASSIFIER = NaiveSignalClassifier()
165
+
166
+
167
+ def _evidence_for(label: str, text: str) -> str:
168
+ words = set(TRAINING[label][0].split())
169
+ fragments = re.split(r"(?<=[.!?])\s+|\n+", text.strip())
170
+ for fragment in fragments:
171
+ if words.intersection(tokenize(fragment)):
172
+ return fragment.strip()[:180]
173
+ return text.strip()[:180]
174
+
175
+
176
+ def sentiment_score(text: str) -> int:
177
+ tokens = tokenize(text)
178
+ positives = sum(1 for token in tokens if token in POSITIVE_WORDS)
179
+ negatives = sum(1 for token in tokens if token in NEGATIVE_WORDS)
180
+ return max(-5, min(5, positives - negatives))
181
+
182
+
183
+ def extract_interests(text: str) -> list[str]:
184
+ found: list[str] = []
185
+ for match in INTEREST_RE.finditer(text):
186
+ phrase = re.sub(r"\band\b", ",", match.group(1), flags=re.I)
187
+ for part in re.split(r"[,/]| and ", phrase):
188
+ cleaned = re.sub(r"[^a-zA-Z0-9 '-]", "", part).strip().lower()
189
+ if 2 <= len(cleaned) <= 36:
190
+ found.append(cleaned)
191
+ return _unique(found)[:8]
192
+
193
+
194
+ def extract_promises(text: str) -> list[str]:
195
+ promises = []
196
+ for match in PROMISE_RE.finditer(text):
197
+ item = re.sub(r"\s+", " ", match.group(0)).strip(" .")
198
+ if len(item) > 5:
199
+ promises.append(item[0].upper() + item[1:])
200
+ return _unique(promises)[:8]
201
+
202
+
203
+ def extract_dates(text: str, today: date | None = None) -> list[str]:
204
+ today = today or date.today()
205
+ dates: list[str] = []
206
+ month_lookup = {
207
+ "jan": 1,
208
+ "feb": 2,
209
+ "mar": 3,
210
+ "apr": 4,
211
+ "may": 5,
212
+ "jun": 6,
213
+ "jul": 7,
214
+ "aug": 8,
215
+ "sep": 9,
216
+ "sept": 9,
217
+ "oct": 10,
218
+ "nov": 11,
219
+ "dec": 12,
220
+ }
221
+ for match in DATE_RE.finditer(text):
222
+ if match.group(1):
223
+ month = month_lookup[match.group(1).lower()[:3]]
224
+ day = int(match.group(2))
225
+ year = today.year
226
+ candidate = date(year, month, day)
227
+ if candidate < today - timedelta(days=7):
228
+ candidate = date(year + 1, month, day)
229
+ else:
230
+ candidate = date(int(match.group(3)), int(match.group(4)), int(match.group(5)))
231
+ dates.append(candidate.isoformat())
232
+ return _unique(dates)[:8]
233
+
234
+
235
+ def analyze_note(text: str, today: date | None = None) -> dict:
236
+ signals = CLASSIFIER.score(text)
237
+ return {
238
+ "signals": [signal.__dict__ for signal in signals],
239
+ "sentiment": sentiment_score(text),
240
+ "interests": extract_interests(text),
241
+ "promises": extract_promises(text),
242
+ "dates": extract_dates(text, today=today),
243
+ }
244
+
245
+
246
+ def build_contact_summary(contact: dict, notes: Iterable[dict], today: date | None = None) -> dict:
247
+ today = today or date.today()
248
+ notes = sorted(notes, key=lambda row: row.get("created_at", ""), reverse=True)
249
+ all_interests: list[str] = list(contact.get("interests", []))
250
+ open_promises: list[str] = []
251
+ next_dates: list[str] = []
252
+ signal_counts: Counter[str] = Counter()
253
+ sentiment_total = 0
254
+ for note in notes:
255
+ analysis = note.get("analysis", {})
256
+ all_interests.extend(analysis.get("interests", []))
257
+ open_promises.extend(analysis.get("promises", []))
258
+ next_dates.extend(d for d in analysis.get("dates", []) if d >= today.isoformat())
259
+ sentiment_total += int(analysis.get("sentiment", 0))
260
+ for signal in analysis.get("signals", []):
261
+ signal_counts[signal["label"]] += 1
262
+ last_seen = contact.get("last_seen") or (notes[0]["created_at"][:10] if notes else "")
263
+ days_since = _days_since(last_seen, today)
264
+ cadence = int(contact.get("cadence_days") or 30)
265
+ warmth = max(0, min(100, 74 - min(days_since, 90) + min(len(notes) * 4, 18) + sentiment_total * 3))
266
+ attention = max(0, min(100, int((days_since / max(cadence, 1)) * 55) + len(open_promises) * 12 + signal_counts["support"] * 14))
267
+ return {
268
+ "id": contact["id"],
269
+ "name": contact["name"],
270
+ "relationship": contact.get("relationship", ""),
271
+ "last_seen": last_seen,
272
+ "days_since": days_since,
273
+ "cadence_days": cadence,
274
+ "warmth": warmth,
275
+ "attention": attention,
276
+ "interests": _unique(all_interests)[:8],
277
+ "open_promises": _unique(open_promises)[:6],
278
+ "upcoming_dates": sorted(_unique(next_dates))[:4],
279
+ "top_signals": signal_counts.most_common(4),
280
+ "note_count": len(notes),
281
+ }
282
+
283
+
284
+ def plan_actions(contacts: list[dict], notes_by_contact: dict[str, list[dict]], today: date | None = None) -> list[PlannedAction]:
285
+ today = today or date.today()
286
+ actions: list[PlannedAction] = []
287
+ for contact in contacts:
288
+ summary = build_contact_summary(contact, notes_by_contact.get(contact["id"], []), today=today)
289
+ due_base = today + timedelta(days=max(0, min(14, summary["cadence_days"] - summary["days_since"])))
290
+ if summary["open_promises"]:
291
+ actions.append(
292
+ PlannedAction(
293
+ contact["id"],
294
+ contact["name"],
295
+ summary["open_promises"][0],
296
+ "You wrote down a commitment that is still worth closing.",
297
+ min(100, summary["attention"] + 25),
298
+ today.isoformat(),
299
+ "clear and reliable",
300
+ )
301
+ )
302
+ if summary["upcoming_dates"]:
303
+ event_date = datetime.fromisoformat(summary["upcoming_dates"][0]).date()
304
+ days_until = (event_date - today).days
305
+ if 0 <= days_until <= 21:
306
+ actions.append(
307
+ PlannedAction(
308
+ contact["id"],
309
+ contact["name"],
310
+ f"Prepare a note for {event_date.strftime('%b %-d')}",
311
+ "An upcoming date appeared in your notes.",
312
+ 85 - days_until,
313
+ max(today, event_date - timedelta(days=3)).isoformat(),
314
+ "warm and specific",
315
+ )
316
+ )
317
+ if summary["attention"] >= 45:
318
+ interest = summary["interests"][0] if summary["interests"] else contact.get("relationship", "life")
319
+ actions.append(
320
+ PlannedAction(
321
+ contact["id"],
322
+ contact["name"],
323
+ f"Send a check-in about {interest}",
324
+ f"It has been {summary['days_since']} days since the last touchpoint.",
325
+ summary["attention"],
326
+ due_base.isoformat(),
327
+ "low-pressure",
328
+ )
329
+ )
330
+ actions.sort(key=lambda item: (-item.priority, item.due, item.contact_name))
331
+ return actions[:10]
332
+
333
+
334
+ def suggest_message(contact: dict, summary: dict, action: PlannedAction) -> str:
335
+ interest = summary["interests"][0] if summary["interests"] else "how things are going"
336
+ if "Prepare a note" in action.title:
337
+ return f"Hi {contact['name'].split()[0]}, I remembered {action.title.replace('Prepare a note for ', '')} is coming up and wanted to send something thoughtful. Hope it is a good one."
338
+ if summary["open_promises"] and action.title == summary["open_promises"][0]:
339
+ return f"Hi {contact['name'].split()[0]}, I remembered I said I would {action.title[0].lower() + action.title[1:]}. I am on it and wanted to close the loop."
340
+ return f"Hi {contact['name'].split()[0]}, I was thinking about you and {interest}. How has that been going lately?"
341
+
342
+
343
+ def _days_since(value: str, today: date) -> int:
344
+ if not value:
345
+ return 999
346
+ try:
347
+ return max(0, (today - datetime.fromisoformat(value[:10]).date()).days)
348
+ except ValueError:
349
+ return 999
350
+
351
+
352
+ def _unique(items: Iterable[str]) -> list[str]:
353
+ seen = set()
354
+ result = []
355
+ for item in items:
356
+ key = item.lower().strip()
357
+ if key and key not in seen:
358
+ seen.add(key)
359
+ result.append(item.strip())
360
+ return result